import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/network/api_exception.dart'; import '../../models/bill.dart'; import '../../models/statistics_response.dart'; import '../../providers/bill_provider.dart'; import '../../providers/bill_refresh.dart'; import '../../providers/budget_provider.dart'; import '../../utils/amount_util.dart'; import '../../utils/bill_grouping.dart'; import '../../utils/date_util.dart'; import '../../widgets/state_views.dart'; import 'bill_edit_page.dart'; import 'bill_statistics_page.dart'; import 'widgets/bill_group_card.dart'; import 'widgets/budget_card.dart'; import 'widgets/month_switcher.dart'; import '../../widgets/cute_icon.dart'; /// 记账模块的首页:月份切换 + 本月汇总 + 预算环 + 按日期分组的账单列表。 /// /// 这个页面同时承担两个入口角色: /// · 从首页仪表盘的「记账」卡片进来(会被 push,这时有返回箭头) /// · 直接 push 进来时底部没有导航栏,所以底部留白要按情况给 class BillHomePage extends ConsumerWidget { const BillHomePage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final anchor = ref.watch(monthAnchorProvider); final billsAsync = ref.watch(billsProvider(anchor)); final statsAsync = ref.watch(monthlyStatisticsProvider(anchor)); final budgetAsync = ref.watch(budgetProvider); final isCurrentMonth = isSameMonth(anchor, DateTime.now()); 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.fromLTRB(16, 12, 16, 0), children: [ _header(context), const SizedBox(height: 14), _summaryCard(anchor, statsAsync), const SizedBox(height: 12), // 后端预算接口只提供当月数据,历史月份不展示,避免串数据 if (isCurrentMonth) BudgetCard(budget: budgetAsync) else _budgetUnavailableTip(), const SizedBox(height: 16), ..._billSection(context, ref, billsAsync), // 被 push 进来时底部没有导航栏,就不用留出避让高度 SizedBox(height: Navigator.of(context).canPop() ? 24 : 90), ], ), ), ), ), ); } // ---- 顶部:返回 + 标题 + 统计入口 / 月份切换 ---- // // 为什么要分两行:月份切换条本身就有 ~206px 宽,再并上返回箭头(34)、 // 标题(80)、统计图标(34),一行要约 364px —— 而 393 宽的常规手机只有 // 361px 可用,实测溢出 32px。分两行之后每行都很宽松。 Widget _header(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ if (Navigator.of(context).canPop()) ...[ _iconButton( icon: Icons.arrow_back, tooltip: '返回', onTap: () => Navigator.of(context).pop(), ), const SizedBox(width: 6), ], const Text( '我的账单', style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: Color(0xFF5D4037), ), ), const Spacer(), _iconButton( icon: Icons.bar_chart, tooltip: '统计分析', onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const BillStatisticsPage()), ), ), ], ), const SizedBox(height: 10), const MonthSwitcher(), ], ); } Widget _iconButton({ required IconData icon, required String tooltip, required VoidCallback onTap, }) { return Tooltip( message: tooltip, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(20), child: Padding( padding: const EdgeInsets.all(6), child: Icon(icon, size: 22, color: const Color(0xFF5D4037)), ), ), ); } // ---- 本月汇总 ---- Widget _summaryCard(DateTime anchor, AsyncValue statsAsync) { final stats = statsAsync.value; return Container( padding: const EdgeInsets.symmetric(vertical: 16), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), ), child: Row( children: [ _summaryItem('${anchor.month}月收入', stats?.totalIncome, const Color(0xFF19BE6B)), _vDivider(), _summaryItem('${anchor.month}月支出', stats?.totalExpense, const Color(0xFFFA3534)), _vDivider(), _summaryItem('结余', stats?.balance, const Color(0xFF5D4037)), ], ), ); } Widget _summaryItem(String label, double? amount, Color color) { return Expanded( child: Column( children: [ Text(label, style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63))), const SizedBox(height: 6), Text( // 统计未加载完时显示占位,避免整块转圈导致头部抖动 amount == null ? '--' : '¥${formatAmount(amount)}', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: color), ), ], ), ); } Widget _vDivider() => Container(width: 1, height: 28, color: const Color(0xFFF0E6D8)); Widget _budgetUnavailableTip() { return Container( padding: const EdgeInsets.symmetric(vertical: 14), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.7), borderRadius: BorderRadius.circular(16), ), child: const Row( mainAxisAlignment: MainAxisAlignment.center, children: [ CuteIcon('target', size: 15), Text( '仅可查看本月预算', style: TextStyle(fontSize: 13, color: Color(0xFF8D6E63)), ), ], ), ); } // ---- 账单列表 ---- List _billSection( BuildContext context, WidgetRef ref, AsyncValue> billsAsync, ) { return [ billsAsync.when( loading: () => const LoadingView(), error: (e, _) => ErrorView( message: asApiException(e).message, onRetry: () => ref.invalidate(billsProvider), ), data: (bills) { if (bills.isEmpty) { return const EmptyView( title: '本月暂无账单记录', tip: '点击底部「记录」添加,或用 OCR 识别小票', ); } return Column( children: groupBillsByDate(bills) .map((group) => BillGroupCard( group: group, onTapBill: (bill) => Navigator.of(context).push( MaterialPageRoute(builder: (_) => BillEditPage(billId: bill.id)), ), )) .toList(), ); }, ), ]; } // ---- 下拉刷新 ---- Future _onRefresh(WidgetRef ref) async { final anchor = ref.read(monthAnchorProvider); invalidateBillDataFromWidget(ref); // 等三个请求结束再收起下拉动画;异常吞掉,由各自的 AsyncValue 渲染错误态 await Future.wait([ _silent(ref.read(billsProvider(anchor).future)), _silent(ref.read(monthlyStatisticsProvider(anchor).future)), _silent(ref.read(budgetProvider.future)), ]); } Future _silent(Future future) => future.then((_) {}, onError: (_, _) {}); }