refactor: 优化支付相关UI和升级套餐屏幕
- 优化PlanCard、QuotaIndicator、PlanDetailsCard - 重构UpgradePlanScreen升级套餐页面 - 扩展PaymentViewModel、ViewModelFactory Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,7 +17,7 @@ import com.huaga.life_echo.ui.theme.LightPurple
|
||||
import com.huaga.life_echo.utils.PaymentUtils
|
||||
|
||||
/**
|
||||
* 套餐卡片组件(显示权益)
|
||||
* 套餐卡片组件(显示权益与额度)
|
||||
*/
|
||||
@Composable
|
||||
fun PlanCard(
|
||||
@@ -40,6 +40,7 @@ fun PlanCard(
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp)
|
||||
) {
|
||||
// 标题行
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -60,32 +61,38 @@ fun PlanCard(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
|
||||
// 价格
|
||||
Text(
|
||||
text = "${PaymentUtils.formatPrice(plan.price, plan.currency)}${PaymentUtils.formatBillingCycle(plan.billingCycle)}",
|
||||
fontSize = 18.sp,
|
||||
text = if (plan.price == 0.0) "免费" else "${PaymentUtils.formatPrice(plan.price, plan.currency)}",
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = LightPurple
|
||||
)
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 套餐权益列表
|
||||
PlanBenefitsList(
|
||||
benefits = plan.benefits,
|
||||
features = plan.features
|
||||
)
|
||||
|
||||
if (!plan.isCurrentPlan) {
|
||||
|
||||
// 额度信息(核心展示)
|
||||
PlanQuotaInfo(plan)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// 功能特性列表
|
||||
plan.features.forEach { feature ->
|
||||
BenefitItem(feature)
|
||||
}
|
||||
|
||||
if (!plan.isCurrentPlan && plan.price > 0) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = LightPurple
|
||||
)
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
) {
|
||||
Text("立即升级", color = Color.White)
|
||||
}
|
||||
@@ -95,31 +102,72 @@ fun PlanCard(
|
||||
}
|
||||
|
||||
/**
|
||||
* 套餐权益列表组件
|
||||
* 套餐额度信息展示(优先使用接口返回的 maxConversations / maxChapters)
|
||||
*/
|
||||
@Composable
|
||||
fun PlanBenefitsList(
|
||||
benefits: List<com.huaga.life_echo.network.models.PlanBenefitDto>,
|
||||
features: List<String>,
|
||||
private fun PlanQuotaInfo(plan: PlanDto) {
|
||||
val conversationQuota = plan.maxConversations?.let { max ->
|
||||
"$max 轮"
|
||||
} ?: "无限制"
|
||||
val chapterQuota = when (val max = plan.maxChapters) {
|
||||
null -> "不限"
|
||||
1 -> "1 个章节"
|
||||
else -> "$max 个章节"
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// 对话额度
|
||||
QuotaBadge(
|
||||
label = "对话轮数",
|
||||
value = conversationQuota,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
// 章节额度
|
||||
QuotaBadge(
|
||||
label = "章节整理",
|
||||
value = chapterQuota,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 额度数字标签
|
||||
*/
|
||||
@Composable
|
||||
private fun QuotaBadge(
|
||||
label: String,
|
||||
value: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
// 显示权益详情
|
||||
benefits.forEach { benefit ->
|
||||
if (benefit.maxWords != null) {
|
||||
BenefitItem("可生成字数: ${benefit.maxWords}字")
|
||||
}
|
||||
if (benefit.maxChapters != null) {
|
||||
BenefitItem("可生成章节: ${benefit.maxChapters}章")
|
||||
}
|
||||
if (benefit.maxConversations != null) {
|
||||
BenefitItem("每月对话次数: ${benefit.maxConversations}次")
|
||||
}
|
||||
}
|
||||
|
||||
// 显示功能列表
|
||||
features.forEach { feature ->
|
||||
BenefitItem(feature)
|
||||
Card(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = LightPurple.copy(alpha = 0.08f)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = value,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = LightPurple
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,18 +175,18 @@ fun PlanBenefitsList(
|
||||
@Composable
|
||||
private fun BenefitItem(text: String) {
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
modifier = Modifier.padding(vertical = 3.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "• ",
|
||||
fontSize = 16.sp,
|
||||
text = "✓ ",
|
||||
fontSize = 14.sp,
|
||||
color = LightPurple,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 14.sp,
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -44,20 +45,49 @@ fun QuotaIndicator(
|
||||
) {
|
||||
if (quota.hasQuota) {
|
||||
Text(
|
||||
text = "剩余额度",
|
||||
text = "当前额度",
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
quota.remainingWords?.let {
|
||||
QuotaItem("字数", it)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// 对话轮数
|
||||
val usedConv = quota.usedConversations
|
||||
val maxConv = quota.maxConversations
|
||||
if (usedConv != null && maxConv != null) {
|
||||
QuotaProgressItem(
|
||||
label = "对话轮数",
|
||||
used = usedConv,
|
||||
max = maxConv
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
} else if (quota.remainingConversations != null) {
|
||||
QuotaValueItem("剩余对话轮数", quota.remainingConversations!!)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
quota.remainingChapters?.let {
|
||||
QuotaItem("章节", it)
|
||||
|
||||
// 章节
|
||||
val usedCh = quota.usedChapters
|
||||
val maxCh = quota.maxChapters
|
||||
if (usedCh != null && maxCh != null) {
|
||||
QuotaProgressItem(
|
||||
label = "章节整理",
|
||||
used = usedCh,
|
||||
max = maxCh
|
||||
)
|
||||
} else if (quota.remainingChapters != null) {
|
||||
QuotaValueItem("剩余章节", quota.remainingChapters!!)
|
||||
}
|
||||
quota.remainingConversations?.let {
|
||||
QuotaItem("对话次数", it)
|
||||
|
||||
// 无限制提示
|
||||
if (maxConv == null && maxCh == null) {
|
||||
Text(
|
||||
text = quota.message ?: "当前套餐无使用限制",
|
||||
fontSize = 14.sp,
|
||||
color = LightPurple,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// 超额提示
|
||||
@@ -81,10 +111,54 @@ fun QuotaIndicator(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 带进度条的额度项(已用 X / 共 Y)
|
||||
*/
|
||||
@Composable
|
||||
private fun QuotaItem(label: String, value: Int) {
|
||||
private fun QuotaProgressItem(label: String, used: Int, max: Int) {
|
||||
val progress = if (max > 0) (used.toFloat() / max).coerceIn(0f, 1f) else 0f
|
||||
val isNearLimit = progress >= 0.9f
|
||||
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "$used / $max",
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (isNearLimit) MaterialTheme.colorScheme.error else LightPurple
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { progress },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(6.dp)
|
||||
.clip(RoundedCornerShape(3.dp)),
|
||||
color = if (isNearLimit) MaterialTheme.colorScheme.error else LightPurple,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯数值额度项(无上限场景)
|
||||
*/
|
||||
@Composable
|
||||
private fun QuotaValueItem(label: String, value: Int) {
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
|
||||
@@ -42,7 +42,7 @@ fun PlanDetailsCard(
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "${PaymentUtils.formatPrice(plan.price, plan.currency)}${PaymentUtils.formatBillingCycle(plan.billingCycle)}",
|
||||
text = "${PaymentUtils.formatPrice(plan.price, plan.currency)}${PaymentUtils.formatBillingCycle(plan.billingCycle ?: "")}",
|
||||
fontSize = 16.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.huaga.life_echo.ui.screens
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -11,13 +13,17 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.huaga.life_echo.config.AppConfig
|
||||
import com.huaga.life_echo.ui.components.common.LoadingIndicator
|
||||
import com.huaga.life_echo.ui.components.payment.*
|
||||
import com.huaga.life_echo.ui.icons.AppIcons
|
||||
import com.huaga.life_echo.ui.theme.LightPurple
|
||||
import com.huaga.life_echo.ui.viewmodel.PaymentState
|
||||
import com.huaga.life_echo.ui.viewmodel.PaymentViewModel
|
||||
import com.huaga.life_echo.ui.viewmodel.ViewModelFactory
|
||||
|
||||
@@ -34,9 +40,35 @@ fun UpgradePlanScreen(
|
||||
val quota by viewModel.quota.collectAsState()
|
||||
val isLoading by viewModel.isLoading.collectAsState()
|
||||
val error by viewModel.error.collectAsState()
|
||||
var showPaywall by remember { mutableStateOf(false) }
|
||||
val paymentState by viewModel.paymentState.collectAsState()
|
||||
val testSubscriptionMessage by viewModel.testSubscriptionMessage.collectAsState()
|
||||
val testSubscriptionLoading by viewModel.testSubscriptionLoading.collectAsState()
|
||||
|
||||
var showPaymentMethodDialog by remember { mutableStateOf(false) }
|
||||
var selectedPlanId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
|
||||
LaunchedEffect(testSubscriptionMessage) {
|
||||
testSubscriptionMessage?.let { msg ->
|
||||
if (msg.isNotEmpty()) {
|
||||
kotlinx.coroutines.delay(3000)
|
||||
viewModel.clearTestSubscriptionMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val activity = context as? Activity
|
||||
|
||||
// 监听支付状态变化
|
||||
LaunchedEffect(paymentState) {
|
||||
when (paymentState) {
|
||||
is PaymentState.Success -> {
|
||||
// 支付成功后延迟关闭对话框
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -77,70 +109,396 @@ fun UpgradePlanScreen(
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// 订阅选项标题
|
||||
item {
|
||||
Text(
|
||||
text = "订阅选项",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// 测试通道(仅调试模式):免费 / Pro / Pro+ 三档,点击即切换
|
||||
if (AppConfig.isDebugMode) {
|
||||
item {
|
||||
TestSubscriptionSection(
|
||||
currentPlanId = currentPlan?.id,
|
||||
isLoading = testSubscriptionLoading,
|
||||
message = testSubscriptionMessage,
|
||||
onSelectPlan = { planId -> viewModel.setTestPlan(planId) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 额度指示器
|
||||
quota?.let {
|
||||
item {
|
||||
QuotaIndicator(quota = it)
|
||||
}
|
||||
}
|
||||
|
||||
// 套餐列表
|
||||
items(
|
||||
items = plans,
|
||||
key = { plan -> plan.id }
|
||||
) { plan ->
|
||||
val planWithCurrentFlag = com.huaga.life_echo.network.models.PlanDto(
|
||||
id = plan.id,
|
||||
name = plan.name,
|
||||
displayName = plan.displayName,
|
||||
price = plan.price,
|
||||
currency = plan.currency,
|
||||
billingCycle = plan.billingCycle,
|
||||
benefits = plan.benefits,
|
||||
features = plan.features,
|
||||
isActive = plan.isActive,
|
||||
isCurrentPlan = currentPlan?.id == plan.id
|
||||
)
|
||||
PlanCard(
|
||||
plan = planWithCurrentFlag,
|
||||
onClick = {
|
||||
if (plan.id != currentPlan?.id) {
|
||||
selectedPlanId = plan.id
|
||||
viewModel.createOrder(
|
||||
planId = plan.id,
|
||||
onSuccess = { order ->
|
||||
// 支付成功处理
|
||||
showPaywall = false
|
||||
// TODO: 处理支付流程
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
// 支付失败处理
|
||||
showPaywall = true
|
||||
}
|
||||
)
|
||||
|
||||
// 套餐订阅选项列表
|
||||
if (plans.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "暂无套餐",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(
|
||||
items = plans,
|
||||
key = { plan -> plan.id }
|
||||
) { plan ->
|
||||
val planWithCurrentFlag = com.huaga.life_echo.network.models.PlanDto(
|
||||
id = plan.id,
|
||||
name = plan.name,
|
||||
displayName = plan.displayName,
|
||||
price = plan.price,
|
||||
currency = plan.currency,
|
||||
billingCycle = plan.billingCycle,
|
||||
benefits = plan.benefits,
|
||||
features = plan.features,
|
||||
isActive = plan.isActive,
|
||||
isCurrentPlan = currentPlan?.id == plan.id,
|
||||
maxConversations = plan.maxConversations,
|
||||
maxChapters = plan.maxChapters,
|
||||
isPopular = plan.isPopular
|
||||
)
|
||||
PlanCard(
|
||||
plan = planWithCurrentFlag,
|
||||
onClick = {
|
||||
if (plan.id != currentPlan?.id && plan.price > 0) {
|
||||
selectedPlanId = plan.id
|
||||
showPaymentMethodDialog = true
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 支付方式选择弹窗
|
||||
if (showPaymentMethodDialog && selectedPlanId != null) {
|
||||
PaymentMethodDialog(
|
||||
onSelectMethod = { method ->
|
||||
showPaymentMethodDialog = false
|
||||
if (activity != null) {
|
||||
viewModel.startPayment(activity, selectedPlanId!!, method)
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
showPaymentMethodDialog = false
|
||||
selectedPlanId = null
|
||||
},
|
||||
isWeChatInstalled = viewModel.isWeChatInstalled()
|
||||
)
|
||||
}
|
||||
|
||||
// 支付状态弹窗
|
||||
when (val state = paymentState) {
|
||||
is PaymentState.CreatingOrder -> {
|
||||
PaymentStatusDialog(
|
||||
title = "正在创建订单",
|
||||
message = "请稍候...",
|
||||
showLoading = true,
|
||||
onDismiss = { }
|
||||
)
|
||||
}
|
||||
is PaymentState.Paying -> {
|
||||
PaymentStatusDialog(
|
||||
title = "等待支付",
|
||||
message = "请在${if (state.method == "wechat") "微信" else "支付宝"}中完成支付",
|
||||
showLoading = true,
|
||||
onDismiss = { }
|
||||
)
|
||||
}
|
||||
is PaymentState.Success -> {
|
||||
PaymentStatusDialog(
|
||||
title = "支付成功",
|
||||
message = "已成功升级为${state.planName}",
|
||||
showLoading = false,
|
||||
isSuccess = true,
|
||||
onDismiss = {
|
||||
viewModel.resetPaymentState()
|
||||
}
|
||||
)
|
||||
}
|
||||
is PaymentState.Failed -> {
|
||||
PaymentStatusDialog(
|
||||
title = "支付失败",
|
||||
message = state.message,
|
||||
showLoading = false,
|
||||
isError = true,
|
||||
onDismiss = {
|
||||
viewModel.resetPaymentState()
|
||||
}
|
||||
)
|
||||
}
|
||||
is PaymentState.Cancelled -> {
|
||||
PaymentStatusDialog(
|
||||
title = "支付取消",
|
||||
message = "您已取消支付",
|
||||
showLoading = false,
|
||||
onDismiss = {
|
||||
viewModel.resetPaymentState()
|
||||
}
|
||||
)
|
||||
}
|
||||
PaymentState.Idle -> { /* 不显示 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 测试通道的套餐项:id、展示名 */
|
||||
private val TEST_PLAN_OPTIONS = listOf(
|
||||
"free" to "免费体验版",
|
||||
"pro" to "Pro 版",
|
||||
"pro_plus" to "Pro+ 版"
|
||||
)
|
||||
|
||||
/**
|
||||
* 测试订阅区块(仅调试模式显示)
|
||||
* 三个选项:免费 / Pro / Pro+,点击即切换到对应套餐。
|
||||
*/
|
||||
@Composable
|
||||
fun TestSubscriptionSection(
|
||||
currentPlanId: String?,
|
||||
isLoading: Boolean,
|
||||
message: String?,
|
||||
onSelectPlan: (planId: String) -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "测试通道",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "点击对应套餐即切换,用于体验各档额度",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
TEST_PLAN_OPTIONS.forEach { (planId, label) ->
|
||||
val isCurrent = currentPlanId == planId
|
||||
FilterChip(
|
||||
selected = isCurrent,
|
||||
onClick = { if (!isCurrent) onSelectPlan(planId) },
|
||||
enabled = !isLoading,
|
||||
label = { Text(text = label, fontSize = 13.sp) },
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = LightPurple,
|
||||
selectedLabelColor = Color.White
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
message?.let { msg ->
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = msg,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付方式选择弹窗
|
||||
*/
|
||||
@Composable
|
||||
fun PaymentMethodDialog(
|
||||
onSelectMethod: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
isWeChatInstalled: Boolean = true
|
||||
) {
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "选择支付方式",
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// 微信支付按钮
|
||||
Button(
|
||||
onClick = { onSelectMethod("wechat") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = isWeChatInstalled,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF07C160),
|
||||
contentColor = Color.White
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (isWeChatInstalled) "微信支付" else "微信支付(未安装微信)",
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// 支付宝按钮(开发中,点击后弹窗提示)
|
||||
Button(
|
||||
onClick = { onSelectMethod("alipay") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF1677FF),
|
||||
contentColor = Color.White
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "支付宝支付",
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.padding(vertical = 2.dp)
|
||||
)
|
||||
Text(
|
||||
text = "开发中,暂不可用",
|
||||
fontSize = 12.sp,
|
||||
color = Color.White.copy(alpha = 0.9f),
|
||||
modifier = Modifier.padding(bottom = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 取消按钮
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "取消",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 付费墙
|
||||
if (showPaywall) {
|
||||
Paywall(
|
||||
message = "需要升级套餐才能使用此功能",
|
||||
onUpgrade = {
|
||||
selectedPlanId?.let { planId ->
|
||||
viewModel.createOrder(
|
||||
planId = planId,
|
||||
onSuccess = { },
|
||||
onError = { }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付状态弹窗
|
||||
*/
|
||||
@Composable
|
||||
fun PaymentStatusDialog(
|
||||
title: String,
|
||||
message: String,
|
||||
showLoading: Boolean = false,
|
||||
isSuccess: Boolean = false,
|
||||
isError: Boolean = false,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
Dialog(onDismissRequest = {
|
||||
// 仅在非加载状态时可以关闭
|
||||
if (!showLoading) onDismiss()
|
||||
}) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (showLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(48.dp),
|
||||
color = LightPurple
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = when {
|
||||
isSuccess -> Color(0xFF07C160)
|
||||
isError -> MaterialTheme.colorScheme.error
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = message,
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
if (!showLoading) {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (isSuccess) Color(0xFF07C160) else LightPurple
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (isSuccess) "完成" else "确定",
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
onDismiss = { showPaywall = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +1,89 @@
|
||||
package com.huaga.life_echo.ui.viewmodel
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.huaga.life_echo.data.repository.PaymentRepository
|
||||
import com.huaga.life_echo.network.models.OrderDto
|
||||
import com.huaga.life_echo.network.models.PlanDto
|
||||
import com.huaga.life_echo.network.models.QuotaCheckDto
|
||||
import com.huaga.life_echo.network.models.WeChatPayParamsDto
|
||||
import com.huaga.life_echo.payment.PaymentManager
|
||||
import com.huaga.life_echo.payment.PaymentResult
|
||||
import com.huaga.life_echo.payment.WeChatPayParams
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 支付状态
|
||||
*/
|
||||
sealed class PaymentState {
|
||||
/** 空闲状态 */
|
||||
data object Idle : PaymentState()
|
||||
|
||||
/** 正在创建订单 */
|
||||
data object CreatingOrder : PaymentState()
|
||||
|
||||
/** 正在支付中(SDK 已调起) */
|
||||
data class Paying(val orderId: String, val method: String) : PaymentState()
|
||||
|
||||
/** 支付成功 */
|
||||
data class Success(val orderId: String, val planName: String) : PaymentState()
|
||||
|
||||
/** 支付失败 */
|
||||
data class Failed(val message: String) : PaymentState()
|
||||
|
||||
/** 用户取消 */
|
||||
data object Cancelled : PaymentState()
|
||||
}
|
||||
|
||||
class PaymentViewModel(
|
||||
private val paymentRepository: PaymentRepository
|
||||
private val paymentRepository: PaymentRepository,
|
||||
private val paymentManager: PaymentManager? = null
|
||||
) : ViewModel() {
|
||||
|
||||
|
||||
private val _plans = MutableStateFlow<List<PlanDto>>(emptyList())
|
||||
val plans: StateFlow<List<PlanDto>> = _plans
|
||||
|
||||
|
||||
private val _currentPlan = MutableStateFlow<PlanDto?>(null)
|
||||
val currentPlan: StateFlow<PlanDto?> = _currentPlan
|
||||
|
||||
|
||||
private val _quota = MutableStateFlow<QuotaCheckDto?>(null)
|
||||
val quota: StateFlow<QuotaCheckDto?> = _quota
|
||||
|
||||
|
||||
private val _orders = MutableStateFlow<List<OrderDto>>(emptyList())
|
||||
val orders: StateFlow<List<OrderDto>> = _orders
|
||||
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading
|
||||
|
||||
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _error
|
||||
|
||||
|
||||
/** 支付流程状态 */
|
||||
private val _paymentState = MutableStateFlow<PaymentState>(PaymentState.Idle)
|
||||
val paymentState: StateFlow<PaymentState> = _paymentState
|
||||
|
||||
/** 测试订阅操作结果提示(成功/失败文案,用于 Snackbar) */
|
||||
private val _testSubscriptionMessage = MutableStateFlow<String?>(null)
|
||||
val testSubscriptionMessage: StateFlow<String?> = _testSubscriptionMessage
|
||||
|
||||
/** 测试订阅请求中 */
|
||||
private val _testSubscriptionLoading = MutableStateFlow(false)
|
||||
val testSubscriptionLoading: StateFlow<Boolean> = _testSubscriptionLoading
|
||||
|
||||
private var pollJob: Job? = null
|
||||
|
||||
init {
|
||||
loadPlans()
|
||||
loadCurrentPlan()
|
||||
checkQuota()
|
||||
loadOrders()
|
||||
}
|
||||
|
||||
|
||||
fun loadPlans() {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
@@ -50,7 +95,7 @@ class PaymentViewModel(
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun loadCurrentPlan() {
|
||||
viewModelScope.launch {
|
||||
paymentRepository.getCurrentPlan().fold(
|
||||
@@ -59,7 +104,7 @@ class PaymentViewModel(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun checkQuota() {
|
||||
viewModelScope.launch {
|
||||
paymentRepository.checkQuota().fold(
|
||||
@@ -68,7 +113,16 @@ class PaymentViewModel(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun loadOrders() {
|
||||
viewModelScope.launch {
|
||||
paymentRepository.getOrders().fold(
|
||||
onSuccess = { _orders.value = it },
|
||||
onFailure = { }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createOrder(planId: String, onSuccess: (OrderDto) -> Unit, onError: (String) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
@@ -86,13 +140,222 @@ class PaymentViewModel(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadOrders() {
|
||||
|
||||
// ==================== 微信/支付宝支付流程 ====================
|
||||
|
||||
/**
|
||||
* 发起支付
|
||||
*
|
||||
* @param activity 当前 Activity
|
||||
* @param planId 套餐 ID
|
||||
* @param paymentMethod "wechat" 或 "alipay"
|
||||
*/
|
||||
fun startPayment(activity: Activity, planId: String, paymentMethod: String) {
|
||||
if (paymentManager == null) {
|
||||
_paymentState.value = PaymentState.Failed("支付模块未初始化")
|
||||
return
|
||||
}
|
||||
|
||||
// 支付宝支付接口正在开发中,直接提示不发起请求
|
||||
if (paymentMethod == "alipay") {
|
||||
_paymentState.value = PaymentState.Failed("支付宝支付接口正在开发中,暂时不可用")
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
paymentRepository.getOrders().fold(
|
||||
onSuccess = { _orders.value = it },
|
||||
onFailure = { }
|
||||
_paymentState.value = PaymentState.CreatingOrder
|
||||
|
||||
// 1. 创建支付订单
|
||||
paymentRepository.createPaymentOrder(planId, paymentMethod).fold(
|
||||
onSuccess = { response ->
|
||||
_paymentState.value = PaymentState.Paying(response.orderId, paymentMethod)
|
||||
|
||||
when (paymentMethod) {
|
||||
"wechat" -> {
|
||||
val params = response.wechatParams
|
||||
if (params != null) {
|
||||
invokeWeChatPay(params, response.orderId)
|
||||
} else {
|
||||
_paymentState.value =
|
||||
PaymentState.Failed("未获取到微信支付参数")
|
||||
}
|
||||
}
|
||||
"alipay" -> {
|
||||
val orderString = response.alipayOrderString
|
||||
if (!orderString.isNullOrEmpty()) {
|
||||
invokeAlipay(activity, orderString, response.orderId)
|
||||
} else {
|
||||
_paymentState.value =
|
||||
PaymentState.Failed("未获取到支付宝订单信息")
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
_paymentState.value =
|
||||
PaymentState.Failed("不支持的支付方式: $paymentMethod")
|
||||
}
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
_paymentState.value =
|
||||
PaymentState.Failed(error.message ?: "创建订单失败")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调起微信支付
|
||||
*/
|
||||
private fun invokeWeChatPay(params: WeChatPayParamsDto, orderId: String) {
|
||||
val wechatParams = WeChatPayParams(
|
||||
appId = params.appId,
|
||||
partnerId = params.partnerId,
|
||||
prepayId = params.prepayId,
|
||||
nonceStr = params.nonceStr,
|
||||
timeStamp = params.timeStamp,
|
||||
sign = params.sign,
|
||||
packageValue = params.packageValue,
|
||||
)
|
||||
|
||||
paymentManager?.startWeChatPay(wechatParams, orderId) { result ->
|
||||
handlePaymentResult(result, orderId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调起支付宝支付
|
||||
*/
|
||||
private fun invokeAlipay(activity: Activity, orderString: String, orderId: String) {
|
||||
viewModelScope.launch {
|
||||
val result = paymentManager?.startAlipay(activity, orderString, orderId)
|
||||
if (result != null) {
|
||||
handlePaymentResult(result, orderId)
|
||||
} else {
|
||||
_paymentState.value = PaymentState.Failed("支付宝支付调用失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理支付 SDK 返回的结果
|
||||
* 注意:最终支付状态以服务端回调为准,这里先给用户一个即时反馈
|
||||
*/
|
||||
private fun handlePaymentResult(result: PaymentResult, orderId: String) {
|
||||
when (result) {
|
||||
is PaymentResult.Success -> {
|
||||
// SDK 返回成功,开始轮询服务端确认
|
||||
pollOrderStatus(orderId)
|
||||
}
|
||||
is PaymentResult.Cancelled -> {
|
||||
_paymentState.value = PaymentState.Cancelled
|
||||
}
|
||||
is PaymentResult.Failure -> {
|
||||
_paymentState.value = PaymentState.Failed(result.errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询服务端订单状态(最多轮询 30 秒)
|
||||
*/
|
||||
private fun pollOrderStatus(orderId: String) {
|
||||
pollJob?.cancel()
|
||||
pollJob = viewModelScope.launch {
|
||||
val maxAttempts = 10
|
||||
val intervalMs = 3000L
|
||||
|
||||
for (i in 0 until maxAttempts) {
|
||||
delay(intervalMs)
|
||||
|
||||
paymentRepository.getPaymentOrderStatus(orderId).fold(
|
||||
onSuccess = { status ->
|
||||
when (status.status) {
|
||||
"paid" -> {
|
||||
_paymentState.value = PaymentState.Success(
|
||||
orderId = orderId,
|
||||
planName = status.planName
|
||||
)
|
||||
// 刷新当前套餐和额度
|
||||
loadCurrentPlan()
|
||||
checkQuota()
|
||||
loadOrders()
|
||||
return@launch
|
||||
}
|
||||
"failed", "cancelled" -> {
|
||||
_paymentState.value = PaymentState.Failed("支付未完成")
|
||||
return@launch
|
||||
}
|
||||
// "pending" 继续轮询
|
||||
}
|
||||
},
|
||||
onFailure = {
|
||||
// 查询失败,继续重试
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 轮询超时,视为待确认(后台回调可能延迟)
|
||||
_paymentState.value = PaymentState.Success(
|
||||
orderId = orderId,
|
||||
planName = "订阅套餐"
|
||||
)
|
||||
loadCurrentPlan()
|
||||
checkQuota()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置支付状态
|
||||
*/
|
||||
fun resetPaymentState() {
|
||||
pollJob?.cancel()
|
||||
_paymentState.value = PaymentState.Idle
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查微信是否已安装
|
||||
*/
|
||||
fun isWeChatInstalled(): Boolean {
|
||||
return paymentManager?.isWeChatInstalled() ?: false
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试通道:切换到指定套餐(仅当服务端开启 ENABLE_TEST_SUBSCRIPTION 时可用)
|
||||
* @param planId "free" | "pro" | "pro_plus"
|
||||
*/
|
||||
fun setTestPlan(planId: String) {
|
||||
viewModelScope.launch {
|
||||
_testSubscriptionLoading.value = true
|
||||
_testSubscriptionMessage.value = null
|
||||
val action = if (planId == "free") "deactivate" else "activate"
|
||||
val targetPlanId = if (planId == "free") "pro" else planId
|
||||
paymentRepository.setTestSubscription(action, targetPlanId).fold(
|
||||
onSuccess = { res ->
|
||||
_testSubscriptionMessage.value = res.message
|
||||
loadCurrentPlan()
|
||||
checkQuota()
|
||||
},
|
||||
onFailure = { e ->
|
||||
_testSubscriptionMessage.value = e.message ?: "测试订阅未开放或请求失败"
|
||||
}
|
||||
)
|
||||
_testSubscriptionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated 请使用 setTestPlan("pro") */
|
||||
fun activateTestSubscription(planId: String = "pro") = setTestPlan(planId)
|
||||
|
||||
/** @deprecated 请使用 setTestPlan("free") */
|
||||
fun deactivateTestSubscription() = setTestPlan("free")
|
||||
|
||||
fun clearTestSubscriptionMessage() {
|
||||
_testSubscriptionMessage.value = null
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
pollJob?.cancel()
|
||||
paymentManager?.release()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@ package com.huaga.life_echo.ui.viewmodel
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.huaga.life_echo.config.AppConfig
|
||||
import com.huaga.life_echo.data.auth.TokenManager
|
||||
import com.huaga.life_echo.data.database.AppDatabase
|
||||
import com.huaga.life_echo.data.repository.*
|
||||
import com.huaga.life_echo.network.ApiService
|
||||
import com.huaga.life_echo.network.AuthService
|
||||
import com.huaga.life_echo.payment.PaymentManager
|
||||
|
||||
class ViewModelFactory(private val context: Context) : ViewModelProvider.Factory {
|
||||
|
||||
@@ -51,6 +53,13 @@ class ViewModelFactory(private val context: Context) : ViewModelProvider.Factory
|
||||
ProfileRepository(apiService = apiService)
|
||||
}
|
||||
|
||||
private val paymentManager by lazy {
|
||||
PaymentManager(
|
||||
context = context.applicationContext,
|
||||
wechatAppId = AppConfig.WECHAT_APP_ID
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return when {
|
||||
@@ -78,7 +87,8 @@ class ViewModelFactory(private val context: Context) : ViewModelProvider.Factory
|
||||
}
|
||||
modelClass.isAssignableFrom(PaymentViewModel::class.java) -> {
|
||||
PaymentViewModel(
|
||||
paymentRepository = paymentRepository
|
||||
paymentRepository = paymentRepository,
|
||||
paymentManager = paymentManager
|
||||
) as T
|
||||
}
|
||||
modelClass.isAssignableFrom(ProfileViewModel::class.java) -> {
|
||||
|
||||
Reference in New Issue
Block a user