Add VIP selection page and related data models

This commit introduces a new VIP selection page (`VipSelPage.kt`) that allows users to choose between Premium and Standard membership plans.

Key changes include:

*   **New VIP Selection UI:**
    *   `VipSelPage.kt`: Implements the UI for selecting VIP plans, displaying prices, and benefits.
    *   `SelfProfileAction.kt`: Updated to include a "Rave Premium" button alongside "Edit Profile".
*   **Data Models for Membership:**
    *   `MembershipModels.kt`: Defines data classes for membership configuration (`MembershipConfigData`, `ConfigData`, `Member`, `Benefit`, `Good`), price models (`VipPriceModel`), page data models (`VipPageDataModel`), and request bodies (`ValidateProductRequestBody`, `ValidateData`).
    *   `VipModelMapper`: Provides functions to transform backend data into UI-friendly models for price and benefit display.
*   **API Integration:**
    *   `RiderProAPI.kt`: Added new endpoints `getMembershipConfig` to fetch membership details and `validateAndroidProduct` for product validation.
*   **Navigation:**
    *   `Navi.kt`: Added `VipSelPage` to the navigation routes.
    *   `ProfileV3.kt`: The "Rave Premium" button in the self profile action now navigates to the `VipSelPage`.
*   **Theming:**
    *   `Colors.kt`: Added new color definitions for premium buttons, VIP benefit highlighting, and price card states (selected/unselected).
*   **Assets:**
    *   `ic_member.webp`: New icon for the "Rave Premium" button.
This commit is contained in:
2025-08-31 22:17:20 +08:00
parent 5759d4ec95
commit 40ccd70e80
8 changed files with 593 additions and 25 deletions

View File

