247 lines
8.3 KiB
Dart
247 lines
8.3 KiB
Dart
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';
|
|
|
|
/// 记账模块的首页:月份切换 + 本月汇总 + 预算环 + 按日期分组的账单列表。
|
|
///
|
|
/// 这个页面同时承担两个入口角色:
|
|
/// · 从首页仪表盘的「记账」卡片进来(会被 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<StatisticsResponse> 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: [
|
|
Text('🎯 ', style: TextStyle(fontSize: 14)),
|
|
Text(
|
|
'仅可查看本月预算',
|
|
style: TextStyle(fontSize: 13, color: Color(0xFF8D6E63)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ---- 账单列表 ----
|
|
List<Widget> _billSection(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
AsyncValue<List<Bill>> 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<void> _onRefresh(WidgetRef ref) async {
|
|
final anchor = ref.read(monthAnchorProvider);
|
|
invalidateBillDataFromWidget(ref);
|
|
// 等三个请求结束再收起下拉动画;异常吞掉,由各自的 AsyncValue 渲染错误态
|
|
await Future.wait<void>([
|
|
_silent(ref.read(billsProvider(anchor).future)),
|
|
_silent(ref.read(monthlyStatisticsProvider(anchor).future)),
|
|
_silent(ref.read(budgetProvider.future)),
|
|
]);
|
|
}
|
|
|
|
Future<void> _silent(Future<Object?> future) =>
|
|
future.then<void>((_) {}, onError: (_, _) {});
|
|
}
|