Files
jizhi/frontend/lib/features/stats/report_page.dart
T

588 lines
19 KiB
Dart

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<ReportPage> createState() => _ReportPageState();
}
class _ReportPageState extends State<ReportPage> {
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<void> _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<ReportCategoryRank> 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,
),
),
],
),
),
],
),
);
}
}