UI更新
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
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: (_, _) {});
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/network/api_exception.dart';
|
||||
import '../../models/statistics_response.dart';
|
||||
import '../../providers/statistics_provider.dart';
|
||||
import '../../utils/amount_util.dart';
|
||||
import '../../widgets/state_views.dart';
|
||||
import 'widgets/category_pie_card.dart';
|
||||
import 'widgets/category_rank_list.dart';
|
||||
|
||||
/// 统计页:按日/周/月切换 + 汇总 + 分类占比饼图 + 分类明细
|
||||
///
|
||||
/// 属于记账模块,从账单首页右上角进(不是顶层 Tab)。
|
||||
class BillStatisticsPage extends ConsumerWidget {
|
||||
const BillStatisticsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tab = ref.watch(statisticsTabProvider);
|
||||
final statsAsync = ref.watch(statisticsProvider(tab));
|
||||
|
||||
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, 12, 16, 0),
|
||||
children: [
|
||||
_titleRow(context),
|
||||
const SizedBox(height: 14),
|
||||
_tabBar(tab, ref),
|
||||
const SizedBox(height: 14),
|
||||
_content(ref, statsAsync),
|
||||
// 被 push 进来时底部没有导航栏,就不用留出避让高度
|
||||
SizedBox(height: Navigator.of(context).canPop() ? 24 : 90),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 顶部标题(带返回,因为这个页面是 push 进来的) ----
|
||||
Widget _titleRow(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
if (Navigator.of(context).canPop())
|
||||
InkWell(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(6),
|
||||
child: Icon(Icons.arrow_back, size: 22, color: Color(0xFF5D4037)),
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'统计分析',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 时间粒度切换 ----
|
||||
Widget _tabBar(StatisticsTab current, WidgetRef ref) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: const Color(0xFFC8956E)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_tabItem('按日', StatisticsTab.daily, current, ref),
|
||||
_tabItem('按周', StatisticsTab.weekly, current, ref),
|
||||
_tabItem('按月', StatisticsTab.monthly, current, ref),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tabItem(String text, StatisticsTab tab, StatisticsTab current, WidgetRef ref) {
|
||||
final active = current == tab;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => ref.read(statisticsTabProvider.notifier).set(tab),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
gradient: active
|
||||
? const LinearGradient(colors: [Color(0xFFC8956E), Color(0xFFF5D59E)])
|
||||
: null,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 14.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: active ? const Color(0xFF5D4037) : const Color(0xFF8D6E63),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 内容区 ----
|
||||
Widget _content(WidgetRef ref, AsyncValue<StatisticsResponse> statsAsync) {
|
||||
return statsAsync.when(
|
||||
loading: () => const LoadingView(),
|
||||
error: (e, _) => ErrorView(
|
||||
message: asApiException(e).message,
|
||||
onRetry: () => ref.invalidate(statisticsProvider),
|
||||
),
|
||||
data: (stats) => Column(
|
||||
children: [
|
||||
_summaryCard(stats),
|
||||
const SizedBox(height: 12),
|
||||
if (stats.categoryStatistics.isEmpty)
|
||||
const EmptyView(title: '暂无统计数据', tip: '所选时间范围内还没有账单')
|
||||
else ...[
|
||||
CategoryPieCard(stats: stats),
|
||||
const SizedBox(height: 12),
|
||||
CategoryRankList(stats: stats),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 汇总卡 ----
|
||||
Widget _summaryCard(StatisticsResponse stats) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFFC8956E), Color(0xFFF5D59E), Color(0xFFFFD700)],
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF5D4037).withValues(alpha: 0.18),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_summaryItem('总收入', stats.totalIncome, const Color(0xFF1B5E20)),
|
||||
_vDivider(),
|
||||
_summaryItem('总支出', stats.totalExpense, const Color(0xFFB71C1C)),
|
||||
_vDivider(),
|
||||
_summaryItem('余额', stats.balance, const Color(0xFF3E2723)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _summaryItem(String label, double amount, Color color) {
|
||||
return Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF4E342E)),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'¥${formatAmount(amount)}',
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _vDivider() =>
|
||||
Container(width: 1, height: 26, color: const Color(0xFF5D4037).withValues(alpha: 0.25));
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../providers/bill_provider.dart';
|
||||
import '../../../utils/amount_util.dart';
|
||||
|
||||
/// 首页仪表盘上「记账」卡片的摘要:当前查看月份的收入 / 支出 / 结余。
|
||||
///
|
||||
/// 故意和账单首页共用 `monthAnchorProvider`,而不是自己取 `DateTime.now()`:
|
||||
/// · 两边看到的永远是同一个月,不会出现「卡片写 9 月、点进去是 8 月」
|
||||
/// · provider 已经是现成的,不会多打一次接口
|
||||
/// 所以标签用「N月」而不是「本月」。
|
||||
class BillModuleSummary extends ConsumerWidget {
|
||||
const BillModuleSummary({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final anchor = ref.watch(monthAnchorProvider);
|
||||
final stats = ref.watch(monthlyStatisticsProvider(anchor)).value;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _item('${anchor.month}月支出', stats?.totalExpense, const Color(0xFFFA3534)),
|
||||
),
|
||||
Expanded(
|
||||
child: _item('结余', stats?.balance, const Color(0xFF5D4037)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _item(String label, double? amount, Color color) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontSize: 11, color: Color(0xFF8D6E63))),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
// 统计还没回来时显示占位,避免卡片高度跳动
|
||||
amount == null ? '--' : '¥${formatAmount(amount)}',
|
||||
style: TextStyle(fontSize: 14.5, fontWeight: FontWeight.bold, color: color),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/statistics_response.dart';
|
||||
import '../../../utils/amount_util.dart';
|
||||
import '../../../widgets/donut_chart.dart';
|
||||
|
||||
/// 支出分类占比环形图卡
|
||||
class CategoryPieCard extends StatelessWidget {
|
||||
const CategoryPieCard({super.key, required this.stats});
|
||||
|
||||
final StatisticsResponse stats;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 只画支出分类(收入分类在下方排行列表里看)
|
||||
final items = stats.categoryStatistics
|
||||
.where((e) => e.isExpense && e.amount > 0)
|
||||
.toList()
|
||||
..sort((a, b) => b.amount.compareTo(a.amount));
|
||||
|
||||
if (items.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'支出分类占比',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
DonutChart(
|
||||
size: 150,
|
||||
centerSpaceRadius: 48,
|
||||
slices: items
|
||||
.asMap()
|
||||
.entries
|
||||
.map((e) => DonutSlice(
|
||||
value: e.value.amount,
|
||||
color: chartColorAt(e.key),
|
||||
))
|
||||
.toList(),
|
||||
center: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('支出', style: TextStyle(fontSize: 11, color: Color(0xFF8D6E63))),
|
||||
Text(
|
||||
'¥${formatAmount(stats.totalExpense)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// 图例最多显示前 6 项,避免色块与名称对不上号
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: items
|
||||
.take(6)
|
||||
.toList()
|
||||
.asMap()
|
||||
.entries
|
||||
.map((e) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: chartColorAt(e.key),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
e.value.categoryName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF8D6E63),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${percentOf(e.value.amount, stats.totalExpense).toStringAsFixed(1)}%',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/statistics_response.dart';
|
||||
import '../../../utils/amount_util.dart';
|
||||
|
||||
/// 分类排行:emoji + 名称 + 进度条 + 金额 + 占比
|
||||
class CategoryRankList extends StatelessWidget {
|
||||
const CategoryRankList({super.key, required this.stats});
|
||||
|
||||
final StatisticsResponse stats;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final items = [...stats.categoryStatistics]
|
||||
..sort((a, b) => b.amount.compareTo(a.amount));
|
||||
|
||||
if (items.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'分类明细',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...items.map(_row),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(CategoryStat item) {
|
||||
// 分母按行内类型取:支出项用总支出、收入项用总收入
|
||||
// (uniApp 原版一律用总支出做分母,导致收入项占比显示成百分比 > 100%)
|
||||
final total = item.isExpense ? stats.totalExpense : stats.totalIncome;
|
||||
final pct = percentOf(item.amount, total);
|
||||
final barColor = item.isExpense ? const Color(0xFFC8956E) : const Color(0xFF19BE6B);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(item.categoryIcon, style: const TextStyle(fontSize: 16)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.categoryName,
|
||||
style: const TextStyle(fontSize: 13.5, color: Color(0xFF5D4037)),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'¥${formatAmount(item.amount)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 46,
|
||||
child: Text(
|
||||
'${pct.toStringAsFixed(1)}%',
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(height: 6, color: const Color(0xFFF5EEE4)),
|
||||
FractionallySizedBox(
|
||||
widthFactor: (pct / 100).clamp(0.0, 1.0),
|
||||
child: Container(
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [barColor, barColor.withValues(alpha: 0.55)],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user