Files
eryao/apps/lib/features/settings/presentation/screens/coin_center_screen.dart
T
ZL-Q 940c67e642 feat(points): 实现积分流水列表功能
- 后端新增 GET /api/v1/points/ledger 接口
- 前端新增积分流水列表页面
- 积分中心添加「查看流水」入口
- 重命名 AccountDeleteScreen 为 AccountDataScreen
- 流水列表支持分页加载和空状态展示
2026-04-28 17:19:08 +08:00

332 lines
10 KiB
Dart

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import '../../../../app/di/injection.dart';
import '../../../../core/auth/session_store.dart';
import '../../../../core/logging/logger.dart';
import '../../../../core/network/api_problem_mapper.dart';
import '../../../../data/network/api_client.dart';
import '../../../../data/storage/local_kv_store.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/app_color_palette.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../payments/data/services/apple_iap_service.dart';
import '../../../points/data/apis/points_api.dart';
import '../../../points/data/models/package_info.dart';
import '../widgets/settings_section_widgets.dart';
class CoinCenterScreen extends StatefulWidget {
const CoinCenterScreen({
super.key,
required this.balance,
required this.userId,
required this.onBalanceChanged,
});
final int balance;
final String userId;
final void Function(int newBalance) onBalanceChanged;
@override
State<CoinCenterScreen> createState() => _CoinCenterScreenState();
}
class _CoinCenterScreenState extends State<CoinCenterScreen> {
final Logger _logger = getLogger('features.settings.coin_center_screen');
List<PackageInfo>? _packages;
bool _isLoading = true;
AppleIapService? _iapService;
@override
void initState() {
super.initState();
_loadPackages();
}
Future<void> _loadPackages() async {
try {
final sessionStore = SessionStore(LocalKvStore());
final apiClient = ApiClient(
baseUrl: appDependencies.backendUrl,
tokenProvider: sessionStore.getToken,
);
final api = PointsApi(apiClient.rawDio);
final result = await api.getPackages();
final service = AppleIapService(
apiClient: apiClient,
userId: widget.userId,
);
service.init();
service.addListener(_onPurchaseStateChanged);
if (await InAppPurchase.instance.isAvailable()) {
await service.loadStoreKitProducts(result.packages);
}
if (mounted) {
setState(() {
_packages = result.packages;
_isLoading = false;
_iapService = service;
});
}
} catch (e, stackTrace) {
_logger.error(
message: 'Failed to load packages',
error: e,
stackTrace: stackTrace,
);
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
Future<void> _reloadPackages() async {
try {
final sessionStore = SessionStore(LocalKvStore());
final apiClient = ApiClient(
baseUrl: appDependencies.backendUrl,
tokenProvider: sessionStore.getToken,
);
final api = PointsApi(apiClient.rawDio);
final result = await api.getPackages();
await _iapService?.loadStoreKitProducts(result.packages);
if (mounted) {
setState(() {
_packages = result.packages;
});
}
} catch (e, stackTrace) {
_logger.warning(
message: 'Failed to reload packages after purchase',
extra: {'error': e.toString()},
);
}
}
void _onPurchaseStateChanged() {
final service = _iapService;
if (service == null || !mounted) return;
final l10n = AppLocalizations.of(context)!;
switch (service.state) {
case PurchaseFlowState.success:
Toast.show(context, l10n.paymentSuccess, type: ToastType.success);
_refreshBalance();
_reloadPackages();
service.resetState();
break;
case PurchaseFlowState.failed:
final apiProblem = service.lastApiProblem;
final error = apiProblem != null
? mapApiProblemToMessage(apiProblem, l10n)
: (service.lastError ?? l10n.paymentVerifyFailed);
Toast.show(context, error, type: ToastType.error);
service.resetState();
break;
case PurchaseFlowState.purchasing:
case PurchaseFlowState.verifying:
case PurchaseFlowState.idle:
break;
}
setState(() {});
}
Future<void> _refreshBalance() async {
try {
final sessionStore = SessionStore(LocalKvStore());
final apiClient = ApiClient(
baseUrl: appDependencies.backendUrl,
tokenProvider: sessionStore.getToken,
);
final response = await apiClient.getJson('/api/v1/points/balance');
final newBalance = response['availableBalance'] as int;
widget.onBalanceChanged(newBalance);
} catch (e, stackTrace) {
_logger.warning(
message: 'Failed to refresh balance after purchase: $e',
extra: {'stackTrace': stackTrace.toString()},
);
}
}
@override
void dispose() {
_iapService?.removeListener(_onPurchaseStateChanged);
_iapService?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
final palette = Theme.of(context).extension<AppColorPalette>()!;
return Scaffold(
backgroundColor: colors.surfaceContainerLow,
appBar: AppBar(
title: Text(l10n.settingsCoinCenterTitle),
centerTitle: true,
backgroundColor: colors.surfaceContainerLow,
surfaceTintColor: colors.surfaceContainerLow,
),
body: ListView(
padding: const EdgeInsets.all(AppSpacing.lg),
children: [
Container(
padding: const EdgeInsets.all(AppSpacing.xl),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.xl),
gradient: LinearGradient(
colors: [colors.primary, palette.accentPurple],
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.monetization_on_rounded,
color: colors.onPrimary,
size: 34,
),
const SizedBox(height: AppSpacing.md),
Text(
l10n.settingsCoinBalanceLabel,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: colors.onPrimary),
),
const SizedBox(height: AppSpacing.xs),
Text(
l10n.settingsCoinBalanceValue(widget.balance),
style: Theme.of(
context,
).textTheme.headlineMedium?.copyWith(color: colors.onPrimary),
),
const SizedBox(height: AppSpacing.sm),
Text(
l10n.settingsCoinCenterDescription,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colors.onPrimary.withValues(alpha: 0.88),
),
),
],
),
),
const SizedBox(height: AppSpacing.xl),
SectionLabel(text: l10n.settingsCoinRechargeSection),
..._buildPackageCards(l10n, colors),
],
),
);
}
List<Widget> _buildPackageCards(AppLocalizations l10n, ColorScheme colors) {
if (_isLoading) {
return [
const Padding(
padding: EdgeInsets.all(AppSpacing.xl),
child: Center(child: CircularProgressIndicator()),
),
];
}
if (_packages == null || _packages!.isEmpty) {
return [];
}
final isBusy = _iapService?.state == PurchaseFlowState.purchasing ||
_iapService?.state == PurchaseFlowState.verifying;
final isPending = _iapService?.state == PurchaseFlowState.purchasing;
return List.generate(_packages!.length, (index) {
final pkg = _packages![index];
final storeKitProduct = _iapService?.getStoreKitProduct(pkg.appStoreProductId);
final isAvailable = storeKitProduct != null;
return Column(
children: [
if (index > 0) const SizedBox(height: AppSpacing.md),
if (isPending && !isBusy)
Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
child: Text(
l10n.paymentPending,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colors.primary,
),
),
),
CoinPackageCard(
title: _getPackageTitle(pkg, l10n),
price: _getDisplayPrice(pkg),
amount: pkg.credits,
badge: _getPackageBadge(pkg, l10n),
onPurchase: () => _handlePurchase(pkg),
isPurchasing: isBusy,
isAvailable: isAvailable,
unavailableMessage: isAvailable ? null : l10n.paymentProductUnavailable,
),
],
);
});
}
String _getDisplayPrice(PackageInfo pkg) {
final product = _iapService?.getStoreKitProduct(pkg.appStoreProductId);
if (product != null) {
return product.price;
}
return '';
}
Future<void> _handlePurchase(PackageInfo pkg) async {
final l10n = AppLocalizations.of(context)!;
if (_iapService == null) {
Toast.show(context, l10n.paymentVerifyFailed, type: ToastType.error);
return;
}
if (!Platform.isIOS) {
Toast.show(context, l10n.paymentVerifyFailed, type: ToastType.error);
return;
}
await _iapService!.purchase(pkg);
}
String? _getPackageBadge(PackageInfo pkg, AppLocalizations l10n) {
if (pkg.productCode == ProductCode.newUserPack) {
return l10n.settingsCoinPackNewUserBadge;
}
if (pkg.productCode == ProductCode.popularPack) {
return l10n.settingsCoinPackPopularBadge;
}
return null;
}
String _getPackageTitle(PackageInfo pkg, AppLocalizations l10n) {
return switch (pkg.productCode) {
ProductCode.newUserPack => l10n.settingsCoinPackStarter,
ProductCode.starterPack => l10n.settingsCoinPackBasic,
ProductCode.popularPack => l10n.settingsCoinPackPopular,
ProductCode.premiumPack => l10n.settingsCoinPackPremium,
};
}
}