优化:支付回调

This commit is contained in:
ivan 2026-03-12 20:40:37 +08:00
parent 6ae2b55677
commit bd6f8ba813
7 changed files with 565 additions and 256 deletions

View File

@ -1,34 +1,109 @@
# 支付界面 # 谷歌支付流程
recharge_screen.dart
```
"extrapolate": "", // note (string) 备注
"helm": "", // code (string) 产品代码
"forge": 0, // bonus (int) 赠送积分
"guardian": "", // actualAmount (string) 实际金额
"lead": "", // discountOff (string) 折扣
"glossary": "", // title (string) 标题
"curriculum": "", // originAmount (string) 原价
"warrior": "", // activityId (string) 活动ID
"distribute": "", // subscriptionPeriod (string) 订阅周期
"greaves": 0, // credits (int) 积分数
"shield": "", // client (string) 客户端
"species": 0, // days (int) 天数
"familiar": "", // currency (string) 货币
"subtract": 0 // productType (int) 产品类型
```
其中helm 为google pay的商品ID
## 界面要展示
1.价格需显示$符号 字段guardian
2.原价需显示中划线 字段curriculum
3.赠送积分 字段forge
# 购买 本文档描述 Android 上 Google Play 内购的完整流程,与 `recharge_screen.dart``GooglePlayPurchaseService``PaymentApi` 实现对应。
从/v1/user/common_info接口里面取到 enable_third_party_payment字段如果这个字段为true则调用第三方支付
## 三方支付流程
获取商品列表/v1/payment/getGooglePayActivities ->
传activityId获取支付列表/v1/payment/get-payment-methods ->
创建订单的时候把选择的支付方式的字段填充,创建订单/v1/payment/createPayment
创建订单传app、userId、activityId、paymentMethod、subPaymentMethod(有的话),其他那些先不管
# 谷歌支付回调接口 ---
/v1/payment/googlepay
## 1. 流程总览
- **第三方支付开启**`enable_third_party_payment === true` 且已登录):先创建订单 → 调起谷歌支付 → 支付成功后回调 `/v1/payment/googlepay`
- **第三方支付关闭或未登录**:仅 Android 直接调起谷歌支付,不创建订单、不回调 googlepay。
```
用户点击 Buy某商品
├─ 第三方支付开 + 已登录
│ ├─ getPaymentMethods(activityId)
│ ├─ 弹窗选择支付方式
│ ├─ 若选「Google Pay」
│ │ ├─ POST /v1/payment/createPayment → 得到 federation订单 id
│ │ ├─ 调起 Google Play 内购productId = 商品 code/helm
│ │ └─ 支付成功后 POST /v1/payment/googlepay见下文
│ └─ 若选其他方式 → 打开 createPayment 返回的 payUrl
└─ 第三方支付关或未登录
└─ 仅 Android直接调起 Google Play 内购productId = 商品 code无 createPayment、无 googlepay 回调
```
---
## 2. 创建订单(仅第三方 + 选 Google Pay 时)
| 项目 | 说明 |
|------|------|
| 接口 | `POST /v1/payment/createPayment` |
| 入参 | sentinel, asset(userId), warrior(activityId), resource, ceremony选 Google Pay 时 resource/ceremony 含 "GooglePay" |
| 关键响应 | **federation**:订单 id后续 googlepay 回调必传;**convert**:其他支付方式的 payUrl选 Google Pay 时不使用 |
- 当返回的 **federation订单 id不为空**时,继续调起谷歌支付并可在成功后回调 googlepay。
- 当返回的 federation 为空时:可按业务要求重试创建订单(如最多 3 次),仍失败则提示失败。
---
## 3. 调起谷歌支付
| 项目 | 说明 |
|------|------|
| 代码 | `GooglePlayPurchaseService.launchPurchaseAndReturnData(productId)`(所有内购统一使用) |
| productId | 当前商品的 **code**(即接口里的 **helm**),须与 Google Play 后台「产品 ID」完全一致 |
| 成功结果 | 返回的凭据用于构造 googlepay 回调 body见下节 |
**Android** 执行;非 Android 提示 "Google Pay is only available on Android"。
---
## 4. 支付成功后回调:`POST /v1/payment/googlepay`
仅在**第三方支付开 + 选 Google Pay + 已先创建订单**时调用。请求体为 JSON服务端用于校验并落单。
### 4.1 请求体字段body
请求体为四个顶层字段语义与取值来源对应关系如下id / purchaseData / signature / userId 分别对应 federation / merchant / sample / asset
| 请求体字段 | 含义(填入的值) | 客户端取值来源 |
|------------|------------------|----------------|
| **sample** | 签名signature | 谷歌支付成功后,`GooglePlayPurchaseDetails.billingClientPurchase`**signature** |
| **merchant** | 购买凭据 JSONpurchaseData | 同上 **billingClientPurchase****originalJson** |
| **federation** | 支付/订单 idid | 创建订单接口 `createPayment` 返回的 **federation** |
| **asset** | 用户 iduserId | 当前登录用户 id与 createPayment 的 asset 一致) |
### 4.2 示例 body 结构
```json
{
"sample": "YbOntv0sVOsZ5d4F8hIYdPNSMy9a4+5oAsV/...",
"merchant": "{\"orderId\":\"GPA.3327-0087-2324-9960\",\"packageName\":\"com.xxx.xxxx\",\"productId\":\"com.xxx.xxxx599\",\"purchaseTime\":1773305500428,\"purchaseState\":0,\"purchaseToken\":\"...\",\"quantity\":1,\"acknowledged\":false}",
"federation": "1315538320560683421235",
"asset": "135303839048"
}
```
- **federation**:来自 createPayment 的 **federation**(服务端订单 id
- **merchant**:来自 Google Play 的 **originalJson**(整段购买凭据 JSON 字符串)。
- **sample**:来自 Google Play 的 **signature**,服务端用其校验 merchant。
- **asset**:当前用户 id。
### 4.3 与客户端实现对应
- 内购成功后,从 `GooglePlayPurchaseDetails.billingClientPurchase``PurchaseWrapper`)取 **originalJson**、**signature**,分别填入请求体的 **merchant**、**sample**。
- createPayment 返回的 **federation** 填入 **federation**,当前用户 id 填入 **asset**
---
## 5. 代码位置速查
| 步骤 | 位置 |
|------|------|
| Buy 分支(第三方 vs 直接谷歌) | `recharge_screen.dart``_onBuy``_runThirdPartyPayment` / `_runGooglePay` |
| 创建订单 | `PaymentApi.createPayment`;调用处在 `_createOrderAndOpenUrl` |
| 调起内购并拿凭据 | `GooglePlayPurchaseService.launchPurchaseAndReturnData(productId)` |
| 回调 googlepay | `PaymentApi.googlepay(...)`,在 `_createOrderAndOpenUrl` 内、内购成功后调用 |
| 凭据数据结构 | Android`GooglePlayPurchaseDetails.billingClientPurchase`orderId, originalJson, signature |
---
## 6. 小结
1. **创建订单**仅在选择「Google Pay」且第三方支付开启时调用 createPayment拿到 **federation** 后才会调起谷歌支付并回调 googlepay。
2. **调起谷歌支付**productId 固定为当前商品的 **codehelm**,与 Play 后台产品 ID 一致。
3. **回调 googlepay**body 为四字段 **sample**signature、**merchant**purchaseData/originalJson、**federation**(订单 id、**asset**userIdfederation 为空则不回调,可按策略重试创建订单或提示失败。

View File

@ -80,7 +80,7 @@
- 条件:`_isGooglePay(paymentMethod, subPaymentMethod)` 为 true即 paymentMethod 或 subPaymentMethod 转为小写后等于 `"googlepay"`)。 - 条件:`_isGooglePay(paymentMethod, subPaymentMethod)` 为 true即 paymentMethod 或 subPaymentMethod 转为小写后等于 `"googlepay"`)。
- 仅 Android 执行;非 Android 提示 "Google Pay is only available on Android" 并结束。 - 仅 Android 执行;非 Android 提示 "Google Pay is only available on Android" 并结束。
- 调用 **GooglePlayPurchaseService.launchPurchaseAndReturnData(productId)** - 调用 **GooglePlayPurchaseService.launchPurchaseAndReturnData(productId)**(所有内购统一使用)
- productId 为当前商品的 **code**helm - productId 为当前商品的 **code**helm
- 成功返回 **serverVerificationData**merchant失败/取消返回 null。 - 成功返回 **serverVerificationData**merchant失败/取消返回 null。
- 若有 merchant 且订单 IDfederation存在调用 **PaymentApi.googlepay(merchant, federation, asset)**根据返回提示成功或失败并打点AdjustEvents - 若有 merchant 且订单 IDfederation存在调用 **PaymentApi.googlepay(merchant, federation, asset)**根据返回提示成功或失败并打点AdjustEvents
@ -89,7 +89,7 @@
## 5. 直接谷歌支付enable_third_party_payment !== true ## 5. 直接谷歌支付enable_third_party_payment !== true
- 仅 **Android**:调用 **GooglePlayPurchaseService.launchPurchase(item.code)**,不请求 getPaymentMethods / createPayment。 - 仅 **Android**:调用 **GooglePlayPurchaseService.launchPurchaseAndReturnData(item.code)**,不请求 getPaymentMethods / createPayment;凭据可用于后续服务端回调
- 成功/失败通过 SnackBar 与 AdjustEvents 打点;商品 ID 仍为 **item.code**(须与 Play 后台产品 ID 一致)。 - 成功/失败通过 SnackBar 与 AdjustEvents 打点;商品 ID 仍为 **item.code**(须与 Play 后台产品 ID 一致)。
- 非 Android提示 "Google Pay is only available on Android"。 - 非 Android提示 "Google Pay is only available on Android"。
@ -122,8 +122,8 @@
| 支付分支与 Buy 入口 | recharge_screen.dart_onBuy → enableThirdPartyPayment ? _runThirdPartyPayment : _runGooglePay | | 支付分支与 Buy 入口 | recharge_screen.dart_onBuy → enableThirdPartyPayment ? _runThirdPartyPayment : _runGooglePay |
| 第三方:获取支付方式 + 弹窗 | _runThirdPartyPaymentPaymentApi.getPaymentMethods → _PaymentMethodDialog | | 第三方:获取支付方式 + 弹窗 | _runThirdPartyPaymentPaymentApi.getPaymentMethods → _PaymentMethodDialog |
| 第三方:创建订单 + Google Pay / 打开链接 | _createOrderAndOpenUrlcreatePayment → _isGooglePay ? launchPurchaseAndReturnData + googlepay : launchUrl(convert) | | 第三方:创建订单 + Google Pay / 打开链接 | _createOrderAndOpenUrlcreatePayment → _isGooglePay ? launchPurchaseAndReturnData + googlepay : launchUrl(convert) |
| 直接谷歌支付 | _runGooglePay → _launchGooglePlayPurchase → GooglePlayPurchaseService.launchPurchase | | 直接谷歌支付 | _runGooglePay → _launchGooglePlayPurchase → GooglePlayPurchaseService.launchPurchaseAndReturnData |
| 谷歌内购 + 凭据上报 | google_play_purchase_service.dartlaunchPurchaseAndReturnData / launchPurchasePaymentApi.googlepay | | 谷歌内购 + 凭据上报 | google_play_purchase_service.dartlaunchPurchaseAndReturnDataPaymentApi.googlepay |
| 商品未找到排查 | docs/google_pay_product_not_found.md | | 商品未找到排查 | docs/google_pay_product_not_found.md |
--- ---

