优化:支付回调

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
# 谷歌支付流程
# 购买
从/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(有的话),其他那些先不管
本文档描述 Android 上 Google Play 内购的完整流程,与 `recharge_screen.dart``GooglePlayPurchaseService``PaymentApi` 实现对应。
# 谷歌支付回调接口
/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"`)。
- 仅 Android 执行;非 Android 提示 "Google Pay is only available on Android" 并结束。
- 调用 **GooglePlayPurchaseService.launchPurchaseAndReturnData(productId)**
- 调用 **GooglePlayPurchaseService.launchPurchaseAndReturnData(productId)**(所有内购统一使用)
- productId 为当前商品的 **code**helm
- 成功返回 **serverVerificationData**merchant失败/取消返回 null。
- 若有 merchant 且订单 IDfederation存在调用 **PaymentApi.googlepay(merchant, federation, asset)**根据返回提示成功或失败并打点AdjustEvents
@ -89,7 +89,7 @@
## 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 一致)。
- 非 Android提示 "Google Pay is only available on Android"。
@ -122,8 +122,8 @@
| 支付分支与 Buy 入口 | recharge_screen.dart_onBuy → enableThirdPartyPayment ? _runThirdPartyPayment : _runGooglePay |
| 第三方:获取支付方式 + 弹窗 | _runThirdPartyPaymentPaymentApi.getPaymentMethods → _PaymentMethodDialog |
| 第三方:创建订单 + Google Pay / 打开链接 | _createOrderAndOpenUrlcreatePayment → _isGooglePay ? launchPurchaseAndReturnData + googlepay : launchUrl(convert) |
| 直接谷歌支付 | _runGooglePay → _launchGooglePlayPurchase → GooglePlayPurchaseService.launchPurchase |
| 谷歌内购 + 凭据上报 | google_play_purchase_service.dartlaunchPurchaseAndReturnData / launchPurchasePaymentApi.googlepay |
| 直接谷歌支付 | _runGooglePay → _launchGooglePlayPurchase → GooglePlayPurchaseService.launchPurchaseAndReturnData |
| 谷歌内购 + 凭据上报 | google_play_purchase_service.dartlaunchPurchaseAndReturnDataPaymentApi.googlepay |
| 商品未找到排查 | 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({
required String sample,
required String merchant,
required String federation,
required String asset,
String? sample,
}) async {
return _client.request(
path: '/v1/payment/googlepay',
@ -103,10 +103,10 @@ abstract final class PaymentApi {
'asset': asset,
},
body: {
'sample': sample,
'merchant': merchant,
'federation': federation,
'asset': asset,
if (sample != null && sample.isNotEmpty) 'sample': sample,
},
);
}

View File

