优化:支付回调
This commit is contained in:
parent
6ae2b55677
commit
bd6f8ba813
@ -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** | 购买凭据 JSON(purchaseData) | 同上 **billingClientPurchase** 的 **originalJson** |
|
||||||
|
| **federation** | 支付/订单 id(id) | 创建订单接口 `createPayment` 返回的 **federation** |
|
||||||
|
| **asset** | 用户 id(userId) | 当前登录用户 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 固定为当前商品的 **code(helm)**,与 Play 后台产品 ID 一致。
|
||||||
|
3. **回调 googlepay**:body 为四字段 **sample**(signature)、**merchant**(purchaseData/originalJson)、**federation**(订单 id)、**asset**(userId);federation 为空则不回调,可按策略重试创建订单或提示失败。
|
||||||
|
|||||||
@ -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 且订单 ID(federation)存在,调用 **PaymentApi.googlepay(merchant, federation, asset)**;根据返回提示成功或失败并打点(AdjustEvents)。
|
- 若有 merchant 且订单 ID(federation)存在,调用 **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 |
|
||||||
| 第三方:获取支付方式 + 弹窗 | _runThirdPartyPayment:PaymentApi.getPaymentMethods → _PaymentMethodDialog |
|
| 第三方:获取支付方式 + 弹窗 | _runThirdPartyPayment:PaymentApi.getPaymentMethods → _PaymentMethodDialog |
|
||||||
| 第三方:创建订单 + Google Pay / 打开链接 | _createOrderAndOpenUrl:createPayment → _isGooglePay ? launchPurchaseAndReturnData + googlepay : launchUrl(convert) |
|
| 第三方:创建订单 + Google Pay / 打开链接 | _createOrderAndOpenUrl:createPayment → _isGooglePay ? launchPurchaseAndReturnData + googlepay : launchUrl(convert) |
|
||||||
| 直接谷歌支付 | _runGooglePay → _launchGooglePlayPurchase → GooglePlayPurchaseService.launchPurchase |
|
| 直接谷歌支付 | _runGooglePay → _launchGooglePlayPurchase → GooglePlayPurchaseService.launchPurchaseAndReturnData |
|
||||||
| 谷歌内购 + 凭据上报 | google_play_purchase_service.dart:launchPurchaseAndReturnData / launchPurchase;PaymentApi.googlepay |
|
| 谷歌内购 + 凭据上报 | google_play_purchase_service.dart:launchPurchaseAndReturnData;PaymentApi.googlepay |
|
||||||
| 商品未找到排查 | docs/google_pay_product_not_found.md |
|
| 商品未找到排查 | docs/google_pay_product_not_found.md |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -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,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)。
|
||||||
/// 成功返回 purchaseData(serverVerificationData),取消/失败返回 null。
|
/// 成功返回 [GooglePayVerificationPayload],取消/失败返回 null。调用方填入 id(federation)、userId 后组 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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,14 @@
|
|||||||
|
/// 谷歌支付成功后用于构造 /v1/payment/googlepay 的 body。
|
||||||
|
/// 本类提供 purchaseData(→ merchant)、signature(→ sample);调用方填入 federation(订单 id)、userId(→ 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;
|
||||||
|
}
|
||||||
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user