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 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),
|
||||||
|
|
||||||
)
|
)
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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.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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
@@ -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