新增:积分消费记录
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:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
@ -81,7 +91,9 @@ void _logLong(String text) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 遇到 { 或 [ 视为 JSON 开始,直到与之匹配的 } 或 ] 结束;格式化后单条不超过 1000 字,分条时按行切分保持对齐。
|
/// 遇到 `{` / `[` 视为 JSON 片段并尝试美化输出;否则按普通字符串处理。
|
||||||
|
///
|
||||||
|
/// 无输出条件:非 debug 且未开启 [ApiConfig.debugLogs](与 [AppLogger] 一致)。
|
||||||
void logWithEmbeddedJson(Object? msg) {
|
void logWithEmbeddedJson(Object? msg) {
|
||||||
if (!kDebugMode && !ApiConfig.debugLogs) return;
|
if (!kDebugMode && !ApiConfig.debugLogs) return;
|
||||||
|
|
||||||
@ -163,11 +175,12 @@ void logWithEmbeddedJson(Object? msg) {
|
|||||||
if (out.isNotEmpty) _logLong(out);
|
if (out.isNotEmpty) _logLong(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 代理调试输出的统一入口(走 [logWithEmbeddedJson])。
|
||||||
void _log(String msg) {
|
void _log(String msg) {
|
||||||
logWithEmbeddedJson(msg);
|
logWithEmbeddedJson(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 代理请求体字段名(统一请求参数)
|
/// 发往网关 JSON 体中的固定键名(各槽位承载加密后的 path/method/headers/query/body)。
|
||||||
abstract final class ProxyKeys {
|
abstract final class ProxyKeys {
|
||||||
static const String heroClass = 'hero_class';
|
static const String heroClass = 'hero_class';
|
||||||
static const String petSpecies = 'pet_species';
|
static const String petSpecies = 'pet_species';
|
||||||
@ -183,7 +196,7 @@ abstract final class ProxyKeys {
|
|||||||
static const String dirPath = 'dir_path';
|
static const String dirPath = 'dir_path';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 代理请求客户端
|
/// 代理请求客户端:维护可选 `knight`(用户 Token),并对单次请求做加密与解析。
|
||||||
class ProxyClient {
|
class ProxyClient {
|
||||||
ProxyClient({
|
ProxyClient({
|
||||||
this.baseUrl,
|
this.baseUrl,
|
||||||
@ -222,12 +235,14 @@ class ProxyClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 发送代理请求
|
/// 发送代理请求。
|
||||||
/// [path] 接口路径,如 /v1/user/fast_login
|
///
|
||||||
/// [method] HTTP 方法,POST 或 GET
|
/// `path`、`method`、`headers`、`queryParams`、`body` 会先序列化为 JSON,再分别加密写入 [ProxyKeys];
|
||||||
/// [headers] 请求头,使用 V2 字段名(portal、knight 等)
|
/// 实际 HTTP 始终为 POST,`Content-Type: application/json`。
|
||||||
/// [queryParams] 查询参数,使用 V2 字段名(sentinel、asset 等)
|
/// 超时见 [ApiConfig.httpRequestTimeout];超时返回 `code == -1`。
|
||||||
/// [body] 请求体,使用 V2 字段名,将填入 sanctum
|
///
|
||||||
|
/// [path] 例如 `/v1/user/fast_login`;[headers] 常用 `portal`、`knight`;
|
||||||
|
/// [queryParams] 常用 `sentinel`、`asset`;[body] 映射进包装体 `sanctum`。
|
||||||
Future<ApiResponse> request({
|
Future<ApiResponse> request({
|
||||||
required String path,
|
required String path,
|
||||||
required String method,
|
required String method,
|
||||||
@ -250,7 +265,7 @@ class ProxyClient {
|
|||||||
final sanctum = body ?? {};
|
final sanctum = body ?? {};
|
||||||
final v2Body = _buildV2Wrapper(sanctum);
|
final v2Body = _buildV2Wrapper(sanctum);
|
||||||
|
|
||||||
// 原始入参
|
// 加密前明文(仅调试日志使用)
|
||||||
final headersEncoded = jsonEncode(headersMap);
|
final headersEncoded = jsonEncode(headersMap);
|
||||||
final paramsEncoded = jsonEncode(paramsMap);
|
final paramsEncoded = jsonEncode(paramsMap);
|
||||||
final v2BodyEncoded = jsonEncode(v2Body);
|
final v2BodyEncoded = jsonEncode(v2Body);
|
||||||
@ -310,9 +325,10 @@ class ProxyClient {
|
|||||||
return _parseResponse(response);
|
return _parseResponse(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 解密响应体 JSON,映射为 [ApiResponse](业务字段:`helm`=code,`rampart`=msg,`sidekick`=data)。
|
||||||
ApiResponse _parseResponse(http.Response response) {
|
ApiResponse _parseResponse(http.Response response) {
|
||||||
try {
|
try {
|
||||||
// 响应解密流程:Base64 解码 → AES-ECB 解密 → PKCS5 去填充 → UTF-8 字符串
|
// Base64 → AES-ECB → UTF-8 JSON
|
||||||
var responseLogStr = '========== 响应 ===========';
|
var responseLogStr = '========== 响应 ===========';
|
||||||
final decrypted = ApiCrypto.decrypt(response.body);
|
final decrypted = ApiCrypto.decrypt(response.body);
|
||||||
final json = jsonDecode(decrypted) as Map<String, dynamic>;
|
final json = jsonDecode(decrypted) as Map<String, dynamic>;
|
||||||
@ -338,7 +354,7 @@ class ProxyClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 统一 API 响应
|
/// 代理层统一响应:`code == 0` 表示成功。
|
||||||
class ApiResponse {
|
class ApiResponse {
|
||||||
ApiResponse({
|
ApiResponse({
|
||||||
required this.code,
|
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';
|
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 {
|
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;
|
static bool get debugLogs => kDebugMode || ApiConfig.debugLogs;
|
||||||
|
|
||||||
/// Facebook 应用 ID(应用编号)
|
/// Facebook 应用编号(Application ID),用于 AndroidManifest / Info.plist 元数据等。
|
||||||
static const String appId = '1684216162986495';
|
static const String appId = '1684216162986495';
|
||||||
|
|
||||||
/// Facebook Client Token(客户端口令)
|
/// Facebook **客户端**口令(Client Token)。
|
||||||
///
|
///
|
||||||
/// 在 Facebook 开发者后台获取:应用 → 设置 → 高级 → 客户端口令
|
/// 路径:Meta 开发者后台 → 应用 → **设置 → 高级 → 客户端口令**。\
|
||||||
|
/// 若为空字符串,部分 SDK 初始化可能受限;[hasClientToken] 可用于分支判断。
|
||||||
static const String clientToken = '';
|
static const String clientToken = '';
|
||||||
|
|
||||||
/// 安装引荐来源解密密钥
|
/// Meta / FB **安装广告**引荐来源的解密密钥(与客户端 Client Token 不同)。
|
||||||
///
|
///
|
||||||
/// 用于解密 Facebook App Install Ads 的加密 referrer。
|
/// 用于解密加密后的 Install Referrer;需在 **Adjust** 控制台 Partner setup → Meta/Facebook 等处配置,
|
||||||
/// 请在 Adjust 控制台填写此密钥以启用解密
|
/// 与 Adjust 文档路径一致。
|
||||||
/// (路径:App Settings → Partner setup → Meta/Facebook)。
|
|
||||||
static const String installReferrerDecryptionKey =
|
static const String installReferrerDecryptionKey =
|
||||||
'068aff9bac7e8846b94e9fc73d51c7a5ab7c8ac39fe9a2b16d0ff8b74f98f';
|
'068aff9bac7e8846b94e9fc73d51c7a5ab7c8ac39fe9a2b16d0ff8b74f98f';
|
||||||
|
|
||||||
/// 应用密钥(App Secret)仅用于服务端,勿放入客户端。
|
/// [clientToken] 是否已配置(非空)。
|
||||||
|
///
|
||||||
|
/// **App Secret** 仅用于服务端,切勿打进客户端包。
|
||||||
static bool get hasClientToken => clientToken.isNotEmpty;
|
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/activity_item.dart';
|
||||||
import 'models/payment_method_item.dart';
|
import 'models/payment_method_item.dart';
|
||||||
import 'payment_webview_screen.dart';
|
import 'payment_webview_screen.dart';
|
||||||
|
import 'points_history_screen.dart';
|
||||||
|
|
||||||
/// Recharge screen - matches Pencil tPjdN
|
/// Recharge screen - matches Pencil tPjdN
|
||||||
/// Tier cards (DC6NS, YRaOG, QlNz6) 对接 getGooglePayActivities / getApplePayActivities
|
/// Tier cards (DC6NS, YRaOG, QlNz6) 对接 getGooglePayActivities / getApplePayActivities
|
||||||
@ -119,7 +120,8 @@ class _RechargeScreenState extends State<RechargeScreen>
|
|||||||
setState(() {
|
setState(() {
|
||||||
_activities = [];
|
_activities = [];
|
||||||
_loadingTiers = false;
|
_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) {
|
} catch (e) {
|
||||||
@ -127,7 +129,8 @@ class _RechargeScreenState extends State<RechargeScreen>
|
|||||||
setState(() {
|
setState(() {
|
||||||
_activities = [];
|
_activities = [];
|
||||||
_loadingTiers = false;
|
_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) {
|
} catch (e) {
|
||||||
if (mounted) {
|
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();
|
AdjustEvents.trackPaymentFailed();
|
||||||
} finally {
|
} finally {
|
||||||
@ -298,7 +303,8 @@ class _RechargeScreenState extends State<RechargeScreen>
|
|||||||
|
|
||||||
if (_shouldUseSystemBrowser(openType)) {
|
if (_shouldUseSystemBrowser(openType)) {
|
||||||
// 打开系统浏览器
|
// 打开系统浏览器
|
||||||
await launchUrl(Uri.parse(payUrl), mode: LaunchMode.externalApplication);
|
await launchUrl(Uri.parse(payUrl),
|
||||||
|
mode: LaunchMode.externalApplication);
|
||||||
// 系统浏览器返回后,用户回到应用时会通过 didChangeAppLifecycleState 触发轮询
|
// 系统浏览器返回后,用户回到应用时会通过 didChangeAppLifecycleState 触发轮询
|
||||||
_pendingOrderId = orderId;
|
_pendingOrderId = orderId;
|
||||||
_pendingUserId = userId;
|
_pendingUserId = userId;
|
||||||
@ -340,7 +346,9 @@ class _RechargeScreenState extends State<RechargeScreen>
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
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();
|
AdjustEvents.trackPaymentFailed();
|
||||||
} finally {
|
} finally {
|
||||||
@ -447,7 +455,9 @@ class _RechargeScreenState extends State<RechargeScreen>
|
|||||||
serverOrderId: orderId, userId: userId);
|
serverOrderId: orderId, userId: userId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
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();
|
AdjustEvents.trackPaymentFailed();
|
||||||
} finally {
|
} finally {
|
||||||
@ -525,7 +535,8 @@ class _RechargeScreenState extends State<RechargeScreen>
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_showSnackBar(context, 'Google Pay is temporarily unavailable. Please try again.',
|
_showSnackBar(
|
||||||
|
context, 'Google Pay is temporarily unavailable. Please try again.',
|
||||||
isError: true);
|
isError: true);
|
||||||
}
|
}
|
||||||
AdjustEvents.trackPaymentFailed();
|
AdjustEvents.trackPaymentFailed();
|
||||||
@ -556,6 +567,40 @@ class _RechargeScreenState extends State<RechargeScreen>
|
|||||||
child: TopNavBar(
|
child: TopNavBar(
|
||||||
title: 'Recharge',
|
title: 'Recharge',
|
||||||
showBackButton: true,
|
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
|
onBack: _loadingProductId != null
|
||||||
? () {}
|
? () {}
|
||||||
: () => Navigator.of(context).pop(),
|
: () => Navigator.of(context).pop(),
|
||||||
@ -630,19 +675,13 @@ class _RechargeScreenState extends State<RechargeScreen>
|
|||||||
else
|
else
|
||||||
...List.generate(_activities.length, (i) {
|
...List.generate(_activities.length, (i) {
|
||||||
final item = _activities[i];
|
final item = _activities[i];
|
||||||
const isRecommended = false;
|
|
||||||
const isPopular = false;
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
bottom: i < _activities.length - 1 ? AppSpacing.xl : 0,
|
bottom: i < _activities.length - 1 ? AppSpacing.xl : 0,
|
||||||
),
|
),
|
||||||
child: _TierCardFromActivity(
|
child: _TierCardFromActivity(
|
||||||
item: item,
|
item: item,
|
||||||
badge: isRecommended
|
badge: _TierBadge.none,
|
||||||
? _TierBadge.recommended
|
|
||||||
: isPopular
|
|
||||||
? _TierBadge.popular
|
|
||||||
: _TierBadge.none,
|
|
||||||
loading: _loadingProductId == item.code,
|
loading: _loadingProductId == item.code,
|
||||||
onBuy: () => _onBuy(item),
|
onBuy: () => _onBuy(item),
|
||||||
),
|
),
|
||||||
@ -931,6 +970,8 @@ class _TierCardFromActivity extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final isRecommended = badge == _TierBadge.recommended;
|
||||||
|
final isPopular = badge == _TierBadge.popular;
|
||||||
final content = Container(
|
final content = Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.screenPadding),
|
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.screenPadding),
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
@ -1034,11 +1075,14 @@ class _TierCardFromActivity extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
badge == _TierBadge.recommended ? 'Recommended' : 'Most Popular',
|
isRecommended
|
||||||
|
? 'Recommended'
|
||||||
|
: isPopular
|
||||||
|
? 'Most Popular'
|
||||||
|
: '',
|
||||||
style: AppTypography.label.copyWith(
|
style: AppTypography.label.copyWith(
|
||||||
color: badge == _TierBadge.recommended
|
color:
|
||||||
? AppColors.primary
|
isRecommended ? AppColors.primary : AppColors.accentOrange,
|
||||||
: AppColors.accentOrange,
|
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -11,6 +11,7 @@ class TopNavBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.title,
|
required this.title,
|
||||||
this.credits,
|
this.credits,
|
||||||
|
this.trailing,
|
||||||
this.showBackButton = false,
|
this.showBackButton = false,
|
||||||
this.onBack,
|
this.onBack,
|
||||||
this.onCreditsTap,
|
this.onCreditsTap,
|
||||||
@ -20,11 +21,14 @@ class TopNavBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
|
|
||||||
final String title;
|
final String title;
|
||||||
final String? credits;
|
final String? credits;
|
||||||
|
final Widget? trailing;
|
||||||
final bool showBackButton;
|
final bool showBackButton;
|
||||||
final VoidCallback? onBack;
|
final VoidCallback? onBack;
|
||||||
final VoidCallback? onCreditsTap;
|
final VoidCallback? onCreditsTap;
|
||||||
|
|
||||||
/// 例如全屏背景页上叠半透明导航栏时用 [Colors.transparent]
|
/// 例如全屏背景页上叠半透明导航栏时用 [Colors.transparent]
|
||||||
final Color backgroundColor;
|
final Color backgroundColor;
|
||||||
|
|
||||||
/// 标题与返回键颜色;默认 [AppColors.textPrimary]
|
/// 标题与返回键颜色;默认 [AppColors.textPrimary]
|
||||||
final Color? foregroundColor;
|
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(
|
CreditsBadge(
|
||||||
credits: credits!,
|
credits: credits!,
|
||||||
onTap: onCreditsTap,
|
onTap: onCreditsTap,
|
||||||
foregroundColor: foregroundColor,
|
foregroundColor: foregroundColor,
|
||||||
capsuleColor:
|
capsuleColor: foregroundColor?.withValues(alpha: 0.22),
|
||||||
foregroundColor?.withValues(alpha: 0.22),
|
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
const SizedBox(width: 40),
|
const SizedBox(width: 40),
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
name: pets_hero_ai
|
name: pets_hero_ai
|
||||||
description: PetsHero AI Application.
|
description: PetsHero AI Application.
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.1.17+28
|
version: 1.2.0+120
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user