View File

@ -88,12 +88,12 @@ abstract final class PaymentApi {
); );
} }
/// Google ID ID /// Google body sample(signature)merchant(purchaseData)federation(id)asset(userId) docs/googlepay.md
static Future<ApiResponse> googlepay({ static Future<ApiResponse> googlepay({
required String sample,
required String merchant, required String merchant,
required String federation, required String federation,
required String asset, required String asset,
String? sample,
}) async { }) async {
return _client.request( return _client.request(
path: '/v1/payment/googlepay', path: '/v1/payment/googlepay',
@ -103,10 +103,10 @@ abstract final class PaymentApi {
'asset': asset, 'asset': asset,
}, },
body: { body: {
'sample': sample,
'merchant': merchant, 'merchant': merchant,
'federation': federation, 'federation': federation,
'asset': asset, 'asset': asset,
if (sample != null && sample.isNotEmpty) 'sample': sample,
}, },
); );
} }

View File

@ -1,17 +1,22 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:in_app_purchase_android/in_app_purchase_android.dart';
import 'package:pets_hero_ai/core/api/api.dart';
import '../../core/log/app_logger.dart'; import '../../core/log/app_logger.dart';
import 'models/google_pay_verification_payload.dart';
/// Google Play false 使 true ceremony==GooglePay launchPurchaseAndReturnData /// Google Play
abstract final class GooglePlayPurchaseService { abstract final class GooglePlayPurchaseService {
static final _log = AppLogger('GooglePlayPurchase'); static final _log = AppLogger('GooglePlayPurchase');
/// GooglePay /v1/payment/googlepay /// purchaseData=originalJson, signature
/// purchaseDataserverVerificationData/ null /// [GooglePayVerificationPayload]/ null idfederationuserId merchant
static Future<String?> launchPurchaseAndReturnData(String productId) async { static Future<GooglePayVerificationPayload?> launchPurchaseAndReturnData(
String productId) async {
_log.d('谷歌支付请求商品 ID(helm): "$productId"'); _log.d('谷歌支付请求商品 ID(helm): "$productId"');
if (defaultTargetPlatform != TargetPlatform.android) { if (defaultTargetPlatform != TargetPlatform.android) {
_log.d('非 Android跳过内购'); _log.d('非 Android跳过内购');
@ -24,22 +29,69 @@ abstract final class GooglePlayPurchaseService {
} }
final response = await iap.queryProductDetails({productId}); final response = await iap.queryProductDetails({productId});
if (response.notFoundIDs.isNotEmpty || response.productDetails.isEmpty) { if (response.notFoundIDs.isNotEmpty || response.productDetails.isEmpty) {
_log.w('商品未找到: 请求的 productId="$productId", notFoundIDs=${response.notFoundIDs}, 请核对与 Play 后台配置的「产品 ID」是否完全一致区分大小写'); _log.w(
'商品未找到: 请求的 productId="$productId", notFoundIDs=${response.notFoundIDs}, 请核对与 Play 后台配置的「产品 ID」是否完全一致区分大小写');
return null; return null;
} }
final product = response.productDetails.first; final product = response.productDetails.first;
final completer = Completer<String?>(); final completer = Completer<GooglePayVerificationPayload?>();
StreamSubscription<List<PurchaseDetails>>? sub; StreamSubscription<List<PurchaseDetails>>? sub;
sub = iap.purchaseStream.listen( sub = iap.purchaseStream.listen(
(purchases) { (purchases) {
// purchases JSON 便
try {
final list = purchases.map((p) {
final base = <String, Object?>{
'productID': p.productID,
'status': p.status.toString(),
'transactionDate': p.transactionDate,
'verificationData': {
'serverVerificationData':
p.verificationData.serverVerificationData,
'localVerificationData':
p.verificationData.localVerificationData,
'source': p.verificationData.source,
},
'pendingCompletePurchase': p.pendingCompletePurchase,
'error': p.error?.message,
};
if (p is GooglePlayPurchaseDetails) {
final b = p.billingClientPurchase;
base['googlePlay'] = {
'orderId': b.orderId,
'packageName': b.packageName,
'purchaseTime': b.purchaseTime,
'purchaseToken': b.purchaseToken,
'signature': b.signature,
'originalJson': b.originalJson,
'isAcknowledged': b.isAcknowledged,
'purchaseState': b.purchaseState.toString(),
};
}
return base;
}).toList();
_log.d('Google Play purchases json: ${jsonEncode(list)}');
logWithEmbeddedJson(jsonEncode(list));
} catch (e) {
_log.w('序列化 purchases 失败: $e');
}
for (final p in purchases) { for (final p in purchases) {
if (p.productID != productId) continue; if (p.productID != productId) continue;
if (p.status == PurchaseStatus.purchased || if (p.status == PurchaseStatus.purchased ||
p.status == PurchaseStatus.restored) { p.status == PurchaseStatus.restored) {
_log.d('购买成功: ${p.toString()}');
if (!completer.isCompleted) { if (!completer.isCompleted) {
final data = p.verificationData.serverVerificationData; GooglePayVerificationPayload? payload;
if (p is GooglePlayPurchaseDetails) {
final b = p.billingClientPurchase;
payload = GooglePayVerificationPayload(
purchaseData: b.originalJson,
signature: b.signature,
);
}
iap.completePurchase(p); iap.completePurchase(p);
completer.complete(data); completer.complete(payload);
} }
sub?.cancel(); sub?.cancel();
return; return;
@ -52,7 +104,7 @@ abstract final class GooglePlayPurchaseService {
} }
} }
}, },
onError: (_) { onError: (e) {
if (!completer.isCompleted) completer.complete(null); if (!completer.isCompleted) completer.complete(null);
sub?.cancel(); sub?.cancel();
}, },
@ -72,76 +124,4 @@ abstract final class GooglePlayPurchaseService {
}, },
); );
} }
/// ID [productId] ActivityItem.code / helm
/// true false
static Future<bool> launchPurchase(String productId) async {
if (defaultTargetPlatform != TargetPlatform.android) {
return false;
}
final iap = InAppPurchase.instance;
final available = await iap.isAvailable();
if (!available) {
return false;
}
final response = await iap.queryProductDetails({productId});
if (response.notFoundIDs.isNotEmpty || response.productDetails.isEmpty) {
_log.w('商品未找到: productId="$productId", notFoundIDs=${response.notFoundIDs}');
return false;
}
final product = response.productDetails.first;
final completer = Completer<bool>();
StreamSubscription<List<PurchaseDetails>>? sub;
sub = iap.purchaseStream.listen(
(purchases) {
for (final p in purchases) {
if (p.productID != productId) continue;
if (p.status == PurchaseStatus.purchased ||
p.status == PurchaseStatus.restored) {
if (!completer.isCompleted) {
iap.completePurchase(p);
completer.complete(true);
}
sub?.cancel();
return;
}
if (p.status == PurchaseStatus.error) {
if (!completer.isCompleted) {
completer.complete(false);
}
sub?.cancel();
return;
}
if (p.status == PurchaseStatus.canceled) {
if (!completer.isCompleted) {
completer.complete(false);
}
sub?.cancel();
return;
}
}
},
onError: (Object e) {
if (!completer.isCompleted) completer.complete(false);
sub?.cancel();
},
);
final purchaseParam = PurchaseParam(productDetails: product);
final success = await iap.buyConsumable(purchaseParam: purchaseParam);
if (!success) {
sub.cancel();
return false;
}
return completer.future.timeout(const Duration(seconds: 120),
onTimeout: () {
sub?.cancel();
return false;
});
}
} }

View File

@ -0,0 +1,14 @@
/// /v1/payment/googlepay body
/// purchaseData merchantsignature sample federation iduserId asset docs/googlepay.md
class GooglePayVerificationPayload {
const GooglePayVerificationPayload({
required this.purchaseData,
required this.signature,
});
/// JSON billingClientPurchase.originalJson
final String purchaseData;
/// purchaseData RSA billingClientPurchase.signature
final String signature;
}

View File

@ -12,7 +12,7 @@ class PaymentMethodItem {
final String? subPaymentMethod; // ceremony final String? subPaymentMethod; // ceremony
final String? name; // brigade final String? name; // brigade
final String? icon; // greylist URL final String? icon; // greylist URL
final bool recommend; // deny final bool recommend; // deny true Recommended
factory PaymentMethodItem.fromJson(Map<String, dynamic> json) { factory PaymentMethodItem.fromJson(Map<String, dynamic> json) {
return PaymentMethodItem( return PaymentMethodItem(
@ -20,7 +20,7 @@ class PaymentMethodItem {
subPaymentMethod: json['ceremony']?.toString(), subPaymentMethod: json['ceremony']?.toString(),
name: json['brigade']?.toString(), name: json['brigade']?.toString(),
icon: json['greylist']?.toString(), icon: json['greylist']?.toString(),
recommend: json['deny'] != true, recommend: json['deny'] == true,
); );
} }

View File

@ -29,10 +29,12 @@ class RechargeScreen extends StatefulWidget {
State<RechargeScreen> createState() => _RechargeScreenState(); State<RechargeScreen> createState() => _RechargeScreenState();
} }
class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObserver { class _RechargeScreenState extends State<RechargeScreen>
with WidgetsBindingObserver {
List<ActivityItem> _activities = []; List<ActivityItem> _activities = [];
bool _loadingTiers = true; bool _loadingTiers = true;
String? _tierError; String? _tierError;
/// code item Buy loading /// code item Buy loading
String? _loadingProductId; String? _loadingProductId;
@ -52,7 +54,9 @@ class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObse
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed && _loadingProductId != null && mounted) { if (state == AppLifecycleState.resumed &&
_loadingProductId != null &&
mounted) {
setState(() => _loadingProductId = null); setState(() => _loadingProductId = null);
} }
} }
@ -65,7 +69,7 @@ class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObse
try { try {
await AuthService.loginComplete; await AuthService.loginComplete;
final country = UserState.navigate.value ?? ''; final country = UserState.navigate.value ?? '';
final res = defaultTargetPlatform == TargetPlatform.iOS final res = defaultTargetPlatform == TargetPlatform.iOS
? await PaymentApi.getApplePayActivities( ? await PaymentApi.getApplePayActivities(
vambrace: country.isEmpty ? null : country, vambrace: country.isEmpty ? null : country,
@ -122,8 +126,7 @@ class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObse
AdjustEvents.trackWeeklyVip(); AdjustEvents.trackWeeklyVip();
} }
final useThirdParty = final useThirdParty = UserState.enableThirdPartyPayment.value == true;
UserState.enableThirdPartyPayment.value == true;
final uid = UserState.userId.value; final uid = UserState.userId.value;
if (useThirdParty && uid != null && uid.isNotEmpty) { if (useThirdParty && uid != null && uid.isNotEmpty) {
@ -157,7 +160,9 @@ class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObse
if (!methodsRes.isSuccess || methodsRes.data == null) { if (!methodsRes.isSuccess || methodsRes.data == null) {
_showSnackBar( _showSnackBar(
context, context,
methodsRes.msg.isNotEmpty ? methodsRes.msg : 'Failed to load payment methods', methodsRes.msg.isNotEmpty
? methodsRes.msg
: 'Failed to load payment methods',
isError: true, isError: true,
); );
AdjustEvents.trackPaymentFailed(); AdjustEvents.trackPaymentFailed();
@ -165,7 +170,8 @@ class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObse
} }
final methodsList = methodsRes.data is Map<String, dynamic> final methodsList = methodsRes.data is Map<String, dynamic>
? (methodsRes.data! as Map<String, dynamic>)['renew'] as List<dynamic>? ? (methodsRes.data! as Map<String, dynamic>)['renew']
as List<dynamic>?
: null; : null;
if (methodsList == null || methodsList.isEmpty) { if (methodsList == null || methodsList.isEmpty) {
_showSnackBar(context, 'No payment methods available', isError: true); _showSnackBar(context, 'No payment methods available', isError: true);
@ -185,9 +191,11 @@ class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObse
} }
if (!mounted) return; if (!mounted) return;
final selected = await showDialog<PaymentMethodItem>( final selected = await showModalBottomSheet<PaymentMethodItem>(
context: context, context: context,
builder: (ctx) => _PaymentMethodDialog(methods: methods), backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (ctx) => _PaymentMethodSheet(methods: methods),
); );
if (!mounted) return; if (!mounted) return;
@ -201,7 +209,9 @@ class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObse
activityId: activityId, activityId: activityId,
productId: item.code, productId: item.code,
paymentMethod: selected.paymentMethod, paymentMethod: selected.paymentMethod,
subPaymentMethod: selected.subPaymentMethod?.isEmpty == true ? null : selected.subPaymentMethod, subPaymentMethod: selected.subPaymentMethod?.isEmpty == true
? null
: selected.subPaymentMethod,
); );
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
@ -251,17 +261,23 @@ class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObse
if (_isGooglePay(paymentMethod, subPaymentMethod)) { if (_isGooglePay(paymentMethod, subPaymentMethod)) {
if (defaultTargetPlatform != TargetPlatform.android) { if (defaultTargetPlatform != TargetPlatform.android) {
_showSnackBar(context, 'Google Pay is only available on Android.', isError: true); _showSnackBar(context, 'Google Pay is only available on Android.',
isError: true);
AdjustEvents.trackPaymentFailed(); AdjustEvents.trackPaymentFailed();
return; return;
} }
final purchaseData = await GooglePlayPurchaseService.launchPurchaseAndReturnData(productId); if (mounted) setState(() => _loadingProductId = null);
final payload =
await GooglePlayPurchaseService.launchPurchaseAndReturnData(
productId);
if (!mounted) return; if (!mounted) return;
if (purchaseData != null && purchaseData.isNotEmpty && orderId != null && orderId.isNotEmpty) { if (payload != null &&
RechargeScreen._log.d('googlepay 入参: federation=$orderId, asset=$userId'); orderId != null &&
RechargeScreen._log.d('googlepay 入参: merchant(length=${purchaseData.length}) ${purchaseData.length > 400 ? "${purchaseData.substring(0, 400)}..." : purchaseData}'); orderId.isNotEmpty) {
RechargeScreen._log.d('googlepay 入参: federation=$orderId');
final googlepayRes = await PaymentApi.googlepay( final googlepayRes = await PaymentApi.googlepay(
merchant: purchaseData, sample: payload.signature,
merchant: payload.purchaseData,
federation: orderId, federation: orderId,
asset: userId, asset: userId,
); );
@ -270,11 +286,17 @@ class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObse
_showSnackBar(context, 'Purchase completed.'); _showSnackBar(context, 'Purchase completed.');
AdjustEvents.trackPurchaseSuccess(); AdjustEvents.trackPurchaseSuccess();
} else { } else {
_showSnackBar(context, googlepayRes.msg.isNotEmpty ? googlepayRes.msg : 'Payment verification failed.', isError: true); _showSnackBar(
context,
googlepayRes.msg.isNotEmpty
? googlepayRes.msg
: 'Payment verification failed.',
isError: true);
AdjustEvents.trackPaymentFailed(); AdjustEvents.trackPaymentFailed();
} }
} else { } else {
_showSnackBar(context, 'Purchase was cancelled or failed.', isError: true); _showSnackBar(context, 'Purchase was cancelled or failed.',
isError: true);
AdjustEvents.trackPaymentFailed(); AdjustEvents.trackPaymentFailed();
} }
return; return;
@ -283,17 +305,21 @@ class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObse
final payUrl = data?['convert']?.toString(); final payUrl = data?['convert']?.toString();
if (payUrl != null && payUrl.isNotEmpty) { if (payUrl != null && payUrl.isNotEmpty) {
if (mounted) { if (mounted) {
setState(() => _loadingProductId = null);
await Navigator.of(context).push( await Navigator.of(context).push(
MaterialPageRoute<void>( MaterialPageRoute<void>(
builder: (_) => PaymentWebViewScreen(paymentUrl: payUrl), builder: (_) => PaymentWebViewScreen(paymentUrl: payUrl),
), ),
); );
_showSnackBar(context, 'Order created. Complete payment in the page.'); _showSnackBar(
context, 'Order created. Complete payment in the page.');
AdjustEvents.trackPurchaseSuccess(); AdjustEvents.trackPurchaseSuccess();
} }
} else { } else {
if (mounted) { if (mounted) {
_showSnackBar(context, 'Order created. Awaiting payment confirmation.'); setState(() => _loadingProductId = null);
_showSnackBar(
context, 'Order created. Awaiting payment confirmation.');
} }
AdjustEvents.trackPurchaseSuccess(); AdjustEvents.trackPurchaseSuccess();
} }
@ -319,30 +345,37 @@ class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObse
/// false /// false
Future<void> _runGooglePay(ActivityItem item) async { Future<void> _runGooglePay(ActivityItem item) async {
if (defaultTargetPlatform != TargetPlatform.android) { if (defaultTargetPlatform != TargetPlatform.android) {
_showSnackBar(context, 'Google Pay is only available on Android.', isError: true); _showSnackBar(context, 'Google Pay is only available on Android.',
isError: true);
AdjustEvents.trackPaymentFailed(); AdjustEvents.trackPaymentFailed();
return; return;
} }
await _launchGooglePlayPurchase(item); await _launchGooglePlayPurchase(item);
} }
/// Google Play ID = item.code / helm /// Google Play ID = item.code / helm
Future<void> _launchGooglePlayPurchase(ActivityItem item) async { Future<void> _launchGooglePlayPurchase(ActivityItem item) async {
if (!mounted) return; if (!mounted) return;
try { try {
final success = await GooglePlayPurchaseService.launchPurchase(item.code); if (mounted) setState(() => _loadingProductId = null);
final payload =
await GooglePlayPurchaseService.launchPurchaseAndReturnData(
item.code);
if (!mounted) return; if (!mounted) return;
if (success) { if (payload != null) {
_showSnackBar(context, 'Purchase completed.'); _showSnackBar(context, 'Purchase completed.');
AdjustEvents.trackPurchaseSuccess(); AdjustEvents.trackPurchaseSuccess();
// createPayment federation googlepay
} else { } else {
_showSnackBar(context, 'Purchase was cancelled or failed.', isError: true); _showSnackBar(context, 'Purchase was cancelled or failed.',
isError: true);
AdjustEvents.trackPaymentFailed(); AdjustEvents.trackPaymentFailed();
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
_showSnackBar(context, 'Google Pay error: ${e.toString()}', isError: true); _showSnackBar(context, 'Google Pay error: ${e.toString()}',
isError: true);
} }
AdjustEvents.trackPaymentFailed(); AdjustEvents.trackPaymentFailed();
} finally { } finally {
@ -352,7 +385,8 @@ class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObse
} }
} }
void _showSnackBar(BuildContext context, String message, {bool isError = false}) { void _showSnackBar(BuildContext context, String message,
{bool isError = false}) {
ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@ -371,95 +405,209 @@ class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObse
child: TopNavBar( child: TopNavBar(
title: 'Recharge', title: 'Recharge',
showBackButton: true, showBackButton: true,
onBack: () => Navigator.of(context).pop(), onBack: _loadingProductId != null
? () {}
: () => Navigator.of(context).pop(),
), ),
), ),
body: SingleChildScrollView( body: Stack(
padding: const EdgeInsets.only(bottom: AppSpacing.screenPaddingLarge), children: [
child: Column( SingleChildScrollView(
crossAxisAlignment: CrossAxisAlignment.start, padding:
children: [ const EdgeInsets.only(bottom: AppSpacing.screenPaddingLarge),
_CreditsSection( child: Column(
currentCredits: crossAxisAlignment: CrossAxisAlignment.start,
UserCreditsData.of(context)?.creditsDisplay ?? '--', children: [
), _CreditsSection(
Padding( currentCredits:
padding: const EdgeInsets.symmetric( UserCreditsData.of(context)?.creditsDisplay ?? '--',
horizontal: AppSpacing.screenPaddingLarge,
vertical: AppSpacing.xxl,
),
child: Text(
'Select Tier',
style: AppTypography.bodyMedium.copyWith(
color: AppColors.textPrimary,
), ),
), Padding(
), padding: const EdgeInsets.symmetric(
if (_loadingTiers) horizontal: AppSpacing.screenPaddingLarge,
const Padding( vertical: AppSpacing.xxl,
padding: EdgeInsets.all(AppSpacing.xxl), ),
child: Center( child: Text(
child: SizedBox( 'Select Tier',
width: 24, style: AppTypography.bodyMedium.copyWith(
height: 24, color: AppColors.textPrimary,
child: CircularProgressIndicator(strokeWidth: 2), ),
), ),
), ),
) if (_loadingTiers)
else if (_tierError != null) const Padding(
Padding( padding: EdgeInsets.all(AppSpacing.xxl),
padding: const EdgeInsets.symmetric( child: Center(
horizontal: AppSpacing.screenPadding), child: SizedBox(
child: Column( width: 24,
children: [ height: 24,
Text( child: CircularProgressIndicator(strokeWidth: 2),
_tierError!, ),
),
)
else if (_tierError != null)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.screenPadding),
child: Column(
children: [
Text(
_tierError!,
style: AppTypography.bodyRegular.copyWith(
color: AppColors.textSecondary,
),
),
const SizedBox(height: AppSpacing.md),
TextButton(
onPressed: _fetchActivities,
child: const Text('Retry'),
),
],
),
)
else if (_activities.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.screenPadding),
child: Text(
'No packages available',
style: AppTypography.bodyRegular.copyWith( style: AppTypography.bodyRegular.copyWith(
color: AppColors.textSecondary, color: AppColors.textSecondary,
), ),
), ),
const SizedBox(height: AppSpacing.md), )
TextButton( else
onPressed: _fetchActivities, ...List.generate(_activities.length, (i) {
child: const Text('Retry'), final item = _activities[i];
final isRecommended = false;
final isPopular = false;
return Padding(
padding: EdgeInsets.only(
bottom: i < _activities.length - 1 ? AppSpacing.xl : 0,
),
child: _TierCardFromActivity(
item: item,
badge: isRecommended
? _TierBadge.recommended
: isPopular
? _TierBadge.popular
: _TierBadge.none,
loading: _loadingProductId == item.code,
onBuy: () => _onBuy(item),
),
);
}),
],
),
),
if (_loadingProductId != null)
Positioned.fill(
child: IgnorePointer(
child: Container(
color: AppColors.overlayDark,
child: const Center(
child: SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(AppColors.surface),
),
), ),
], ),
),
),
),
],
),
);
}
}
/// - 1:1 Pencil PaymentMethodModal
class _PaymentMethodSheet extends StatefulWidget {
const _PaymentMethodSheet({required this.methods});
final List<PaymentMethodItem> methods;
@override
State<_PaymentMethodSheet> createState() => _PaymentMethodSheetState();
}
class _PaymentMethodSheetState extends State<_PaymentMethodSheet> {
PaymentMethodItem? _selected;
@override
void initState() {
super.initState();
_selected = widget.methods.first;
}
@override
Widget build(BuildContext context) {
final bottomPadding = MediaQuery.of(context).padding.bottom;
return Container(
decoration: const BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(24),
topRight: Radius.circular(24),
),
),
padding: EdgeInsets.fromLTRB(24, 20, 24, 32 + bottomPadding),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Select Payment Method',
style: AppTypography.bodyLarge.copyWith(
color: AppColors.textPrimary,
fontWeight: FontWeight.w700,
fontSize: 18,
),
),
const SizedBox(height: 20),
...List.generate(widget.methods.length, (i) {
final m = widget.methods[i];
final isSelected = _selected == m;
return Padding(
padding: EdgeInsets.only(
bottom: i < widget.methods.length - 1 ? 12 : 0,
),
child: _PaymentMethodItem(
item: m,
isSelected: isSelected,
onTap: () => setState(() => _selected = m),
),
);
}),
const SizedBox(height: 20),
SizedBox(
height: 48,
child: ElevatedButton(
onPressed: _selected != null
? () => Navigator.of(context).pop(_selected)
: null,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: AppColors.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
), ),
)
else if (_activities.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.screenPadding),
child: Text( child: Text(
'No packages available', 'Pay',
style: AppTypography.bodyRegular.copyWith( style: AppTypography.bodyRegular.copyWith(
color: AppColors.textSecondary, color: AppColors.surface,
fontWeight: FontWeight.w600,
fontSize: 16,
), ),
), ),
) ),
else ),
...List.generate(_activities.length, (i) {
final item = _activities[i];
final isRecommended = i == 1;
final isPopular = i == 2;
return Padding(
padding: EdgeInsets.only(
bottom: i < _activities.length - 1
? AppSpacing.xl
: 0,
),
child: _TierCardFromActivity(
item: item,
badge: isRecommended
? _TierBadge.recommended
: isPopular
? _TierBadge.popular
: _TierBadge.none,
loading: _loadingProductId == item.code,
onBuy: () => _onBuy(item),
),
);
}),
], ],
), ),
), ),
@ -467,41 +615,130 @@ class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObse
} }
} }
/// /// - Recommended(deny取反)
class _PaymentMethodDialog extends StatelessWidget { class _PaymentMethodItem extends StatelessWidget {
const _PaymentMethodDialog({required this.methods}); const _PaymentMethodItem({
required this.item,
required this.isSelected,
required this.onTap,
});
final List<PaymentMethodItem> methods; final PaymentMethodItem item;
final bool isSelected;
final VoidCallback onTap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( return GestureDetector(
title: const Text('Select payment method'), onTap: onTap,
content: SingleChildScrollView( child: AnimatedContainer(
child: Column( duration: const Duration(milliseconds: 150),
mainAxisSize: MainAxisSize.min, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
children: methods height: 64,
.map( decoration: BoxDecoration(
(m) => ListTile( color: isSelected ? const Color(0x158B5CF6) : AppColors.surface,
title: Text(m.displayName), borderRadius: BorderRadius.circular(12),
subtitle: m.subPaymentMethod != null && m.subPaymentMethod!.isNotEmpty border: Border.all(
? Text(m.subPaymentMethod!, style: AppTypography.caption) color: isSelected ? AppColors.primary : AppColors.border,
: null, width: isSelected ? 2 : 1,
trailing: m.recommend ),
? Text('Recommended', style: AppTypography.caption.copyWith(color: AppColors.primary)) ),
: null, child: Row(
onTap: () => Navigator.of(context).pop(m), children: [
), Expanded(
) child: Row(
.toList(), children: [
_PaymentIcon(iconUrl: item.icon),
const SizedBox(width: 12),
Expanded(
child: Row(
children: [
Flexible(
child: Text(
item.displayName,
style: AppTypography.bodyRegular.copyWith(
color: AppColors.textPrimary,
fontWeight: FontWeight.w600,
fontSize: 16,
),
overflow: TextOverflow.ellipsis,
),
),
if (item.recommend) ...[
const SizedBox(width: 6),
Text(
'Recommended',
style: AppTypography.caption.copyWith(
color: AppColors.primary,
fontWeight: FontWeight.w600,
fontSize: 11,
),
),
],
],
),
),
],
),
),
_RadioIndicator(selected: isSelected),
],
), ),
), ),
actions: [ );
TextButton( }
onPressed: () => Navigator.of(context).pop(), }
child: const Text('Cancel'),
/// - greylist fallback 40x40 64
class _PaymentIcon extends StatelessWidget {
const _PaymentIcon({this.iconUrl});
final String? iconUrl;
static const double _size = 40;
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: _size,
child: iconUrl != null && iconUrl!.isNotEmpty
? Image.network(
iconUrl!,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => Icon(
LucideIcons.credit_card,
size: 24,
color: AppColors.primary,
),
)
: Icon(
LucideIcons.credit_card,
size: 24,
color: AppColors.primary,
),
);
}
}
/// -
class _RadioIndicator extends StatelessWidget {
const _RadioIndicator({required this.selected});
final bool selected;
@override
Widget build(BuildContext context) {
return Container(
width: 20,
height: 20,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: selected ? AppColors.primary : Colors.transparent,
border: Border.all(
color: selected ? AppColors.primary : AppColors.border,
width: selected ? 0 : 1,
), ),
], ),
); );
} }
} }
@ -554,7 +791,10 @@ class _TierCardFromActivity extends StatelessWidget {
color: AppColors.textPrimary, color: AppColors.textPrimary,
), ),
), ),
SizedBox(height: badge == _TierBadge.none ? AppSpacing.xs : AppSpacing.sm), SizedBox(
height: badge == _TierBadge.none
? AppSpacing.xs
: AppSpacing.sm),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.baseline, crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic, textBaseline: TextBaseline.alphabetic,
@ -623,9 +863,7 @@ class _TierCardFromActivity extends StatelessWidget {
), ),
), ),
child: Text( child: Text(
badge == _TierBadge.recommended badge == _TierBadge.recommended ? 'Recommended' : 'Most Popular',
? 'Recommended'
: 'Most Popular',
style: AppTypography.label.copyWith( style: AppTypography.label.copyWith(
color: badge == _TierBadge.recommended color: badge == _TierBadge.recommended
? AppColors.primary ? AppColors.primary
@ -708,7 +946,9 @@ class _BuyButton extends StatelessWidget {
vertical: AppSpacing.md, vertical: AppSpacing.md,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: loading ? AppColors.primary.withValues(alpha: 0.7) : AppColors.primary, color: loading
? AppColors.primary.withValues(alpha: 0.7)
: AppColors.primary,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: loading child: loading