diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/payment/PlanCard.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/payment/PlanCard.kt index 628b166..8dd0223 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/payment/PlanCard.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/payment/PlanCard.kt @@ -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, - features: List, +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 ) } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/payment/QuotaIndicator.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/payment/QuotaIndicator.kt index 2608a7a..0209691 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/payment/QuotaIndicator.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/payment/QuotaIndicator.kt @@ -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 ) { diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/profile/PlanDetailsCard.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/profile/PlanDetailsCard.kt index f4c177c..e3be732 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/profile/PlanDetailsCard.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/profile/PlanDetailsCard.kt @@ -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 ) diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/UpgradePlanScreen.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/UpgradePlanScreen.kt index 8fe9ce8..08439bc 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/UpgradePlanScreen.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/UpgradePlanScreen.kt @@ -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(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 } - ) + } + } } } } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/PaymentViewModel.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/PaymentViewModel.kt index bec8363..0d5de2e 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/PaymentViewModel.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/PaymentViewModel.kt @@ -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>(emptyList()) val plans: StateFlow> = _plans - + private val _currentPlan = MutableStateFlow(null) val currentPlan: StateFlow = _currentPlan - + private val _quota = MutableStateFlow(null) val quota: StateFlow = _quota - + private val _orders = MutableStateFlow>(emptyList()) val orders: StateFlow> = _orders - + private val _isLoading = MutableStateFlow(false) val isLoading: StateFlow = _isLoading - + private val _error = MutableStateFlow(null) val error: StateFlow = _error - + + /** 支付流程状态 */ + private val _paymentState = MutableStateFlow(PaymentState.Idle) + val paymentState: StateFlow = _paymentState + + /** 测试订阅操作结果提示(成功/失败文案,用于 Snackbar) */ + private val _testSubscriptionMessage = MutableStateFlow(null) + val testSubscriptionMessage: StateFlow = _testSubscriptionMessage + + /** 测试订阅请求中 */ + private val _testSubscriptionLoading = MutableStateFlow(false) + val testSubscriptionLoading: StateFlow = _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() + } } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/ViewModelFactory.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/ViewModelFactory.kt index 949edb8..28748d3 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/ViewModelFactory.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/ViewModelFactory.kt @@ -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 create(modelClass: Class): 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) -> {