@@ -35,6 +35,16 @@ open class AppThemeData(
var tabUnselectedText: Color, var tabUnselectedText: Color,
var bubbleBackground: Color, var bubbleBackground: Color,
var profileBackground:Color, var profileBackground:Color,
// Premium 按钮相关颜色
var premiumText: Color,
var premiumBackground: Color,
// VIP 权益强调色(用于 2X / 勾选高亮)
var vipHave: Color,
// 价格卡片颜色
var priceCardSelectedBorder: Color,
var priceCardSelectedBackground: Color,
var priceCardUnselectedBorder: Color,
var priceCardUnselectedBackground: Color,
) )
class LightThemeColors : AppThemeData( class LightThemeColors : AppThemeData(
@@ -66,7 +76,14 @@ class LightThemeColors : AppThemeData(
tabSelectedText = Color(0xffffffff), tabSelectedText = Color(0xffffffff),
tabUnselectedText = Color(0xff000000), tabUnselectedText = Color(0xff000000),
bubbleBackground = Color(0xfff5f5f5), bubbleBackground = Color(0xfff5f5f5),
profileBackground = Color(0xffffffff) profileBackground = Color(0xffffffff),
premiumText = Color(0xFFCD7B00),
premiumBackground = Color(0xFFFFF5D4),
vipHave = Color(0xFFFAAD14),
priceCardSelectedBorder = Color(0xFF000000),
priceCardSelectedBackground = Color(0xFFFFF5D4),
priceCardUnselectedBorder = Color(0xFFF0EEF1),
priceCardUnselectedBackground = Color(0xFFFAF9FB),
) )
class DarkThemeColors : AppThemeData( class DarkThemeColors : AppThemeData(
@@ -98,6 +115,13 @@ class DarkThemeColors : AppThemeData(
tabSelectedText = Color(0xff000000), tabSelectedText = Color(0xff000000),
tabUnselectedText = Color(0xffffffff), tabUnselectedText = Color(0xffffffff),
bubbleBackground = Color(0xfff2d2c2e), bubbleBackground = Color(0xfff2d2c2e),
profileBackground = Color(0xff100c12) profileBackground = Color(0xff100c12),
premiumText = Color(0xFFCD7B00),
premiumBackground = Color(0xFFFFF5D4),
vipHave = Color(0xFFFAAD14),
priceCardSelectedBorder = Color(0xFF000000),
priceCardSelectedBackground = Color(0xFFFFF5D4),
priceCardUnselectedBorder = Color(0xFFF0EEF1),
priceCardUnselectedBackground = Color(0xFFFAF9FB),
) )

View File

@@ -12,6 +12,9 @@ import com.aiosman.ravenow.data.ListContainer
import com.aiosman.ravenow.data.Moment import com.aiosman.ravenow.data.Moment
import com.aiosman.ravenow.data.Room import com.aiosman.ravenow.data.Room
import com.aiosman.ravenow.entity.ChatNotification import com.aiosman.ravenow.entity.ChatNotification
import com.aiosman.ravenow.data.membership.MembershipConfigData
import com.aiosman.ravenow.data.membership.ValidateData
import com.aiosman.ravenow.data.membership.ValidateProductRequestBody
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody import okhttp3.RequestBody
@@ -256,6 +259,15 @@ data class RemoveAccountRequestBody(
) )
interface RaveNowAPI { interface RaveNowAPI {
@GET("membership/config")
@retrofit2.http.Headers("X-Requires-Auth: true")
suspend fun getMembershipConfig(): Response<DataContainer<MembershipConfigData>>
@POST("membership/android/product/validate")
@retrofit2.http.Headers("X-Requires-Auth: true")
suspend fun validateAndroidProduct(
@Body body: ValidateProductRequestBody
): Response<DataContainer<ValidateData>>
@POST("register") @POST("register")
suspend fun register(@Body body: RegisterRequestBody): Response<Unit> suspend fun register(@Body body: RegisterRequestBody): Response<Unit>

View File

@@ -0,0 +1,192 @@
package com.aiosman.ravenow.data.membership
import com.google.gson.JsonElement
import com.google.gson.annotations.SerializedName
data class MembershipConfigData(
@SerializedName("id") val id: Int,
@SerializedName("name") val name: String,
@SerializedName("description") val description: String,
@SerializedName("version") val version: String,
@SerializedName("config_data") val configData: ConfigData,
@SerializedName("createdAt") val createdAt: String,
@SerializedName("updatedAt") val updatedAt: String,
)
data class ConfigData(
@SerializedName("members") val members: List<Member>
)
data class Member(
@SerializedName("name") val name: String,
@SerializedName("benefits") val benefits: List<Benefit>,
@SerializedName("goods") val goods: List<Good>,
)
data class Benefit(
@SerializedName("level") val level: Int,
@SerializedName("name") val name: String,
@SerializedName("value") val value: JsonElement,
@SerializedName("order") val order: Int,
)
data class Good(
@SerializedName("description") val description: String,
@SerializedName("discount") val discount: Double,
@SerializedName("goods_id") val goodsId: String,
@SerializedName("originalPrice") val originalPrice: Double,
@SerializedName("period") val period: String,
@SerializedName("price") val price: Double,
)
data class ValidateProductRequestBody(
@SerializedName("plan_id") val planId: String,
@SerializedName("product_id") val productId: String,
)
data class ValidateData(
@SerializedName("isValid") val isValid: Boolean,
@SerializedName("mapped") val mapped: Boolean,
@SerializedName("hasUnfinishedOrder") val hasUnfinishedOrder: Boolean,
)
data class VipPriceModel(
val title: String,
val proPrice: String,
val standardPrice: String,
val proDesc: String,
val standardDesc: String,
val id: String,
val proGoodsId: String?,
val standardGoodsId: String?,
) {
companion object {
const val MONTH_ID = "monthly"
const val YEAR_ID = "yearly"
}
}
data class VipPageDataModel(
val title: String,
val proHave: Boolean?,
val proDesc: String,
val standardHave: Boolean?,
val standardDesc: String,
val freeHave: Boolean?,
val freeDesc: String,
val order: Int,
)
object VipModelMapper {
fun generatePageDataList(members: List<Member>): List<VipPageDataModel> {
if (members.size < 3) return emptyList()
val free = members[0]
val standard = members[1]
val pro = members[2]
val names = (members.flatMap { it.benefits.map { b -> b.name } }).toSet().sorted()
val list = names.map { name ->
val freeB = free.benefits.firstOrNull { it.name == name }
val stdB = standard.benefits.firstOrNull { it.name == name }
val proB = pro.benefits.firstOrNull { it.name == name }
val order = proB?.order ?: stdB?.order ?: freeB?.order ?: 0
VipPageDataModel(
title = name,
proHave = proB?.value?.asBooleanOrNull(),
proDesc = proB?.value?.asStringOrEmpty() ?: "",
standardHave = stdB?.value?.asBooleanOrNull(),
standardDesc = stdB?.value?.asStringOrEmpty() ?: "",
freeHave = freeB?.value?.asBooleanOrNull(),
freeDesc = freeB?.value?.asStringOrEmpty() ?: "",
order = order,
)
}
return list.sortedBy { it.order }
}
fun generatePriceDataList(members: List<Member>): List<VipPriceModel> {
if (members.size < 3) return emptyList()
val standard = members[1]
val pro = members[2]
val list = mutableListOf<VipPriceModel>()
// 首月(示例:如果后端 period = "first_month"
val stdFirst = standard.goods.firstOrNull { it.period == "first_month" }
val proFirst = pro.goods.firstOrNull { it.period == "first_month" }
if (stdFirst != null && proFirst != null) {
list.add(
VipPriceModel(
title = "首月",
proPrice = proFirst.price.toInt().toString(),
standardPrice = stdFirst.price.toInt().toString(),
proDesc = proFirst.originalPrice.takeIf { it > 0 }?.toInt()?.toString() ?: "",
standardDesc = stdFirst.originalPrice.takeIf { it > 0 }?.toInt()?.toString() ?: "",
id = "first_month",
proGoodsId = proFirst.goodsId,
standardGoodsId = stdFirst.goodsId,
)
)
}
val stdMonth = standard.goods.firstOrNull { it.period == "month" }
val proMonth = pro.goods.firstOrNull { it.period == "month" }
if (stdMonth != null && proMonth != null) {
list.add(
VipPriceModel(
title = "月付",
proPrice = proMonth.price.toInt().toString(),
standardPrice = stdMonth.price.toInt().toString(),
proDesc = proMonth.originalPrice.takeIf { it > 0 }?.toInt()?.toString() ?: "",
standardDesc = stdMonth.originalPrice.takeIf { it > 0 }?.toInt()?.toString() ?: "",
id = VipPriceModel.MONTH_ID,
proGoodsId = proMonth.goodsId,
standardGoodsId = stdMonth.goodsId,
)
)
}
// 半年
val stdHalf = standard.goods.firstOrNull { it.period == "half_year" }
val proHalf = pro.goods.firstOrNull { it.period == "half_year" }
if (stdHalf != null && proHalf != null) {
list.add(
VipPriceModel(
title = "半年",
proPrice = proHalf.price.toInt().toString(),
standardPrice = stdHalf.price.toInt().toString(),
proDesc = proHalf.originalPrice.takeIf { it > 0 }?.toInt()?.toString() ?: "",
standardDesc = stdHalf.originalPrice.takeIf { it > 0 }?.toInt()?.toString() ?: "",
id = "half_year",
proGoodsId = proHalf.goodsId,
standardGoodsId = stdHalf.goodsId,
)
)
}
val stdYear = standard.goods.firstOrNull { it.period == "year" }
val proYear = pro.goods.firstOrNull { it.period == "year" }
if (stdYear != null && proYear != null) {
list.add(
VipPriceModel(
title = "每年",
proPrice = proYear.price.toInt().toString(),
standardPrice = stdYear.price.toInt().toString(),
proDesc = proYear.originalPrice.takeIf { it > 0 }?.toInt()?.toString() ?: "",
standardDesc = stdYear.originalPrice.takeIf { it > 0 }?.toInt()?.toString() ?: "",
id = VipPriceModel.YEAR_ID,
proGoodsId = proYear.goodsId,
standardGoodsId = stdYear.goodsId,
)
)
}
return list
}
}
private fun JsonElement.asStringOrEmpty(): String {
return try { if (isJsonPrimitive && asJsonPrimitive.isString) asString else "" } catch (_: Exception) { "" }
}
private fun JsonElement.asBooleanOrNull(): Boolean? {
return try { if (isJsonPrimitive && asJsonPrimitive.isBoolean) asBoolean else null } catch (_: Exception) { null }
}

View File

@@ -65,6 +65,7 @@ import com.aiosman.ravenow.ui.post.NewPostImageGridScreen
import com.aiosman.ravenow.ui.post.NewPostScreen import com.aiosman.ravenow.ui.post.NewPostScreen
import com.aiosman.ravenow.ui.post.PostScreen import com.aiosman.ravenow.ui.post.PostScreen
import com.aiosman.ravenow.ui.profile.AccountProfileV2 import com.aiosman.ravenow.ui.profile.AccountProfileV2
import com.aiosman.ravenow.ui.index.tabs.profile.vip.VipSelPage
sealed class NavigationRoute( sealed class NavigationRoute(
val route: String, val route: String,
@@ -107,6 +108,7 @@ sealed class NavigationRoute(
data object AddAgent : NavigationRoute("AddAgent") data object AddAgent : NavigationRoute("AddAgent")
data object CreateGroupChat : NavigationRoute("CreateGroupChat") data object CreateGroupChat : NavigationRoute("CreateGroupChat")
data object GroupInfo : NavigationRoute("GroupInfo/{id}") data object GroupInfo : NavigationRoute("GroupInfo/{id}")
data object VipSelPage : NavigationRoute("VipSelPage")
data object RemoveAccountScreen: NavigationRoute("RemoveAccount") data object RemoveAccountScreen: NavigationRoute("RemoveAccount")
} }
@@ -343,6 +345,9 @@ fun NavigationController(
composable(route = NavigationRoute.RemoveAccountScreen.route) { composable(route = NavigationRoute.RemoveAccountScreen.route) {
RemoveAccountScreen() RemoveAccountScreen()
} }
composable(route = NavigationRoute.VipSelPage.route) {
VipSelPage()
}
composable(route = NavigationRoute.FavouritesScreen.route) { composable(route = NavigationRoute.FavouritesScreen.route) {
FavouriteNoticeScreen() FavouriteNoticeScreen()
} }

View File

@@ -338,11 +338,16 @@ fun ProfileV3(
modifier = Modifier.padding(horizontal = 16.dp) modifier = Modifier.padding(horizontal = 16.dp)
) { ) {
if (isSelf) { if (isSelf) {
SelfProfileAction { SelfProfileAction(
onEditProfile = {
navController.navigate( navController.navigate(
NavigationRoute.AccountEdit.route NavigationRoute.AccountEdit.route
) )
},
onPremiumClick = {
navController.navigate(NavigationRoute.VipSelPage.route)
} }
)
} else { } else {
if (it.id != AppState.UserId) { if (it.id != AppState.UserId) {
OtherProfileAction( OtherProfileAction(

View File

@@ -1,16 +1,22 @@
package com.aiosman.ravenow.ui.index.tabs.profile.composable package com.aiosman.ravenow.ui.index.tabs.profile.composable
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -21,18 +27,24 @@ import com.aiosman.ravenow.ui.modifiers.noRippleClickable
@Composable @Composable
fun SelfProfileAction( fun SelfProfileAction(
onEditProfile: () -> Unit onEditProfile: () -> Unit,
onPremiumClick: (() -> Unit)? = null
) { ) {
val AppColors = LocalAppTheme.current val AppColors = LocalAppTheme.current
// 编辑个人资料按钮 - 参考私信按钮样式 Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth()
) {
// 编辑个人资料按钮(左侧)
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .weight(1f)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(AppColors.nonActive) // 使用主题灰色背景 .background(AppColors.nonActive)
.padding(horizontal = 16.dp, vertical = 12.dp) .padding(horizontal = 16.dp, vertical = 12.dp)
.noRippleClickable { .noRippleClickable {
onEditProfile() onEditProfile()
@@ -42,7 +54,36 @@ fun SelfProfileAction(
text = stringResource(R.string.edit_profile), text = stringResource(R.string.edit_profile),
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.W600, fontWeight = FontWeight.W600,
color = AppColors.text, // 使用主题文字颜色 color = AppColors.text,
)
}
// Rave Premium 按钮(右侧)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(8.dp))
.background(AppColors.premiumBackground)
.padding(horizontal = 16.dp, vertical = 12.dp)
.noRippleClickable {
onPremiumClick?.invoke()
}
) {
Image(
painter = painterResource(id = R.drawable.ic_member),
contentDescription = "",
modifier = Modifier.size(18.dp),
colorFilter = ColorFilter.tint(AppColors.premiumText)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Rave Premium",
fontSize = 14.sp,
fontWeight = FontWeight.W600,
color = AppColors.premiumText,
) )
} }
} }
}

View File

@@ -0,0 +1,289 @@
package com.aiosman.ravenow.ui.index.tabs.profile.vip
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.data.membership.MembershipConfigData
import com.aiosman.ravenow.data.membership.VipModelMapper
import com.aiosman.ravenow.data.membership.VipPageDataModel
import com.aiosman.ravenow.data.membership.VipPriceModel
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
import com.aiosman.ravenow.R
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.SpanStyle
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
@Composable
fun VipSelPage() {
val AppColors = LocalAppTheme.current
val navController = LocalNavController.current
var isLoading by remember { mutableStateOf(true) }
var errorMessage by remember { mutableStateOf<String?>(null) }
var config by remember { mutableStateOf<MembershipConfigData?>(null) }
var selectedTabIndex by remember { mutableStateOf(0) } // 0 标准版, 1 专业版
var selPrice by remember { mutableStateOf<VipPriceModel?>(null) }
val prices: List<VipPriceModel> = remember(config) {
config?.let { VipModelMapper.generatePriceDataList(it.configData.members) } ?: emptyList()
}
val pageDataList: List<VipPageDataModel> = remember(config) {
config?.let { VipModelMapper.generatePageDataList(it.configData.members) } ?: emptyList()
}
LaunchedEffect(Unit) {
try {
val resp = ApiClient.api.getMembershipConfig()
val body = resp.body()
if (resp.isSuccessful && body != null) {
config = body.data
selPrice = VipModelMapper.generatePriceDataList(body.data.configData.members).firstOrNull()
isLoading = false
} else {
errorMessage = "加载失败"
isLoading = false
}
} catch (e: Exception) {
errorMessage = e.message
isLoading = false
}
}
Column(modifier = Modifier.fillMaxSize().background(AppColors.profileBackground)) {
StatusBarSpacer()
// 顶部栏(居中标题 + 返回)
Box(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) {
Image(
painter = painterResource(id = R.drawable.rider_pro_back_icon),
contentDescription = "back",
colorFilter = ColorFilter.tint(AppColors.text),
modifier = Modifier.align(Alignment.CenterStart).noRippleClickable {
navController.navigateUp()
}
)
Text(
text = "Rave Premium",
fontSize = 18.sp,
fontWeight = FontWeight.W700,
color = AppColors.text,
modifier = Modifier.align(Alignment.Center)
)
}
when {
isLoading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
errorMessage != null -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = errorMessage ?: "错误", color = AppColors.error)
}
}
config != null -> {
Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
// 顶部分段选择 Premium / Standard
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Segment(text = "Premium", selected = selectedTabIndex == 1) { selectedTabIndex = 1 }
Segment(text = "Standard", selected = selectedTabIndex == 0) { selectedTabIndex = 0 }
}
// 三列价格卡
val displayedPrices = remember(prices, selectedTabIndex) {
prices.filter { it.id == VipPriceModel.MONTH_ID || it.id == VipPriceModel.YEAR_ID }
}
LaunchedEffect(selectedTabIndex, prices) {
selPrice = displayedPrices.firstOrNull()
}
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
displayedPrices.forEach { model ->
PriceCard(
model = model,
isPremium = selectedTabIndex == 1,
selected = selPrice?.id == model.id,
onClick = { selPrice = model }
)
}
Spacer(modifier = Modifier.weight(1f))
}
// 权益表格(带圆角背景)
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(16.dp))
.background(AppColors.priceCardUnselectedBackground)
.border(
width = 1.dp,
color = AppColors.priceCardUnselectedBorder,
shape = RoundedCornerShape(16.dp)
)
) {
// 权益表头
BenefitTableHeader(modifier = Modifier.padding(horizontal = 22.dp, vertical = 16.dp))
// 权益内容
LazyColumn(modifier = Modifier.weight(1f)) {
items(pageDataList, key = { it.title }) { item ->
BenefitRow(item, selectedTabIndex, modifier = Modifier.padding(horizontal = 22.dp))
}
item {
Spacer(modifier = Modifier.height(16.dp))
}
}
}
// 底部订阅按钮(大按钮,浅黄色背景)
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 20.dp)) {
Button(
onClick = {
val goodsId = if (selectedTabIndex == 1) selPrice?.proGoodsId else selPrice?.standardGoodsId
// TODO: 接入 Billing 与 validate
},
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp)),
colors = ButtonDefaults.buttonColors(containerColor = AppColors.premiumBackground, contentColor = AppColors.premiumText)
) {
Text(text = "订阅", fontSize = 16.sp, fontWeight = FontWeight.W700)
}
}
}
}
}
}
}
@Composable
private fun Segment(text: String, selected: Boolean, onClick: () -> Unit) {
val AppColors = LocalAppTheme.current
Box(
modifier = Modifier.clip(RoundedCornerShape(14.dp))
.background(if (selected) AppColors.text else AppColors.nonActive)
.padding(horizontal = 12.dp, vertical = 6.dp)
.noRippleClickable { onClick() }
) {
Text(text = text, color = if (selected) AppColors.background else AppColors.text, fontSize = 13.sp, fontWeight = FontWeight.W700)
}
}
@Composable
private fun PriceCard(model: VipPriceModel, isPremium: Boolean, selected: Boolean, onClick: () -> Unit) {
val AppColors = LocalAppTheme.current
Column(
modifier = Modifier
.width(100.dp)
.clip(RoundedCornerShape(12.dp))
.background(if (selected) AppColors.priceCardSelectedBackground else AppColors.priceCardUnselectedBackground)
.border(
width = 1.dp,
color = if (selected) AppColors.priceCardSelectedBorder else AppColors.priceCardUnselectedBorder,
shape = RoundedCornerShape(12.dp)
)
.padding(vertical = 16.dp, horizontal = 12.dp)
.noRippleClickable { onClick() }
) {
Text(modifier = Modifier.fillMaxWidth(),text = model.title, fontSize = 13.sp, fontWeight = FontWeight.W600, color = AppColors.text, textAlign = TextAlign.Center)
Spacer(modifier = Modifier.height(16.dp))
val price = if (isPremium) model.proPrice else model.standardPrice
val origin = if (isPremium) model.proDesc else model.standardDesc
Text(
modifier = Modifier.fillMaxWidth(),
text = buildAnnotatedString {
withStyle(style = SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.W600)) {
append("US$")
}
withStyle(style = SpanStyle(fontSize = 18.sp, fontWeight = FontWeight.W800)) {
append(price)
}
},
color = AppColors.text,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
if (origin.isNotEmpty()) {
Text(
text = origin,
fontSize = 11.sp,
color = AppColors.nonActiveText,
textDecoration = TextDecoration.LineThrough,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
private fun BenefitTableHeader(modifier: Modifier = Modifier) {
val AppColors = LocalAppTheme.current
Row(modifier = modifier.fillMaxWidth()) {
Text(text = "What you get", modifier = Modifier.weight(1f), fontSize = 13.sp, color = AppColors.secondaryText, fontWeight = FontWeight.W700)
Text(text = "Premium", modifier = Modifier.width(80.dp), fontSize = 13.sp, color = AppColors.secondaryText, fontWeight = FontWeight.W700)
Text(text = "Standard", modifier = Modifier.width(80.dp), fontSize = 13.sp, color = AppColors.secondaryText, fontWeight = FontWeight.W700)
}
}
@Composable
private fun BenefitRow(item: VipPageDataModel, selectedTabIndex: Int, modifier: Modifier = Modifier) {
val AppColors = LocalAppTheme.current
Row(modifier = modifier.fillMaxWidth().padding(vertical = 14.dp)) {
Text(text = item.title, modifier = Modifier.weight(1f), fontSize = 14.sp, color = AppColors.text)
// Premium 列
Text(
text = item.proDesc.ifEmpty { if (item.proHave == true) "2X" else "" },
color = if (item.proHave == true || item.proDesc.isNotEmpty()) AppColors.vipHave else AppColors.nonActiveText,
fontSize = 13.sp,
modifier = Modifier.width(80.dp)
)
// Standard 列
Text(
text = item.standardDesc.ifEmpty { if (item.standardHave == true) "2X" else "" },
color = if (item.standardHave == true || item.standardDesc.isNotEmpty()) AppColors.nonActiveText else AppColors.nonActiveText,
fontSize = 13.sp,
modifier = Modifier.width(80.dp)
)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB