452 lines
13 KiB
Dart
452 lines
13 KiB
Dart
// Author: fruit.com.cn
|
|
|
|
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../../core/data/models/wallet.dart';
|
|
import '../../core/data/services/wallet_controller.dart';
|
|
import '../../core/formatting/error_text.dart';
|
|
import '../../core/formatting/localized_date_time.dart';
|
|
import '../../core/theme/app_colors.dart';
|
|
import '../../core/theme/app_dimens.dart';
|
|
import '../../core/theme/app_theme.dart';
|
|
import '../../core/widgets/app_pull_to_refresh.dart';
|
|
import '../../core/widgets/empty_state.dart';
|
|
import '../../core/widgets/plain_page_scaffold.dart';
|
|
import '../store/recharge_page.dart';
|
|
import '../store/vip_page.dart';
|
|
import '../../l10n/app_localizations.dart';
|
|
|
|
/// 金币钱包:余额、累计收支与最近流水。
|
|
class WalletPage extends StatefulWidget {
|
|
const WalletPage({super.key});
|
|
|
|
@override
|
|
State<WalletPage> createState() => _WalletPageState();
|
|
}
|
|
|
|
class _WalletPageState extends State<WalletPage> {
|
|
bool _requested = false;
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
if (_requested) return;
|
|
_requested = true;
|
|
final wallet = context.read<WalletController>();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) unawaited(wallet.load());
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final wallet = context.watch<WalletController>();
|
|
return Theme(
|
|
data: AppTheme.light(),
|
|
child: PlainPageScaffold(
|
|
pageKey: const Key('wallet-page'),
|
|
backKey: const Key('wallet-back'),
|
|
title: l10n.wallet,
|
|
child: _body(l10n, wallet),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _storeActions(AppLocalizations l10n, WalletController wallet) {
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
child: FilledButton.icon(
|
|
key: const Key('wallet-top-up'),
|
|
onPressed: () => _openRecharge(wallet),
|
|
icon: const Icon(Icons.add_card_rounded, size: 16),
|
|
label: Text(l10n.topUpWallet),
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: AppColors.blue,
|
|
foregroundColor: AppColors.onPrimary,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: OutlinedButton.icon(
|
|
key: const Key('wallet-vip'),
|
|
onPressed: () => _openVip(wallet),
|
|
icon: const Icon(Icons.workspace_premium_outlined, size: 16),
|
|
label: Text(l10n.vipMembership),
|
|
style: OutlinedButton.styleFrom(foregroundColor: AppColors.gold),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
void _openRecharge(WalletController wallet) {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) =>
|
|
RechargePage(onPurchased: () => unawaited(wallet.load())),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openVip(WalletController wallet) {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => VipPage(onPurchased: () => unawaited(wallet.load())),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _body(AppLocalizations l10n, WalletController wallet) {
|
|
if (wallet.loading && wallet.snapshot == null) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
final snapshot = wallet.snapshot;
|
|
if (snapshot == null) {
|
|
return EmptyState(
|
|
key: const Key('wallet-error'),
|
|
icon: Icons.account_balance_wallet_outlined,
|
|
title: errorText(l10n, wallet.error, l10n.walletLoadFailed),
|
|
actionLabel: l10n.retry,
|
|
actionKey: const Key('wallet-retry'),
|
|
onAction: () => unawaited(wallet.load()),
|
|
);
|
|
}
|
|
return AppPullToRefresh(
|
|
onRefresh: wallet.load,
|
|
child: ListView(
|
|
physics: AppPullToRefresh.physics,
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 28),
|
|
children: [
|
|
_BalanceHero(wallet: snapshot.wallet),
|
|
const SizedBox(height: 12),
|
|
_storeActions(l10n, wallet),
|
|
const SizedBox(height: 14),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _SummaryTile(
|
|
icon: Icons.add_card_rounded,
|
|
label: l10n.totalRecharged,
|
|
value: snapshot.wallet.totalRechargedCoin,
|
|
color: AppColors.success,
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: _SummaryTile(
|
|
icon: Icons.local_fire_department_rounded,
|
|
label: l10n.totalConsumed,
|
|
value: snapshot.wallet.totalConsumedCoin,
|
|
color: AppColors.accent,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 22),
|
|
Text(
|
|
l10n.coinLedger,
|
|
style: const TextStyle(
|
|
color: AppColors.onSurface,
|
|
fontSize: AppDimens.sectionTitle,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
if (snapshot.ledger.isEmpty)
|
|
_LedgerEmpty(message: l10n.noCoinLedger)
|
|
else
|
|
for (final entry in snapshot.ledger) ...[
|
|
_LedgerTile(entry: entry),
|
|
const SizedBox(height: 8),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _BalanceHero extends StatelessWidget {
|
|
const _BalanceHero({required this.wallet});
|
|
|
|
final CoinWallet wallet;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return Container(
|
|
key: const Key('wallet-balance-card'),
|
|
padding: const EdgeInsets.fromLTRB(20, 22, 20, 20),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(20),
|
|
gradient: const LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [AppColors.goldCard, AppColors.card],
|
|
),
|
|
border: Border.all(color: AppColors.gold.withValues(alpha: 0.35)),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: AppColors.gold.withValues(alpha: 0.08),
|
|
blurRadius: 28,
|
|
offset: const Offset(0, 14),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
width: 38,
|
|
height: 38,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.gold.withValues(alpha: 0.15),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(
|
|
Icons.monetization_on_rounded,
|
|
color: AppColors.gold,
|
|
size: 21,
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Text(
|
|
l10n.coinBalance,
|
|
style: const TextStyle(
|
|
color: AppColors.muted,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 18),
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Text(
|
|
'${wallet.coinBalance}',
|
|
key: const Key('wallet-balance'),
|
|
style: const TextStyle(
|
|
color: AppColors.onSurface,
|
|
fontSize: 42,
|
|
height: 1,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 5),
|
|
child: Text(
|
|
l10n.coins,
|
|
style: const TextStyle(
|
|
color: AppColors.gold,
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SummaryTile extends StatelessWidget {
|
|
const _SummaryTile({
|
|
required this.icon,
|
|
required this.label,
|
|
required this.value,
|
|
required this.color,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String label;
|
|
final int value;
|
|
final Color color;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.card,
|
|
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
|
|
border: Border.all(color: AppColors.border),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(icon, color: color, size: 18),
|
|
const SizedBox(height: 10),
|
|
Text(
|
|
'$value',
|
|
style: const TextStyle(
|
|
color: AppColors.onSurface,
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
label,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(color: AppColors.subtle, fontSize: 11),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LedgerTile extends StatelessWidget {
|
|
const _LedgerTile({required this.entry});
|
|
|
|
final CoinLedgerEntry entry;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final positive = entry.changeAmount >= 0;
|
|
final color = positive ? AppColors.success : AppColors.accent;
|
|
final amount = positive
|
|
? '+${entry.changeAmount}'
|
|
: '${entry.changeAmount}';
|
|
return Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.card,
|
|
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
|
|
border: Border.all(color: AppColors.border),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 34,
|
|
height: 34,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: color.withValues(alpha: 0.12),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Icon(
|
|
positive ? Icons.south_west_rounded : Icons.north_east_rounded,
|
|
color: color,
|
|
size: 18,
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
_ledgerLabel(context, entry.bizType),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: AppColors.onSurface,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
if (entry.remark.isNotEmpty) ...[
|
|
const SizedBox(height: 3),
|
|
Text(
|
|
entry.remark,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: AppColors.subtle,
|
|
fontSize: 11,
|
|
),
|
|
),
|
|
],
|
|
if (entry.createdAt != null) ...[
|
|
const SizedBox(height: 3),
|
|
Text(
|
|
formatLocalizedDateTime(context, entry.createdAt!),
|
|
style: const TextStyle(
|
|
color: AppColors.subtle,
|
|
fontSize: 10,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Text(
|
|
amount,
|
|
key: Key('wallet-ledger-${entry.id}'),
|
|
style: TextStyle(
|
|
color: color,
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'${entry.balanceAfter}',
|
|
style: const TextStyle(color: AppColors.subtle, fontSize: 10),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _ledgerLabel(BuildContext context, String bizType) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return switch (bizType) {
|
|
'UNLOCK' => l10n.ledgerUnlock,
|
|
'RECHARGE' || 'ORDER' => l10n.ledgerRecharge,
|
|
'REFUND' => l10n.ledgerRefund,
|
|
_ => l10n.ledgerAdjustment,
|
|
};
|
|
}
|
|
}
|
|
|
|
class _LedgerEmpty extends StatelessWidget {
|
|
const _LedgerEmpty({required this.message});
|
|
|
|
final String message;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 22),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.card,
|
|
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
|
|
border: Border.all(color: AppColors.border),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.receipt_long_outlined, color: AppColors.subtle),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
message,
|
|
style: const TextStyle(color: AppColors.subtle, fontSize: 12),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|