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:
@@ -35,6 +35,16 @@ open class AppThemeData(
|
||||
var tabUnselectedText: Color,
|
||||
var bubbleBackground: 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(
|
||||
@@ -66,7 +76,14 @@ class LightThemeColors : AppThemeData(
|
||||
tabSelectedText = Color(0xffffffff),
|
||||
tabUnselectedText = Color(0xff000000),
|
||||
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(
|
||||
@@ -98,6 +115,13 @@ class DarkThemeColors : AppThemeData(
|
||||
tabSelectedText = Color(0xff000000),
|
||||
tabUnselectedText = Color(0xffffffff),
|
||||
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),
|
||||
|
||||
)
|
||||
@@ -12,6 +12,9 @@ import com.aiosman.ravenow.data.ListContainer
|
||||
import com.aiosman.ravenow.data.Moment
|
||||
import com.aiosman.ravenow.data.Room
|
||||
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 okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
@@ -256,6 +259,15 @@ data class RemoveAccountRequestBody(
|
||||
)
|
||||
|
||||
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")
|
||||
suspend fun register(@Body body: RegisterRequestBody): Response<Unit>
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ import com.aiosman.ravenow.ui.post.NewPostImageGridScreen
|
||||
import com.aiosman.ravenow.ui.post.NewPostScreen
|
||||
import com.aiosman.ravenow.ui.post.PostScreen
|
||||
import com.aiosman.ravenow.ui.profile.AccountProfileV2
|
||||
import com.aiosman.ravenow.ui.index.tabs.profile.vip.VipSelPage
|
||||
|
||||
sealed class NavigationRoute(
|
||||
val route: String,
|
||||
@@ -107,6 +108,7 @@ sealed class NavigationRoute(
|
||||
data object AddAgent : NavigationRoute("AddAgent")
|
||||
data object CreateGroupChat : NavigationRoute("CreateGroupChat")
|
||||
data object GroupInfo : NavigationRoute("GroupInfo/{id}")
|
||||
data object VipSelPage : NavigationRoute("VipSelPage")
|
||||
data object RemoveAccountScreen: NavigationRoute("RemoveAccount")
|
||||
}
|
||||
|
||||
@@ -343,6 +345,9 @@ fun NavigationController(
|
||||
composable(route = NavigationRoute.RemoveAccountScreen.route) {
|
||||
RemoveAccountScreen()
|
||||
}
|
||||
composable(route = NavigationRoute.VipSelPage.route) {
|
||||
VipSelPage()
|
||||
}
|
||||
composable(route = NavigationRoute.FavouritesScreen.route) {
|
||||
FavouriteNoticeScreen()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
BIN
app/src/main/res/drawable/ic_member.webp
Normal file
BIN
app/src/main/res/drawable/ic_member.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Reference in New Issue
Block a user