940c67e642
- 后端新增 GET /api/v1/points/ledger 接口 - 前端新增积分流水列表页面 - 积分中心添加「查看流水」入口 - 重命名 AccountDeleteScreen 为 AccountDataScreen - 流水列表支持分页加载和空状态展示
332 lines
10 KiB
Dart
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,
|
|
};
|
|
}
|
|
}
|