AI 接入 V1.0

UI更新
This commit is contained in:
2026-09-24 14:24:34 +08:00
parent 34bc2e04b0
commit 9a144f159f
65 changed files with 1195 additions and 196 deletions
+3 -3
View File
@@ -30,7 +30,7 @@ class AppModule {
/// 一句话说明,让用户在首页就能看懂这个模块是干什么的
final String blurb;
/// 用 emoji 当图标 —— 和项目现有的卡片风格一致(笔记入口、预算提示都是 emoji)
/// 图标名,对应 `assets/icons/<name>.svg`(见 [cuteIconNames])
final String icon;
/// 打开模块入口页
@@ -53,7 +53,7 @@ final List<AppModule> appModules = [
id: 'bill',
name: '记账',
blurb: '收支记录、统计与预算',
icon: '💰',
icon: 'bill',
open: (_) => const BillHomePage(),
summary: (_) => const BillModuleSummary(),
),
@@ -61,7 +61,7 @@ final List<AppModule> appModules = [
id: 'note',
name: '笔记',
blurb: 'Markdown 知识库,随时查看和记录',
icon: '📝',
icon: 'note',
open: (_) => const NoteListPage(),
summary: (_) => const NoteModuleSummary(),
),
+1 -1
View File
@@ -39,7 +39,7 @@ class Bill {
id: asInt(json['id']),
categoryId: asInt(json['categoryId']),
categoryName: asNonEmpty(json['categoryName'], '未分类'),
categoryIcon: asNonEmpty(json['categoryIcon'], '📦'),
categoryIcon: asNonEmpty(json['categoryIcon'], 'other'),
amount: asDouble(json['amount']),
description: asString(json['description']),
billDate: parseBillDate(asString(json['billDate'])),
+1 -1
View File
@@ -17,7 +17,7 @@ class Category {
factory Category.fromJson(Map<String, dynamic> json) => Category(
id: (json['id'] as num?)?.toInt() ?? 0,
name: json['name']?.toString() ?? '',
icon: json['icon']?.toString() ?? '📦',
icon: json['icon']?.toString() ?? 'other',
type: (json['type'] as num?)?.toInt() ?? 1,
sortOrder: (json['sortOrder'] as num?)?.toInt(),
);
+1 -1
View File
@@ -68,7 +68,7 @@ class CategoryStat {
factory CategoryStat.fromJson(Map<String, dynamic> json) => CategoryStat(
categoryId: asInt(json['categoryId']),
categoryName: asNonEmpty(json['categoryName'], '未分类'),
categoryIcon: asNonEmpty(json['categoryIcon'], '📦'),
categoryIcon: asNonEmpty(json['categoryIcon'], 'other'),
amount: asDouble(json['amount']),
type: asInt(json['type'], fallback: 1) == 2 ? 2 : 1,
);
+5 -4
View File
@@ -9,6 +9,7 @@ import '../../../utils/date_util.dart';
import '../../../utils/toast.dart';
import '../../../widgets/category_picker_grid.dart';
import '../../../widgets/state_views.dart';
import '../../../widgets/cute_icon.dart';
/// 手动记账表单(复刻 uniApp add.vue 手动 Tab)
class ManualEntryTab extends ConsumerStatefulWidget {
@@ -78,9 +79,9 @@ class _ManualEntryTabState extends ConsumerState<ManualEntryTab> {
Widget _typeSelector(AddBillFormState form, AddBillFormNotifier notifier) {
return Row(
children: [
_typeCard('📤', '支出', 1, form, notifier),
_typeCard('expense', '支出', 1, form, notifier),
const SizedBox(width: 10),
_typeCard('📥', '收入', 2, form, notifier),
_typeCard('income', '收入', 2, form, notifier),
],
);
}
@@ -107,7 +108,7 @@ class _ManualEntryTabState extends ConsumerState<ManualEntryTab> {
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(icon, style: const TextStyle(fontSize: 20)),
CuteIcon(icon, size: 22),
const SizedBox(width: 8),
Text(
label,
@@ -228,7 +229,7 @@ class _ManualEntryTabState extends ConsumerState<ManualEntryTab> {
await notifier.submit();
_amountController.clear();
_descriptionController.clear();
if (context.mounted) showToast(context, '✅ 记账成功');
if (context.mounted) showToast(context, '记账成功', icon: 'check');
} on ApiException catch (e) {
if (context.mounted) showToast(context, e.message, error: true);
} catch (_) {
+3 -2
View File
@@ -8,6 +8,7 @@ import '../../../core/network/api_exception.dart';
import '../../../providers/ocr_provider.dart';
import '../../../utils/toast.dart';
import 'ocr_result_card.dart';
import '../../../widgets/cute_icon.dart';
/// OCR 识别 Tab:选图 → 识别 → 结果列表(复刻 uniApp add.vue OCR Tab)
class OcrTab extends ConsumerWidget {
@@ -38,7 +39,7 @@ class OcrTab extends ConsumerWidget {
Future<void> _saveDirectly(BuildContext context, WidgetRef ref) async {
try {
await ref.read(ocrProvider.notifier).saveDirectly();
if (context.mounted) showToast(context, '✅ 已保存识别的账单');
if (context.mounted) showToast(context, '已保存识别的账单', icon: 'check');
} on ApiException catch (e) {
if (context.mounted) showToast(context, e.message, error: true);
} catch (_) {
@@ -86,7 +87,7 @@ class OcrTab extends ConsumerWidget {
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('📷', style: TextStyle(fontSize: 36)),
CuteIcon('camera', size: 40),
SizedBox(height: 6),
Text('点击选择图片', style: TextStyle(color: Color(0xFF8D6E63))),
],
+13 -1
View File
@@ -9,6 +9,7 @@ import '../../models/ai.dart';
import '../../providers/ai_provider.dart';
import '../../utils/toast.dart';
import '../../widgets/state_views.dart';
import '../../widgets/cute_icon.dart';
/// 助手:和 AI 对话,问账单、问笔记。
///
@@ -32,6 +33,17 @@ class _AssistantPageState extends ConsumerState<AssistantPage> {
'我的笔记里有哪些关于 MySQL 的?',
];
@override
void initState() {
super.initState();
// 打开就把上一次的对话展开,而不是每次都从空白开始。
// 放进 postFrameCallback:initState 里改 provider 状态会撞上「构建期间改状态」。
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ref.read(assistantProvider.notifier).restoreLastSessionIfNeeded();
});
}
@override
void dispose() {
_input.dispose();
@@ -156,7 +168,7 @@ class _AssistantPageState extends ConsumerState<AssistantPage> {
return ListView(
padding: const EdgeInsets.fromLTRB(24, 40, 24, 0),
children: [
const Center(child: Text('✨', style: TextStyle(fontSize: 44))),
const Center(child: CuteIcon('assistant', size: 48)),
const SizedBox(height: 14),
const Center(
child: Text(
+5 -4
View File
@@ -9,6 +9,7 @@ import '../../utils/date_util.dart';
import '../../utils/toast.dart';
import '../../widgets/category_picker_grid.dart';
import '../../widgets/state_views.dart';
import '../../widgets/cute_icon.dart';
/// 账单编辑页(复刻 uniApp pages/bill/detail.vue 的编辑能力,并补上删除)
class BillEditPage extends ConsumerStatefulWidget {
@@ -117,9 +118,9 @@ class _BillEditPageState extends ConsumerState<BillEditPage> {
Widget _typeSelector(BillEditState state, BillEditNotifier notifier) {
return Row(
children: [
_typeCard('📤', '支出', 1, state, notifier),
_typeCard('expense', '支出', 1, state, notifier),
const SizedBox(width: 10),
_typeCard('📥', '收入', 2, state, notifier),
_typeCard('income', '收入', 2, state, notifier),
],
);
}
@@ -149,7 +150,7 @@ class _BillEditPageState extends ConsumerState<BillEditPage> {
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(icon, style: const TextStyle(fontSize: 20)),
CuteIcon(icon, size: 22),
const SizedBox(width: 8),
Text(
label,
@@ -314,7 +315,7 @@ class _BillEditPageState extends ConsumerState<BillEditPage> {
try {
await notifier.save();
if (!mounted) return;
showToast(context, '✅ 已保存');
showToast(context, '已保存', icon: 'check');
Navigator.of(context).pop();
} on ApiException catch (e) {
if (mounted) showToast(context, e.message, error: true);
+2 -1
View File
@@ -16,6 +16,7 @@ import 'bill_statistics_page.dart';
import 'widgets/bill_group_card.dart';
import 'widgets/budget_card.dart';
import 'widgets/month_switcher.dart';
import '../../widgets/cute_icon.dart';
/// 记账模块的首页:月份切换 + 本月汇总 + 预算环 + 按日期分组的账单列表。
///
@@ -184,7 +185,7 @@ class BillHomePage extends ConsumerWidget {
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('🎯 ', style: TextStyle(fontSize: 14)),
CuteIcon('target', size: 15),
Text(
'仅可查看本月预算',
style: TextStyle(fontSize: 13, color: Color(0xFF8D6E63)),
+2 -1
View File
@@ -4,6 +4,7 @@ import '../../../models/bill.dart';
import '../../../utils/amount_util.dart';
import '../../../utils/bill_grouping.dart';
import '../../../utils/date_util.dart';
import '../../../widgets/cute_icon.dart';
/// 单日分组卡:组头(日期 + 分开的支出/收入小计)+ 账单条目
class BillGroupCard extends StatelessWidget {
@@ -91,7 +92,7 @@ class _BillTile extends StatelessWidget {
color: Color(0xFFFBEEDD),
),
alignment: Alignment.center,
child: Text(bill.categoryIcon, style: const TextStyle(fontSize: 19)),
child: CuteIcon(bill.categoryIcon, size: 21),
),
const SizedBox(width: 12),
Expanded(
+5 -2
View File
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/budget.dart';
import '../../../utils/amount_util.dart';
import '../../../widgets/cute_icon.dart';
import '../../../widgets/donut_chart.dart';
import '../../../widgets/state_views.dart';
@@ -30,7 +31,7 @@ class BudgetCard extends StatelessWidget {
Widget _noBudget() {
return const EmptyView(
icon: '🎯',
icon: 'target',
title: '本月未设置预算',
tip: '可在「我的 → 预算设置」中设置',
padding: EdgeInsets.symmetric(vertical: 20),
@@ -110,7 +111,9 @@ class BudgetCard extends StatelessWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('💰 日均可用 ', style: TextStyle(fontSize: 13, color: Color(0xFF6D4C41))),
const CuteIcon('target', size: 15),
const SizedBox(width: 5),
const Text('日均可用 ', style: TextStyle(fontSize: 13, color: Color(0xFF6D4C41))),
Text(
'¥${formatAmount(info.remainingDaily)}',
style: const TextStyle(
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../../models/statistics_response.dart';
import '../../../utils/amount_util.dart';
import '../../../widgets/cute_icon.dart';
/// 分类排行:emoji + 名称 + 进度条 + 金额 + 占比
class CategoryRankList extends StatelessWidget {
@@ -50,7 +51,7 @@ class CategoryRankList extends StatelessWidget {
children: [
Row(
children: [
Text(item.categoryIcon, style: const TextStyle(fontSize: 16)),
CuteIcon(item.categoryIcon, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
+3 -2
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../../core/module/app_module.dart';
import '../../widgets/cute_icon.dart';
import '../assistant/assistant_page.dart';
/// 首页仪表盘:AI 输入入口 + 模块卡片网格。
@@ -106,7 +107,7 @@ class DashboardPage extends StatelessWidget {
),
child: Row(
children: [
const Text('✨', style: TextStyle(fontSize: 18)),
const CuteIcon('assistant', size: 19),
const SizedBox(width: 10),
const Expanded(
child: Text(
@@ -175,7 +176,7 @@ class DashboardPage extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(module.icon, style: const TextStyle(fontSize: 24)),
CuteIcon(module.icon, size: 26),
const SizedBox(height: 8),
Text(
module.name,
+44 -143
View File
@@ -10,8 +10,13 @@ import '../../providers/auth_provider.dart';
import '../../utils/crypto.dart';
import '../../utils/toast.dart';
import '../shell/main_shell.dart';
import '../../widgets/cute_icon.dart';
/// 登录/注册页(复刻 uniApp pages/login/login.vue)
/// 登录页(复刻 uniApp pages/login/login.vue)
///
/// **注册功能已下线**(2026-09-24):这是单人应用,开放注册等于任何人都能建号。
/// App 端的入口和界面都去掉了;后端 `/api/auth/register` 按用户要求保留着,
/// 真要加人时把入口加回来即可。
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@@ -21,15 +26,12 @@ class LoginPage extends ConsumerStatefulWidget {
class _LoginPageState extends ConsumerState<LoginPage>
with SingleTickerProviderStateMixin {
bool _isRegister = false;
bool _showPassword = false;
bool _loading = false;
bool _agreeProtocol = false;
bool _rememberMe = false;
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _nicknameController = TextEditingController();
final _formKey = GlobalKey<FormState>();
late final AnimationController _floatController = AnimationController(
@@ -53,7 +55,6 @@ class _LoginPageState extends ConsumerState<LoginPage>
_floatController.dispose();
_usernameController.dispose();
_passwordController.dispose();
_nicknameController.dispose();
super.dispose();
}
@@ -66,43 +67,24 @@ class _LoginPageState extends ConsumerState<LoginPage>
showToast(context, '请输入用户名和密码', error: true);
return;
}
if (_isRegister) {
if (password.length < 6) {
showToast(context, '密码至少6位', error: true);
return;
}
if (!_agreeProtocol) {
showToast(context, '请先阅读并同意用户协议');
return;
}
}
setState(() => _loading = true);
try {
final api = ref.read(authApiProvider);
final response = _isRegister
? await api.register(
username,
password,
_nicknameController.text.trim().isEmpty
? username
: _nicknameController.text.trim(),
)
: await api.login(username, password);
final response = await ref.read(authApiProvider).login(username, password);
// 记住密码
if (!_isRegister && _rememberMe) {
if (_rememberMe) {
await LocalStorage.instance.saveRememberedPassword(
username,
encryptPassword(password),
);
} else if (!_isRegister && !_rememberMe) {
} else {
await LocalStorage.instance.clearRememberedPassword();
}
await ref.read(authProvider.notifier).loginSuccess(response);
if (!mounted) return;
showToast(context, _isRegister ? '🎉 注册成功' : '🚀 登录成功');
showToast(context, '登录成功', icon: 'check');
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const MainShell()),
(route) => false,
@@ -308,90 +290,55 @@ class _LoginPageState extends ConsumerState<LoginPage>
key: _formKey,
child: Column(
children: [
_buildSegmentTabs(),
const SizedBox(height: 20),
_inputField(
icon: '👤',
icon: 'user',
controller: _usernameController,
hint: '请输入用户名',
maxLength: 20,
),
const SizedBox(height: 14),
_inputField(
icon: '🔒',
icon: 'lock',
controller: _passwordController,
hint: '请输入密码',
maxLength: 20,
obscure: !_showPassword,
suffix: IconButton(
icon: Text(
_showPassword ? '👁️' : '👁️‍🗨️',
style: const TextStyle(fontSize: 16),
// 一个图标用颜色表示状态:显示中=品牌色,隐藏中=浅灰
icon: CuteIcon(
'eye',
size: 18,
tint: _showPassword
? const Color(0xFFC8956E)
: const Color(0xFFBCAAA4),
),
onPressed: () =>
setState(() => _showPassword = !_showPassword),
),
),
if (_isRegister) ...[
const SizedBox(height: 14),
_inputField(
icon: '✨',
controller: _nicknameController,
hint: '请输入昵称(可选)',
maxLength: 20,
),
],
if (!_isRegister) ...[
const SizedBox(height: 8),
Row(
children: [
SizedBox(
width: 22,
height: 22,
child: Checkbox(
value: _rememberMe,
activeColor: const Color(0xFFC8956E),
onChanged: (v) =>
setState(() => _rememberMe = v ?? false),
),
const SizedBox(height: 8),
Row(
children: [
SizedBox(
width: 22,
height: 22,
child: Checkbox(
value: _rememberMe,
activeColor: const Color(0xFFC8956E),
onChanged: (v) =>
setState(() => _rememberMe = v ?? false),
),
const SizedBox(width: 6),
const Text(
'记住密码',
style: TextStyle(
fontSize: 13,
color: Color(0xFF8D6E63),
),
),
const SizedBox(width: 6),
const Text(
'记住密码',
style: TextStyle(
fontSize: 13,
color: Color(0xFF8D6E63),
),
],
),
],
if (_isRegister) ...[
const SizedBox(height: 8),
Row(
children: [
SizedBox(
width: 22,
height: 22,
child: Checkbox(
value: _agreeProtocol,
activeColor: const Color(0xFFC8956E),
onChanged: (v) =>
setState(() => _agreeProtocol = v ?? false),
),
),
const Expanded(
child: Text(
'我已阅读并同意用户协议',
style: TextStyle(
fontSize: 13,
color: Color(0xFF8D6E63),
),
),
),
],
),
],
),
],
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
@@ -414,9 +361,9 @@ class _LoginPageState extends ConsumerState<LoginPage>
color: Colors.white,
),
)
: Text(
_isRegister ? '注 册' : '登 录',
style: const TextStyle(
: const Text(
'登 录',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
letterSpacing: 4,
@@ -437,52 +384,6 @@ class _LoginPageState extends ConsumerState<LoginPage>
sigmaY: 10,
);
// ---- 登录/注册切换 ----
Widget _buildSegmentTabs() {
return Container(
decoration: BoxDecoration(
color: const Color(0x33C8956E),
borderRadius: BorderRadius.circular(20),
),
padding: const EdgeInsets.all(4),
child: Row(
children: [
_segment(
'登录',
!_isRegister,
() => setState(() => _isRegister = false),
),
_segment('注册', _isRegister, () => setState(() => _isRegister = true)),
],
),
);
}
Widget _segment(String text, bool active, VoidCallback onTap) {
return Expanded(
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: active ? const Color(0xFFC8956E) : Colors.transparent,
),
child: Center(
child: Text(
text,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: active ? Colors.white : const Color(0xFF8D6E63),
),
),
),
),
),
);
}
Widget _inputField({
required String icon,
@@ -501,7 +402,7 @@ class _LoginPageState extends ConsumerState<LoginPage>
),
child: Row(
children: [
Text(icon, style: const TextStyle(fontSize: 18)),
CuteIcon(icon, size: 20),
const SizedBox(width: 10),
Expanded(
child: TextField(
+8 -5
View File
@@ -12,6 +12,7 @@ import '../../providers/budget_provider.dart';
import '../../utils/amount_input_formatter.dart';
import '../../utils/amount_util.dart';
import '../../utils/toast.dart';
import '../../widgets/cute_icon.dart';
import '../../widgets/state_views.dart';
/// 「我的」页面
@@ -101,7 +102,7 @@ class MinePage extends ConsumerWidget {
height: 60,
decoration: const BoxDecoration(shape: BoxShape.circle, color: Colors.white),
alignment: Alignment.center,
child: const Text('👤', style: TextStyle(fontSize: 30)),
child: const CuteIcon('user', size: 32),
),
const SizedBox(width: 16),
Expanded(
@@ -338,7 +339,9 @@ class MinePage extends ConsumerWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('💰 日均可用 ', style: TextStyle(fontSize: 13, color: Color(0xFF6D4C41))),
const CuteIcon('target', size: 15),
const SizedBox(width: 5),
const Text('日均可用 ', style: TextStyle(fontSize: 13, color: Color(0xFF6D4C41))),
Text(
'¥${formatAmount(budget.remainingDaily)}',
style: const TextStyle(
@@ -408,7 +411,7 @@ class MinePage extends ConsumerWidget {
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
children: [
Text(module.icon, style: const TextStyle(fontSize: 20)),
CuteIcon(module.icon, size: 21),
const SizedBox(width: 12),
Expanded(
child: Column(
@@ -456,7 +459,7 @@ class MinePage extends ConsumerWidget {
try {
await ref.read(accountProvider.notifier).setBalance(input);
if (context.mounted) showToast(context, '✅ 余额已更新');
if (context.mounted) showToast(context, '余额已更新', icon: 'check');
} on ApiException catch (e) {
if (context.mounted) showToast(context, e.message, error: true);
} catch (_) {
@@ -477,7 +480,7 @@ class MinePage extends ConsumerWidget {
try {
await ref.read(budgetProvider.notifier).setMonthBudget(input);
if (context.mounted) showToast(context, '✅ 预算已保存');
if (context.mounted) showToast(context, '预算已保存', icon: 'check');
} on ApiException catch (e) {
if (context.mounted) showToast(context, e.message, error: true);
} catch (_) {
+5 -4
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../../models/note.dart';
import '../../widgets/cute_icon.dart';
/// 目录选择面板
///
@@ -114,7 +115,7 @@ class _FolderPickerSheetState extends State<FolderPickerSheet> {
children: [
if (widget.showAll)
_row(
icon: '📚',
icon: 'education',
name: '全部',
count: widget.tree.totalCount,
depth: 0,
@@ -122,7 +123,7 @@ class _FolderPickerSheetState extends State<FolderPickerSheet> {
onTap: () => Navigator.pop(context, NoteLocation.all),
),
_row(
icon: '📥',
icon: 'income',
name: '未分类',
count: widget.tree.uncategorizedCount,
depth: 0,
@@ -153,7 +154,7 @@ class _FolderPickerSheetState extends State<FolderPickerSheet> {
mainAxisSize: MainAxisSize.min,
children: [
_row(
icon: (open && hasChildren) ? '📂' : '📁',
icon: (open && hasChildren) ? 'folder-open' : 'folder',
name: node.name,
count: node.noteCount,
depth: depth,
@@ -221,7 +222,7 @@ class _FolderPickerSheetState extends State<FolderPickerSheet> {
),
child: Row(
children: [
Text(icon, style: const TextStyle(fontSize: 15)),
CuteIcon(icon, size: 17),
const SizedBox(width: 10),
Expanded(
child: Text(
+3 -3
View File
@@ -231,7 +231,7 @@ class NoteDetailPage extends ConsumerWidget {
borderRadius: BorderRadius.circular(10),
),
child: const Text(
'🖼 图片加载失败',
'图片加载失败',
style: TextStyle(fontSize: 13, color: Color(0xFFBCAAA4)),
),
),
@@ -277,7 +277,7 @@ class NoteDetailPage extends ConsumerWidget {
onPressed: () {
Clipboard.setData(ClipboardData(text: resolveAssetUrl(href)));
Navigator.pop(sheetContext);
showToast(context, '✅ 已复制链接');
showToast(context, '已复制链接', icon: 'check');
},
style: FilledButton.styleFrom(backgroundColor: const Color(0xFFC8956E)),
child: const Text('复制'),
@@ -358,7 +358,7 @@ class NoteDetailPage extends ConsumerWidget {
await ref.read(noteApiProvider).update(note.id, pinned: !note.pinned);
ref.invalidate(noteDetailProvider(noteId));
await ref.read(noteListProvider.notifier).refresh();
if (context.mounted) showToast(context, note.pinned ? '已取消置顶' : '📌 已置顶');
if (context.mounted) showToast(context, note.pinned ? '已取消置顶' : '已置顶', icon: 'pin');
} on ApiException catch (e) {
if (context.mounted) showToast(context, e.message, error: true);
}
+2 -2
View File
@@ -106,7 +106,7 @@ class _NoteEditPageState extends ConsumerState<NoteEditPage> {
}
if (!mounted) return;
_saved = true;
showToast(context, '✅ 已保存');
showToast(context, '已保存', icon: 'check');
Navigator.of(context).pop(true);
} on ApiException catch (e) {
if (mounted) showToast(context, e.message, error: true);
@@ -246,7 +246,7 @@ class _NoteEditPageState extends ConsumerState<NoteEditPage> {
final content = _contentController.text;
if (content.trim().isEmpty) {
return const Center(
child: EmptyView(icon: '📄', title: '还没有内容', tip: '切回编辑模式写点什么吧'),
child: EmptyView(icon: 'file', title: '还没有内容', tip: '切回编辑模式写点什么吧'),
);
}
+4 -3
View File
@@ -9,6 +9,7 @@ import '../../utils/toast.dart';
import '../../widgets/state_views.dart';
import 'folder_picker_sheet.dart';
import 'note_detail_page.dart';
import '../../widgets/cute_icon.dart';
/// 笔记列表页
///
@@ -308,7 +309,7 @@ class _NoteListPageState extends ConsumerState<NoteListPage> {
children: [
SizedBox(height: MediaQuery.of(context).size.height * 0.18),
EmptyView(
icon: state.hasFilter ? '🔍' : '📝',
icon: state.hasFilter ? 'search' : 'note',
title: state.hasFilter ? '没有匹配的笔记' : '还没有笔记',
tip: state.hasFilter
? '换个关键词或标签试试'
@@ -383,7 +384,7 @@ class _NoteListPageState extends ConsumerState<NoteListPage> {
Row(
children: [
if (note.pinned) ...[
const Text('📌', style: TextStyle(fontSize: 13)),
const CuteIcon('pin', size: 14),
const SizedBox(width: 4),
],
Expanded(
@@ -471,7 +472,7 @@ class _NoteListPageState extends ConsumerState<NoteListPage> {
await ref.read(noteListProvider.notifier).refresh();
ref.invalidate(noteTagsProvider);
ref.invalidate(noteFolderTreeProvider); // 目录上的笔记数变了
if (mounted) showToast(context, '✅ 已记录');
if (mounted) showToast(context, '已记录', icon: 'check');
} on ApiException catch (e) {
if (mounted) showToast(context, e.message, error: true);
} catch (_) {
+62 -1
View File
@@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/ai_api.dart';
import '../core/network/api_exception.dart';
import '../models/ai.dart';
import 'bill_refresh.dart';
import 'note_provider.dart';
/// 后端 AI 是否配好了(没配 key 时 App 显示提示,而不是让用户白问一句)
final aiStatusProvider = FutureProvider.autoDispose<bool>((ref) {
@@ -65,12 +67,45 @@ class AssistantState {
static const _sentinel = Object();
}
/// 会让服务端数据发生变化的工具。**名字要和后端 AiToolService 保持一致** ——
/// 改名了这里就会静默失效(不报错,只是数据不刷新)。
const _mutatingTools = {
'create_bill': _Mutated.bill,
'create_note': _Mutated.note,
};
enum _Mutated { bill, note }
class AssistantNotifier extends Notifier<AssistantState> {
/// 本次 App 运行期间是否已经自动恢复过。
/// 用它拦住「用户主动开了新对话,切个 Tab 回来旧对话又被拉回来」。
bool _autoRestored = false;
@override
AssistantState build() => const AssistantState();
/// 新对话:清空界面,但**不清 sessionId 为 null 之前的会话**(后端那边还在,能从历史里翻出来)
/// 打开助手页时调用:界面还是空的就把最近一次对话展开。
///
/// 为什么放在页面里而不是 provider 的 build():build() 里不能同步改 state,
/// 而这个恢复动作是异步的、而且只该在这个页面被打开时发生。
Future<void> restoreLastSessionIfNeeded() async {
if (_autoRestored) return;
_autoRestored = true;
if (state.sessionId != null || state.bubbles.isNotEmpty) return;
try {
// 会话列表本来就是按最近活跃倒序,第一条就是「上一次的对话」
final sessions = await ref.read(aiApiProvider).sessions();
if (!ref.mounted || sessions.isEmpty) return;
await openSession(sessions.first.id);
} catch (_) {
// 拉不到就算了,用户还能从「历史对话」里自己找
}
}
/// 新对话:清空界面。后端那条旧会话还在,能从历史里翻出来。
void newChat() {
_autoRestored = true;
state = const AssistantState();
}
@@ -119,6 +154,8 @@ class AssistantNotifier extends Notifier<AssistantState> {
// SSE 的收尾阶段出错(服务端异步派发、代理截断)很常见,
// 弹个红条出来只会让人以为回答没拿到。
var sawDone = false;
// 这次对话动过哪些数据
final mutated = <_Mutated>{};
try {
await for (final event in ref
.read(aiApiProvider)
@@ -129,6 +166,8 @@ class AssistantNotifier extends Notifier<AssistantState> {
case AiEventType.delta:
_appendDelta(event.content ?? '');
case AiEventType.tool:
final what = _mutatingTools[event.toolName];
if (what != null) mutated.add(what);
state = state.copyWith(activeTool: event.toolName);
case AiEventType.done:
// 新会话时后端在这一刻才把 ID 告诉我们,必须记下来,
@@ -150,6 +189,28 @@ class AssistantNotifier extends Notifier<AssistantState> {
}
_finish(failed: failed);
_refreshMutatedData(mutated);
}
/// 让被 AI 改过的数据在 App 里失效重取。
///
/// 为什么必须显式做:AI 是在**服务端直接写库**的,App 毫不知情。
/// 而这些数据的 provider 被常驻页面(首页仪表盘的模块卡片、「我的」页)
/// 一直监听着,永远不会被 autoDispose 回收 —— 不失效就是旧数。
/// 表现会很迷惑:账单列表反而刷新了(它是 push 出来的页面,provider 被回收过),
/// 于是看起来"列表对了、汇总和预算环没对"。
void _refreshMutatedData(Set<_Mutated> mutated) {
if (mutated.contains(_Mutated.bill)) {
invalidateBillData(ref);
}
if (mutated.contains(_Mutated.note)) {
// 四个都要失效:列表是 keepAlive 的 Notifier;概览被首页的笔记卡片常驻监听;
// 标签和目录计数会随新建的笔记一起变
ref.invalidate(noteListProvider);
ref.invalidate(noteOverviewProvider);
ref.invalidate(noteTagsProvider);
ref.invalidate(noteFolderTreeProvider);
}
}
void _appendDelta(String delta) {
+18 -4
View File
@@ -1,13 +1,27 @@
import 'package:flutter/material.dart';
import '../core/navigation/navigator_key.dart';
import '../widgets/cute_icon.dart';
/// 统一提示封装(对齐 uni.showToast 的常用场景)
void showToast(BuildContext context, String message, {bool error = false}) {
///
/// [icon] 传图标名(见 [cuteIconNames]),会在文字前面显示一个小图标 ——
/// 比在文案里塞 emoji 干净,而且能跟着主题色走。
void showToast(BuildContext context, String message,
{bool error = false, String? icon}) {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
content: Row(
children: [
if (icon != null) ...[
// 深色底上用单色,双色在小尺寸下会糊成一团
CuteIcon(icon, size: 18, tint: Colors.white),
const SizedBox(width: 8),
],
Expanded(child: Text(message)),
],
),
backgroundColor: error ? const Color(0xFFB71C1C) : const Color(0xFF323232),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
@@ -16,7 +30,7 @@ void showToast(BuildContext context, String message, {bool error = false}) {
}
/// 全局提示:供没有 BuildContext 的地方调用(网络层、Provider)
void showGlobalToast(String message, {bool error = false}) {
void showGlobalToast(String message, {bool error = false, String? icon}) {
final context = navigatorKey.currentContext;
if (context != null) showToast(context, message, error: error);
if (context != null) showToast(context, message, error: error, icon: icon);
}
+2 -1
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../models/category.dart';
import 'cute_icon.dart';
/// 分类 emoji 宫格(手动记账页与账单编辑页共用)
///
@@ -67,7 +68,7 @@ class CategoryPickerGrid extends StatelessWidget {
),
),
alignment: Alignment.center,
child: Text(category.icon, style: const TextStyle(fontSize: 24)),
child: CuteIcon(category.icon, size: 26),
),
const SizedBox(height: 4),
Text(
+83
View File
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
/// 双色图标组件。
///
/// 图标是 SVG 资源(`assets/icons/*.svg`),由 `tools/make_icons.py` 生成 ——
/// 改形状或配色请改那个脚本再重跑,不要手工编辑 SVG 文件。
///
/// 每个图标自带一套配色(浅色填充 + 深色描边)。需要单色时传 [tint],
/// 它会把整个图标压成一种颜色 —— 比如放在焦糖色主按钮上的白色图标。
class CuteIcon extends StatelessWidget {
const CuteIcon(this.name, {super.key, this.size = 22, this.tint});
/// 图标名,见 [cuteIconNames]
final String name;
final double size;
/// 传了就压成单色(丢失双色效果,但能适配任意背景)
final Color? tint;
@override
Widget build(BuildContext context) {
final resolved = resolveIconName(name);
return SvgPicture.asset(
'assets/icons/$resolved.svg',
width: size,
height: size,
colorFilter:
tint == null ? null : ColorFilter.mode(tint!, BlendMode.srcIn),
);
}
}
/// 所有合法的图标名(和 tools/make_icons.py 里的 ICONS 一一对应)
const cuteIconNames = <String>{
// 记账分类
'food', 'transport', 'shopping', 'entertainment', 'medical', 'education',
'housing', 'utilities', 'phone', 'other', 'salary', 'bonus', 'invest',
'parttime',
// 模块
'bill', 'note', 'assistant',
// 界面
'expense', 'income', 'camera', 'search', 'user', 'lock', 'eye', 'image',
'file', 'folder', 'folder-open', 'check', 'pin', 'target', 'inbox', 'error',
};
/// 老数据的兼容映射:emoji → 图标名。
///
/// 分类图标以前是 emoji 存在数据库里(`category.icon`),现在换成了图标名。
/// 这份映射有两个作用:
/// ① 迁移还没跑的时候,老数据照样能显示
/// ② 迁移跑完之后,用户自己建分类时随手挑个 emoji 也能兜住
/// 都不认识就退到 `other`,不会出现空白图标。
///
/// 公开出来是为了能写测试(映射表里写错一个名字会静默退化成兜底图标,
/// 不测的话很难发现)。
const cuteEmojiAliases = <String, String>{
'🍔': 'food', '🍜': 'food', '🍚': 'food', '🍕': 'food', '☕': 'food',
'🚗': 'transport', '🚌': 'transport', '🚇': 'transport', '✈': 'transport',
'🛍': 'shopping', '🛒': 'shopping', '👕': 'shopping', '🎁': 'bonus',
'🎬': 'entertainment', '🎮': 'entertainment', '🎵': 'entertainment',
'🏥': 'medical', '💊': 'medical', '💉': 'medical',
'📚': 'education', '🎓': 'education', '📖': 'education', '✏': 'education',
'🏠': 'housing', '🏡': 'housing', '🛏': 'housing',
'💡': 'utilities', '💧': 'utilities', '⚡': 'utilities', '🔌': 'utilities',
'📱': 'phone', '☎': 'phone', '💻': 'phone',
'💰': 'salary', '💵': 'salary', '🏦': 'salary', '💴': 'salary',
'📈': 'invest', '📊': 'invest', '💹': 'invest', '🪙': 'invest',
'💼': 'parttime', '🧑‍💻': 'parttime', '🔧': 'parttime',
'📦': 'other', '📌': 'other', '🏷': 'other', '❓': 'other',
};
/// 把任意来源的图标值(图标名 / emoji / 空)解析成一个合法的图标名
String resolveIconName(String? raw) {
final value = (raw ?? '').trim();
if (value.isEmpty) return 'other';
if (cuteIconNames.contains(value)) return value;
// emoji 里可能带变体选择符(U+FE0F),去掉再查一次
final stripped = value.replaceAll('\uFE0F', '');
return cuteEmojiAliases[value] ?? cuteEmojiAliases[stripped] ?? 'other';
}
+10 -5
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'cute_icon.dart';
/// 加载态
class LoadingView extends StatelessWidget {
const LoadingView({super.key, this.tip = '加载中...', this.padding = const EdgeInsets.all(40)});
@@ -43,8 +45,8 @@ class ErrorView extends StatelessWidget {
),
child: Column(
children: [
const Text('😵', style: TextStyle(fontSize: 30)),
const SizedBox(height: 8),
const CuteIcon('error', size: 32),
const SizedBox(height: 10),
Text(
message,
textAlign: TextAlign.center,
@@ -69,10 +71,13 @@ class ErrorView extends StatelessWidget {
}
/// 空状态
///
/// [icon] 传图标名(见 [cuteIconNames]);为了兼容以前传 emoji 的调用点,
/// 走的是 [resolveIconName],emoji 和未知值都能落到一个兜底图标上。
class EmptyView extends StatelessWidget {
const EmptyView({
super.key,
this.icon = '📭',
this.icon = 'inbox',
required this.title,
this.tip,
this.padding = const EdgeInsets.symmetric(vertical: 40),
@@ -89,8 +94,8 @@ class EmptyView extends StatelessWidget {
padding: padding,
child: Column(
children: [
Text(icon, style: const TextStyle(fontSize: 44)),
const SizedBox(height: 10),
CuteIcon(icon, size: 46),
const SizedBox(height: 12),
Text(title, style: const TextStyle(fontSize: 15, color: Color(0xFF5D4037))),
if (tip != null) ...[
const SizedBox(height: 6),