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:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user