import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/module/app_module.dart'; import '../../core/network/api_exception.dart'; import '../../core/network/auth_redirect.dart'; import '../../models/account.dart'; import '../../models/budget.dart'; import '../../providers/account_provider.dart'; import '../../providers/auth_provider.dart'; import '../../providers/budget_provider.dart'; import '../../utils/amount_input_formatter.dart'; import '../../utils/amount_util.dart'; import '../../utils/toast.dart'; import '../../widgets/cute_icon.dart'; import '../../widgets/state_views.dart'; /// 「我的」页面 /// /// 账户余额可手动修改;总收入 / 总支出由账单汇总得出,只读; /// 本月预算可设置与随时修改(按月生效)。 class MinePage extends ConsumerWidget { const MinePage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final user = ref.watch(authProvider).user; final accountAsync = ref.watch(accountProvider); final budgetAsync = ref.watch(budgetProvider); return Scaffold( body: Container( width: double.infinity, height: double.infinity, decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFFFE5CC), Color(0xFFFFD4A8), Color(0xFFFFC08A)], ), ), child: SafeArea( child: RefreshIndicator( color: const Color(0xFFC8956E), onRefresh: () => _onRefresh(ref), child: ListView( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.all(16), children: [ _userCard(user?.nickname ?? '用户', user?.username ?? '-'), const SizedBox(height: 14), _accountCard(context, ref, accountAsync), const SizedBox(height: 14), _budgetCard(context, ref, budgetAsync), const SizedBox(height: 14), _moduleSection(context), const SizedBox(height: 24), _logoutButton(context), const SizedBox(height: 90), ], ), ), ), ), ); } Future _onRefresh(WidgetRef ref) async { ref.invalidate(accountProvider); ref.invalidate(budgetProvider); await Future.wait([ _silent(ref.read(accountProvider.future)), _silent(ref.read(budgetProvider.future)), ]); } Future _silent(Future future) => future.then((_) {}, onError: (_, _) {}); // ---------------- 用户信息 ---------------- Widget _userCard(String nickname, String username) { return Container( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), decoration: BoxDecoration( borderRadius: BorderRadius.circular(20), gradient: const LinearGradient( colors: [Color(0xFFC8956E), Color(0xFFF5D59E), Color(0xFFFFD700)], ), border: Border.all(color: const Color(0xFF5D4037), width: 1.5), boxShadow: [ BoxShadow( color: const Color(0xFF5D4037).withValues(alpha: 0.2), blurRadius: 12, offset: const Offset(0, 5), ), ], ), child: Row( children: [ Container( width: 60, height: 60, decoration: const BoxDecoration(shape: BoxShape.circle, color: Colors.white), alignment: Alignment.center, child: const CuteIcon('user', size: 32), ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( nickname.isEmpty ? '用户' : nickname, style: const TextStyle( fontSize: 19, fontWeight: FontWeight.bold, color: Color(0xFF5D4037), ), ), const SizedBox(height: 4), Text( '账号:$username', style: const TextStyle(fontSize: 13, color: Color(0xFF6D4C41)), ), ], ), ), ], ), ); } // ---------------- 账户卡 ---------------- Widget _accountCard(BuildContext context, WidgetRef ref, AsyncValue accountAsync) { return Container( padding: const EdgeInsets.all(18), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), ), child: accountAsync.when( loading: () => const LoadingView(tip: '账户加载中...', padding: EdgeInsets.all(20)), error: (e, _) => ErrorView( message: asApiException(e).message, onRetry: () => ref.invalidate(accountProvider), ), data: (account) => _accountContent(context, ref, account), ), ); } Widget _accountContent(BuildContext context, WidgetRef ref, AccountInfo account) { final negative = account.balance < 0; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Text( '账户余额', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037)), ), const Spacer(), _editButton( label: '修改', onPressed: () => _editBalance(context, ref, account), ), ], ), const SizedBox(height: 6), Text( '¥${formatAmount(account.balance)}', style: TextStyle( fontSize: 30, fontWeight: FontWeight.bold, color: negative ? const Color(0xFFFA3534) : const Color(0xFF5D4037), ), ), const SizedBox(height: 16), const Divider(height: 1, color: Color(0xFFF5EEE4)), const SizedBox(height: 14), Row( children: [ _readOnlyStat('总收入', account.totalIncome, const Color(0xFF19BE6B)), Container(width: 1, height: 26, color: const Color(0xFFF0E6D8)), _readOnlyStat('总支出', account.totalExpense, const Color(0xFFFA3534)), ], ), ], ); } /// 只读统计项:来自账单汇总,不可修改(用锁图标提示) Widget _readOnlyStat(String label, double amount, Color color) { return Expanded( child: Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text(label, style: const TextStyle(fontSize: 12.5, color: Color(0xFF8D6E63))), const SizedBox(width: 4), const Icon(Icons.lock_outline, size: 11, color: Color(0xFFBCAAA4)), ], ), const SizedBox(height: 6), Text( '¥${formatAmount(amount)}', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: color), ), ], ), ); } // ---------------- 预算卡 ---------------- Widget _budgetCard(BuildContext context, WidgetRef ref, AsyncValue budgetAsync) { return Container( padding: const EdgeInsets.all(18), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), ), child: budgetAsync.when( loading: () => const LoadingView(tip: '预算加载中...', padding: EdgeInsets.all(20)), error: (e, _) => ErrorView( message: asApiException(e).message, onRetry: () => ref.invalidate(budgetProvider), ), data: (budget) => _budgetContent(context, ref, budget), ), ); } Widget _budgetContent(BuildContext context, WidgetRef ref, BudgetInfo budget) { final hasBudget = budget.hasBudget; final over = budget.isOverBudget; final ratio = budget.usedRatio; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Text( '本月预算', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037)), ), const SizedBox(width: 6), if (!hasBudget) const Text( '(未设置)', style: TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)), ), const Spacer(), _editButton( label: hasBudget ? '修改' : '设置', onPressed: () => _editBudget(context, ref, budget), ), ], ), const SizedBox(height: 10), Row( crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ Text( '¥${formatAmount(budget.amount)}', style: const TextStyle( fontSize: 26, fontWeight: FontWeight.bold, color: Color(0xFF5D4037), ), ), const SizedBox(width: 8), if (hasBudget) Text( over ? '已超支' : '每月生效,可随时修改', style: TextStyle( fontSize: 12, color: over ? const Color(0xFFE53935) : const Color(0xFFBCAAA4), ), ), ], ), if (hasBudget) ...[ const SizedBox(height: 14), ClipRRect( borderRadius: BorderRadius.circular(5), child: Stack( children: [ Container(height: 8, color: const Color(0xFFF5EEE4)), FractionallySizedBox( widthFactor: ratio, child: Container( height: 8, decoration: BoxDecoration( gradient: LinearGradient( colors: over ? const [Color(0xFFE53935), Color(0xFFFF8A80)] : const [Color(0xFFC8956E), Color(0xFFFFD700)], ), ), ), ), ], ), ), const SizedBox(height: 8), Row( children: [ Text( '已用 ${(ratio * 100).round()}%', style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63)), ), const Spacer(), Text( '剩余 ¥${formatAmount(budget.remainingAmount)}', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: budget.remainingAmount < 0 ? const Color(0xFFE53935) : const Color(0xFF19BE6B), ), ), ], ), const SizedBox(height: 14), Container( width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( gradient: const LinearGradient(colors: [Color(0x33C8956E), Color(0x33FFD700)]), borderRadius: BorderRadius.circular(12), border: Border.all(color: const Color(0xFFC8956E)), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ const CuteIcon('target', size: 15), const SizedBox(width: 5), const Text('日均可用 ', style: TextStyle(fontSize: 13, color: Color(0xFF6D4C41))), Text( '¥${formatAmount(budget.remainingDaily)}', style: const TextStyle( fontSize: 15, fontWeight: FontWeight.bold, color: Color(0xFF5D4037), ), ), ], ), ), ], ], ); } Widget _editButton({required String label, required VoidCallback onPressed}) { return TextButton.icon( onPressed: onPressed, icon: const Icon(Icons.edit_outlined, size: 16), label: Text(label, style: const TextStyle(fontSize: 13)), style: TextButton.styleFrom( foregroundColor: const Color(0xFFC8956E), padding: const EdgeInsets.symmetric(horizontal: 8), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), ); } // ---------------- 功能模块 ---------------- // // 和首页仪表盘的卡片共用同一份注册表(core/module/app_module.dart), // 所以新增模块时这个页面不用改。 Widget _moduleSection(BuildContext context) { return Material( color: Colors.white, borderRadius: BorderRadius.circular(16), clipBehavior: Clip.antiAlias, // 让水波纹被圆角裁掉 child: Padding( padding: const EdgeInsets.fromLTRB(18, 16, 8, 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '功能模块', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037), ), ), const SizedBox(height: 2), for (final module in appModules) _moduleRow(context, module), ], ), ), ); } Widget _moduleRow(BuildContext context, AppModule module) { return InkWell( onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: module.open), ), child: Padding( padding: const EdgeInsets.symmetric(vertical: 12), child: Row( children: [ CuteIcon(module.icon, size: 21), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( module.name, style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF5D4037), ), ), const SizedBox(height: 2), Text( module.blurb, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63)), ), ], ), ), const Padding( padding: EdgeInsets.only(right: 3), child: Icon(Icons.chevron_right, color: Color(0xFFBCAAA4)), ), ], ), ), ); } // ---------------- 修改余额 ---------------- Future _editBalance(BuildContext context, WidgetRef ref, AccountInfo account) async { final input = await _showAmountDialog( context: context, title: '修改账户余额', initialValue: account.balance, hint: '请输入当前账户余额', note: '修改后的余额将作为新的基准,之后记账会在其基础上增减。', allowNegative: true, // 余额可能为负 ); if (input == null) return; try { await ref.read(accountProvider.notifier).setBalance(input); if (context.mounted) showToast(context, '余额已更新', icon: 'check'); } on ApiException catch (e) { if (context.mounted) showToast(context, e.message, error: true); } catch (_) { if (context.mounted) showToast(context, '余额更新失败,请重试', error: true); } } // ---------------- 修改预算 ---------------- Future _editBudget(BuildContext context, WidgetRef ref, BudgetInfo budget) async { final input = await _showAmountDialog( context: context, title: budget.hasBudget ? '修改本月预算' : '设置本月预算', initialValue: budget.amount, hint: '请输入预算金额', note: '预算按月生效,可随时修改;本月账单支出会累计到「已用」。', ); if (input == null) return; try { await ref.read(budgetProvider.notifier).setMonthBudget(input); if (context.mounted) showToast(context, '预算已保存', icon: 'check'); } on ApiException catch (e) { if (context.mounted) showToast(context, e.message, error: true); } catch (_) { if (context.mounted) showToast(context, '预算保存失败,请重试', error: true); } } /// 金额输入弹窗(修改余额 / 修改预算 共用) /// /// [allowNegative] 仅余额编辑开启——余额可能为负,预算与记账金额不会。 Future _showAmountDialog({ required BuildContext context, required String title, required double initialValue, required String hint, required String note, bool allowNegative = false, }) async { final controller = TextEditingController(text: initialValue.toStringAsFixed(2)); // 预填内容默认全选,输入即替换,省去手动清空 controller.selection = TextSelection( baseOffset: 0, extentOffset: controller.text.length, ); try { return await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(title), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( controller: controller, autofocus: true, keyboardType: const TextInputType.numberWithOptions( decimal: true, signed: true, ), inputFormatters: [AmountInputFormatter(allowNegative: allowNegative)], style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: Color(0xFF5D4037), ), decoration: InputDecoration( hintText: hint, prefixText: '¥ ', prefixStyle: const TextStyle( fontSize: 18, color: Color(0xFFC8956E), fontWeight: FontWeight.bold, ), ), ), const SizedBox(height: 12), Text(note, style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63))), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext), child: const Text('取消', style: TextStyle(color: Color(0xFF8D6E63))), ), TextButton( onPressed: () => Navigator.pop(dialogContext, double.tryParse(controller.text)), child: const Text('保存', style: TextStyle(color: Color(0xFFC8956E))), ), ], ), ); } finally { controller.dispose(); } } // ---------------- 退出登录 ---------------- Widget _logoutButton(BuildContext context) { return SizedBox( width: double.infinity, height: 48, child: OutlinedButton( style: OutlinedButton.styleFrom( backgroundColor: Colors.white, foregroundColor: const Color(0xFFD32F2F), side: const BorderSide(color: Color(0xFFE0C9A6)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), ), onPressed: () => _confirmLogout(context), child: const Text('退出登录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), ), ); } Future _confirmLogout(BuildContext context) async { final confirmed = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('提示'), content: const Text('确定要退出登录吗?'), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext, false), child: const Text('取消', style: TextStyle(color: Color(0xFF8D6E63))), ), TextButton( onPressed: () => Navigator.pop(dialogContext, true), child: const Text('确定退出', style: TextStyle(color: Color(0xFFD32F2F))), ), ], ), ); if (confirmed != true) return; await logoutAndGoLogin(); } }