新增:积分消费记录

This commit is contained in:
ivan 2026-04-28 17:41:36 +08:00
parent cd6a9ade0a
commit 6747e97cf9
8 changed files with 472 additions and 49 deletions

View File

@ -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 portalknight
/// [queryParams] 使 V2 sentinelasset
/// [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,

View File

@ -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,
},
);
}
}

View File

@ -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;
}

View 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';
}
}

View 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)}';
}

View File

@ -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
@ -38,7 +39,7 @@ class _RechargeScreenState extends State<RechargeScreen>
/// code item Buy loading
String? _loadingProductId;
///
String? _pendingOrderId;
String? _pendingUserId;
@ -67,7 +68,7 @@ class _RechargeScreenState extends State<RechargeScreen>
mounted) {
setState(() => _loadingProductId = null);
}
//
if (state == AppLifecycleState.resumed &&
_pendingOrderId != null &&
@ -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 {
@ -295,10 +300,11 @@ class _RechargeScreenState extends State<RechargeScreen>
if (payUrl != null && payUrl.isNotEmpty) {
if (mounted) {
setState(() => _loadingProductId = null);
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,
),
),

View File

@ -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),

View File

@ -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'