Files
jizhi/frontend/lib/features/ai_mode/pages/ai_mode_page.dart
T
2026-07-24 23:11:20 +08:00

571 lines
19 KiB
Dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:miaoji_zhang/features/chat/chat_page.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/auth_api.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/transaction_events.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
class AiModePage extends StatefulWidget {
const AiModePage({super.key});
@override
State<AiModePage> createState() => _AiModePageState();
}
class _AiModePageState extends State<AiModePage> {
MonthSummary? _summary;
BudgetsData? _budgets;
String? _summaryError;
String? _budgetError;
bool _summaryLoading = true;
bool _budgetLoading = true;
int _loadRevision = 0;
final _chatController = ChatPageController();
@override
void initState() {
super.initState();
CurrentLedgerStore.instance.addListener(_load);
TransactionEvents.revision.addListener(_load);
_load();
}
@override
void dispose() {
CurrentLedgerStore.instance.removeListener(_load);
TransactionEvents.revision.removeListener(_load);
super.dispose();
}
Future<void> _load() async {
final revision = ++_loadRevision;
if (mounted) {
setState(() {
_summaryLoading = true;
_budgetLoading = true;
_summaryError = null;
_budgetError = null;
});
}
final now = ShanghaiTime.now;
await Future.wait([
() async {
try {
final summary = await TxApi.month(now.year, now.month);
if (!mounted || revision != _loadRevision) return;
setState(() => _summary = summary);
} catch (error) {
if (!mounted || revision != _loadRevision) return;
setState(() => _summaryError = apiErrorMessage(error));
} finally {
if (mounted && revision == _loadRevision) {
setState(() => _summaryLoading = false);
}
}
}(),
() async {
try {
final budgets = await BudgetApi.get(now.year, now.month);
if (!mounted || revision != _loadRevision) return;
setState(() => _budgets = budgets);
} catch (error) {
if (!mounted || revision != _loadRevision) return;
setState(() => _budgetError = apiErrorMessage(error));
} finally {
if (mounted && revision == _loadRevision) {
setState(() => _budgetLoading = false);
}
}
}(),
]);
}
Future<void> _openBudget() async {
await context.push('/budget');
await _load();
}
Future<void> _switchToNormal() async {
try {
await AuthApi.switchMode('normal');
if (mounted) context.go('/home');
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
}
}
Widget _summarySection(MonthSummary? summary, double todayExpense) {
if (_summaryLoading && summary == null) {
return const _AiLoadCard(message: '正在加载本月收支', loading: true);
}
if (_summaryError != null && summary == null) {
return _AiLoadCard(message: _summaryError!, onRetry: _load);
}
return Column(
children: [
if (_summaryError != null)
_AiRefreshNotice(message: _summaryError!, onRetry: _load),
Row(
children: [
_MiniCard(
label: '今日支出',
value: ${todayExpense.toStringAsFixed(2)}',
color: AppTheme.red,
),
SizedBox(width: 8),
_MiniCard(
label: '本月结余',
value: ${summary!.balance.toStringAsFixed(0)}',
color: AppTheme.primary,
),
SizedBox(width: 8),
_MiniCard(
label: '本月支出',
value: ${summary.expense.toStringAsFixed(0)}',
color: context.jz.text,
),
],
),
],
);
}
Widget _budgetSection() {
if (_budgetLoading && _budgets == null) {
return const _AiLoadCard(message: '正在加载本月预算', loading: true);
}
if (_budgetError != null && _budgets == null) {
return _AiLoadCard(message: _budgetError!, onRetry: _load);
}
return Column(
children: [
if (_budgetError != null)
_AiRefreshNotice(message: _budgetError!, onRetry: _load),
AiBudgetStrip(data: _budgets!, onTap: _openBudget),
],
);
}
@override
Widget build(BuildContext context) {
final s = _summary;
final today = ShanghaiTime.now.day;
final todayExpense =
s?.days
.where((d) => d.date.day == today)
.fold<double>(0, (sum, d) => sum + d.expense) ??
0;
return Scaffold(
body: Container(
decoration: BoxDecoration(color: context.jz.background),
child: SafeArea(
bottom: false,
child: Column(
children: [
ValueListenableBuilder<CompanionDisplay>(
valueListenable: PublicConfigApi.companionNotifier,
builder: (_, companion, __) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Row(
children: [
CircleAvatar(
radius: 22,
backgroundColor: AppTheme.ai,
child: AppIcons.icon(
AppIcons.keyMap[companion.avatarKey] ??
AppIcons.cat,
size: 24,
color: Colors.white,
),
),
SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
companion.name,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
Text(
'全 AI 模式 · 说句话就能记账',
style: TextStyle(
fontSize: 10,
color: context.jz.text3,
),
),
],
),
),
IconButton(
tooltip: '清理上下文',
onPressed: _chatController.clearContext,
icon: Icon(
Icons.restart_alt_rounded,
size: 21,
color: context.jz.text2,
),
),
GestureDetector(
onTap: _switchToNormal,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 11,
vertical: 5,
),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: AppTheme.ai.withValues(alpha: 0.35),
),
),
child: Text(
'切换模式',
style: TextStyle(
fontSize: 10.5,
color: AppTheme.ai,
),
),
),
),
],
),
);
},
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _summarySection(s, todayExpense),
),
SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _budgetSection(),
),
SizedBox(height: 8),
Expanded(
child: ChatPage(aiMode: true, controller: _chatController),
),
],
),
),
),
);
}
}
class _AiLoadCard extends StatelessWidget {
final String message;
final bool loading;
final Future<void> Function()? onRetry;
const _AiLoadCard({
required this.message,
this.loading = false,
this.onRetry,
});
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(minHeight: 58),
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 10),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: loading
? context.jz.line
: AppTheme.red.withValues(alpha: 0.22),
),
),
child: Row(
children: [
if (loading)
SizedBox(
width: 17,
height: 17,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppTheme.primary,
),
)
else
Icon(Icons.cloud_off_rounded, size: 18, color: AppTheme.red),
SizedBox(width: 10),
Expanded(
child: Text(
message,
style: TextStyle(fontSize: 11.5, color: context.jz.text2),
),
),
if (!loading && onRetry != null)
TextButton(onPressed: onRetry, child: Text('重试')),
],
),
);
}
}
class _AiRefreshNotice extends StatelessWidget {
final String message;
final Future<void> Function() onRetry;
const _AiRefreshNotice({required this.message, required this.onRetry});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 7),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
decoration: BoxDecoration(
color: context.jz.warningBackground,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Icon(Icons.info_outline_rounded, size: 15, color: AppTheme.orange),
SizedBox(width: 7),
Expanded(
child: Text(
'刷新失败,当前显示上次数据:' + message,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 10.5, color: context.jz.text2),
),
),
TextButton(
onPressed: onRetry,
style: TextButton.styleFrom(
minimumSize: const Size(0, 30),
padding: const EdgeInsets.symmetric(horizontal: 7),
),
child: Text('重试', style: TextStyle(fontSize: 10.5)),
),
],
),
);
}
}
class AiBudgetStrip extends StatelessWidget {
final BudgetsData? data;
final VoidCallback onTap;
const AiBudgetStrip({super.key, required this.data, required this.onTap});
@override
Widget build(BuildContext context) {
final total = data?.total;
final categories = data?.categories ?? const <BudgetItem>[];
final amount =
total?.amount ??
categories.fold<double>(0, (sum, item) => sum + item.amount);
final spent =
total?.spent ??
categories.fold<double>(0, (sum, item) => sum + item.spent);
final hasBudget = amount > 0;
final ratio = hasBudget ? (spent / amount).clamp(0.0, 1.0) : 0.0;
final over = hasBudget && spent > amount;
return Semantics(
button: true,
label: hasBudget ? '查看本月预算' : '设置本月预算',
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.fromLTRB(13, 10, 13, 9),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: context.jz.line),
),
child: hasBudget
? Column(
children: [
Row(
children: [
AppIcons.icon(
AppIcons.target,
size: 16,
color: over ? AppTheme.red : AppTheme.primary,
),
SizedBox(width: 7),
Text(
'本月预算',
style: TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w700,
),
),
Spacer(),
Text(
over
? '已超 ¥${(spent - amount).toStringAsFixed(0)}'
: '剩余 ¥${(amount - spent).toStringAsFixed(0)}',
style: TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w700,
color: over ? AppTheme.red : AppTheme.primaryDeep,
),
),
SizedBox(width: 3),
Icon(
Icons.chevron_right_rounded,
size: 17,
color: context.jz.text3,
),
],
),
SizedBox(height: 7),
ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
value: ratio,
minHeight: 5,
backgroundColor: context.jz.line,
color: over
? AppTheme.red
: ratio > 0.8
? AppTheme.orange
: AppTheme.primary,
),
),
SizedBox(height: 5),
Row(
children: [
Text(
'已用 ¥${spent.toStringAsFixed(0)}',
style: TextStyle(
fontSize: 9.5,
color: context.jz.text3,
),
),
Spacer(),
Text(
'总额 ¥${amount.toStringAsFixed(0)}',
style: TextStyle(
fontSize: 9.5,
color: context.jz.text3,
),
),
],
),
],
)
: Row(
children: [
Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: context.jz.primaryBackground,
borderRadius: BorderRadius.circular(9),
),
child: Center(
child: AppIcons.icon(
AppIcons.target,
size: 16,
color: AppTheme.primary,
),
),
),
SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'本月还没设置预算',
style: TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 2),
Text(
'设个上限,让 AI 帮你一起盯住',
style: TextStyle(
fontSize: 9.5,
color: context.jz.text3,
),
),
],
),
),
Text(
'去设置',
style: TextStyle(
fontSize: 11,
color: AppTheme.primaryDeep,
fontWeight: FontWeight.w700,
),
),
Icon(
Icons.chevron_right_rounded,
size: 17,
color: AppTheme.primary,
),
],
),
),
),
);
}
}
class _MiniCard extends StatelessWidget {
final String label, value;
final Color color;
const _MiniCard({
required this.label,
required this.value,
required this.color,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: Container(
padding: const EdgeInsets.all(9),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: context.jz.line),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(fontSize: 9, color: context.jz.text3)),
SizedBox(height: 2),
Text(
value,
style: TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w700,
color: color,
),
),
],
),
),
);
}
}