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

@@ -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>

View File

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