AI 接入 V1.0
UI更新
This commit is contained in:
@@ -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(),
|
||||
),
|
||||
|
||||
@@ -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'])),
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 (_) {
|
||||
|
||||
@@ -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))),
|
||||
],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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 (_) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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: '切回编辑模式写点什么吧'),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (_) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user