refactor: 优化支付相关UI和升级套餐屏幕

- 优化PlanCard、QuotaIndicator、PlanDetailsCard
- 重构UpgradePlanScreen升级套餐页面
- 扩展PaymentViewModel、ViewModelFactory

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
iammm0
2026-02-10 14:24:01 +08:00
parent 0af0074e23
commit 4f7a4c3ad4
6 changed files with 876 additions and 123 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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