import 'dart:math' as math; import 'dart:async'; import 'package:miaoji_zhang/shared/services/shanghai_time.dart'; 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/services/current_ledger_store.dart'; import 'package:miaoji_zhang/shared/theme/app_theme.dart'; import 'package:miaoji_zhang/shared/widgets/app_controls.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'; class StatsPage extends StatefulWidget { const StatsPage({super.key}); @override State createState() => _StatsPageState(); } class _StatsPageState extends State { String _period = 'month'; DateTime _anchor = ShanghaiTime.now; PeriodStats? _stats; String? _error; bool _loading = true; int _loadRevision = 0; @override void initState() { super.initState(); CurrentLedgerStore.instance.addListener(_load); unawaited(_load()); } @override void dispose() { CurrentLedgerStore.instance.removeListener(_load); super.dispose(); } Future _load() async { final revision = ++_loadRevision; if (mounted) { setState(() { _loading = true; _error = null; }); } try { final local = TxApi.periodStatsLocal(_period, _anchor); if (mounted && revision == _loadRevision) { setState(() => _stats = local); } } catch (error) { if (mounted && revision == _loadRevision) { setState(() => _error = apiErrorMessage(error)); } } try { final remote = await TxApi.periodStatsRemote(_period, _anchor); if (mounted && revision == _loadRevision) { setState(() => _stats = remote); } } catch (_) { } finally { if (mounted && revision == _loadRevision) { setState(() => _loading = false); } } } void _selectPeriod(String value) { if (_period == value) return; setState(() { _period = value; _anchor = ShanghaiTime.now; _stats = null; }); _load(); } void _shift(int direction) { final next = switch (_period) { 'week' => _anchor.add(Duration(days: 7 * direction)), 'year' => DateTime(_anchor.year + direction, 1, 1), _ => DateTime(_anchor.year, _anchor.month + direction, 1), }; if (direction > 0 && _isAfterCurrentPeriod(next)) return; setState(() { _anchor = next; _stats = null; }); _load(); } bool _isAfterCurrentPeriod(DateTime value) { final now = ShanghaiTime.now; return switch (_period) { 'week' => value.isAfter(now), 'year' => value.year > now.year, _ => value.year > now.year || (value.year == now.year && value.month > now.month), }; } @override Widget build(BuildContext context) { final stats = _stats; return Scaffold( appBar: AppBar( title: Text('统计'), actions: [BackendStatusIcon(onRetry: _load)], ), body: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), child: JzSegmentedControl( value: _period, options: const [ JzOption(value: 'week', label: '周'), JzOption(value: 'month', label: '月'), JzOption(value: 'year', label: '年'), ], onChanged: _selectPeriod, ), ), Expanded( child: _loading && stats == null ? Center( child: CircularProgressIndicator( color: AppTheme.primary, strokeWidth: 2, ), ) : stats == null && _error != null ? AsyncErrorView(message: _error!, onRetry: _load) : RefreshIndicator( onRefresh: _load, child: ListView( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), children: [ _periodNavigator(stats?.periodLabel ?? ''), if (_loading) LinearProgressIndicator( minHeight: 2, color: AppTheme.primary, backgroundColor: context.jz.primaryBackground, ), if (_error != null) _refreshNotice(_error!), if (stats != null) ...[ SizedBox(height: 10), _summaryCard(stats), SizedBox(height: 10), if (stats.totalExpense == 0 && stats.totalIncome == 0) _emptyCard() else ...[ _categoryCard(stats), SizedBox(height: 10), if (stats.analysis case final analysis?) _analysisCard(analysis), SizedBox(height: 10), _trendCard(stats), if (stats.byCategory.isNotEmpty) ...[ SizedBox(height: 10), _rankingCard(stats), ], ], ], ], ), ), ), ], ), ); } Widget _periodNavigator(String label) { final nextAnchor = switch (_period) { 'week' => _anchor.add(const Duration(days: 7)), 'year' => DateTime(_anchor.year + 1, 1, 1), _ => DateTime(_anchor.year, _anchor.month + 1, 1), }; return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ IconButton( tooltip: '上一个周期', onPressed: () => _shift(-1), icon: Icon(Icons.chevron_left_rounded), ), SizedBox( width: 170, child: Text( label, textAlign: TextAlign.center, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700), ), ), IconButton( tooltip: '下一个周期', onPressed: _isAfterCurrentPeriod(nextAnchor) ? null : () => _shift(1), icon: Icon(Icons.chevron_right_rounded), ), ], ); } Widget _summaryCard(PeriodStats stats) { return Card( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 17), child: Row( children: [ _summaryValue('支出', stats.totalExpense, AppTheme.red), _divider(), _summaryValue('收入', stats.totalIncome, AppTheme.primaryDeep), _divider(), _summaryValue( '结余', stats.balance, stats.balance < 0 ? AppTheme.red : context.jz.text, ), ], ), ), ); } Widget _summaryValue(String label, double amount, Color color) { return Expanded( child: Column( children: [ Text( label, style: TextStyle(fontSize: 10.5, color: context.jz.text3), ), SizedBox(height: 5), FittedBox( fit: BoxFit.scaleDown, child: Text( '¥${amount.toStringAsFixed(2)}', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w800, color: color, ), ), ), ], ), ); } Widget _divider() => Container(width: 1, height: 34, color: context.jz.line); Widget _emptyCard() { return Card( child: Padding( padding: EdgeInsets.symmetric(vertical: 48), child: Center( child: Text( '这个周期还没有账单记录', style: TextStyle(fontSize: 13, color: context.jz.text3), ), ), ), ); } Widget _categoryCard(PeriodStats stats) { return Card( child: Padding( padding: const EdgeInsets.fromLTRB(16, 18, 16, 16), child: Column( children: [ SizedBox( height: 178, child: _CategoryDonut( categories: stats.byCategory, total: stats.totalExpense, ), ), SizedBox(height: 12), Wrap( spacing: 12, runSpacing: 8, children: stats.byCategory.take(8).map((category) { final color = categoryColorMeta(category.colorKey).foreground; return Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 9, height: 9, decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(3), ), ), SizedBox(width: 5), Text( '${category.name} ${category.percent.toStringAsFixed(0)}%', style: TextStyle(fontSize: 10.5, color: context.jz.text2), ), ], ); }).toList(), ), ], ), ), ); } Widget _analysisCard(String text) { return 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: Row( children: [ Container( width: 30, height: 30, decoration: BoxDecoration( color: context.jz.card, borderRadius: BorderRadius.circular(10), ), child: Icon( Icons.auto_awesome_rounded, size: 16, color: AppTheme.ai, ), ), SizedBox(width: 10), Expanded( child: Text( text, style: TextStyle( fontSize: 12, color: context.jz.text2, height: 1.5, ), ), ), ], ), ); } Widget _trendCard(PeriodStats stats) { final title = switch (_period) { 'week' => '每日支出趋势', 'year' => '每月支出趋势', _ => '每日支出趋势', }; return Card( child: Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700), ), SizedBox(height: 16), SizedBox( height: 116, child: _TrendBars(points: stats.trend, period: _period), ), ], ), ), ); } Widget _rankingCard(PeriodStats stats) { final maxAmount = stats.byCategory.first.amount; return Card( child: Padding( padding: const EdgeInsets.fromLTRB(14, 8, 14, 8), child: Column( children: stats.byCategory.take(8).map((category) { final color = categoryColorMeta(category.colorKey).foreground; return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Row( children: [ CategoryIconBox( iconKey: category.iconKey, colorKey: category.colorKey, size: 34, ), SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( category.name, style: TextStyle( fontSize: 12.5, fontWeight: FontWeight.w600, ), ), Spacer(), Text( '¥${category.amount.toStringAsFixed(2)}', style: TextStyle( fontSize: 12.5, fontWeight: FontWeight.w700, ), ), ], ), SizedBox(height: 6), ClipRRect( borderRadius: BorderRadius.circular(3), child: LinearProgressIndicator( value: maxAmount == 0 ? 0 : category.amount / maxAmount, minHeight: 4, backgroundColor: context.jz.background, color: color, ), ), ], ), ), ], ), ); }).toList(), ), ), ); } Widget _refreshNotice(String message) { return Container( margin: const EdgeInsets.only(top: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), decoration: BoxDecoration( color: context.jz.warningBackground, borderRadius: BorderRadius.circular(12), ), child: Row( children: [ Icon(Icons.info_outline_rounded, size: 16, color: AppTheme.orange), SizedBox(width: 8), Expanded( child: Text( message, style: TextStyle(fontSize: 11, color: context.jz.text2), ), ), TextButton(onPressed: _load, child: Text('重试')), ], ), ); } } class _CategoryDonut extends StatelessWidget { final List categories; final double total; const _CategoryDonut({required this.categories, required this.total}); @override Widget build(BuildContext context) { return CustomPaint( painter: _DonutPainter(categories.take(8).toList(), context.jz.line), child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( '周期支出', style: TextStyle(fontSize: 10.5, color: context.jz.text3), ), SizedBox(height: 3), Text( '¥${total.toStringAsFixed(0)}', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800), ), ], ), ), ); } } class _DonutPainter extends CustomPainter { final List categories; final Color lineColor; _DonutPainter(this.categories, this.lineColor); @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); final radius = math.min(size.width, size.height) / 2 - 12; const strokeWidth = 22.0; final rect = Rect.fromCircle(center: center, radius: radius); canvas.drawCircle( center, radius, Paint() ..color = lineColor ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth, ); final total = categories.fold(0, (sum, item) => sum + item.amount); if (total == 0) return; var start = -math.pi / 2; for (final category in categories) { final sweep = category.amount / total * math.pi * 2; final gap = math.min(0.025, sweep / 4); canvas.drawArc( rect, start + gap, math.max(0, sweep - gap * 2), false, Paint() ..color = categoryColorMeta(category.colorKey).foreground ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth ..strokeCap = StrokeCap.round, ); start += sweep; } } @override bool shouldRepaint(covariant _DonutPainter oldDelegate) => true; } class _TrendBars extends StatelessWidget { final List points; final String period; const _TrendBars({required this.points, required this.period}); @override Widget build(BuildContext context) { final maxValue = points.fold( 0, (maximum, point) => math.max(maximum, point.expense), ); return Row( crossAxisAlignment: CrossAxisAlignment.end, children: points.indexed.map((entry) { final index = entry.$1; final point = entry.$2; final height = maxValue == 0 ? 2.0 : math.max(2.0, point.expense / maxValue * 78).toDouble(); final showLabel = period != 'month' || index == 0 || index == points.length - 1 || (index + 1) % 5 == 0; return Expanded( child: Padding( padding: EdgeInsets.symmetric( horizontal: period == 'month' ? 1 : 3, ), child: Column( mainAxisAlignment: MainAxisAlignment.end, children: [ Tooltip( message: '${point.label} ¥${point.expense.toStringAsFixed(2)}', child: Container( height: height, decoration: BoxDecoration( color: point.expense == maxValue && maxValue > 0 ? AppTheme.primary : context.jz.primaryBackground, borderRadius: const BorderRadius.vertical( top: Radius.circular(4), ), ), ), ), SizedBox(height: 6), SizedBox( height: 14, child: showLabel ? FittedBox( fit: BoxFit.scaleDown, child: Text( point.label, style: TextStyle( fontSize: 9, color: context.jz.text3, ), ), ) : null, ), ], ), ), ); }).toList(), ); } }