新增:积分消费记录
This commit is contained in:
parent
cd6a9ade0a
commit
6747e97cf9
@ -1,3 +1,13 @@
|
||||
/// HTTP 代理客户端:业务请求统一 POST 到 [ApiConfig.proxyPath],正文经 AES 加密包装。
|
||||
///
|
||||
/// **日志**:`_log` / [logWithEmbeddedJson] 仅在 `kDebugMode` 或 [ApiConfig.debugLogs] 为 true 时输出
|
||||
///(release 排障:`flutter run --release --dart-define=APP_LOG_LEVEL=trace`)。
|
||||
/// 会打印明文入参(headers/query/body 加密前)、加密后的 JSON、`Content-Type` 等传输层头,以及解密后的响应。
|
||||
///
|
||||
/// **字段**:业务语义放在 V2 `sanctum`;`portal`/`knight` 等落在逻辑 headers,再加密进 `quest_rank` 等槽位,
|
||||
/// 见下方 [ProxyKeys] 与 [ProxyClient.request]。
|
||||
library proxy_client;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
@ -81,7 +91,9 @@ void _logLong(String text) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 遇到 { 或 [ 视为 JSON 开始,直到与之匹配的 } 或 ] 结束;格式化后单条不超过 1000 字,分条时按行切分保持对齐。
|
||||
/// 遇到 `{` / `[` 视为 JSON 片段并尝试美化输出;否则按普通字符串处理。
|
||||
///
|
||||
/// 无输出条件:非 debug 且未开启 [ApiConfig.debugLogs](与 [AppLogger] 一致)。
|
||||
void logWithEmbeddedJson(Object? msg) {
|
||||
if (!kDebugMode && !ApiConfig.debugLogs) return;
|
||||
|
||||
@ -163,11 +175,12 @@ void logWithEmbeddedJson(Object? msg) {
|
||||
if (out.isNotEmpty) _logLong(out);
|
||||
}
|
||||
|
||||
/// 代理调试输出的统一入口(走 [logWithEmbeddedJson])。
|
||||
void _log(String msg) {
|
||||
logWithEmbeddedJson(msg);
|
||||
}
|
||||
|
||||
/// 代理请求体字段名(统一请求参数)
|
||||
/// 发往网关 JSON 体中的固定键名(各槽位承载加密后的 path/method/headers/query/body)。
|
||||
abstract final class ProxyKeys {
|
||||
static const String heroClass = 'hero_class';
|
||||
static const String petSpecies = 'pet_species';
|
||||
@ -183,7 +196,7 @@ abstract final class ProxyKeys {
|
||||
static const String dirPath = 'dir_path';
|
||||
}
|
||||
|
||||
/// 代理请求客户端
|
||||
/// 代理请求客户端:维护可选 `knight`(用户 Token),并对单次请求做加密与解析。
|
||||
class ProxyClient {
|
||||
ProxyClient({
|
||||
this.baseUrl,
|
||||
@ -222,12 +235,14 @@ class ProxyClient {
|
||||
};
|
||||
}
|
||||
|
||||
/// 发送代理请求
|
||||
/// [path] 接口路径,如 /v1/user/fast_login
|
||||
/// [method] HTTP 方法,POST 或 GET
|
||||
/// [headers] 请求头,使用 V2 字段名(portal、knight 等)
|
||||
/// [queryParams] 查询参数,使用 V2 字段名(sentinel、asset 等)
|
||||
/// [body] 请求体,使用 V2 字段名,将填入 sanctum
|
||||
/// 发送代理请求。
|
||||
///
|
||||
/// `path`、`method`、`headers`、`queryParams`、`body` 会先序列化为 JSON,再分别加密写入 [ProxyKeys];
|
||||
/// 实际 HTTP 始终为 POST,`Content-Type: application/json`。
|
||||
/// 超时见 [ApiConfig.httpRequestTimeout];超时返回 `code == -1`。
|
||||
///
|
||||
/// [path] 例如 `/v1/user/fast_login`;[headers] 常用 `portal`、`knight`;
|
||||
/// [queryParams] 常用 `sentinel`、`asset`;[body] 映射进包装体 `sanctum`。
|
||||
Future<ApiResponse> request({
|
||||
required String path,
|
||||
required String method,
|
||||
@ -250,7 +265,7 @@ class ProxyClient {
|
||||
final sanctum = body ?? {};
|
||||
final v2Body = _buildV2Wrapper(sanctum);
|
||||
|
||||
// 原始入参
|
||||
// 加密前明文(仅调试日志使用)
|
||||
final headersEncoded = jsonEncode(headersMap);
|
||||
final paramsEncoded = jsonEncode(paramsMap);
|
||||
final v2BodyEncoded = jsonEncode(v2Body);
|
||||
@ -310,9 +325,10 @@ class ProxyClient {
|
||||
return _parseResponse(response);
|
||||
}
|
||||
|
||||
/// 解密响应体 JSON,映射为 [ApiResponse](业务字段:`helm`=code,`rampart`=msg,`sidekick`=data)。
|
||||
ApiResponse _parseResponse(http.Response response) {
|
||||
try {
|
||||
// 响应解密流程:Base64 解码 → AES-ECB 解密 → PKCS5 去填充 → UTF-8 字符串
|
||||
// Base64 → AES-ECB → UTF-8 JSON
|
||||
var responseLogStr = '========== 响应 ===========';
|
||||
final decrypted = ApiCrypto.decrypt(response.body);
|
||||
final json = jsonDecode(decrypted) as Map<String, dynamic>;
|
||||
@ -338,7 +354,7 @@ class ProxyClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一 API 响应
|
||||
/// 代理层统一响应:`code == 0` 表示成功。
|
||||
class ApiResponse {
|
||||
ApiResponse({
|
||||
required this.code,
|
||||
|
||||
@ -113,4 +113,24 @@ abstract final class UserApi {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取积分记录分页数据
|
||||
/// trophy=page, heatmap=size, accolade=type(可选)
|
||||
static Future<ApiResponse> getCreditsPage({
|
||||
String? sentinel,
|
||||
required String trophy,
|
||||
required String heatmap,
|
||||
String? accolade,
|
||||
}) async {
|
||||
return _client.request(
|
||||
path: '/v1/user/credits-page',
|
||||
method: 'GET',
|
||||
queryParams: {
|
||||
'sentinel': sentinel ?? ApiConfig.appId,
|
||||
'trophy': trophy,
|
||||
'heatmap': heatmap,
|
||||
if (accolade != null && accolade.isNotEmpty) 'accolade': accolade,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,30 +2,39 @@ import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../api/api_config.dart';
|
||||
|
||||
/// Facebook SDK 配置
|
||||
/// Facebook / Meta 相关常量与客户端开关。
|
||||
///
|
||||
/// 用于 Adjust Meta Install Referrer 归因与 Facebook App Events 埋点。
|
||||
/// **用途**
|
||||
/// - **Adjust**:Meta Install Referrer 归因(配合 [installReferrerDecryptionKey])。
|
||||
/// - **Facebook App Events**:`AdjustEvents` 中通过 `facebook_app_events` 上报自定义事件等。
|
||||
///
|
||||
/// **控制台日志**:[debugLogs] 为 true 时,Dart 侧会打印 FB 事件调试信息(与 [ApiConfig.debugLogs]、
|
||||
/// `--dart-define=APP_LOG_LEVEL=trace` 对齐);release 默认关闭,避免刷屏。
|
||||
abstract final class FacebookConfig {
|
||||
/// Dart 层 FB 事件控制台日志:debug 构建或 `--dart-define=APP_LOG_LEVEL=trace` 等为 true。
|
||||
/// 是否在 Dart 控制台打印 FB SDK / 埋点相关调试输出。
|
||||
///
|
||||
/// `true`:`kDebugMode` **或** [ApiConfig.debugLogs](含 `APP_LOG_LEVEL=trace` 等)。
|
||||
static bool get debugLogs => kDebugMode || ApiConfig.debugLogs;
|
||||
|
||||
/// Facebook 应用 ID(应用编号)
|
||||
/// Facebook 应用编号(Application ID),用于 AndroidManifest / Info.plist 元数据等。
|
||||
static const String appId = '1684216162986495';
|
||||
|
||||
/// Facebook Client Token(客户端口令)
|
||||
/// Facebook **客户端**口令(Client Token)。
|
||||
///
|
||||
/// 在 Facebook 开发者后台获取:应用 → 设置 → 高级 → 客户端口令
|
||||
/// 路径:Meta 开发者后台 → 应用 → **设置 → 高级 → 客户端口令**。\
|
||||
/// 若为空字符串,部分 SDK 初始化可能受限;[hasClientToken] 可用于分支判断。
|
||||
static const String clientToken = '';
|
||||
|
||||
/// 安装引荐来源解密密钥
|
||||
/// Meta / FB **安装广告**引荐来源的解密密钥(与客户端 Client Token 不同)。
|
||||
///
|
||||
/// 用于解密 Facebook App Install Ads 的加密 referrer。
|
||||
/// 请在 Adjust 控制台填写此密钥以启用解密
|
||||
/// (路径:App Settings → Partner setup → Meta/Facebook)。
|
||||
/// 用于解密加密后的 Install Referrer;需在 **Adjust** 控制台 Partner setup → Meta/Facebook 等处配置,
|
||||
/// 与 Adjust 文档路径一致。
|
||||
static const String installReferrerDecryptionKey =
|
||||
'068aff9bac7e8846b94e9fc73d51c7a5ab7c8ac39fe9a2b16d0ff8b74f98f';
|
||||
|
||||
/// 应用密钥(App Secret)仅用于服务端,勿放入客户端。
|
||||
/// [clientToken] 是否已配置(非空)。
|
||||
///
|
||||
/// **App Secret** 仅用于服务端,切勿打进客户端包。
|
||||
static bool get hasClientToken => clientToken.isNotEmpty;
|
||||
}
|
||||
|
||||
|
||||
27
lib/features/recharge/models/credits_record_item.dart
Normal file
27
lib/features/recharge/models/credits_record_item.dart
Normal file
@ -0,0 +1,27 @@
|
||||
class CreditsRecordItem {
|
||||
const CreditsRecordItem({
|
||||
required this.credits,
|
||||
required this.type,
|
||||
required this.createTime,
|
||||
});
|
||||
|
||||
final int credits;
|
||||
final int type; // 1: increase, 2: decrease
|
||||
final int createTime; // unix timestamp (seconds or milliseconds)
|
||||
|
||||
factory CreditsRecordItem.fromJson(Map<String, dynamic> json) {
|
||||
return CreditsRecordItem(
|
||||
credits: (json['greaves'] as num?)?.toInt() ?? 0,
|
||||
type: (json['accolade'] as num?)?.toInt() ?? 0,
|
||||
createTime: (json['discover'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isIncrease => type == 1;
|
||||
|
||||
String get deltaText {
|
||||
final abs = credits.abs();
|
||||
final sign = isIncrease ? '+' : '-';
|
||||
return '$sign$abs';
|
||||
}
|
||||
}
|
||||
302
lib/features/recharge/points_history_screen.dart
Normal file
302
lib/features/recharge/points_history_screen.dart
Normal file
@ -0,0 +1,302 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/api/api_config.dart';
|
||||
import '../../core/api/services/user_api.dart';
|
||||
import '../../core/auth/auth_service.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_spacing.dart';
|
||||
import '../../core/theme/app_typography.dart';
|
||||
import '../../core/user/user_state.dart';
|
||||
import '../../shared/widgets/top_nav_bar.dart';
|
||||
import 'models/credits_record_item.dart';
|
||||
|
||||
class PointsHistoryScreen extends StatefulWidget {
|
||||
const PointsHistoryScreen({super.key});
|
||||
|
||||
@override
|
||||
State<PointsHistoryScreen> createState() => _PointsHistoryScreenState();
|
||||
}
|
||||
|
||||
class _PointsHistoryScreenState extends State<PointsHistoryScreen> {
|
||||
static const int _pageSize = 20;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final List<CreditsRecordItem> _records = [];
|
||||
|
||||
bool _loading = true;
|
||||
bool _loadingMore = false;
|
||||
bool _hasNext = true;
|
||||
String? _error;
|
||||
int _currentPage = 1;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_loadRecords(refresh: true);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (!_scrollController.hasClients ||
|
||||
_loadingMore ||
|
||||
!_hasNext ||
|
||||
_loading) {
|
||||
return;
|
||||
}
|
||||
final pos = _scrollController.position;
|
||||
if (pos.pixels >= pos.maxScrollExtent - 200) {
|
||||
_loadRecords(refresh: false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadRecords({required bool refresh}) async {
|
||||
if (refresh) {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
_currentPage = 1;
|
||||
_hasNext = true;
|
||||
});
|
||||
} else {
|
||||
if (_loadingMore || !_hasNext) return;
|
||||
setState(() => _loadingMore = true);
|
||||
}
|
||||
|
||||
try {
|
||||
await AuthService.loginComplete;
|
||||
final page = refresh ? 1 : _currentPage;
|
||||
final res = await UserApi.getCreditsPage(
|
||||
sentinel: ApiConfig.appId,
|
||||
trophy: page.toString(),
|
||||
heatmap: _pageSize.toString(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (res.isSuccess && res.data is Map<String, dynamic>) {
|
||||
final data = res.data as Map<String, dynamic>;
|
||||
final intensify = data['intensify'] as List<dynamic>? ?? [];
|
||||
final list = intensify
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(CreditsRecordItem.fromJson)
|
||||
.toList();
|
||||
final pages = (data['coordinate'] as num?)?.toInt() ?? page;
|
||||
final current = (data['empower'] as num?)?.toInt() ?? page;
|
||||
setState(() {
|
||||
if (refresh) {
|
||||
_records
|
||||
..clear()
|
||||
..addAll(list);
|
||||
} else {
|
||||
_records.addAll(list);
|
||||
}
|
||||
_currentPage = current + 1;
|
||||
_hasNext = current < pages;
|
||||
_loading = false;
|
||||
_loadingMore = false;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
if (refresh) _records.clear();
|
||||
_loading = false;
|
||||
_loadingMore = false;
|
||||
_error = 'Unable to load points history right now. Please try again.';
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
if (refresh) _records.clear();
|
||||
_loading = false;
|
||||
_loadingMore = false;
|
||||
_error =
|
||||
'Something went wrong while loading points history. Please try again.';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final points = UserCreditsData.of(context)?.creditsDisplay ?? '--';
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: const PreferredSize(
|
||||
preferredSize: Size.fromHeight(56),
|
||||
child: TopNavBar(
|
||||
title: 'Points History',
|
||||
showBackButton: true,
|
||||
),
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: AppSpacing.xxl),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_error!,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
TextButton(
|
||||
onPressed: () => _loadRecords(refresh: true),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: () => _loadRecords(refresh: true),
|
||||
child: ListView(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.screenPadding,
|
||||
AppSpacing.xxl,
|
||||
AppSpacing.screenPadding,
|
||||
AppSpacing.screenPaddingLarge,
|
||||
),
|
||||
children: [
|
||||
_CurrentPointsCard(points: points),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
if (_records.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: AppSpacing.screenPaddingLarge,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No history yet',
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
..._records.map(
|
||||
(item) => Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(bottom: AppSpacing.md),
|
||||
child: _HistoryRow(item: item),
|
||||
),
|
||||
),
|
||||
if (_loadingMore)
|
||||
const Padding(
|
||||
padding:
|
||||
EdgeInsets.symmetric(vertical: AppSpacing.md),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CurrentPointsCard extends StatelessWidget {
|
||||
const _CurrentPointsCard({required this.points});
|
||||
|
||||
final String points;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.xl,
|
||||
vertical: AppSpacing.xl,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Current Points',
|
||||
style:
|
||||
AppTypography.caption.copyWith(color: AppColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
points,
|
||||
style: AppTypography.bodyLarge.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 34,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HistoryRow extends StatelessWidget {
|
||||
const _HistoryRow({required this.item});
|
||||
|
||||
final CreditsRecordItem item;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isPositive = item.isIncrease;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.xl,
|
||||
vertical: AppSpacing.lg,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(item.createTime),
|
||||
style:
|
||||
AppTypography.caption.copyWith(color: AppColors.textSecondary),
|
||||
),
|
||||
Text(
|
||||
item.deltaText,
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
color: isPositive
|
||||
? const Color(0xFF16A34A)
|
||||
: const Color(0xFFDC2626),
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatTime(int raw) {
|
||||
if (raw <= 0) return '--';
|
||||
final dt = raw > 1000000000000
|
||||
? DateTime.fromMillisecondsSinceEpoch(raw)
|
||||
: DateTime.fromMillisecondsSinceEpoch(raw * 1000);
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
return '${dt.year}-${two(dt.month)}-${two(dt.day)} ${two(dt.hour)}:${two(dt.minute)}';
|
||||
}
|
||||
@ -19,6 +19,7 @@ import 'google_play_purchase_service.dart';
|
||||
import 'models/activity_item.dart';
|
||||
import 'models/payment_method_item.dart';
|
||||
import 'payment_webview_screen.dart';
|
||||
import 'points_history_screen.dart';
|
||||
|
||||
/// Recharge screen - matches Pencil tPjdN
|
||||
/// Tier cards (DC6NS, YRaOG, QlNz6) 对接 getGooglePayActivities / getApplePayActivities
|
||||
@ -119,7 +120,8 @@ class _RechargeScreenState extends State<RechargeScreen>
|
||||
setState(() {
|
||||
_activities = [];
|
||||
_loadingTiers = false;
|
||||
_tierError = 'Unable to load plans right now. Please check your connection and try again.';
|
||||
_tierError =
|
||||
'Unable to load plans right now. Please check your connection and try again.';
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
@ -127,7 +129,8 @@ class _RechargeScreenState extends State<RechargeScreen>
|
||||
setState(() {
|
||||
_activities = [];
|
||||
_loadingTiers = false;
|
||||
_tierError = 'Something went wrong while loading plans. Please try again.';
|
||||
_tierError =
|
||||
'Something went wrong while loading plans. Please try again.';
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -237,7 +240,9 @@ class _RechargeScreenState extends State<RechargeScreen>
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
_showSnackBar(context, 'Payment could not be completed. Please try again.', isError: true);
|
||||
_showSnackBar(
|
||||
context, 'Payment could not be completed. Please try again.',
|
||||
isError: true);
|
||||
}
|
||||
AdjustEvents.trackPaymentFailed();
|
||||
} finally {
|
||||
@ -298,7 +303,8 @@ class _RechargeScreenState extends State<RechargeScreen>
|
||||
|
||||
if (_shouldUseSystemBrowser(openType)) {
|
||||
// 打开系统浏览器
|
||||
await launchUrl(Uri.parse(payUrl), mode: LaunchMode.externalApplication);
|
||||
await launchUrl(Uri.parse(payUrl),
|
||||
mode: LaunchMode.externalApplication);
|
||||
// 系统浏览器返回后,用户回到应用时会通过 didChangeAppLifecycleState 触发轮询
|
||||
_pendingOrderId = orderId;
|
||||
_pendingUserId = userId;
|
||||
@ -340,7 +346,9 @@ class _RechargeScreenState extends State<RechargeScreen>
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
_showSnackBar(context, 'Payment could not be completed. Please try again.', isError: true);
|
||||
_showSnackBar(
|
||||
context, 'Payment could not be completed. Please try again.',
|
||||
isError: true);
|
||||
}
|
||||
AdjustEvents.trackPaymentFailed();
|
||||
} finally {
|
||||
@ -447,7 +455,9 @@ class _RechargeScreenState extends State<RechargeScreen>
|
||||
serverOrderId: orderId, userId: userId);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
_showSnackBar(context, 'Payment could not be completed. Please try again.', isError: true);
|
||||
_showSnackBar(
|
||||
context, 'Payment could not be completed. Please try again.',
|
||||
isError: true);
|
||||
}
|
||||
AdjustEvents.trackPaymentFailed();
|
||||
} finally {
|
||||
@ -525,7 +535,8 @@ class _RechargeScreenState extends State<RechargeScreen>
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
_showSnackBar(context, 'Google Pay is temporarily unavailable. Please try again.',
|
||||
_showSnackBar(
|
||||
context, 'Google Pay is temporarily unavailable. Please try again.',
|
||||
isError: true);
|
||||
}
|
||||
AdjustEvents.trackPaymentFailed();
|
||||
@ -556,6 +567,40 @@ class _RechargeScreenState extends State<RechargeScreen>
|
||||
child: TopNavBar(
|
||||
title: 'Recharge',
|
||||
showBackButton: true,
|
||||
trailing: GestureDetector(
|
||||
onTap: _loadingProductId != null
|
||||
? null
|
||||
: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const PointsHistoryScreen(),
|
||||
),
|
||||
),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.list,
|
||||
size: 14,
|
||||
color: _loadingProductId != null
|
||||
? AppColors.textMuted
|
||||
: AppColors.primary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'History',
|
||||
style: AppTypography.caption.copyWith(
|
||||
color: _loadingProductId != null
|
||||
? AppColors.textMuted
|
||||
: AppColors.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
onBack: _loadingProductId != null
|
||||
? () {}
|
||||
: () => Navigator.of(context).pop(),
|
||||
@ -630,19 +675,13 @@ class _RechargeScreenState extends State<RechargeScreen>
|
||||
else
|
||||
...List.generate(_activities.length, (i) {
|
||||
final item = _activities[i];
|
||||
const isRecommended = false;
|
||||
const 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,
|
||||
badge: _TierBadge.none,
|
||||
loading: _loadingProductId == item.code,
|
||||
onBuy: () => _onBuy(item),
|
||||
),
|
||||
@ -782,7 +821,7 @@ class _PaymentMethodItem extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
constraints: const BoxConstraints(minHeight: 64),
|
||||
@ -931,6 +970,8 @@ class _TierCardFromActivity extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isRecommended = badge == _TierBadge.recommended;
|
||||
final isPopular = badge == _TierBadge.popular;
|
||||
final content = Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.screenPadding),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@ -1034,11 +1075,14 @@ class _TierCardFromActivity extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
badge == _TierBadge.recommended ? 'Recommended' : 'Most Popular',
|
||||
isRecommended
|
||||
? 'Recommended'
|
||||
: isPopular
|
||||
? 'Most Popular'
|
||||
: '',
|
||||
style: AppTypography.label.copyWith(
|
||||
color: badge == _TierBadge.recommended
|
||||
? AppColors.primary
|
||||
: AppColors.accentOrange,
|
||||
color:
|
||||
isRecommended ? AppColors.primary : AppColors.accentOrange,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
|
||||
@ -11,6 +11,7 @@ class TopNavBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
super.key,
|
||||
required this.title,
|
||||
this.credits,
|
||||
this.trailing,
|
||||
this.showBackButton = false,
|
||||
this.onBack,
|
||||
this.onCreditsTap,
|
||||
@ -20,11 +21,14 @@ class TopNavBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
|
||||
final String title;
|
||||
final String? credits;
|
||||
final Widget? trailing;
|
||||
final bool showBackButton;
|
||||
final VoidCallback? onBack;
|
||||
final VoidCallback? onCreditsTap;
|
||||
|
||||
/// 例如全屏背景页上叠半透明导航栏时用 [Colors.transparent]
|
||||
final Color backgroundColor;
|
||||
|
||||
/// 标题与返回键颜色;默认 [AppColors.textPrimary]
|
||||
final Color? foregroundColor;
|
||||
|
||||
@ -87,13 +91,14 @@ class TopNavBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (credits != null)
|
||||
if (trailing != null)
|
||||
trailing!
|
||||
else if (credits != null)
|
||||
CreditsBadge(
|
||||
credits: credits!,
|
||||
onTap: onCreditsTap,
|
||||
foregroundColor: foregroundColor,
|
||||
capsuleColor:
|
||||
foregroundColor?.withValues(alpha: 0.22),
|
||||
capsuleColor: foregroundColor?.withValues(alpha: 0.22),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 40),
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
name: pets_hero_ai
|
||||
description: PetsHero AI Application.
|
||||
publish_to: 'none'
|
||||
version: 1.1.17+28
|
||||
version: 1.2.0+120
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user