@ -1,17 +1,22 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.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 'models/google_pay_verification_payload.dart';
/// Google Play false 使 true ceremony==GooglePay launchPurchaseAndReturnData
/// Google Play
abstract final class GooglePlayPurchaseService {
static final _log = AppLogger('GooglePlayPurchase');
/// GooglePay /v1/payment/googlepay
/// purchaseDataserverVerificationData/ null
static Future<String?> launchPurchaseAndReturnData(String productId) async {
/// purchaseData=originalJson, signature
/// [GooglePayVerificationPayload]/ null idfederationuserId merchant
static Future<GooglePayVerificationPayload?> launchPurchaseAndReturnData(
String productId) async {
_log.d('谷歌支付请求商品 ID(helm): "$productId"');
if (defaultTargetPlatform != TargetPlatform.android) {
_log.d('非 Android跳过内购');
@ -24,22 +29,69 @@ abstract final class GooglePlayPurchaseService {
}
final response = await iap.queryProductDetails({productId});
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;
}
final product = response.productDetails.first;
final completer = Completer<String?>();
final completer = Completer<GooglePayVerificationPayload?>();
StreamSubscription<List<PurchaseDetails>>? sub;
sub = iap.purchaseStream.listen(
(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) {
if (p.productID != productId) continue;
if (p.status == PurchaseStatus.purchased ||
p.status == PurchaseStatus.restored) {
_log.d('购买成功: ${p.toString()}');
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);
completer.complete(data);
completer.complete(payload);
}
sub?.cancel();
return;
@ -52,7 +104,7 @@ abstract final class GooglePlayPurchaseService {
}
}
},
onError: (_) {
onError: (e) {
if (!completer.isCompleted) completer.complete(null);
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? name; // brigade
final String? icon; // greylist URL
final bool recommend; // deny
final bool recommend; // deny true Recommended
factory PaymentMethodItem.fromJson(Map<String, dynamic> json) {
return PaymentMethodItem(
@ -20,7 +20,7 @@ class PaymentMethodItem {
subPaymentMethod: json['ceremony']?.toString(),
name: json['brigade']?.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();
}
class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObserver {
class _RechargeScreenState extends State<RechargeScreen>
with WidgetsBindingObserver {
List<ActivityItem> _activities = [];
bool _loadingTiers = true;
String? _tierError;
/// code item Buy loading
String? _loadingProductId;
@ -52,7 +54,9 @@ class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObse
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed && _loadingProductId != null && mounted) {
if (state == AppLifecycleState.resumed &&
_loadingProductId != null &&
mounted) {
setState(() => _loadingProductId = null);
}
}
@ -65,7 +69,7 @@ class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObse
try {
await AuthService.loginComplete;
final country = UserState.navigate.value ?? '';
final res = defaultTargetPlatform == TargetPlatform.iOS
? await PaymentApi.getApplePayActivities(
vambrace: country.isEmpty ? null : country,
@ -122,8 +126,7 @@ class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObse
AdjustEvents.trackWeeklyVip();
}
final useThirdParty =
UserState.enableThirdPartyPayment.value == true;
final useThirdParty = UserState.enableThirdPartyPayment.value == true;
final uid = UserState.userId.value;
if (useThirdParty && uid != null && uid.isNotEmpty) {
@ -157,7 +160,9 @@ class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObse
if (!methodsRes.isSuccess || methodsRes.data == null) {
_showSnackBar(
context,
methodsRes.msg.isNotEmpty ? methodsRes.msg : 'Failed to load payment methods',
methodsRes.msg.isNotEmpty
? methodsRes.msg
: 'Failed to load payment methods',
isError: true,
);
AdjustEvents.trackPaymentFailed();
@ -165,7 +170,8 @@ class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObse
}
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;
if (methodsList == null || methodsList.isEmpty) {
_showSnackBar(context, 'No payment methods available', isError: true);
@ -185,9 +191,11 @@ class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObse
}
if (!mounted) return;
final selected = await showDialog<PaymentMethodItem>(
final selected = await showModalBottomSheet<PaymentMethodItem>(
context: context,
builder: (ctx) => _PaymentMethodDialog(methods: methods),
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (ctx) => _PaymentMethodSheet(methods: methods),
);
if (!mounted) return;
@ -201,7 +209,9 @@ class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObse
activityId: activityId,
productId: item.code,
paymentMethod: selected.paymentMethod,
subPaymentMethod: selected.subPaymentMethod?.isEmpty == true ? null : selected.subPaymentMethod,
subPaymentMethod: selected.subPaymentMethod?.isEmpty == true
? null
: selected.subPaymentMethod,
);
} catch (e) {
if (mounted) {
@ -251,17 +261,23 @@ class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObse
if (_isGooglePay(paymentMethod, subPaymentMethod)) {
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();
return;
}
final purchaseData = await GooglePlayPurchaseService.launchPurchaseAndReturnData(productId);
if (mounted) setState(() => _loadingProductId = null);
final payload =
await GooglePlayPurchaseService.launchPurchaseAndReturnData(
productId);
if (!mounted) return;
if (purchaseData != null && purchaseData.isNotEmpty && orderId != null && orderId.isNotEmpty) {
RechargeScreen._log.d('googlepay 入参: federation=$orderId, asset=$userId');
RechargeScreen._log.d('googlepay 入参: merchant(length=${purchaseData.length}) ${purchaseData.length > 400 ? "${purchaseData.substring(0, 400)}..." : purchaseData}');
if (payload != null &&
orderId != null &&
orderId.isNotEmpty) {
RechargeScreen._log.d('googlepay 入参: federation=$orderId');
final googlepayRes = await PaymentApi.googlepay(
merchant: purchaseData,
sample: payload.signature,
merchant: payload.purchaseData,
federation: orderId,
asset: userId,
);
@ -270,11 +286,17 @@ class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObse
_showSnackBar(context, 'Purchase completed.');
AdjustEvents.trackPurchaseSuccess();
} 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();
}
} else {
_showSnackBar(context, 'Purchase was cancelled or failed.', isError: true);
_showSnackBar(context, 'Purchase was cancelled or failed.',
isError: true);
AdjustEvents.trackPaymentFailed();
}
return;
@ -283,17 +305,21 @@ class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObse
final payUrl = data?['convert']?.toString();
if (payUrl != null && payUrl.isNotEmpty) {
if (mounted) {
setState(() => _loadingProductId = null);
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => PaymentWebViewScreen(paymentUrl: payUrl),
),
);
_showSnackBar(context, 'Order created. Complete payment in the page.');
_showSnackBar(
context, 'Order created. Complete payment in the page.');
AdjustEvents.trackPurchaseSuccess();
}
} else {
if (mounted) {
_showSnackBar(context, 'Order created. Awaiting payment confirmation.');
setState(() => _loadingProductId = null);
_showSnackBar(
context, 'Order created. Awaiting payment confirmation.');
}
AdjustEvents.trackPurchaseSuccess();
}
@ -319,30 +345,37 @@ class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObse
/// false
Future<void> _runGooglePay(ActivityItem item) async {
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();
return;
}
await _launchGooglePlayPurchase(item);
}
/// Google Play ID = item.code / helm
/// Google Play ID = item.code / helm
Future<void> _launchGooglePlayPurchase(ActivityItem item) async {
if (!mounted) return;
try {
final success = await GooglePlayPurchaseService.launchPurchase(item.code);
if (mounted) setState(() => _loadingProductId = null);
final payload =
await GooglePlayPurchaseService.launchPurchaseAndReturnData(
item.code);
if (!mounted) return;
if (success) {
if (payload != null) {
_showSnackBar(context, 'Purchase completed.');
AdjustEvents.trackPurchaseSuccess();
// createPayment federation googlepay
} else {
_showSnackBar(context, 'Purchase was cancelled or failed.', isError: true);
_showSnackBar(context, 'Purchase was cancelled or failed.',
isError: true);
AdjustEvents.trackPaymentFailed();
}
} catch (e) {
if (mounted) {
_showSnackBar(context, 'Google Pay error: ${e.toString()}', isError: true);
_showSnackBar(context, 'Google Pay error: ${e.toString()}',
isError: true);
}
AdjustEvents.trackPaymentFailed();
} 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).showSnackBar(
SnackBar(
@ -371,95 +405,209 @@ class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObse
child: TopNavBar(
title: 'Recharge',
showBackButton: true,
onBack: () => Navigator.of(context).pop(),
onBack: _loadingProductId != null
? () {}
: () => Navigator.of(context).pop(),
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.only(bottom: AppSpacing.screenPaddingLarge),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CreditsSection(
currentCredits:
UserCreditsData.of(context)?.creditsDisplay ?? '--',
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.screenPaddingLarge,
vertical: AppSpacing.xxl,
),
child: Text(
'Select Tier',
style: AppTypography.bodyMedium.copyWith(
color: AppColors.textPrimary,
body: Stack(
children: [
SingleChildScrollView(
padding:
const EdgeInsets.only(bottom: AppSpacing.screenPaddingLarge),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CreditsSection(
currentCredits:
UserCreditsData.of(context)?.creditsDisplay ?? '--',
),
),
),
if (_loadingTiers)
const Padding(
padding: EdgeInsets.all(AppSpacing.xxl),
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.screenPaddingLarge,
vertical: AppSpacing.xxl,
),
child: Text(
'Select Tier',
style: AppTypography.bodyMedium.copyWith(
color: AppColors.textPrimary,
),
),
),
)
else if (_tierError != null)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.screenPadding),
child: Column(
children: [
Text(
_tierError!,
if (_loadingTiers)
const Padding(
padding: EdgeInsets.all(AppSpacing.xxl),
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
)
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(
color: AppColors.textSecondary,
),
),
const SizedBox(height: AppSpacing.md),
TextButton(
onPressed: _fetchActivities,
child: const Text('Retry'),
)
else
...List.generate(_activities.length, (i) {
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(
'No packages available',
'Pay',
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
}
}
///
class _PaymentMethodDialog extends StatelessWidget {
const _PaymentMethodDialog({required this.methods});
/// - Recommended(deny取反)
class _PaymentMethodItem extends StatelessWidget {
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
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Select payment method'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: methods
.map(
(m) => ListTile(
title: Text(m.displayName),
subtitle: m.subPaymentMethod != null && m.subPaymentMethod!.isNotEmpty
? Text(m.subPaymentMethod!, style: AppTypography.caption)
: null,
trailing: m.recommend
? Text('Recommended', style: AppTypography.caption.copyWith(color: AppColors.primary))
: null,
onTap: () => Navigator.of(context).pop(m),
),
)
.toList(),
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
height: 64,
decoration: BoxDecoration(
color: isSelected ? const Color(0x158B5CF6) : AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected ? AppColors.primary : AppColors.border,
width: isSelected ? 2 : 1,
),
),
child: Row(
children: [
Expanded(
child: Row(
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,
),
),
SizedBox(height: badge == _TierBadge.none ? AppSpacing.xs : AppSpacing.sm),
SizedBox(
height: badge == _TierBadge.none
? AppSpacing.xs
: AppSpacing.sm),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
@ -623,9 +863,7 @@ class _TierCardFromActivity extends StatelessWidget {
),
),
child: Text(
badge == _TierBadge.recommended
? 'Recommended'
: 'Most Popular',
badge == _TierBadge.recommended ? 'Recommended' : 'Most Popular',
style: AppTypography.label.copyWith(
color: badge == _TierBadge.recommended
? AppColors.primary
@ -708,7 +946,9 @@ class _BuyButton extends StatelessWidget {
vertical: AppSpacing.md,
),
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),
),
child: loading