import 'dart:async'; import 'package:flutter/material.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/theme/app_theme.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/shared/widgets/category_icon.dart'; import 'package:miaoji_zhang/shared/services/shanghai_time.dart'; import 'package:miaoji_zhang/shared/services/session_store.dart'; enum _ReportKind { weekly, monthly, yearly } class ReportPage extends StatefulWidget { final String? initialPeriod; final DateTime? initialAnchor; const ReportPage({super.key, this.initialPeriod, this.initialAnchor}); @override State createState() => _ReportPageState(); } class _ReportPageState extends State { PeriodReport? _report; bool _loading = true; String? _error; DateTime _anchor = ShanghaiTime.now; _ReportKind _kind = _ReportKind.monthly; int _loadRevision = 0; @override void initState() { super.initState(); _anchor = widget.initialAnchor ?? ShanghaiTime.now; _kind = switch (widget.initialPeriod) { 'week' => _ReportKind.weekly, 'year' => _ReportKind.yearly, _ => _ReportKind.monthly, }; PublicConfigApi.companionNotifier.addListener(_refreshCompanion); CurrentLedgerStore.instance.addListener(_load); unawaited(_load()); } @override void dispose() { PublicConfigApi.companionNotifier.removeListener(_refreshCompanion); CurrentLedgerStore.instance.removeListener(_load); super.dispose(); } void _refreshCompanion() { if (mounted) setState(() {}); } Future _load() async { final revision = ++_loadRevision; setState(() { _loading = true; _error = null; }); try { final period = switch (_kind) { _ReportKind.weekly => 'week', _ReportKind.monthly => 'month', _ReportKind.yearly => 'year', }; final local = ReportApi.local(period, _anchor); if (mounted && revision == _loadRevision) { setState(() => _report = local); } final remote = await ReportApi.remote(period, _anchor); if (mounted && revision == _loadRevision) { setState(() => _report = remote); } } catch (error) { if (mounted && revision == _loadRevision && _report == null) { setState(() => _error = apiErrorMessage(error)); } } finally { if (mounted && revision == _loadRevision) { setState(() => _loading = false); } } } void _selectKind(_ReportKind kind) { if (_kind == kind) return; setState(() { _kind = kind; _anchor = ShanghaiTime.now; _report = null; }); _load(); } void _move(int direction) { final next = switch (_kind) { _ReportKind.weekly => _anchor.add(Duration(days: 7 * direction)), _ReportKind.monthly => DateTime(_anchor.year, _anchor.month + direction), _ReportKind.yearly => DateTime(_anchor.year + direction), }; if (direction > 0 && _isFuture(next)) return; setState(() { _anchor = next; _report = null; }); _load(); } bool _isFuture(DateTime value) { final now = ShanghaiTime.now; return switch (_kind) { _ReportKind.weekly => value.isAfter(now), _ReportKind.monthly => value.year > now.year || (value.year == now.year && value.month > now.month), _ReportKind.yearly => value.year > now.year, }; } @override Widget build(BuildContext context) { final report = _report; return Scaffold( appBar: AppBar( title: Text(SessionStore.instance.aiEnabled ? 'AI 报告' : '报告'), actions: [BackendStatusIcon(onRetry: _load)], ), body: Column( children: [ _periodTabs(), _periodNavigator(), Expanded( child: !_loading && report == null && _error != null ? AsyncErrorView(message: _error!, onRetry: _load) : _loading && report == null ? Center( child: CircularProgressIndicator( color: AppTheme.primary, strokeWidth: 2, ), ) : report == null ? Center( child: Text( '暂无数据', style: TextStyle(color: context.jz.text3), ), ) : _reportBody(report), ), ], ), ); } Widget _periodTabs() { const labels = { _ReportKind.weekly: '周报', _ReportKind.monthly: '月报', _ReportKind.yearly: '年报', }; return Container( margin: const EdgeInsets.fromLTRB(16, 4, 16, 8), padding: const EdgeInsets.all(3), decoration: BoxDecoration( color: context.jz.line, borderRadius: BorderRadius.circular(11), ), child: Row( children: labels.entries.map((entry) { final selected = entry.key == _kind; return Expanded( child: InkWell( onTap: () => _selectKind(entry.key), borderRadius: BorderRadius.circular(9), child: AnimatedContainer( duration: const Duration(milliseconds: 180), padding: const EdgeInsets.symmetric(vertical: 8), decoration: BoxDecoration( color: selected ? context.jz.card : Colors.transparent, borderRadius: BorderRadius.circular(9), ), child: Text( entry.value, textAlign: TextAlign.center, style: TextStyle( fontSize: 12.5, fontWeight: selected ? FontWeight.w800 : FontWeight.w500, color: selected ? AppTheme.ai : context.jz.text2, ), ), ), ), ); }).toList(), ), ); } Widget _periodNavigator() { final label = _report?.periodLabel ?? switch (_kind) { _ReportKind.weekly => '本周', _ReportKind.monthly => _anchor.year.toString() + '年' + _anchor.month.toString() + '月', _ReportKind.yearly => _anchor.year.toString() + '年', }; return Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ IconButton( tooltip: '上一个周期', onPressed: () => _move(-1), icon: Icon(Icons.chevron_left_rounded), ), Container( constraints: const BoxConstraints(minWidth: 150), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: context.jz.card, borderRadius: BorderRadius.circular(18), border: Border.all(color: context.jz.line), ), child: Text( label, textAlign: TextAlign.center, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700), ), ), IconButton( tooltip: '下一个周期', onPressed: _isFuture(_nextAnchor()) ? null : () => _move(1), icon: Icon(Icons.chevron_right_rounded), ), ], ), ); } DateTime _nextAnchor() => switch (_kind) { _ReportKind.weekly => _anchor.add(const Duration(days: 7)), _ReportKind.monthly => DateTime(_anchor.year, _anchor.month + 1), _ReportKind.yearly => DateTime(_anchor.year + 1), }; Widget _reportBody(PeriodReport report) { return RefreshIndicator( onRefresh: _load, child: ListView( padding: const EdgeInsets.fromLTRB(16, 4, 16, 24), children: [ Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: AppTheme.ai, borderRadius: BorderRadius.circular(18), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '✦ ' + PublicConfigApi.companionName + _kindLabel + '报告', style: TextStyle( fontSize: 11, color: Colors.white.withValues(alpha: 0.85), ), ), SizedBox(height: 8), Text( report.periodLabel + ',一共支出 ¥' + report.expense.toStringAsFixed(0), style: TextStyle( fontSize: 20, fontWeight: FontWeight.w800, color: Colors.white, ), ), SizedBox(height: 16), Row( children: [ _num('收入', report.income), _num('支出', report.expense), _num('结余', report.balance), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '笔数', style: TextStyle( fontSize: 10, color: Colors.white.withValues(alpha: 0.8), ), ), SizedBox(height: 3), Text( report.count.toString(), style: TextStyle( fontSize: 15, fontWeight: FontWeight.w700, color: Colors.white, ), ), ], ), ), ], ), ], ), ), SizedBox(height: 10), Card( child: Column( children: [ if (report.peakLabel != null) _highlight( AppIcons.fire, context.jz.expenseBackground, AppTheme.red, (_kind == _ReportKind.yearly ? '支出最高的月份:' : '最烧钱的一天:') + report.peakLabel!, '支出 ¥' + report.peakAmount.toStringAsFixed(0) + (report.peakNote != null ? ',最大一笔是“' + report.peakNote! + '”' : ''), ), if (SessionStore.instance.aiEnabled) _highlight( AppIcons.chat, context.jz.aiBackground, AppTheme.ai, PublicConfigApi.companionName + '记账占比 ' + report.aiRatio.toStringAsFixed(0) + '%', report.aiRatio > 50 ? '大部分账单都通过 AI 完成' : '可以试试用一句话完成记账', ), ], ), ), if (report.categoryRanking.isNotEmpty) ...[ SizedBox(height: 10), _categoryRanking(report.categoryRanking), ], SizedBox(height: 10), if (SessionStore.instance.aiEnabled) Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: context.jz.aiBackground, borderRadius: BorderRadius.circular(14), border: Border.all(color: AppTheme.ai.withValues(alpha: 0.12)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ AppIcons.icon( AppIcons.avatarAsset( PublicConfigApi.companionAvatarKey, ), size: 15, color: AppTheme.ai, ), SizedBox(width: 7), Text( PublicConfigApi.companionName + '点评', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w700, color: AppTheme.ai, ), ), ], ), SizedBox(height: 8), Text( '“' + report.commentary + '”', style: TextStyle( fontSize: 12.5, height: 1.75, color: context.jz.text2, ), ), ], ), ), ], ), ); } Widget _categoryRanking(List ranking) { return Card( child: Padding( padding: const EdgeInsets.fromLTRB(14, 14, 14, 10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '支出分类排行', style: TextStyle(fontSize: 13.5, fontWeight: FontWeight.w800), ), SizedBox(height: 12), ...ranking.indexed.map((entry) { final index = entry.$1; final item = entry.$2; return Padding( padding: const EdgeInsets.only(bottom: 11), child: Row( children: [ SizedBox( width: 22, child: Text( (index + 1).toString().padLeft(2, '0'), style: TextStyle( fontSize: 11, fontWeight: FontWeight.w800, color: index == 0 ? AppTheme.primary : context.jz.text3, ), ), ), CategoryIconBox( iconKey: item.iconKey, colorKey: item.colorKey, size: 32, ), SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( item.name, style: TextStyle( fontSize: 12.5, fontWeight: FontWeight.w700, ), ), ), Text( '¥' + item.amount.toStringAsFixed(0), style: TextStyle( fontSize: 12, fontWeight: FontWeight.w700, ), ), SizedBox(width: 7), SizedBox( width: 38, child: Text( item.percent.toStringAsFixed(0) + '%', textAlign: TextAlign.right, style: TextStyle( fontSize: 10.5, color: context.jz.text3, ), ), ), ], ), SizedBox(height: 6), ClipRRect( borderRadius: BorderRadius.circular(3), child: LinearProgressIndicator( minHeight: 5, value: (item.percent / 100).clamp(0.0, 1.0), backgroundColor: context.jz.line, color: index == 0 ? AppTheme.primary : AppTheme.primaryLight, ), ), ], ), ), ], ), ); }), ], ), ), ); } String get _kindLabel => switch (_kind) { _ReportKind.weekly => '周', _ReportKind.monthly => '月', _ReportKind.yearly => '年', }; Widget _num(String label, double value) { return Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, style: TextStyle( fontSize: 10, color: Colors.white.withValues(alpha: 0.8), ), ), SizedBox(height: 3), Text( '¥' + value.toStringAsFixed(0), style: TextStyle( fontSize: 15, fontWeight: FontWeight.w700, color: Colors.white, ), ), ], ), ); } Widget _highlight( String iconAsset, Color background, Color foreground, String title, String description, ) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 30, height: 30, decoration: BoxDecoration( color: background, borderRadius: BorderRadius.circular(9), ), child: Center( child: AppIcons.icon(iconAsset, size: 16, color: foreground), ), ), SizedBox(width: 11), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: TextStyle(fontSize: 12.5, fontWeight: FontWeight.w600), ), SizedBox(height: 3), Text( description, style: TextStyle( fontSize: 11, color: context.jz.text2, height: 1.55, ), ), ], ), ), ], ), ); } }