This commit is contained in:
2026-09-23 15:33:59 +08:00
parent e4cdfbc42f
commit a83cda63f5
24 changed files with 874 additions and 81 deletions
+214
View File
@@ -0,0 +1,214 @@
import 'package:flutter/material.dart';
import '../../core/module/app_module.dart';
import '../assistant/assistant_page.dart';
/// 首页仪表盘:AI 输入入口 + 模块卡片网格。
///
/// 这个页面是「模块目录」,不是某个模块的主页 —— 卡片全部从 [appModules]
/// 生成,所以将来加模块时**这个文件不需要改**。
///
/// 副作用是:打开 App 第一眼看不到账单了,要点一下「记账」卡片才进账单首页。
/// 这是刻意的 —— 记账不再是主角。
class DashboardPage extends StatelessWidget {
const DashboardPage({super.key});
@override
Widget build(BuildContext context) {
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: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
children: [
_header(),
const SizedBox(height: 16),
_aiEntry(context),
const SizedBox(height: 22),
const Text(
'功能模块',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
const SizedBox(height: 10),
_moduleGrid(context),
const SizedBox(height: 90), // 底部导航避让
],
),
),
),
);
}
// ---- 标题 + 问候 ----
Widget _header() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'强宝小助手',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
letterSpacing: 1,
),
),
const SizedBox(height: 4),
Text(
_greeting(),
style: const TextStyle(fontSize: 13, color: Color(0xFF8D6E63)),
),
],
);
}
String _greeting() {
final hour = DateTime.now().hour;
if (hour < 6) return '夜深了,注意休息';
if (hour < 12) return '早上好,今天想做点什么?';
if (hour < 18) return '下午好,今天想做点什么?';
return '晚上好,今天想做点什么?';
}
// ---- AI 入口(阶段 B 接上,现在点进去是占位页)----
Widget _aiEntry(BuildContext context) {
return GestureDetector(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const AssistantPage()),
),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFC8956E), width: 1.2),
boxShadow: [
BoxShadow(
color: const Color(0xFF5D4037).withValues(alpha: 0.08),
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: Row(
children: [
const Text('✨', style: TextStyle(fontSize: 18)),
const SizedBox(width: 10),
const Expanded(
child: Text(
'问点什么,或者说一句…',
style: TextStyle(fontSize: 14, color: Color(0xFFBCAAA4)),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFFF5EFE6),
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'建设中',
style: TextStyle(fontSize: 11, color: Color(0xFF8D6E63)),
),
),
],
),
),
);
}
// ---- 模块卡片网格 ----
//
// 两列。不用 GridView + childAspectRatio:卡片高度由内容(摘要可有可无)
// 决定,估错比例就会在开发时刷一屏溢出警告 —— GridView 卡片的经典坑。
// 这里让每张卡片自己撑高,居上对齐。
Widget _moduleGrid(BuildContext context) {
const gap = 12.0;
final rows = <Widget>[];
for (var i = 0; i < appModules.length; i += 2) {
final end = i + 2 <= appModules.length ? i + 2 : appModules.length;
final pair = appModules.sublist(i, end);
if (rows.isNotEmpty) rows.add(const SizedBox(height: gap));
rows.add(
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var j = 0; j < pair.length; j++) ...[
if (j > 0) const SizedBox(width: gap),
Expanded(child: _moduleCard(context, pair[j])),
],
// 模块数是奇数时补一个空位,否则最后一张卡片会被拉成通栏
if (pair.length == 1) ...[
const SizedBox(width: gap),
const Expanded(child: SizedBox()),
],
],
),
);
}
return Column(children: rows);
}
Widget _moduleCard(BuildContext context, AppModule module) {
return GestureDetector(
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: module.open)),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: const Color(0xFF5D4037).withValues(alpha: 0.08),
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(module.icon, style: const TextStyle(fontSize: 24)),
const SizedBox(height: 8),
Text(
module.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
const SizedBox(height: 3),
Text(
module.blurb,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11.5, color: Color(0xFF8D6E63)),
),
if (module.summary != null) ...[
const SizedBox(height: 10),
module.summary!(context),
],
],
),
),
);
}
}
-197
View File
@@ -1,197 +0,0 @@
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/bill_edit_page.dart';
import 'widgets/bill_group_card.dart';
import 'widgets/budget_card.dart';
import 'widgets/month_switcher.dart';
/// 首页:月份切换 + 本月汇总 + 预算环 + 按日期分组的账单列表
class HomePage extends ConsumerWidget {
const HomePage({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(anchor),
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),
const SizedBox(height: 90), // 底部导航避让
],
),
),
),
),
);
}
// ---- 顶部标题 + 月份切换 ----
Widget _header(DateTime anchor) {
return Row(
children: [
const Text(
'我的账单',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
const Spacer(),
const MonthSwitcher(),
],
);
}
// ---- 本月汇总 ----
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: (_, _) {});
}
-132
View File
@@ -1,132 +0,0 @@
import 'package:flutter/material.dart';
import '../../../models/bill.dart';
import '../../../utils/amount_util.dart';
import '../../../utils/bill_grouping.dart';
import '../../../utils/date_util.dart';
/// 单日分组卡:组头(日期 + 分开的支出/收入小计)+ 账单条目
class BillGroupCard extends StatelessWidget {
const BillGroupCard({super.key, required this.group, required this.onTapBill});
final BillDayGroup group;
final ValueChanged<Bill> onTapBill;
static const _expenseColor = Color(0xFFFA3534);
static const _incomeColor = Color(0xFF19BE6B);
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 8),
child: Row(
children: [
Text(
formatGroupDate(group.date),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
const Spacer(),
// 原版把当日收入与支出直接相加(无意义),这里分开展示
if (group.expenseTotal > 0)
Text(
'支 ¥${formatAmount(group.expenseTotal)}',
style: const TextStyle(fontSize: 12, color: _expenseColor),
),
if (group.expenseTotal > 0 && group.incomeTotal > 0)
const SizedBox(width: 10),
if (group.incomeTotal > 0)
Text(
'收 ¥${formatAmount(group.incomeTotal)}',
style: const TextStyle(fontSize: 12, color: _incomeColor),
),
],
),
),
const Divider(height: 1, color: Color(0xFFF5EEE4)),
for (var i = 0; i < group.bills.length; i++) ...[
_BillTile(bill: group.bills[i], onTap: () => onTapBill(group.bills[i])),
if (i != group.bills.length - 1)
const Divider(height: 1, indent: 56, color: Color(0xFFF5EEE4)),
],
],
),
);
}
}
class _BillTile extends StatelessWidget {
const _BillTile({required this.bill, required this.onTap});
final Bill bill;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
// 金额正负与颜色按 type 判断(后端 amount 恒为正数,不能用金额正负判色)
final color = bill.isExpense ? BillGroupCard._expenseColor : BillGroupCard._incomeColor;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
children: [
Container(
width: 38,
height: 38,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Color(0xFFFBEEDD),
),
alignment: Alignment.center,
child: Text(bill.categoryIcon, style: const TextStyle(fontSize: 19)),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
bill.categoryName,
style: const TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
if (bill.description.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
bill.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)),
),
),
],
),
),
const SizedBox(width: 8),
Text(
formatSignedAmount(bill.absAmount, isExpense: bill.isExpense),
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: color),
),
],
),
),
);
}
}
-141
View File
@@ -1,141 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/budget.dart';
import '../../../utils/amount_util.dart';
import '../../../widgets/donut_chart.dart';
import '../../../widgets/state_views.dart';
/// 本月预算卡:环形图 + 预算 / 剩余 / 日均可用
class BudgetCard extends StatelessWidget {
const BudgetCard({super.key, required this.budget});
final AsyncValue<BudgetInfo> budget;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: budget.when(
loading: () => const LoadingView(tip: '预算加载中...', padding: EdgeInsets.all(20)),
error: (e, _) => ErrorView(message: '预算加载失败'),
data: (info) => info.hasBudget ? _content(info) : _noBudget(),
),
);
}
Widget _noBudget() {
return const EmptyView(
icon: '🎯',
title: '本月未设置预算',
tip: '可在「我的 → 预算设置」中设置',
padding: EdgeInsets.symmetric(vertical: 20),
);
}
Widget _content(BudgetInfo info) {
final over = info.isOverBudget;
final ringColor = over ? const Color(0xFFE53935) : const Color(0xFFC8956E);
final usedRatio = info.usedRatio;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(left: 4, bottom: 12),
child: Text(
'本月预算',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037)),
),
),
Row(
children: [
DonutChart(
size: 116,
centerSpaceRadius: 38,
slices: [
DonutSlice(value: usedRatio, color: ringColor),
DonutSlice(value: 1 - usedRatio, color: const Color(0xFFF0E6D8)),
],
center: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
over ? '超支' : '${(usedRatio * 100).round()}%',
style: TextStyle(
fontSize: over ? 14 : 17,
fontWeight: FontWeight.bold,
color: over ? const Color(0xFFE53935) : const Color(0xFF5D4037),
),
),
if (!over)
const Text('已用', style: TextStyle(fontSize: 10, color: Color(0xFF8D6E63))),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_amountRow('预算总额', '¥${formatAmount(info.amount)}', const Color(0xFF5D4037)),
const SizedBox(height: 8),
_amountRow('已用', '¥${formatAmount(info.usedAmount)}', ringColor),
const SizedBox(height: 8),
_amountRow(
'剩余',
'¥${formatAmount(info.remainingAmount)}',
info.remainingAmount < 0 ? const Color(0xFFE53935) : const Color(0xFF19BE6B),
),
],
),
),
],
),
const SizedBox(height: 12),
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 Text('💰 日均可用 ', style: TextStyle(fontSize: 13, color: Color(0xFF6D4C41))),
Text(
'¥${formatAmount(info.remainingDaily)}',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
],
),
),
],
);
}
Widget _amountRow(String label, String value, Color valueColor) {
return Row(
children: [
Text(label, style: const TextStyle(fontSize: 12.5, color: Color(0xFF8D6E63))),
const Spacer(),
Text(
value,
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: valueColor),
),
],
);
}
}
@@ -1,93 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../providers/bill_provider.dart';
import '../../../utils/date_util.dart';
/// 月份切换条:‹ 2026年9月 ›
class MonthSwitcher extends ConsumerWidget {
const MonthSwitcher({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final anchor = ref.watch(monthAnchorProvider);
final notifier = ref.read(monthAnchorProvider.notifier);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_iconButton(
icon: Icons.chevron_left,
tooltip: '上一月',
onTap: notifier.prev,
),
GestureDetector(
onTap: () => _pickMonth(context, ref),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
formatMonthTitle(anchor),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
const SizedBox(width: 4),
const Icon(Icons.arrow_drop_down, size: 18, color: Color(0xFF8D6E63)),
],
),
),
),
_iconButton(
icon: Icons.chevron_right,
tooltip: '下一月',
// 不允许超过当前月
onTap: notifier.canGoNext ? notifier.next : null,
),
],
),
);
}
Widget _iconButton({
required IconData icon,
required String tooltip,
VoidCallback? onTap,
}) {
final enabled = onTap != null;
return IconButton(
icon: Icon(icon),
iconSize: 20,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
color: enabled ? const Color(0xFF5D4037) : const Color(0xFFBCAAA4),
tooltip: tooltip,
onPressed: onTap,
);
}
Future<void> _pickMonth(BuildContext context, WidgetRef ref) async {
final anchor = ref.read(monthAnchorProvider);
final picked = await showDatePicker(
context: context,
initialDate: anchor,
initialDatePickerMode: DatePickerMode.year,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) {
ref.read(monthAnchorProvider.notifier).pick(picked);
}
}
}