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

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

View File

@@ -1,16 +1,22 @@
package com.aiosman.ravenow.ui.index.tabs.profile.composable
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
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.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -21,28 +27,63 @@ import com.aiosman.ravenow.ui.modifiers.noRippleClickable
@Composable
fun SelfProfileAction(
onEditProfile: () -> Unit
onEditProfile: () -> Unit,
onPremiumClick: (() -> Unit)? = null
) {
val AppColors = LocalAppTheme.current
// 编辑个人资料按钮 - 参考私信按钮样式
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(AppColors.nonActive) // 使用主题灰色背景
.padding(horizontal = 16.dp, vertical = 12.dp)
.noRippleClickable {
onEditProfile()
}
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.edit_profile),
fontSize = 14.sp,
fontWeight = FontWeight.W600,
color = AppColors.text, // 使用主题文字颜色
)
// 编辑个人资料按钮(左侧)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(8.dp))
.background(AppColors.nonActive)
.padding(horizontal = 16.dp, vertical = 12.dp)
.noRippleClickable {
onEditProfile()
}
) {
Text(
text = stringResource(R.string.edit_profile),
fontSize = 14.sp,
fontWeight = FontWeight.W600,
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)
)
}
}