import 'dart:async'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:miaoji_zhang/shared/api/api_client.dart'; import 'package:miaoji_zhang/shared/api/business_api.dart'; import 'package:miaoji_zhang/shared/api/config_api.dart'; import 'package:miaoji_zhang/shared/services/current_ledger_store.dart'; import 'package:miaoji_zhang/shared/services/session_store.dart'; import 'package:miaoji_zhang/shared/services/transaction_events.dart'; import 'package:miaoji_zhang/shared/theme/app_theme.dart'; import 'package:miaoji_zhang/shared/widgets/category_icon.dart'; import 'package:miaoji_zhang/shared/widgets/app_icons.dart'; import 'package:miaoji_zhang/shared/widgets/async_error_view.dart'; import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart'; import 'package:miaoji_zhang/features/home/pages/tx_detail_page.dart'; import 'package:miaoji_zhang/features/home/pages/ledger_sheet.dart'; import 'package:miaoji_zhang/shared/services/shanghai_time.dart'; /// 首页明细(P1 / P20 空状态) class HomePage extends StatefulWidget { const HomePage({super.key}); @override State createState() => HomePageState(); } class HomePageState extends State { MonthSummary? _summary; BudgetsData? _budgets; bool _loading = true; String? _error; late DateTime _month; int _loadRevision = 0; @override void initState() { super.initState(); _month = ShanghaiTime.now; PublicConfigApi.companionNotifier.addListener(_refreshCompanion); TransactionEvents.revision.addListener(_refreshTransactions); CurrentLedgerStore.instance.addListener(_refreshLedger); unawaited(refresh()); } void _refreshCompanion() { if (mounted) setState(() {}); } void _refreshTransactions() { if (mounted) refresh(); } void _refreshLedger() { if (mounted) refresh(); } @override void dispose() { PublicConfigApi.companionNotifier.removeListener(_refreshCompanion); TransactionEvents.revision.removeListener(_refreshTransactions); CurrentLedgerStore.instance.removeListener(_refreshLedger); super.dispose(); } Future refresh() async { final revision = ++_loadRevision; setState(() { _loading = true; _error = null; }); try { await CurrentLedgerStore.instance.loadCached(); final localSummary = TxApi.monthLocal(_month.year, _month.month); final localBudgets = BudgetApi.getLocal(_month.year, _month.month); if (mounted && revision == _loadRevision) { setState(() { _summary = localSummary; _budgets = localBudgets; }); } } catch (error) { if (mounted && revision == _loadRevision) { setState(() { _error = apiErrorMessage(error); _loading = false; }); } return; } await Future.wait([ () async { try { await CurrentLedgerStore.instance.refreshRemote(); } catch (_) {} }(), () async { try { final summary = await TxApi.monthRemote(_month.year, _month.month); if (mounted && revision == _loadRevision) { setState(() => _summary = summary); } } catch (_) {} }(), () async { try { final budgets = await BudgetApi.getRemote(_month.year, _month.month); if (mounted && revision == _loadRevision) { setState(() => _budgets = budgets); } } catch (_) {} }(), ]); if (mounted && revision == _loadRevision) { setState(() => _loading = false); } } @override Widget build(BuildContext context) { final s = _summary; final budgets = _budgets; final session = SessionStore.instance; final showAiEntry = session.isGuest || session.aiEnabled; final budgetsByCategory = { for (final item in budgets?.categories ?? const []) if (item.categoryId != null) item.categoryId!: item, }; if (!_loading && s == null && _error != null) { return Scaffold( body: SafeArea( child: AsyncErrorView(message: _error!, onRetry: refresh), ), ); } return Scaffold( body: SafeArea( child: RefreshIndicator( onRefresh: refresh, color: AppTheme.primary, child: NotificationListener( onNotification: (notification) { if (notification is ScrollStartNotification) { _SwipeReveal.closeOpen(); } return false; }, child: ListView( padding: const EdgeInsets.symmetric(horizontal: 16), children: [ SizedBox(height: 8), Row( children: [ GestureDetector( onTap: () { _month = DateTime(_month.year, _month.month - 1); refresh(); }, child: Transform( alignment: Alignment.center, transform: Matrix4.rotationY(3.1416), child: AppIcons.icon( AppIcons.chevronRight, size: 16, color: context.jz.text3, ), ), ), SizedBox(width: 6), GestureDetector( onTap: () => LedgerSheet.show(context).then((_) => refresh()), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( CurrentLedgerStore.instance.currentName, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w700, ), ), AppIcons.icon( AppIcons.chevronDown, size: 16, color: context.jz.text3, ), ], ), ), SizedBox(width: 6), GestureDetector( onTap: () { _month = DateTime(_month.year, _month.month + 1); refresh(); }, child: AppIcons.icon( AppIcons.chevronRight, size: 16, color: context.jz.text3, ), ), Spacer(), BackendStatusIcon(onRetry: refresh), IconButton( icon: AppIcons.icon( AppIcons.search, size: 20, color: context.jz.text2, ), onPressed: () => context.push('/search'), ), ], ), Text( '${_month.year}年${_month.month}月', style: TextStyle(fontSize: 11, color: context.jz.text3), ), SizedBox(height: 6), Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: AppTheme.primary, borderRadius: BorderRadius.circular(18), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${_month.month}月结余', style: TextStyle( fontSize: 12, color: Colors.white.withValues(alpha: 0.85), ), ), SizedBox(height: 5), Text( '¥ ${(s?.balance ?? 0).toStringAsFixed(2)}', style: TextStyle( fontSize: 30, fontWeight: FontWeight.w700, color: Colors.white, ), ), SizedBox(height: 14), Row( children: [ _HeroCol(label: '收入', value: s?.income ?? 0), _HeroCol(label: '支出', value: s?.expense ?? 0), _HeroCol( label: '笔数', value: (s?.count ?? 0).toDouble(), isCount: true, ), ], ), ], ), ), SizedBox(height: 12), if (budgets != null) ...[ _BudgetSummaryCard( data: budgets, onTap: () async { await context.push('/budget'); refresh(); }, ), SizedBox(height: 12), ], if (showAiEntry) ...[ GestureDetector( onTap: () => context.go('/chat'), child: Container( padding: const EdgeInsets.symmetric( horizontal: 13, vertical: 11, ), decoration: BoxDecoration( color: context.jz.card, borderRadius: BorderRadius.circular(14), border: Border.all(color: context.jz.line, width: 0.5), ), child: Row( children: [ CircleAvatar( radius: 19, backgroundColor: AppTheme.ai, child: AppIcons.icon( AppIcons.avatarAsset( PublicConfigApi.companionAvatarKey, ), size: 20, color: Colors.white, ), ), SizedBox(width: 11), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( PublicConfigApi.companionName, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, ), ), Text( SessionStore.instance.shouldUseLocalOnly ? '登录并连接云端后可使用 AI 记账' : '说句话就能记账', style: TextStyle( fontSize: 11, color: context.jz.text2, ), ), ], ), ), Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 6, ), decoration: BoxDecoration( color: context.jz.aiBackground, borderRadius: BorderRadius.circular(16), ), child: Text( '去聊天', style: TextStyle( fontSize: 12, color: AppTheme.ai, fontWeight: FontWeight.w600, ), ), ), ], ), ), ), SizedBox(height: 15), ], if (_loading && s == null) Padding( padding: EdgeInsets.only(top: 60), child: Center( child: CircularProgressIndicator( color: AppTheme.primary, strokeWidth: 2, ), ), ) else if (s == null || s.days.isEmpty) _EmptyState( aiEnabled: showAiEntry, onAction: showAiEntry ? () => context.go('/chat') : () => context.push('/add'), companionName: PublicConfigApi.companionName, avatarKey: PublicConfigApi.companionAvatarKey, ) else ...[ Padding( padding: EdgeInsets.only(left: 2, bottom: 9), child: Text( '最近账单', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w700, ), ), ), ...s.days.map( (d) => _DayCard( day: d, onDeleted: refresh, budgetsByCategory: budgetsByCategory, ), ), ], SizedBox(height: 12), ], ), ), ), ), ); } } class _BudgetSummaryCard extends StatelessWidget { final BudgetsData data; final VoidCallback onTap; const _BudgetSummaryCard({required this.data, required this.onTap}); @override Widget build(BuildContext context) { final total = data.total; final amount = total?.amount ?? data.categories.fold(0, (sum, item) => sum + item.amount); final spent = total?.spent ?? data.categories.fold(0, (sum, item) => sum + item.spent); final rawRatio = amount <= 0 ? 0.0 : spent / amount; final ratio = rawRatio.clamp(0.0, 1.0); final warningColor = rawRatio >= 1 ? AppTheme.red : rawRatio >= 0.8 ? AppTheme.orange : AppTheme.primary; return Material( color: context.jz.card, borderRadius: BorderRadius.circular(14), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(14), child: Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( borderRadius: BorderRadius.circular(14), border: Border.all(color: context.jz.line, width: 0.5), ), child: amount <= 0 ? Row( children: [ Icon( Icons.donut_small_rounded, size: 22, color: AppTheme.primary, ), SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '本月预算', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w700, ), ), Text( '设一个可执行的消费边界', style: TextStyle( fontSize: 11, color: context.jz.text3, ), ), ], ), ), Text( '去设置', style: TextStyle( fontSize: 12, color: AppTheme.primary, fontWeight: FontWeight.w700, ), ), ], ) : Column( children: [ Row( children: [ Text( total?.isRecurring == true ? '周期预算' : '本月预算', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w700, ), ), Spacer(), Text( rawRatio >= 1 ? '已超 ¥${(spent - amount).toStringAsFixed(0)}' : '剩余 ¥${(amount - spent).toStringAsFixed(0)}', style: TextStyle( fontSize: 11.5, color: warningColor, fontWeight: FontWeight.w700, ), ), ], ), SizedBox(height: 9), ClipRRect( borderRadius: BorderRadius.circular(3), child: LinearProgressIndicator( value: ratio, minHeight: 6, backgroundColor: context.jz.line, color: warningColor, ), ), SizedBox(height: 7), Row( children: [ Text( '已用 ¥${spent.toStringAsFixed(0)}', style: TextStyle( fontSize: 10.5, color: context.jz.text3, ), ), Spacer(), Text( '预算 ¥${amount.toStringAsFixed(0)} · ${(rawRatio * 100).toStringAsFixed(0)}%', style: TextStyle( fontSize: 10.5, color: context.jz.text3, ), ), ], ), ], ), ), ), ); } } class _HeroCol extends StatelessWidget { final String label; final double value; final bool isCount; const _HeroCol({ required this.label, required this.value, this.isCount = false, }); @override Widget build(BuildContext context) { return Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, style: TextStyle( fontSize: 11, color: Colors.white.withValues(alpha: 0.85), ), ), SizedBox(height: 2), Text( isCount ? value.toInt().toString() : '¥ ${value.toStringAsFixed(2)}', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: Colors.white, ), ), ], ), ); } } class _EmptyState extends StatelessWidget { final VoidCallback onAction; final String companionName, avatarKey; final bool aiEnabled; const _EmptyState({ required this.onAction, required this.aiEnabled, required this.companionName, required this.avatarKey, }); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 40), child: Column( children: [ Container( width: 100, height: 100, decoration: BoxDecoration( shape: BoxShape.circle, color: aiEnabled ? context.jz.aiBackground : context.jz.primaryBackground, ), child: AppIcons.icon( aiEnabled ? AppIcons.avatarAsset(avatarKey) : AppIcons.wallet, size: 48, color: aiEnabled ? AppTheme.ai : AppTheme.primary, ), ), SizedBox(height: 16), Text( '还没有账单', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700), ), SizedBox(height: 6), Text( aiEnabled ? '跟我说句话就能记账,比如\n"早饭包子豆浆 6 块" · "打车 45"' : '点击下方按钮手动记录第一笔账单', textAlign: TextAlign.center, style: TextStyle( fontSize: 11.5, color: context.jz.text2, height: 1.7, ), ), SizedBox(height: 18), ElevatedButton.icon( style: ElevatedButton.styleFrom( backgroundColor: aiEnabled ? AppTheme.ai : AppTheme.primary, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), ), onPressed: onAction, icon: AppIcons.icon( aiEnabled ? AppIcons.chat : AppIcons.plus, size: 16, color: Colors.white, ), label: Text(aiEnabled ? '和${companionName}聊聊' : '记一笔'), ), ], ), ); } } class _DayCard extends StatelessWidget { final DayGroup day; final VoidCallback onDeleted; final Map budgetsByCategory; const _DayCard({ required this.day, required this.onDeleted, required this.budgetsByCategory, }); @override Widget build(BuildContext context) { final now = ShanghaiTime.now; final isToday = day.date.year == now.year && day.date.month == now.month && day.date.day == now.day; return Container( margin: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2), decoration: BoxDecoration( color: context.jz.card, borderRadius: BorderRadius.circular(14), border: Border.all(color: context.jz.line, width: 0.5), ), child: Column( children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 9), child: Row( children: [ Text( isToday ? '今天 · ${day.date.month}月${day.date.day}日' : '${day.date.month}月${day.date.day}日', style: TextStyle(fontSize: 11, color: context.jz.text3), ), Spacer(), Text( [ if (day.income > 0) '收 ¥${day.income.toStringAsFixed(2)}', if (day.expense > 0) '支 ¥${day.expense.toStringAsFixed(2)}', ].join(' · '), style: TextStyle(fontSize: 11, color: context.jz.text3), ), ], ), ), Divider(height: 0.5, color: context.jz.line), ...day.items.map( (t) => _TxRow( tx: t, onDeleted: onDeleted, budget: budgetsByCategory[t.categoryId], ), ), ], ), ); } } class _TxRow extends StatelessWidget { final TxItem tx; final VoidCallback onDeleted; final BudgetItem? budget; const _TxRow({required this.tx, required this.onDeleted, this.budget}); @override Widget build(BuildContext context) { final hh = '${tx.occurredAt.hour.toString().padLeft(2, '0')}:${tx.occurredAt.minute.toString().padLeft(2, '0')}'; return _SwipeReveal( id: tx.id, onTap: () => Navigator.push( context, MaterialPageRoute(builder: (_) => TxDetailPage(tx: tx)), ), onDelete: () async { try { await TxApi.delete(tx.id); onDeleted(); if (!context.mounted) return; ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( SnackBar( behavior: SnackBarBehavior.floating, content: Text('已移入回收站'), action: SnackBarAction( label: '撤销', textColor: AppTheme.primary, onPressed: () async { try { await TxApi.restore(tx.id); onDeleted(); } catch (error) { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(apiErrorMessage(error))), ); } }, ), ), ); } catch (error) { if (context.mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(apiErrorMessage(error)))); } } }, child: Padding( padding: const EdgeInsets.symmetric(vertical: 10), child: Row( children: [ CategoryIconBox( iconKey: tx.categoryIcon, colorKey: tx.categoryColor, ), SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( tx.note ?? tx.categoryName, style: TextStyle( fontSize: 13.5, fontWeight: FontWeight.w500, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), SizedBox(height: 2), Row( children: [ Text( hh, style: TextStyle( fontSize: 10.5, color: context.jz.text3, ), ), if (!tx.isIncome && budget != null && budget!.amount > 0 && budget!.spent / budget!.amount >= 0.8) ...[ SizedBox(width: 5), Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 1.5, ), decoration: BoxDecoration( color: budget!.spent >= budget!.amount ? context.jz.expenseBackground : context.jz.warningBackground, borderRadius: BorderRadius.circular(6), ), child: Text( budget!.spent >= budget!.amount ? '预算已超' : '预算 ${(budget!.spent / budget!.amount * 100).toStringAsFixed(0)}%', style: TextStyle( fontSize: 9, color: budget!.spent >= budget!.amount ? AppTheme.red : AppTheme.orange, fontWeight: FontWeight.w700, ), ), ), ], if (tx.isAi) ...[ SizedBox(width: 5), Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 1.5, ), decoration: BoxDecoration( color: context.jz.aiBackground, borderRadius: BorderRadius.circular(6), ), child: Text( '✦ AI', style: TextStyle( fontSize: 9, color: AppTheme.ai, fontWeight: FontWeight.w600, ), ), ), ], ], ), ], ), ), Text( '${tx.isIncome ? '+' : '-'}${tx.amount.toStringAsFixed(2)}', style: TextStyle( fontSize: 14.5, fontWeight: FontWeight.w600, color: tx.isIncome ? AppTheme.primary : context.jz.text, ), ), ], ), ), ); } } class _SwipeReveal extends StatefulWidget { static const double actionWidth = 76; static final ValueNotifier _openId = ValueNotifier(null); final int id; final Widget child; final VoidCallback onTap; final Future Function() onDelete; const _SwipeReveal({ required this.id, required this.child, required this.onTap, required this.onDelete, }); static void closeOpen() { _openId.value = null; } @override State<_SwipeReveal> createState() => _SwipeRevealState(); } class _SwipeRevealState extends State<_SwipeReveal> { double _dragOffset = 0; bool _dragging = false; bool _deleting = false; void _startDrag(DragStartDetails details) { _dragging = true; _dragOffset = _SwipeReveal._openId.value == widget.id ? -_SwipeReveal.actionWidth : 0; if (_SwipeReveal._openId.value != widget.id) { _SwipeReveal.closeOpen(); } } void _updateDrag(DragUpdateDetails details) { setState(() { _dragOffset = (_dragOffset + details.delta.dx).clamp( -_SwipeReveal.actionWidth, 0, ); }); } void _endDrag(DragEndDetails details) { final reveal = _dragOffset <= -36 || (details.primaryVelocity != null && details.primaryVelocity! < -500); _dragging = false; _SwipeReveal._openId.value = reveal ? widget.id : null; setState(() => _dragOffset = reveal ? -_SwipeReveal.actionWidth : 0); } Future _delete() async { if (_deleting) return; setState(() => _deleting = true); _SwipeReveal.closeOpen(); try { await widget.onDelete(); } finally { if (mounted) setState(() => _deleting = false); } } @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: _SwipeReveal._openId, builder: (_, openId, __) { final target = _dragging ? _dragOffset : openId == widget.id ? -_SwipeReveal.actionWidth : 0.0; return ClipRect( child: Stack( alignment: Alignment.centerRight, children: [ Positioned.fill( child: Align( alignment: Alignment.centerRight, child: Semantics( button: true, label: '删除账单', child: Material( color: AppTheme.red, child: InkWell( onTap: _deleting ? null : _delete, child: SizedBox( width: _SwipeReveal.actionWidth, child: Center( child: _deleting ? SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : AppIcons.icon( AppIcons.trash, size: 20, color: Colors.white, ), ), ), ), ), ), ), ), AnimatedContainer( duration: _dragging ? Duration.zero : const Duration(milliseconds: 180), curve: Curves.easeOutCubic, transform: Matrix4.translationValues(target, 0, 0), color: context.jz.card, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: openId == widget.id ? _SwipeReveal.closeOpen : widget.onTap, onHorizontalDragStart: _startDrag, onHorizontalDragUpdate: _updateDrag, onHorizontalDragEnd: _endDrag, onHorizontalDragCancel: () { _dragging = false; _SwipeReveal.closeOpen(); }, child: widget.child, ), ), ], ), ); }, ); } }