571 lines
19 KiB
Dart
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|