Initial project import

This commit is contained in:
2026-07-24 23:11:20 +08:00
commit 6396eabb87
372 changed files with 49682 additions and 0 deletions
+559
View File
@@ -0,0 +1,559 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/business_api.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/widgets/category_icon.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
class AddPage extends StatefulWidget {
const AddPage({super.key});
@override
State<AddPage> createState() => _AddPageState();
}
class _AddPageState extends State<AddPage> {
final _noteCtrl = TextEditingController();
String _tab = 'expense';
final Map<String, List<CategoryItem>> _categoriesByType = {
'expense': <CategoryItem>[],
'income': <CategoryItem>[],
};
final Map<String, CategoryItem?> _selectedByType = {
'expense': null,
'income': null,
};
String _amount = '0';
String? _paymentMethod;
DateTime _occurredAt = ShanghaiTime.now;
bool _saving = false;
bool _loadingCategories = true;
List<CategoryItem> get _categories =>
_categoriesByType[_tab] ?? const <CategoryItem>[];
CategoryItem? get _selected => _selectedByType[_tab];
Color get _activeColor => _tab == 'income' ? AppTheme.primary : AppTheme.red;
@override
void initState() {
super.initState();
_loadCategories();
}
@override
void dispose() {
_noteCtrl.dispose();
super.dispose();
}
Future<void> _loadCategories() async {
setState(() => _loadingCategories = true);
try {
final results = await Future.wait([
TxApi.categories('expense'),
TxApi.categories('income'),
]);
if (!mounted) return;
setState(() {
_categoriesByType['expense'] = results[0];
_categoriesByType['income'] = results[1];
if (_selectedByType['expense'] == null && results[0].isNotEmpty) {
_selectedByType['expense'] = results[0].first;
}
if (_selectedByType['income'] == null && results[1].isNotEmpty) {
_selectedByType['income'] = results[1].first;
}
});
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
}
} finally {
if (mounted) setState(() => _loadingCategories = false);
}
}
void _switchTab(String tab) {
if (_tab == tab) return;
setState(() => _tab = tab);
}
void _pressKey(String key) {
setState(() {
if (key == 'delete') {
_amount = _amount.length > 1
? _amount.substring(0, _amount.length - 1)
: '0';
return;
}
if (key == '.') {
if (!_amount.contains('.')) _amount += '.';
return;
}
if (_amount.contains('.') && _amount.split('.')[1].length >= 2) return;
_amount = _amount == '0' ? key : '$_amount$key';
if (_amount.length > 9) _amount = _amount.substring(0, 9);
});
}
Future<void> _save() async {
final amount = double.tryParse(_amount) ?? 0;
if (amount <= 0) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('请输入金额')));
return;
}
if (_selected == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('请选择分类')));
return;
}
setState(() => _saving = true);
try {
await TxApi.create(
categoryId: _selected!.id,
type: _tab,
amount: amount,
note: _noteCtrl.text.trim().isEmpty ? null : _noteCtrl.text.trim(),
paymentMethod: _paymentMethod,
occurredAt: _occurredAt,
);
TransactionEvents.notifyChanged();
if (mounted) context.pop(amount);
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _editNote() async {
final value = await showJzTextInputSheet(
context,
title: '备注',
label: '备注内容',
initialValue: _noteCtrl.text,
maxLength: 40,
);
if (value != null) setState(() => _noteCtrl.text = value);
}
Future<void> _pickOccurredAt() async {
final value = await showJzDateTimeSheet(
context,
initial: _occurredAt,
firstDate: DateTime(2000),
lastDate: ShanghaiTime.now.add(const Duration(days: 1)),
title: '选择发生时间',
);
if (value != null && mounted) setState(() => _occurredAt = value);
}
Future<void> _pickPaymentMethod() async {
const methods = ['微信支付', '支付宝', '银行卡', '现金', '其他'];
final options = [
...methods.map((method) => JzOption(value: method, label: method)),
if (_paymentMethod != null) const JzOption(value: '', label: '清除支付方式'),
];
final value = await showJzOptionSheet<String>(
context,
title: '支付方式',
options: options,
selected: _paymentMethod,
);
if (value != null) {
setState(() => _paymentMethod = value.isEmpty ? null : value);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('记一笔'),
leading: IconButton(
icon: AppIcons.icon(
AppIcons.close,
size: 20,
color: context.jz.text2,
),
onPressed: () => context.pop(),
),
),
body: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
final compact = constraints.maxHeight < 650;
return Column(
children: [
_buildTypeSelector(),
SizedBox(height: 4),
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 260),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) {
final offset = _tab == 'expense'
? const Offset(-0.04, 0)
: const Offset(0.04, 0);
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: offset,
end: Offset.zero,
).animate(animation),
child: child,
),
);
},
child: _loadingCategories
? Center(
key: ValueKey('loading'),
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppTheme.primary,
),
)
: _buildCategoryGrid(key: ValueKey(_tab)),
),
),
_buildAmountKeyboard(compact),
],
);
},
),
),
);
}
Widget _buildTypeSelector() {
return Container(
width: 220,
height: 38,
margin: const EdgeInsets.only(top: 4),
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: context.jz.line,
borderRadius: BorderRadius.circular(10),
),
child: LayoutBuilder(
builder: (context, constraints) => Stack(
children: [
AnimatedAlign(
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
alignment: _tab == 'expense'
? Alignment.centerLeft
: Alignment.centerRight,
child: Container(
width: constraints.maxWidth / 2,
height: constraints.maxHeight,
decoration: BoxDecoration(
color: _activeColor,
borderRadius: BorderRadius.circular(7),
),
),
),
Row(
children: [_segment('支出', 'expense'), _segment('收入', 'income')],
),
],
),
),
);
}
Widget _segment(String label, String value) {
final selected = _tab == value;
return Expanded(
child: InkWell(
borderRadius: BorderRadius.circular(7),
onTap: () => _switchTab(value),
child: Center(
child: AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
style: TextStyle(
fontSize: 13,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: selected ? Colors.white : context.jz.text2,
),
child: Text(label),
),
),
),
);
}
Widget _buildCategoryGrid({required Key key}) {
return GridView.builder(
key: key,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
childAspectRatio: 0.92,
),
itemCount: _categories.length,
itemBuilder: (context, index) {
final category = _categories[index];
final selected = category.id == _selected?.id;
return InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => setState(() => _selectedByType[_tab] = category),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 160),
width: 48,
height: 48,
decoration: BoxDecoration(
color: selected ? _activeColor : context.jz.card,
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: selected ? _activeColor : context.jz.line,
width: selected ? 1 : 0.5,
),
),
child: Center(
child: AppIcons.byKey(
category.iconKey,
size: 21,
color: selected ? Colors.white : context.jz.text2,
),
),
),
SizedBox(height: 5),
Text(
category.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
color: selected ? _activeColor : context.jz.text2,
fontWeight: selected ? FontWeight.w600 : null,
),
),
],
),
);
},
);
}
Widget _buildAmountKeyboard(bool compact) {
const keys = [
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'.',
'0',
'delete',
];
final dateLabel =
'${_occurredAt.month}/${_occurredAt.day} '
'${_occurredAt.hour.toString().padLeft(2, '0')}:'
'${_occurredAt.minute.toString().padLeft(2, '0')}';
return Container(
padding: EdgeInsets.fromLTRB(16, compact ? 7 : 10, 16, 10),
decoration: BoxDecoration(
color: context.jz.card,
border: Border(top: BorderSide(color: context.jz.line, width: 0.5)),
),
child: Column(
children: [
Row(
children: [
if (_selected != null) ...[
CategoryIconBox(
iconKey: _selected!.iconKey,
colorKey: _selected!.colorKey,
size: 30,
),
SizedBox(width: 8),
Text(
_selected!.name,
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
],
Spacer(),
AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
style: TextStyle(
fontSize: compact ? 25 : 28,
fontWeight: FontWeight.w800,
color: _activeColor,
),
child: Text('¥ $_amount'),
),
],
),
SizedBox(height: 7),
SizedBox(
height: 34,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
_metaChip(
icon: Icons.edit_note_rounded,
label: _noteCtrl.text.isEmpty ? '备注' : _noteCtrl.text,
onTap: _editNote,
),
_metaChip(
icon: Icons.schedule_rounded,
label: dateLabel,
onTap: _pickOccurredAt,
),
_metaChip(
icon: Icons.account_balance_wallet_outlined,
label: _paymentMethod ?? '支付方式',
onTap: _pickPaymentMethod,
),
],
),
),
SizedBox(height: 8),
SizedBox(
height: compact ? 172 : 196,
child: Row(
children: [
Expanded(
child: GridView.count(
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 3,
mainAxisSpacing: 6,
crossAxisSpacing: 6,
childAspectRatio: compact ? 2.15 : 2.0,
children: keys.map(_buildKey).toList(),
),
),
SizedBox(width: 7),
SizedBox(
width: 74,
child: AnimatedContainer(
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
decoration: BoxDecoration(
color: _saving ? context.jz.line : _activeColor,
borderRadius: BorderRadius.circular(11),
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(11),
child: InkWell(
borderRadius: BorderRadius.circular(11),
onTap: _saving ? null : _save,
child: Center(
child: _saving
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.check_rounded,
color: Colors.white,
size: 24,
),
SizedBox(height: 4),
Text(
'完成',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
],
),
),
),
),
),
),
],
),
),
],
),
);
}
Widget _metaChip({
required IconData icon,
required String label,
required VoidCallback onTap,
}) {
return Padding(
padding: const EdgeInsets.only(right: 7),
child: ActionChip(
avatar: Icon(icon, size: 15, color: context.jz.text2),
label: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 105),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: context.jz.text2),
),
),
side: BorderSide.none,
backgroundColor: context.jz.background,
onPressed: onTap,
),
);
}
Widget _buildKey(String key) {
return Material(
color: context.jz.background,
borderRadius: BorderRadius.circular(9),
child: InkWell(
borderRadius: BorderRadius.circular(9),
onTap: () => _pressKey(key),
child: Center(
child: key == 'delete'
? Icon(
Icons.backspace_outlined,
size: 18,
color: context.jz.text2,
)
: Text(
key,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
),
),
);
}
}
+276
View File
@@ -0,0 +1,276 @@
import 'package:flutter/material.dart';
import 'package:miaoji_zhang/shared/services/transaction_events.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/business_api.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
Future<bool?> showParseSheet(
BuildContext context, {
required String source,
String? initialText,
}) {
return showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => _ParseSheet(source: source, initialText: initialText),
);
}
class _ParseSheet extends StatefulWidget {
final String source;
final String? initialText;
const _ParseSheet({required this.source, this.initialText});
@override
State<_ParseSheet> createState() => _ParseSheetState();
}
class _ParseSheetState extends State<_ParseSheet> {
late final TextEditingController _input;
ParsedDraft? _draft;
bool _parsing = false;
bool _saving = false;
bool get isVoice => widget.source == 'voice';
@override
void initState() {
super.initState();
_input = TextEditingController(text: widget.initialText ?? '');
if ((widget.initialText ?? '').trim().isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) => _parse());
}
}
@override
void dispose() {
_input.dispose();
super.dispose();
}
Future<void> _parse() async {
final text = _input.text.trim();
if (text.isEmpty) return;
setState(() => _parsing = true);
try {
final d = await ParseApi.parse(text, source: widget.source);
if (mounted) setState(() => _draft = d);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(e))));
}
} finally {
if (mounted) setState(() => _parsing = false);
}
}
Future<void> _confirm() async {
final d = _draft;
if (d == null || d.amount <= 0) return;
setState(() => _saving = true);
try {
await ParseApi.confirm(d, widget.source, _input.text.trim());
TransactionEvents.notifyChanged();
if (mounted) Navigator.pop(context, true);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(e))));
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final d = _draft;
return Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.vertical(top: Radius.circular(22)),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(22, 22, 22, 30),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Column(
children: [
Text(
isVoice ? '语音记账' : '文字识别',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700),
),
SizedBox(height: 4),
Text(
isVoice
? '识别到的语音会自动填到这里,你也可以手动补充或修改。'
: '把识别到的文字贴进来,AI 会先帮你解析成账单草稿。',
style: TextStyle(fontSize: 10.5, color: context.jz.text3),
textAlign: TextAlign.center,
),
],
),
),
SizedBox(height: 14),
TextField(
controller: _input,
maxLines: 2,
decoration: InputDecoration(
hintText: isVoice
? '例如:昨天晚上和朋友吃烧烤 88 支付宝付的'
: '例如:永辉超市 合计 128.60 微信支付',
),
onChanged: (_) {
if (_draft != null) setState(() => _draft = null);
},
),
SizedBox(height: 12),
if (d == null)
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.ai),
onPressed: _parsing ? null : _parse,
child: _parsing
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text('开始识别'),
),
)
else ...[
Container(
padding: const EdgeInsets.all(13),
decoration: BoxDecoration(
color: context.jz.aiBackground,
borderRadius: BorderRadius.circular(13),
),
child: Row(
children: [
CategoryIconBox(iconKey: d.categoryIcon, size: 38),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'¥${d.amount.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: d.type == 'income'
? AppTheme.primary
: AppTheme.red,
),
),
SizedBox(height: 3),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color:
(d.type == 'income'
? AppTheme.primary
: AppTheme.red)
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(5),
),
child: Text(
d.type == 'income' ? '收入' : '支出',
style: TextStyle(
fontSize: 9.5,
fontWeight: FontWeight.w700,
color: d.type == 'income'
? AppTheme.primary
: AppTheme.red,
),
),
),
SizedBox(width: 6),
Flexible(
child: Text(
d.categoryName +
(d.paymentMethod == null
? ''
: ' · ' + d.paymentMethod!),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
color: context.jz.text2,
),
),
),
],
),
if (!d.matched)
Text(
'没有识别出有效金额,请修改后重新识别。',
style: TextStyle(
fontSize: 10.5,
color: AppTheme.red,
),
),
],
),
),
],
),
),
SizedBox(height: 12),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => setState(() => _draft = null),
child: Text(
'重新识别',
style: TextStyle(color: context.jz.text2),
),
),
),
SizedBox(width: 10),
Expanded(
child: ElevatedButton(
onPressed: (!d.matched || _saving) ? null : _confirm,
child: _saving
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text('确认入账'),
),
),
],
),
],
],
),
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,570 @@
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,
),
),
],
),
),
);
}
}
@@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
class AiWelcomePage extends StatelessWidget {
final void Function(String) onSend;
final String? companionName;
final String? companionAvatarKey;
const AiWelcomePage({
super.key,
required this.onSend,
this.companionName,
this.companionAvatarKey,
});
static const _cards = [
('"早餐包子豆浆 6 块"', '直接说花了多少钱,我帮你记账', AppIcons.food),
('"这个月花最多的是什么"', '问我查账、看报告、定预算', AppIcons.chart),
('"按住说话"', '语音记账、拍小票,都能识别', AppIcons.mic),
];
@override
Widget build(BuildContext context) {
final name = companionName ?? '小账喵';
final avatar = AppIcons.avatarAsset(companionAvatarKey ?? 'cat');
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
children: [
Spacer(flex: 2),
Container(
width: 88,
height: 88,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: context.jz.aiBackground,
boxShadow: [
BoxShadow(
color: AppTheme.ai.withValues(alpha: 0.15),
blurRadius: 24,
),
],
),
child: Center(
child: AppIcons.icon(avatar, size: 44, color: AppTheme.ai),
),
),
SizedBox(height: 18),
Text(
'嗨!我是$name',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800),
),
SizedBox(height: 8),
Text(
'不用翻菜单,不用点按钮\n说句话就能记账,试试看?',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12.5,
color: context.jz.text2,
height: 1.7,
),
),
SizedBox(height: 22),
..._cards.asMap().entries.map(
(e) => GestureDetector(
onTap: () => onSend(e.value.$1.replaceAll('"', '')),
child: Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 13,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: Colors.white.withValues(alpha: 0.9),
),
),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: context.jz.aiBackground,
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: AppIcons.icon(
e.value.$3,
size: 18,
color: AppTheme.ai,
),
),
),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
e.value.$1,
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 3),
Text(
e.value.$2,
style: TextStyle(
fontSize: 10.5,
color: context.jz.text2,
),
),
],
),
),
AppIcons.icon(
AppIcons.chevronRight,
size: 14,
color: context.jz.text3,
),
],
),
),
),
),
Spacer(flex: 3),
],
),
);
}
}
@@ -0,0 +1,228 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.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/config_api.dart';
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
import 'package:miaoji_zhang/shared/services/guest_merge_service.dart';
import 'package:miaoji_zhang/shared/services/local_database.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/version.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
import 'package:miaoji_zhang/shared/widgets/brand_logo.dart';
class LoginPage extends StatefulWidget {
final String? notice;
const LoginPage({super.key, this.notice});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _user = TextEditingController();
final _pass = TextEditingController();
bool _loading = false;
@override
void initState() {
super.initState();
PublicConfigApi.init().ignore();
if (widget.notice case final notice? when notice.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(notice)));
}
});
}
}
@override
void dispose() {
_user.dispose();
_pass.dispose();
super.dispose();
}
Future<void> _login() async {
if (_user.text.trim().isEmpty || _pass.text.isEmpty) {
_showMessage('请输入用户名和密码');
return;
}
final guestSnapshot = SessionStore.instance.isGuest
? LocalDatabase.instance.guestSnapshot()
: null;
setState(() => _loading = true);
try {
final closureCancelled = await AuthApi.login(
_user.text.trim(),
_pass.text,
);
final profile = await AuthApi.me();
await CurrentLedgerStore.instance.ensureLoaded(force: true);
if (!mounted) return;
if (guestSnapshot?['hasData'] == true) {
final shouldMerge = await showJzConfirmSheet(
context,
title: '发现游客数据',
message: '是否将本机游客账单、分类和预算导入当前账号?数据会放入独立的“游客数据”账本,原游客数据仍保留在本机。',
confirmLabel: '导入数据',
);
if (shouldMerge) {
try {
final result = await GuestMergeService.merge(guestSnapshot!);
if (mounted) {
_showMessage('已导入 ${result.transactionCount} 笔游客账单');
}
} catch (error) {
if (mounted) {
_showMessage('游客数据暂未导入:${apiErrorMessage(error)}');
}
}
}
}
if (!mounted) return;
if (closureCancelled) _showMessage('已取消账号注销,欢迎回来');
final destination = !profile.onboardingDone
? '/onboarding'
: profile.appMode == 'ai'
? '/ai-mode'
: '/home';
context.go(destination);
} catch (error) {
if (mounted) _showMessage(apiErrorMessage(error));
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _startGuest() async {
setState(() => _loading = true);
try {
await SessionStore.instance.startGuest();
await CurrentLedgerStore.instance.ensureLoaded(force: true);
if (mounted) context.go('/home');
} catch (error) {
if (mounted) _showMessage(apiErrorMessage(error));
} finally {
if (mounted) setState(() => _loading = false);
}
}
void _showMessage(String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 28),
children: [
SizedBox(height: 48),
Center(
child: Column(
children: [
const BrandLogo(size: 82),
SizedBox(height: 16),
Text(
PublicConfigApi.appName,
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.w800,
letterSpacing: 1,
),
),
SizedBox(height: 8),
Text(
PublicConfigApi.slogan,
style: TextStyle(fontSize: 13, color: context.jz.text2),
),
SizedBox(height: 48),
],
),
),
TextField(
controller: _user,
textInputAction: TextInputAction.next,
decoration: InputDecoration(labelText: '用户名', hintText: '请输入用户名'),
),
SizedBox(height: 12),
TextField(
controller: _pass,
obscureText: true,
decoration: InputDecoration(labelText: '密码', hintText: '请输入密码'),
onSubmitted: (_) => _loading ? null : _login(),
),
SizedBox(height: 22),
JzActionButton(
label: '登录',
loading: _loading,
onPressed: _loading ? null : _login,
),
SizedBox(height: 10),
JzActionButton(
label: '游客模式',
secondary: true,
onPressed: _loading ? null : _startGuest,
),
SizedBox(height: 7),
Text(
'游客数据仅保存在本机;AI、语音解析和图片识别需登录联网',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 10.5,
color: context.jz.text3,
height: 1.45,
),
),
SizedBox(height: 8),
Center(
child: TextButton(
onPressed: _loading ? null : () => context.go('/register'),
child: Text('没有账号?去注册'),
),
),
const LoginLegalLinks(),
SizedBox(height: 20),
Center(
child: Text(
AppVersion.display,
style: TextStyle(fontSize: 10, color: context.jz.text3),
),
),
],
),
),
);
}
}
class LoginLegalLinks extends StatelessWidget {
const LoginLegalLinks({super.key});
@override
Widget build(BuildContext context) {
return Wrap(
alignment: WrapAlignment.center,
spacing: 2,
runSpacing: 0,
children: [
_link(context, '用户协议', 'terms'),
_link(context, '隐私政策', 'privacy'),
],
);
}
Widget _link(BuildContext context, String label, String kind) => TextButton(
onPressed: () => context.push('/legal/$kind'),
child: Text(label, style: TextStyle(fontSize: 11.5)),
);
}
@@ -0,0 +1,178 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/auth_api.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
class RegisterPage extends StatefulWidget {
const RegisterPage({super.key});
@override
State<RegisterPage> createState() => _RegisterPageState();
}
class _RegisterPageState extends State<RegisterPage> {
final _user = TextEditingController();
final _pass = TextEditingController();
bool _loading = false;
bool _agreed = false;
@override
void dispose() {
_user.dispose();
_pass.dispose();
super.dispose();
}
Future<void> _register() async {
final username = _user.text.trim();
if (username.length < 3 || username.length > 32) {
_message('用户名长度需在 3-32 之间');
return;
}
if (_pass.text.length < 6) {
_message('密码至少 6 位');
return;
}
if (!_agreed) {
_message('请先单独勾选同意用户协议与隐私政策');
return;
}
setState(() => _loading = true);
try {
await AuthApi.register(username, _pass.text, agreedToTerms: _agreed);
if (!mounted) return;
context.go('/onboarding');
} catch (error) {
if (!mounted) return;
_message(apiErrorMessage(error));
} finally {
if (mounted) setState(() => _loading = false);
}
}
void _message(String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('注册')),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 28),
children: [
SizedBox(height: 32),
TextField(
controller: _user,
decoration: InputDecoration(hintText: '用户名(3-32 位)'),
),
SizedBox(height: 12),
TextField(
controller: _pass,
obscureText: true,
decoration: InputDecoration(hintText: '密码(至少 6 位)'),
),
SizedBox(height: 18),
Semantics(
checked: _agreed,
button: true,
label: '同意用户协议与隐私政策',
child: InkWell(
onTap: () => setState(() => _agreed = !_agreed),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 7),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 160),
width: 21,
height: 21,
decoration: BoxDecoration(
color: _agreed ? AppTheme.primary : Colors.white,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: _agreed
? AppTheme.primary
: context.jz.text3,
),
),
child: _agreed
? Icon(
Icons.check_rounded,
size: 15,
color: Colors.white,
)
: null,
),
SizedBox(width: 10),
Expanded(
child: Text(
'我已阅读并单独同意以下用户协议与隐私政策',
style: TextStyle(
fontSize: 12.5,
height: 1.5,
color: context.jz.text2,
),
),
),
],
),
),
),
),
Wrap(
spacing: 2,
runSpacing: 0,
children: [
_legalLink('《用户协议》', 'terms'),
_legalLink('《隐私政策》', 'privacy'),
_legalLink('权限用途', 'permissions'),
_legalLink('第三方 SDK 清单', 'sdk'),
],
),
SizedBox(height: 18),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _loading ? null : _register,
child: _loading
? SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text('注册'),
),
),
SizedBox(height: 14),
TextButton(
onPressed: () => context.go('/login'),
child: Text(
'已有账号?去登录',
style: TextStyle(color: context.jz.text2),
),
),
],
),
),
);
}
Widget _legalLink(String label, String kind) => TextButton(
onPressed: () => context.push('/legal/' + kind),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
minimumSize: const Size(0, 34),
),
child: Text(label, style: TextStyle(fontSize: 11.5)),
);
}
@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/update/update_coordinator.dart';
import 'package:miaoji_zhang/shared/version.dart';
import 'package:miaoji_zhang/shared/widgets/brand_logo.dart';
class SplashPage extends StatefulWidget {
const SplashPage({super.key});
@override
State<SplashPage> createState() => _SplashPageState();
}
class _SplashPageState extends State<SplashPage> {
@override
void initState() {
super.initState();
_bootstrap();
}
void _finishStartup(String location) {
if (!mounted) return;
context.go(location);
UpdateCoordinator.instance.scheduleStartupCheck();
}
Future<void> _bootstrap() async {
final session = SessionStore.instance;
if (session.hasSession) {
try {
await CurrentLedgerStore.instance.loadCached(force: true);
} catch (_) {
// A damaged local cache must not leave the app trapped on the splash page.
}
}
if (!mounted) return;
if (!session.hasSession) {
_finishStartup('/login');
return;
}
if (!session.onboardingDone && !session.shouldUseLocalOnly) {
_finishStartup('/onboarding');
} else if (session.appMode == 'ai' && !session.shouldUseLocalOnly) {
_finishStartup('/ai-mode');
} else {
_finishStartup('/home');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const BrandLogo(size: 90),
SizedBox(height: 24),
CircularProgressIndicator(strokeWidth: 2, color: AppTheme.primary),
SizedBox(height: 16),
Text(
AppVersion.display,
style: TextStyle(fontSize: 10, color: context.jz.text3),
),
],
),
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,972 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/business_api.dart';
import 'package:miaoji_zhang/shared/api/config_api.dart';
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
import 'package:miaoji_zhang/shared/services/session_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/category_icon.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
import 'package:miaoji_zhang/shared/widgets/async_error_view.dart';
import 'package:miaoji_zhang/features/home/pages/tx_detail_page.dart';
import 'package:miaoji_zhang/features/home/pages/ledger_sheet.dart';
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
/// 首页明细(P1 / P20 空状态)
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => HomePageState();
}
class HomePageState extends State<HomePage> {
MonthSummary? _summary;
BudgetsData? _budgets;
bool _loading = true;
String? _error;
late DateTime _month;
@override
void initState() {
super.initState();
_month = ShanghaiTime.now;
PublicConfigApi.companionNotifier.addListener(_refreshCompanion);
TransactionEvents.revision.addListener(_refreshTransactions);
CurrentLedgerStore.instance.addListener(_refreshLedger);
refresh();
}
void _refreshCompanion() {
if (mounted) setState(() {});
}
void _refreshTransactions() {
if (mounted) refresh();
}
void _refreshLedger() {
if (mounted) refresh();
}
@override
void dispose() {
PublicConfigApi.companionNotifier.removeListener(_refreshCompanion);
TransactionEvents.revision.removeListener(_refreshTransactions);
CurrentLedgerStore.instance.removeListener(_refreshLedger);
super.dispose();
}
Future<void> refresh() async {
setState(() {
_loading = true;
_error = null;
});
try {
await CurrentLedgerStore.instance.ensureLoaded();
final results = await Future.wait<dynamic>([
TxApi.month(_month.year, _month.month),
BudgetApi.get(_month.year, _month.month),
]);
if (mounted) {
setState(() {
_summary = results[0] as MonthSummary;
_budgets = results[1] as BudgetsData;
});
}
} catch (error) {
try {
final summary = await TxApi.month(_month.year, _month.month);
if (mounted) setState(() => _summary = summary);
} catch (fallbackError) {
if (mounted) {
setState(() => _error = apiErrorMessage(fallbackError));
}
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
final s = _summary;
final budgets = _budgets;
final session = SessionStore.instance;
final showAiEntry = session.isGuest || session.aiEnabled;
final budgetsByCategory = <int, BudgetItem>{
for (final item in budgets?.categories ?? const <BudgetItem>[])
if (item.categoryId != null) item.categoryId!: item,
};
if (!_loading && s == null && _error != null) {
return Scaffold(
body: SafeArea(
child: AsyncErrorView(message: _error!, onRetry: refresh),
),
);
}
return Scaffold(
body: SafeArea(
child: RefreshIndicator(
onRefresh: refresh,
color: AppTheme.primary,
child: NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification is ScrollStartNotification) {
_SwipeReveal.closeOpen();
}
return false;
},
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
SizedBox(height: 8),
Row(
children: [
GestureDetector(
onTap: () {
_month = DateTime(_month.year, _month.month - 1);
refresh();
},
child: Transform(
alignment: Alignment.center,
transform: Matrix4.rotationY(3.1416),
child: AppIcons.icon(
AppIcons.chevronRight,
size: 16,
color: context.jz.text3,
),
),
),
SizedBox(width: 6),
GestureDetector(
onTap: () =>
LedgerSheet.show(context).then((_) => refresh()),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
CurrentLedgerStore.instance.currentName,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
AppIcons.icon(
AppIcons.chevronDown,
size: 16,
color: context.jz.text3,
),
],
),
),
SizedBox(width: 6),
GestureDetector(
onTap: () {
_month = DateTime(_month.year, _month.month + 1);
refresh();
},
child: AppIcons.icon(
AppIcons.chevronRight,
size: 16,
color: context.jz.text3,
),
),
Spacer(),
IconButton(
icon: AppIcons.icon(
AppIcons.search,
size: 20,
color: context.jz.text2,
),
onPressed: () => context.push('/search'),
),
],
),
Text(
'${_month.year}${_month.month}',
style: TextStyle(fontSize: 11, color: context.jz.text3),
),
SizedBox(height: 6),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppTheme.primary,
borderRadius: BorderRadius.circular(18),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${_month.month}月结余',
style: TextStyle(
fontSize: 12,
color: Colors.white.withValues(alpha: 0.85),
),
),
SizedBox(height: 5),
Text(
'¥ ${(s?.balance ?? 0).toStringAsFixed(2)}',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
SizedBox(height: 14),
Row(
children: [
_HeroCol(label: '收入', value: s?.income ?? 0),
_HeroCol(label: '支出', value: s?.expense ?? 0),
_HeroCol(
label: '笔数',
value: (s?.count ?? 0).toDouble(),
isCount: true,
),
],
),
],
),
),
SizedBox(height: 12),
if (budgets != null) ...[
_BudgetSummaryCard(
data: budgets,
onTap: () async {
await context.push('/budget');
refresh();
},
),
SizedBox(height: 12),
],
if (showAiEntry) ...[
GestureDetector(
onTap: () => context.go('/chat'),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 13,
vertical: 11,
),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: context.jz.line, width: 0.5),
),
child: Row(
children: [
CircleAvatar(
radius: 19,
backgroundColor: AppTheme.ai,
child: AppIcons.icon(
AppIcons.avatarAsset(
PublicConfigApi.companionAvatarKey,
),
size: 20,
color: Colors.white,
),
),
SizedBox(width: 11),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
PublicConfigApi.companionName,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
Text(
SessionStore.instance.shouldUseLocalOnly
? '登录并连接云端后可使用 AI 记账'
: '说句话就能记账',
style: TextStyle(
fontSize: 11,
color: context.jz.text2,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: context.jz.aiBackground,
borderRadius: BorderRadius.circular(16),
),
child: Text(
'去聊天',
style: TextStyle(
fontSize: 12,
color: AppTheme.ai,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
SizedBox(height: 15),
],
if (_loading && s == null)
Padding(
padding: EdgeInsets.only(top: 60),
child: Center(
child: CircularProgressIndicator(
color: AppTheme.primary,
strokeWidth: 2,
),
),
)
else if (s == null || s.days.isEmpty)
_EmptyState(
aiEnabled: showAiEntry,
onAction: showAiEntry
? () => context.go('/chat')
: () => context.push('/add'),
companionName: PublicConfigApi.companionName,
avatarKey: PublicConfigApi.companionAvatarKey,
)
else ...[
Padding(
padding: EdgeInsets.only(left: 2, bottom: 9),
child: Text(
'最近账单',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
),
...s.days.map(
(d) => _DayCard(
day: d,
onDeleted: refresh,
budgetsByCategory: budgetsByCategory,
),
),
],
SizedBox(height: 12),
],
),
),
),
),
);
}
}
class _BudgetSummaryCard extends StatelessWidget {
final BudgetsData data;
final VoidCallback onTap;
const _BudgetSummaryCard({required this.data, required this.onTap});
@override
Widget build(BuildContext context) {
final total = data.total;
final amount =
total?.amount ??
data.categories.fold<double>(0, (sum, item) => sum + item.amount);
final spent =
total?.spent ??
data.categories.fold<double>(0, (sum, item) => sum + item.spent);
final rawRatio = amount <= 0 ? 0.0 : spent / amount;
final ratio = rawRatio.clamp(0.0, 1.0);
final warningColor = rawRatio >= 1
? AppTheme.red
: rawRatio >= 0.8
? AppTheme.orange
: AppTheme.primary;
return Material(
color: context.jz.card,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(color: context.jz.line, width: 0.5),
),
child: amount <= 0
? Row(
children: [
Icon(
Icons.donut_small_rounded,
size: 22,
color: AppTheme.primary,
),
SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'本月预算',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
Text(
'设一个可执行的消费边界',
style: TextStyle(
fontSize: 11,
color: context.jz.text3,
),
),
],
),
),
Text(
'去设置',
style: TextStyle(
fontSize: 12,
color: AppTheme.primary,
fontWeight: FontWeight.w700,
),
),
],
)
: Column(
children: [
Row(
children: [
Text(
total?.isRecurring == true ? '周期预算' : '本月预算',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
Spacer(),
Text(
rawRatio >= 1
? '已超 ¥${(spent - amount).toStringAsFixed(0)}'
: '剩余 ¥${(amount - spent).toStringAsFixed(0)}',
style: TextStyle(
fontSize: 11.5,
color: warningColor,
fontWeight: FontWeight.w700,
),
),
],
),
SizedBox(height: 9),
ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
value: ratio,
minHeight: 6,
backgroundColor: context.jz.line,
color: warningColor,
),
),
SizedBox(height: 7),
Row(
children: [
Text(
'已用 ¥${spent.toStringAsFixed(0)}',
style: TextStyle(
fontSize: 10.5,
color: context.jz.text3,
),
),
Spacer(),
Text(
'预算 ¥${amount.toStringAsFixed(0)} · ${(rawRatio * 100).toStringAsFixed(0)}%',
style: TextStyle(
fontSize: 10.5,
color: context.jz.text3,
),
),
],
),
],
),
),
),
);
}
}
class _HeroCol extends StatelessWidget {
final String label;
final double value;
final bool isCount;
const _HeroCol({
required this.label,
required this.value,
this.isCount = false,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 11,
color: Colors.white.withValues(alpha: 0.85),
),
),
SizedBox(height: 2),
Text(
isCount
? value.toInt().toString()
: '¥ ${value.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
);
}
}
class _EmptyState extends StatelessWidget {
final VoidCallback onAction;
final String companionName, avatarKey;
final bool aiEnabled;
const _EmptyState({
required this.onAction,
required this.aiEnabled,
required this.companionName,
required this.avatarKey,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
child: Column(
children: [
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: aiEnabled
? context.jz.aiBackground
: context.jz.primaryBackground,
),
child: AppIcons.icon(
aiEnabled ? AppIcons.avatarAsset(avatarKey) : AppIcons.wallet,
size: 48,
color: aiEnabled ? AppTheme.ai : AppTheme.primary,
),
),
SizedBox(height: 16),
Text(
'还没有账单',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
),
SizedBox(height: 6),
Text(
aiEnabled
? '跟我说句话就能记账,比如\n"早饭包子豆浆 6 块" · "打车 45"'
: '点击下方按钮手动记录第一笔账单',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11.5,
color: context.jz.text2,
height: 1.7,
),
),
SizedBox(height: 18),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: aiEnabled ? AppTheme.ai : AppTheme.primary,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
onPressed: onAction,
icon: AppIcons.icon(
aiEnabled ? AppIcons.chat : AppIcons.plus,
size: 16,
color: Colors.white,
),
label: Text(aiEnabled ? '${companionName}聊聊' : '记一笔'),
),
],
),
);
}
}
class _DayCard extends StatelessWidget {
final DayGroup day;
final VoidCallback onDeleted;
final Map<int, BudgetItem> budgetsByCategory;
const _DayCard({
required this.day,
required this.onDeleted,
required this.budgetsByCategory,
});
@override
Widget build(BuildContext context) {
final now = ShanghaiTime.now;
final isToday =
day.date.year == now.year &&
day.date.month == now.month &&
day.date.day == now.day;
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: context.jz.line, width: 0.5),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 9),
child: Row(
children: [
Text(
isToday
? '今天 · ${day.date.month}${day.date.day}'
: '${day.date.month}${day.date.day}',
style: TextStyle(fontSize: 11, color: context.jz.text3),
),
Spacer(),
Text(
[
if (day.income > 0) '收 ¥${day.income.toStringAsFixed(2)}',
if (day.expense > 0) '支 ¥${day.expense.toStringAsFixed(2)}',
].join(' · '),
style: TextStyle(fontSize: 11, color: context.jz.text3),
),
],
),
),
Divider(height: 0.5, color: context.jz.line),
...day.items.map(
(t) => _TxRow(
tx: t,
onDeleted: onDeleted,
budget: budgetsByCategory[t.categoryId],
),
),
],
),
);
}
}
class _TxRow extends StatelessWidget {
final TxItem tx;
final VoidCallback onDeleted;
final BudgetItem? budget;
const _TxRow({required this.tx, required this.onDeleted, this.budget});
@override
Widget build(BuildContext context) {
final hh =
'${tx.occurredAt.hour.toString().padLeft(2, '0')}:${tx.occurredAt.minute.toString().padLeft(2, '0')}';
return _SwipeReveal(
id: tx.id,
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => TxDetailPage(tx: tx)),
),
onDelete: () async {
try {
await TxApi.delete(tx.id);
onDeleted();
if (!context.mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
content: Text('已移入回收站'),
action: SnackBarAction(
label: '撤销',
textColor: AppTheme.primary,
onPressed: () async {
try {
await TxApi.restore(tx.id);
onDeleted();
} catch (error) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(apiErrorMessage(error))),
);
}
},
),
),
);
} catch (error) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
}
}
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
children: [
CategoryIconBox(
iconKey: tx.categoryIcon,
colorKey: tx.categoryColor,
),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tx.note ?? tx.categoryName,
style: TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 2),
Row(
children: [
Text(
hh,
style: TextStyle(
fontSize: 10.5,
color: context.jz.text3,
),
),
if (!tx.isIncome &&
budget != null &&
budget!.amount > 0 &&
budget!.spent / budget!.amount >= 0.8) ...[
SizedBox(width: 5),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 1.5,
),
decoration: BoxDecoration(
color: budget!.spent >= budget!.amount
? context.jz.expenseBackground
: context.jz.warningBackground,
borderRadius: BorderRadius.circular(6),
),
child: Text(
budget!.spent >= budget!.amount
? '预算已超'
: '预算 ${(budget!.spent / budget!.amount * 100).toStringAsFixed(0)}%',
style: TextStyle(
fontSize: 9,
color: budget!.spent >= budget!.amount
? AppTheme.red
: AppTheme.orange,
fontWeight: FontWeight.w700,
),
),
),
],
if (tx.isAi) ...[
SizedBox(width: 5),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 1.5,
),
decoration: BoxDecoration(
color: context.jz.aiBackground,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'✦ AI',
style: TextStyle(
fontSize: 9,
color: AppTheme.ai,
fontWeight: FontWeight.w600,
),
),
),
],
],
),
],
),
),
Text(
'${tx.isIncome ? '+' : '-'}${tx.amount.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w600,
color: tx.isIncome ? AppTheme.primary : context.jz.text,
),
),
],
),
),
);
}
}
class _SwipeReveal extends StatefulWidget {
static const double actionWidth = 76;
static final ValueNotifier<int?> _openId = ValueNotifier<int?>(null);
final int id;
final Widget child;
final VoidCallback onTap;
final Future<void> Function() onDelete;
const _SwipeReveal({
required this.id,
required this.child,
required this.onTap,
required this.onDelete,
});
static void closeOpen() {
_openId.value = null;
}
@override
State<_SwipeReveal> createState() => _SwipeRevealState();
}
class _SwipeRevealState extends State<_SwipeReveal> {
double _dragOffset = 0;
bool _dragging = false;
bool _deleting = false;
void _startDrag(DragStartDetails details) {
_dragging = true;
_dragOffset = _SwipeReveal._openId.value == widget.id
? -_SwipeReveal.actionWidth
: 0;
if (_SwipeReveal._openId.value != widget.id) {
_SwipeReveal.closeOpen();
}
}
void _updateDrag(DragUpdateDetails details) {
setState(() {
_dragOffset = (_dragOffset + details.delta.dx).clamp(
-_SwipeReveal.actionWidth,
0,
);
});
}
void _endDrag(DragEndDetails details) {
final reveal =
_dragOffset <= -36 ||
(details.primaryVelocity != null && details.primaryVelocity! < -500);
_dragging = false;
_SwipeReveal._openId.value = reveal ? widget.id : null;
setState(() => _dragOffset = reveal ? -_SwipeReveal.actionWidth : 0);
}
Future<void> _delete() async {
if (_deleting) return;
setState(() => _deleting = true);
_SwipeReveal.closeOpen();
try {
await widget.onDelete();
} finally {
if (mounted) setState(() => _deleting = false);
}
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<int?>(
valueListenable: _SwipeReveal._openId,
builder: (_, openId, __) {
final target = _dragging
? _dragOffset
: openId == widget.id
? -_SwipeReveal.actionWidth
: 0.0;
return ClipRect(
child: Stack(
alignment: Alignment.centerRight,
children: [
Positioned.fill(
child: Align(
alignment: Alignment.centerRight,
child: Semantics(
button: true,
label: '删除账单',
child: Material(
color: AppTheme.red,
child: InkWell(
onTap: _deleting ? null : _delete,
child: SizedBox(
width: _SwipeReveal.actionWidth,
child: Center(
child: _deleting
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: AppIcons.icon(
AppIcons.trash,
size: 20,
color: Colors.white,
),
),
),
),
),
),
),
),
AnimatedContainer(
duration: _dragging
? Duration.zero
: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
transform: Matrix4.translationValues(target, 0, 0),
color: context.jz.card,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: openId == widget.id
? _SwipeReveal.closeOpen
: widget.onTap,
onHorizontalDragStart: _startDrag,
onHorizontalDragUpdate: _updateDrag,
onHorizontalDragEnd: _endDrag,
onHorizontalDragCancel: () {
_dragging = false;
_SwipeReveal.closeOpen();
},
child: widget.child,
),
),
],
),
);
},
);
}
}
@@ -0,0 +1,289 @@
import 'package:flutter/material.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
/// P17 账本切换底部弹层
class LedgerSheet extends StatefulWidget {
const LedgerSheet({super.key});
@override
State<LedgerSheet> createState() => _LedgerSheetState();
static Future<void> show(BuildContext context) => showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => const LedgerSheet(),
);
}
class _LedgerSheetState extends State<LedgerSheet> {
List<LedgerInfo> _ledgers = [];
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
await CurrentLedgerStore.instance.ensureLoaded(force: true);
if (mounted) {
setState(() {
_ledgers = CurrentLedgerStore.instance.ledgers;
_loading = false;
});
}
} catch (error) {
if (mounted) {
setState(() => _loading = false);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
}
}
}
Future<void> _setDefault(int id) async {
try {
await CurrentLedgerStore.instance.select(id);
if (mounted) Navigator.pop(context, true);
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
}
}
}
Future<void> _create() async {
final name = await showJzTextInputSheet(
context,
title: '新建账本',
label: '账本名称',
maxLength: 12,
);
if (name == null || name.isEmpty) return;
try {
await CurrentLedgerStore.instance.create(name);
await _load();
} catch (error) {
if (mounted) _showError(error);
}
}
Future<void> _rename(LedgerInfo ledger) async {
final name = await showJzTextInputSheet(
context,
title: '重命名账本',
label: '账本名称',
initialValue: ledger.name,
maxLength: 12,
);
if (name == null || name.isEmpty || name == ledger.name) return;
try {
await CurrentLedgerStore.instance.rename(ledger.id, name, ledger.iconKey);
await _load();
} catch (error) {
if (mounted) _showError(error);
}
}
Future<void> _delete(LedgerInfo ledger) async {
final confirmed = await showJzConfirmSheet(
context,
title: '删除账本',
message: '仅能删除没有账单和预算的非默认账本。',
confirmLabel: '删除',
destructive: true,
);
if (!confirmed) return;
try {
await CurrentLedgerStore.instance.delete(ledger.id);
await _load();
} catch (error) {
if (mounted) _showError(error);
}
}
Future<void> _showActions(LedgerInfo ledger) async {
final action = await showJzOptionSheet<String>(
context,
title: ledger.name,
options: [
const JzOption(value: 'rename', label: '重命名'),
if (!ledger.isDefault)
const JzOption(value: 'delete', label: '删除账本', subtitle: '仅空账本可以删除'),
],
);
if (!mounted) return;
if (action == 'rename') await _rename(ledger);
if (action == 'delete') await _delete(ledger);
}
void _showError(Object error) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.vertical(top: Radius.circular(22)),
),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + 12,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 36,
height: 5,
margin: const EdgeInsets.only(top: 10, bottom: 6),
decoration: BoxDecoration(
color: context.jz.line,
borderRadius: BorderRadius.circular(3),
),
),
Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Text(
'切换账本',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
),
),
if (_loading)
Padding(
padding: EdgeInsets.all(40),
child: CircularProgressIndicator(
color: AppTheme.primary,
strokeWidth: 2,
),
)
else
..._ledgers.map(
(l) => Padding(
padding: const EdgeInsets.symmetric(
horizontal: 18,
vertical: 4,
),
child: InkWell(
onTap: l.isDefault ? null : () => _setDefault(l.id),
child: Container(
padding: const EdgeInsets.all(13),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: l.isDefault ? AppTheme.primary : context.jz.line,
width: l.isDefault ? 1.5 : 0.5,
),
),
child: Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: l.isDefault
? context.jz.primaryBackground
: context.jz.background,
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: AppIcons.byKey(
l.iconKey,
size: 17,
color: l.isDefault
? AppTheme.primary
: context.jz.text3,
),
),
),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.name,
style: TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w600,
color: l.isDefault
? AppTheme.primary
: context.jz.text,
),
),
Text(
'${l.transactionCount}',
style: TextStyle(
fontSize: 11,
color: context.jz.text3,
),
),
],
),
),
if (l.isDefault)
AppIcons.icon(
AppIcons.check,
size: 18,
color: AppTheme.primary,
),
IconButton(
tooltip: '账本操作',
onPressed: () => _showActions(l),
icon: Icon(Icons.more_horiz_rounded, size: 19),
),
],
),
),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 4),
child: InkWell(
onTap: _create,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: context.jz.line, width: 0.5),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AppIcons.icon(
AppIcons.plus,
size: 14,
color: context.jz.text2,
),
SizedBox(width: 6),
Text(
'新建账本',
style: TextStyle(fontSize: 13, color: context.jz.text2),
),
],
),
),
),
),
],
),
);
}
}
@@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:miaoji_zhang/shared/api/config_api.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
class MainShell extends StatefulWidget {
final StatefulNavigationShell shell;
const MainShell({super.key, required this.shell});
@override
State<MainShell> createState() => _MainShellState();
}
class _MainShellState extends State<MainShell> {
@override
void initState() {
super.initState();
PublicConfigApi.refreshCompanion();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: SessionStore.instance,
builder: (context, _) {
final session = SessionStore.instance;
final aiEnabled = session.aiEnabled;
final showAiEntry = session.isGuest || aiEnabled;
if (!showAiEntry && widget.shell.currentIndex == 2) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.shell.goBranch(0);
});
}
return Scaffold(
body: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: KeyedSubtree(
key: ValueKey(widget.shell.currentIndex),
child: widget.shell,
),
),
bottomNavigationBar: Container(
decoration: BoxDecoration(
color: context.jz.card,
border: Border(
top: BorderSide(color: context.jz.line, width: 0.5),
),
),
child: SafeArea(
child: SizedBox(
height: 64,
child: Row(
children: [
_tab('明细', AppIcons.home, 0),
_tab('统计', AppIcons.chart, 1),
SizedBox(width: 72, child: _fab()),
if (showAiEntry)
ValueListenableBuilder<CompanionDisplay>(
valueListenable: PublicConfigApi.companionNotifier,
builder: (_, companion, __) =>
_tab(companion.name, AppIcons.chat, 2),
),
_tab('我的', AppIcons.user, 3),
],
),
),
),
),
);
},
);
}
Widget _tab(String label, String icon, int branch) {
final active = widget.shell.currentIndex == branch;
final color = active ? AppTheme.primary : context.jz.text3;
return Expanded(
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => widget.shell.goBranch(branch),
child: SizedBox(
height: double.infinity,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AppIcons.icon(icon, size: 21, color: color),
const SizedBox(height: 3),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10,
color: color,
fontWeight: active ? FontWeight.w600 : null,
),
),
],
),
),
),
),
);
}
Widget _fab() {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: () async {
final amount = await context.push<double>('/add');
if (!mounted || amount == null) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('已记一笔 ¥${amount.toStringAsFixed(2)}')),
);
},
child: Center(
child: Container(
width: 46,
height: 46,
margin: const EdgeInsets.only(top: 4),
decoration: BoxDecoration(
color: AppTheme.primary,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppTheme.primary.withValues(alpha: 0.24),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: AppIcons.icon(AppIcons.plus, size: 22, color: Colors.white),
),
),
),
);
}
}
@@ -0,0 +1,260 @@
import 'package:flutter/material.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/business_api.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
import 'package:miaoji_zhang/shared/widgets/async_error_view.dart';
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
/// 搜索页(P19
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
@override
State<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
final _input = TextEditingController();
List<TxItem> _results = [];
bool _aiOnly = false;
bool _searched = false;
bool _loading = false;
String? _error;
String? _timeFilter; // today | week | month
double? _minAmount, _maxAmount;
Future<void> _search() async {
final q = _input.text.trim();
if (q.isEmpty &&
!_aiOnly &&
_timeFilter == null &&
_minAmount == null &&
_maxAmount == null)
return;
setState(() {
_loading = true;
_error = null;
});
try {
final now = ShanghaiTime.now;
DateTime? from;
DateTime? to;
if (_timeFilter == 'today') {
from = DateTime(now.year, now.month, now.day);
to = from.add(const Duration(days: 1));
} else if (_timeFilter == 'week') {
final today = DateTime(now.year, now.month, now.day);
from = today.subtract(Duration(days: today.weekday - 1));
to = from.add(const Duration(days: 7));
} else if (_timeFilter == 'month') {
from = DateTime(now.year, now.month);
to = DateTime(now.year, now.month + 1);
}
final list = await SearchApi.search(
q: q,
aiOnly: _aiOnly,
minAmount: _minAmount,
maxAmount: _maxAmount,
from: from,
to: to,
);
if (mounted)
setState(() {
_results = list;
_searched = true;
});
} catch (error) {
if (mounted) setState(() => _error = apiErrorMessage(error));
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
final total = _results
.where((t) => !t.isIncome)
.fold<double>(0, (s, t) => s + t.amount);
return Scaffold(
appBar: AppBar(
title: TextField(
controller: _input,
autofocus: true,
textInputAction: TextInputAction.search,
onSubmitted: (_) => _search(),
decoration: InputDecoration(
hintText: '搜备注 / 分类 / 你说过的话',
contentPadding: EdgeInsets.symmetric(horizontal: 14, vertical: 8),
),
),
actions: [TextButton(onPressed: _search, child: Text('搜索'))],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Wrap(
spacing: 6,
runSpacing: 6,
children: [
FilterChip(
label: Text('仅 AI 记账', style: TextStyle(fontSize: 11)),
selected: _aiOnly,
selectedColor: context.jz.aiBackground,
checkmarkColor: AppTheme.ai,
onSelected: (v) {
setState(() => _aiOnly = v);
_search();
},
),
...['today', 'week', 'month'].map(
(t) => FilterChip(
label: Text(
{'today': '今天', 'week': '本周', 'month': '本月'}[t]!,
style: TextStyle(fontSize: 11),
),
selected: _timeFilter == t,
selectedColor: context.jz.primaryBackground,
onSelected: (v) {
setState(() => _timeFilter = v ? t : null);
_search();
},
),
),
if (_searched && !_loading)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(
'${_results.length} 笔 · ¥${total.toStringAsFixed(2)}',
style: TextStyle(fontSize: 11, color: context.jz.text3),
),
),
],
),
),
Expanded(
child: _loading
? Center(
child: CircularProgressIndicator(
color: AppTheme.primary,
strokeWidth: 2,
),
)
: _error != null
? AsyncErrorView(message: _error!, onRetry: _search)
: !_searched
? Center(
child: Text(
'输入关键词搜账单\n也能搜到你和 AI 说过的原话',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
color: context.jz.text3,
height: 1.8,
),
),
)
: _results.isEmpty
? Center(
child: Text(
'没有找到相关账单',
style: TextStyle(fontSize: 12, color: context.jz.text3),
),
)
: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _results.length,
itemBuilder: (ctx, i) {
final t = _results[i];
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(
horizontal: 13,
vertical: 10,
),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: context.jz.line,
width: 0.5,
),
),
child: Row(
children: [
CategoryIconBox(
iconKey: t.categoryIcon,
colorKey: t.categoryColor,
),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.note ?? t.categoryName,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 2),
Row(
children: [
Text(
'${t.occurredAt.month}${t.occurredAt.day}',
style: TextStyle(
fontSize: 10.5,
color: context.jz.text3,
),
),
if (t.isAi) ...[
SizedBox(width: 5),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 1.5,
),
decoration: BoxDecoration(
color: context.jz.aiBackground,
borderRadius: BorderRadius.circular(
6,
),
),
child: Text(
'✦ AI',
style: TextStyle(
fontSize: 9,
color: AppTheme.ai,
fontWeight: FontWeight.w600,
),
),
),
],
],
),
],
),
),
Text(
'${t.isIncome ? '+' : '-'}${t.amount.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: t.isIncome
? AppTheme.primary
: context.jz.text,
),
),
],
),
);
},
),
),
],
),
);
}
}
@@ -0,0 +1,288 @@
import 'package:flutter/material.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/business_api.dart';
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
class TransactionEditPage extends StatefulWidget {
final TxItem transaction;
const TransactionEditPage({super.key, required this.transaction});
@override
State<TransactionEditPage> createState() => _TransactionEditPageState();
}
class _TransactionEditPageState extends State<TransactionEditPage> {
late final TextEditingController _amount;
late final TextEditingController _note;
late final TextEditingController _payment;
late String _type;
late int _ledgerId;
late int _categoryId;
late DateTime _occurredAt;
List<CategoryItem> _categories = const [];
bool _loading = true;
bool _saving = false;
@override
void initState() {
super.initState();
final transaction = widget.transaction;
_amount = TextEditingController(
text: transaction.amount.toStringAsFixed(2),
);
_note = TextEditingController(text: transaction.note ?? '');
_payment = TextEditingController(text: transaction.paymentMethod ?? '');
_type = transaction.type;
_ledgerId = transaction.ledgerId;
_categoryId = transaction.categoryId;
_occurredAt = transaction.occurredAt;
_loadCategories();
}
@override
void dispose() {
_amount.dispose();
_note.dispose();
_payment.dispose();
super.dispose();
}
Future<void> _loadCategories() async {
setState(() => _loading = true);
try {
await CurrentLedgerStore.instance.ensureLoaded();
final categories = await TxApi.categories(_type);
if (!categories.any((category) => category.id == _categoryId) &&
categories.isNotEmpty) {
_categoryId = categories.first.id;
}
if (mounted) setState(() => _categories = categories);
} catch (error) {
if (mounted) _showError(error);
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _changeType(String type) async {
if (type == _type) return;
setState(() => _type = type);
await _loadCategories();
}
Future<void> _pickDateTime() async {
final value = await showJzDateTimeSheet(
context,
initial: _occurredAt,
firstDate: DateTime(2000),
lastDate: ShanghaiTime.now.add(const Duration(days: 1)),
title: '选择发生时间',
);
if (value != null && mounted) setState(() => _occurredAt = value);
}
Future<void> _selectCategory() async {
final value = await showJzOptionSheet<int>(
context,
title: '选择分类',
options: _categories
.map(
(category) => JzOption(
value: category.id,
label: category.name,
leading: CategoryIconBox(
iconKey: category.iconKey,
colorKey: category.colorKey,
size: 32,
),
),
)
.toList(),
selected: _categoryId,
);
if (value != null && mounted) setState(() => _categoryId = value);
}
Future<void> _selectLedger() async {
final ledgers = CurrentLedgerStore.instance.ledgers;
final value = await showJzOptionSheet<int>(
context,
title: '选择所属账本',
options: ledgers
.map(
(ledger) => JzOption(
value: ledger.id,
label: ledger.name,
subtitle: ledger.isDefault ? '当前默认账本' : null,
),
)
.toList(),
selected: _ledgerId,
);
if (value != null && mounted) setState(() => _ledgerId = value);
}
Future<void> _save() async {
final amount = double.tryParse(_amount.text.trim());
if (amount == null || amount <= 0) {
_showError(StateError('请输入正确金额'));
return;
}
setState(() => _saving = true);
try {
final updated = await TxApi.update(
widget.transaction.id,
ledgerId: _ledgerId,
categoryId: _categoryId,
type: _type,
amount: amount,
note: _note.text.trim(),
paymentMethod: _payment.text.trim(),
occurredAt: _occurredAt,
);
if (mounted) Navigator.pop(context, updated);
} catch (error) {
if (mounted) _showError(error);
} finally {
if (mounted) setState(() => _saving = false);
}
}
void _showError(Object error) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
}
@override
Widget build(BuildContext context) {
final ledgers = CurrentLedgerStore.instance.ledgers;
return Scaffold(
appBar: AppBar(title: Text('编辑账单')),
body: _loading
? Center(
child: CircularProgressIndicator(
color: AppTheme.primary,
strokeWidth: 2,
),
)
: ListView(
padding: const EdgeInsets.all(16),
children: [
JzSegmentedControl<String>(
value: _type,
options: const [
JzOption(value: 'expense', label: '支出'),
JzOption(value: 'income', label: '收入'),
],
onChanged: _changeType,
),
SizedBox(height: 14),
TextField(
controller: _amount,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: InputDecoration(
labelText: '金额',
prefixText: '¥ ',
),
),
SizedBox(height: 12),
_SelectField(
label: '分类',
value:
_categories
.where((category) => category.id == _categoryId)
.map((category) => category.name)
.firstOrNull ??
'请选择',
onTap: _selectCategory,
),
SizedBox(height: 12),
_SelectField(
label: '所属账本',
value:
ledgers
.where((ledger) => ledger.id == _ledgerId)
.map((ledger) => ledger.name)
.firstOrNull ??
'请选择',
onTap: _selectLedger,
),
SizedBox(height: 12),
TextField(
controller: _note,
maxLength: 50,
decoration: InputDecoration(labelText: '备注'),
),
SizedBox(height: 4),
TextField(
controller: _payment,
maxLength: 20,
decoration: InputDecoration(labelText: '支付方式'),
),
SizedBox(height: 4),
ListTile(
contentPadding: EdgeInsets.zero,
title: Text('发生时间'),
subtitle: Text(_occurredAt.toString().substring(0, 16)),
trailing: Icon(Icons.chevron_right),
onTap: _pickDateTime,
),
if (widget.transaction.sourceText?.isNotEmpty == true) ...[
SizedBox(height: 8),
InputDecorator(
decoration: InputDecoration(labelText: 'AI 原话(只读)'),
child: Text(widget.transaction.sourceText!),
),
],
SizedBox(height: 24),
FilledButton(
onPressed: _saving ? null : _save,
child: Text(_saving ? '保存中...' : '保存修改'),
),
],
),
);
}
}
class _SelectField extends StatelessWidget {
final String label;
final String value;
final VoidCallback onTap;
const _SelectField({
required this.label,
required this.value,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: InputDecorator(
decoration: InputDecoration(labelText: label),
child: Row(
children: [
Expanded(
child: Text(
value,
style: TextStyle(fontSize: 13.5, fontWeight: FontWeight.w600),
),
),
Icon(Icons.keyboard_arrow_down_rounded, color: context.jz.text3),
],
),
),
);
}
}
@@ -0,0 +1,214 @@
import 'package:flutter/material.dart';
import 'package:miaoji_zhang/features/home/pages/transaction_edit_page.dart';
import 'package:miaoji_zhang/shared/services/transaction_events.dart';
import 'package:miaoji_zhang/shared/api/business_api.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
/// P11 账单详情:金额、分类、来源追溯(AI 原话)、时间、支付方式
class TxDetailPage extends StatelessWidget {
final TxItem tx;
const TxDetailPage({super.key, required this.tx});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('账单详情'),
actions: [
IconButton(
icon: AppIcons.icon(
AppIcons.edit,
size: 18,
color: context.jz.text2,
),
onPressed: () async {
final updated = await Navigator.push<TxItem>(
context,
MaterialPageRoute(
builder: (_) => TransactionEditPage(transaction: tx),
),
);
if (updated != null && context.mounted) {
TransactionEvents.notifyChanged();
Navigator.pop(context, true);
}
},
),
IconButton(
icon: AppIcons.icon(AppIcons.trash, size: 18, color: AppTheme.red),
onPressed: () async {
await TxApi.delete(tx.id);
TransactionEvents.notifyChanged();
if (context.mounted) {
Navigator.pop(context, true);
}
},
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// 金额头部
Container(
padding: const EdgeInsets.symmetric(vertical: 28),
margin: const EdgeInsets.only(bottom: 14),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: context.jz.line, width: 0.5),
),
child: Column(
children: [
Text(
'${tx.isIncome ? '+' : '-'}¥${tx.amount.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.w800,
color: tx.isIncome ? AppTheme.primary : context.jz.text,
),
),
SizedBox(height: 6),
Text(
ShanghaiTime.formatCivil(tx.occurredAt),
style: TextStyle(fontSize: 12, color: context.jz.text3),
),
],
),
),
// 分类
_Row(
k: '分类',
child: Row(
children: [
CategoryIconBox(
iconKey: tx.categoryIcon,
colorKey: tx.categoryColor,
size: 30,
),
SizedBox(width: 10),
Text(
tx.categoryName,
style: TextStyle(fontSize: 13.5, fontWeight: FontWeight.w600),
),
],
),
),
// 类型
_Row(
k: '类型',
child: Text(
tx.isIncome ? '收入' : '支出',
style: TextStyle(fontSize: 13.5),
),
),
// 备注
if (tx.note != null && tx.note!.isNotEmpty)
_Row(
k: '备注',
child: Text(tx.note!, style: TextStyle(fontSize: 13.5)),
),
// 支付方式
if (tx.paymentMethod != null && tx.paymentMethod!.isNotEmpty)
_Row(
k: '支付方式',
child: Text(tx.paymentMethod!, style: TextStyle(fontSize: 13.5)),
),
_Row(
k: '记账方式',
child: Row(
children: [
if (tx.isAi || tx.isRecognition) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7,
vertical: 2,
),
decoration: BoxDecoration(
color: tx.isAi
? context.jz.aiBackground
: context.jz.primaryBackground,
borderRadius: BorderRadius.circular(6),
),
child: Text(
tx.isAi ? '✦ AI' : '智能识别',
style: TextStyle(
fontSize: 10,
color: tx.isAi ? AppTheme.ai : AppTheme.primary,
fontWeight: FontWeight.w600,
),
),
),
SizedBox(width: 7),
],
Text(tx.sourceLabel, style: TextStyle(fontSize: 13.5)),
],
),
),
if ((tx.isAi || tx.isRecognition) &&
tx.sourceText != null &&
tx.sourceText!.isNotEmpty)
_Row(
k: tx.isAi ? 'AI 原话' : '识别依据',
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: tx.isAi
? context.jz.aiBackground
: context.jz.primaryBackground,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'"${tx.sourceText}"',
style: TextStyle(
fontSize: 12,
color: context.jz.text2,
height: 1.5,
),
),
),
),
// 来源标签
_Row(
k: '来源',
child: Text(tx.sourceLabel, style: TextStyle(fontSize: 13.5)),
),
],
),
);
}
}
class _Row extends StatelessWidget {
final String k;
final Widget child;
const _Row({required this.k, required this.child});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
margin: const EdgeInsets.only(bottom: 9),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(13),
border: Border.all(color: context.jz.line, width: 0.5),
),
child: Row(
children: [
SizedBox(
width: 70,
child: Text(
k,
style: TextStyle(fontSize: 12, color: context.jz.text3),
),
),
Expanded(child: child),
],
),
);
}
}
@@ -0,0 +1,436 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.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/config_api.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
/// 首次引导:选模式 → 选 AI 伙伴(P9 + P10
/// 形象和性格从后台 API 动态拉取,管理员新增/修改即时生效
class OnboardingPage extends StatefulWidget {
const OnboardingPage({super.key});
@override
State<OnboardingPage> createState() => _OnboardingPageState();
}
class _OnboardingPageState extends State<OnboardingPage> {
int _step = 0;
String _mode = 'normal';
String _avatar = 'cat';
String _persona = 'sassy_cat';
bool _saving = false;
List<AvatarItem> _avatars = [];
List<PersonaItem> _personas = [];
bool _loaded = false;
@override
void initState() {
super.initState();
_loadConfig();
}
Future<void> _loadConfig() async {
try {
final avatars = await PublicConfigApi.avatars();
final personas = await PublicConfigApi.personas();
if (mounted)
setState(() {
_avatars = avatars;
_personas = personas;
if (avatars.isNotEmpty) _avatar = avatars.first.key;
if (personas.isNotEmpty) _persona = personas.first.key;
_loaded = true;
});
} catch (_) {
// API 失败用硬编码兜底
if (mounted) setState(() => _loaded = true);
}
}
// 兜底数据(API 不可用时)
static const _fallbackAvatars = [
('cat', '小账喵', AppIcons.cat),
('dog', '阿福汪', AppIcons.dog),
('robot', '账小智', AppIcons.robot),
];
static const _fallbackPersonas = [
('sassy_cat', '毒舌猫娘', '乱花钱会被无情吐槽'),
('gentle', '温柔小暖', '永远鼓励,温柔提醒'),
('strict', '严格管家', '理性专业,数据说话'),
('meme', '沙雕损友', '玩梗高手,快乐记账'),
];
Future<void> _finish() async {
setState(() => _saving = true);
try {
await AuthApi.completeOnboarding(
appMode: _mode,
avatarKey: _avatar,
personaKey: _persona,
);
if (!mounted) return;
context.go(_mode == 'ai' ? '/ai-mode' : '/home');
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(e))));
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
if (!_loaded)
return const Scaffold(
body: Center(
child: CircularProgressIndicator(
color: AppTheme.primary,
strokeWidth: 2,
),
),
);
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 26),
child: _step == 0 ? _ModeStep() : _CompanionStep(),
),
),
);
}
Widget _ModeStep() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 8),
Row(
children: [
Container(
width: 28,
height: 5,
decoration: BoxDecoration(
color: AppTheme.primary,
borderRadius: BorderRadius.circular(3),
),
),
SizedBox(width: 6),
Container(
width: 18,
height: 5,
decoration: BoxDecoration(
color: context.jz.line,
borderRadius: BorderRadius.circular(3),
),
),
],
),
SizedBox(height: 12),
Text(
'你想怎么用它?',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800),
),
SizedBox(height: 6),
Text(
'两种完全不同的体验,之后随时可以切换',
style: TextStyle(fontSize: 12, color: context.jz.text2),
),
SizedBox(height: 18),
_ModeCard(
selected: _mode == 'normal',
iconAsset: AppIcons.wallet,
iconColor: AppTheme.primary,
title: '普通记账模式',
desc: '经典账本界面,手动记账、报表统计一应俱全,AI 助手随叫随到',
onTap: () => setState(() => _mode = 'normal'),
),
SizedBox(height: 11),
_ModeCard(
selected: _mode == 'ai',
iconAsset: AppIcons.sparkle,
iconColor: AppTheme.ai,
title: '全 AI 模式',
desc: '没有复杂界面,打开就是和 AI 聊天,说句话就完成记账、查账、看报告',
onTap: () => setState(() => _mode = 'ai'),
),
Spacer(),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => setState(() => _step = 1),
child: Text('继续'),
),
),
SizedBox(height: 16),
],
);
}
Widget _CompanionStep() {
// 优先 API 数据,回退硬编码
final avatars = _avatars.isNotEmpty
? _avatars
.map(
(a) => (
a.key,
a.defaultName,
a.key == 'dog'
? AppIcons.dog
: a.key == 'robot'
? AppIcons.robot
: AppIcons.cat,
),
)
.toList()
: _fallbackAvatars;
final personas = _personas.isNotEmpty
? _personas.map((p) => (p.key, p.name, p.description)).toList()
: _fallbackPersonas;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 8),
Row(
children: [
Container(
width: 18,
height: 5,
decoration: BoxDecoration(
color: context.jz.line,
borderRadius: BorderRadius.circular(3),
),
),
SizedBox(width: 6),
Container(
width: 28,
height: 5,
decoration: BoxDecoration(
color: AppTheme.primary,
borderRadius: BorderRadius.circular(3),
),
),
],
),
SizedBox(height: 12),
Text(
'认识你的 AI 伙伴',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800),
),
SizedBox(height: 6),
Text(
'选形象、挑性格 —— 它就是你的专属记账搭子',
style: TextStyle(fontSize: 12, color: context.jz.text2),
),
SizedBox(height: 14),
SizedBox(
height: 96,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: avatars.map((a) {
final on = _avatar == a.$1;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 11),
child: GestureDetector(
onTap: () => setState(() => _avatar = a.$1),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.elasticOut,
width: 56,
height: 56,
child: Transform.scale(
scale: on ? 1.18 : 1.0,
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: on ? AppTheme.ai : context.jz.aiBackground,
boxShadow: on
? [
BoxShadow(
color: AppTheme.ai.withValues(
alpha: 0.3,
),
blurRadius: 14,
),
]
: null,
),
child: Center(
child: AppIcons.icon(
a.$3,
size: on ? 28 : 24,
color: on ? Colors.white : context.jz.text3,
),
),
),
),
),
SizedBox(height: 6),
Text(
a.$2,
style: TextStyle(
fontSize: 10,
color: on ? AppTheme.ai : context.jz.text3,
fontWeight: on ? FontWeight.w600 : null,
),
),
],
),
),
);
}).toList(),
),
),
SizedBox(height: 16),
Expanded(
child: GridView.count(
crossAxisCount: 2,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 1.5,
children: personas.map((p) {
final on = _persona == p.$1;
return GestureDetector(
onTap: () => setState(() => _persona = p.$1),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: on ? context.jz.aiBackground : Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: on ? AppTheme.ai : context.jz.line,
width: 1.5,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppIcons.icon(
AppIcons.chat,
size: 18,
color: on ? AppTheme.ai : context.jz.text3,
),
SizedBox(height: 8),
Text(
p.$2,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 3),
Text(
p.$3,
style: TextStyle(fontSize: 10, color: context.jz.text2),
),
],
),
),
);
}).toList(),
),
),
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.ai),
onPressed: _saving ? null : _finish,
child: _saving
? SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text('开始记账之旅'),
),
),
SizedBox(height: 16),
],
);
}
}
class _ModeCard extends StatelessWidget {
final bool selected;
final String iconAsset;
final Color iconColor;
final String title, desc;
final VoidCallback onTap;
const _ModeCard({
required this.selected,
required this.iconAsset,
required this.iconColor,
required this.title,
required this.desc,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
padding: const EdgeInsets.all(15),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: selected ? AppTheme.primary : context.jz.line,
width: selected ? 2 : 1.5,
),
),
child: Row(
children: [
AppIcons.icon(iconAsset, size: 22, color: iconColor),
SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700),
),
SizedBox(height: 4),
Text(
desc,
style: TextStyle(fontSize: 10.5, color: context.jz.text2),
),
],
),
),
SizedBox(width: 8),
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: selected ? AppTheme.primary : context.jz.line,
width: 1.5,
),
color: selected ? AppTheme.primary : null,
),
child: selected
? AppIcons.icon(AppIcons.check, size: 13, color: Colors.white)
: null,
),
],
),
),
);
}
}
@@ -0,0 +1,306 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/auth_api.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
class AccountDataPage extends StatefulWidget {
const AccountDataPage({super.key});
@override
State<AccountDataPage> createState() => _AccountDataPageState();
}
class _AccountDataPageState extends State<AccountDataPage> {
UserProfile? _profile;
bool _busy = false;
@override
void initState() {
super.initState();
_cleanupStaleExports();
_load();
}
Future<void> _cleanupStaleExports() async {
try {
final directory = await getTemporaryDirectory();
final cutoff = DateTime.now().subtract(const Duration(hours: 24));
await for (final entity in directory.list()) {
if (entity is! File ||
!entity.path.contains('jizhi-export-') ||
!entity.path.endsWith('.zip')) {
continue;
}
final modified = await entity.lastModified();
if (modified.isBefore(cutoff)) await entity.delete();
}
} catch (_) {
// Export cleanup is best-effort and must not block the settings page.
}
}
Future<void> _load() async {
try {
final profile = await AuthApi.me();
if (mounted) setState(() => _profile = profile);
} catch (error) {
if (mounted) _showError(error);
}
}
Future<String?> _ask({
required String title,
required String label,
bool obscure = false,
String? initialValue,
String? subtitle,
}) {
return showJzTextInputSheet(
context,
title: title,
label: label,
subtitle: subtitle,
initialValue: initialValue,
obscureText: obscure,
maxLength: obscure ? null : 32,
);
}
Future<void> _editNickname() async {
final nickname = await _ask(
title: '修改昵称',
label: '昵称',
initialValue: _profile?.nickname ?? '',
);
if (nickname == null) return;
try {
final profile = await AuthApi.updateProfile(nickname);
if (mounted) setState(() => _profile = profile);
} catch (error) {
if (mounted) _showError(error);
}
}
Future<void> _changePassword() async {
final current = await _ask(title: '验证当前密码', label: '当前密码', obscure: true);
if (current == null || current.isEmpty) return;
final next = await _ask(
title: '设置新密码',
label: '新密码(至少 6 位)',
obscure: true,
);
if (next == null || next.isEmpty) return;
try {
await AuthApi.changePassword(current, next);
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('密码已修改,其他设备已退出登录')));
}
} catch (error) {
if (mounted) _showError(error);
}
}
Future<void> _export() async {
File? temporaryFile;
setState(() => _busy = true);
try {
final bytes = await AuthApi.exportData();
if (bytes.isEmpty) throw StateError('导出文件为空');
final directory = await getTemporaryDirectory();
final name =
'jizhi-export-' +
DateTime.now().millisecondsSinceEpoch.toString() +
'.zip';
temporaryFile = File(directory.path + Platform.pathSeparator + name);
await temporaryFile.writeAsBytes(bytes, flush: true);
await SharePlus.instance.share(
ShareParams(
files: [XFile(temporaryFile.path, mimeType: 'application/zip')],
fileNameOverrides: [name],
title: '记之数据导出',
subject: '记之数据导出',
),
);
} catch (error) {
if (mounted) _showError(error);
} finally {
if (temporaryFile != null) {
try {
if (await temporaryFile.exists()) await temporaryFile.delete();
} catch (_) {
// The operating system can briefly retain a shared file handle.
}
}
if (mounted) setState(() => _busy = false);
}
}
Future<void> _closeAccount() async {
final understood = await showJzConfirmSheet(
context,
title: '申请注销账号',
message:
'提交后会退出所有设备,并进入 15 天后悔期。期间使用正确账号密码登录会自动取消注销;到期后账本、账单、预算和聊天记录将永久删除。',
confirmLabel: '我已了解',
destructive: true,
content: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: context.jz.warningBackground,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'建议先导出数据。后悔期结束后,数据无法恢复。',
style: TextStyle(
fontSize: 11.5,
color: context.jz.text2,
height: 1.5,
),
),
),
);
if (!understood || !mounted) return;
final password = await _ask(
title: '验证身份',
label: '输入登录密码',
obscure: true,
subtitle: '这是第二次确认,用于验证账号所有权',
);
if (password == null || password.isEmpty || !mounted) return;
final confirmation = await _ask(
title: '最后确认',
label: '输入“注销账号”',
subtitle: '提交后立即退出,15 天内重新登录可恢复账号',
);
if (confirmation == null) return;
if (confirmation.trim() != '注销账号') {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('请输入完整的“注销账号”')));
return;
}
setState(() => _busy = true);
try {
final scheduledAt = await AuthApi.requestAccountClosure(
password,
confirmation,
);
if (!mounted) return;
final notice =
'注销申请已提交,将于 ' +
scheduledAt.month.toString() +
'' +
scheduledAt.day.toString() +
'日永久删除';
context.go(
Uri(path: '/login', queryParameters: {'notice': notice}).toString(),
);
} catch (error) {
if (mounted) _showError(error);
} finally {
if (mounted) setState(() => _busy = false);
}
}
void _showError(Object error) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('账号与数据')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Column(
children: [
ListTile(
title: Text('昵称'),
subtitle: Text(_profile?.nickname ?? '未设置'),
trailing: Icon(Icons.chevron_right_rounded),
onTap: _editNickname,
),
if (!SessionStore.instance.isGuest) ...[
Divider(height: 1),
ListTile(
title: Text('修改密码'),
trailing: Icon(Icons.chevron_right_rounded),
onTap: _changePassword,
),
],
],
),
),
SizedBox(height: 12),
Card(
child: ListTile(
leading: Icon(Icons.archive_outlined, color: AppTheme.primary),
title: Text('导出全部数据'),
subtitle: Text('ZIP 包含交易/预算 CSV 和完整 JSON,不含密码与密钥'),
trailing: _busy
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(Icons.ios_share_rounded),
onTap: _busy ? null : _export,
),
),
SizedBox(height: 12),
Card(
child: Column(
children: [
_legalTile('用户协议', 'terms'),
Divider(height: 1),
_legalTile('隐私政策', 'privacy'),
Divider(height: 1),
_legalTile('权限用途说明', 'permissions'),
Divider(height: 1),
_legalTile('第三方 SDK 清单', 'sdk'),
],
),
),
SizedBox(height: 12),
if (!SessionStore.instance.isGuest)
Card(
child: ListTile(
leading: Icon(Icons.person_off_outlined, color: AppTheme.red),
title: Text('注销账号', style: TextStyle(color: AppTheme.red)),
subtitle: Text('15 天后永久删除,可在后悔期内登录恢复'),
trailing: Icon(
Icons.chevron_right_rounded,
color: AppTheme.red,
),
onTap: _busy ? null : _closeAccount,
),
),
],
),
);
}
Widget _legalTile(String title, String kind) => ListTile(
leading: Icon(Icons.verified_user_outlined, color: AppTheme.primary),
title: Text(title),
trailing: Icon(Icons.chevron_right_rounded),
onTap: () => context.push('/legal/' + kind),
);
}
@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/theme/theme_store.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
class AppearancePage extends StatelessWidget {
const AppearancePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('外观设置')),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: AnimatedBuilder(
animation: ThemeStore.instance,
builder: (context, _) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'显示模式',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 5),
Text(
'跟随系统会随手机的浅色与深色设置自动切换。',
style: TextStyle(color: context.jz.text2, fontSize: 11.5),
),
const SizedBox(height: 14),
JzSegmentedControl<JzThemePreference>(
value: ThemeStore.instance.preference,
options: const [
JzOption(
value: JzThemePreference.system,
label: '跟随系统',
),
JzOption(value: JzThemePreference.light, label: '浅色'),
JzOption(value: JzThemePreference.dark, label: '深色'),
],
onChanged: ThemeStore.instance.setPreference,
),
],
),
),
),
),
],
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,641 @@
import 'package:flutter/material.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/business_api.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
class CategoryManagePage extends StatefulWidget {
const CategoryManagePage({super.key});
@override
State<CategoryManagePage> createState() => _CategoryManagePageState();
}
class _CategoryManagePageState extends State<CategoryManagePage> {
String _type = 'expense';
List<CategoryItem> _cats = const [];
bool _loading = true;
bool _reordering = false;
bool _savingOrder = false;
bool _orderDirty = false;
List<CategoryItem> get _custom =>
_cats.where((category) => category.isCustom).toList();
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _loading = true);
try {
final cats = await TxApi.categories(_type);
if (mounted) {
setState(() {
_cats = cats;
_orderDirty = false;
});
}
} catch (error) {
if (mounted) _showError(error);
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _add() async {
final result = await _showEditor();
if (result == null) return;
try {
await CategoryApi.create(result.name, result.icon, result.color, _type);
await _load();
} catch (error) {
if (mounted) _showError(error);
}
}
Future<void> _edit(CategoryItem category) async {
final result = await _showEditor(category: category);
if (result == null) return;
try {
await CategoryApi.update(
category.id,
name: result.name,
iconKey: result.icon,
colorKey: result.color,
sortOrder: category.sortOrder,
);
await _load();
} catch (error) {
if (mounted) _showError(error);
}
}
Future<({String name, String icon, String color})?> _showEditor({
CategoryItem? category,
}) async {
final controller = TextEditingController(text: category?.name ?? '');
var iconKey = AppIcons.keyMap.containsKey(category?.iconKey)
? category!.iconKey
: 'tag';
var colorKey = category?.colorKey ?? 'mint';
final result =
await showModalBottomSheet<({String name, String icon, String color})>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) => StatefulBuilder(
builder: (context, setSheetState) => FractionallySizedBox(
heightFactor: 0.88,
child: Container(
padding: EdgeInsets.only(
left: 18,
right: 18,
bottom: MediaQuery.viewInsetsOf(context).bottom + 14,
),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: SafeArea(
top: false,
child: Column(
children: [
JzSheetHeader(
title: category == null ? '新建分类' : '编辑分类',
subtitle: category == null
? '选择一个容易辨认的名称和图标'
: '排序请返回列表后使用“调整顺序”',
),
SizedBox(height: 14),
TextField(
controller: controller,
autofocus: category == null,
maxLength: 8,
decoration: InputDecoration(
labelText: '分类名称',
hintText: '1-8 个字',
),
),
SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: Text(
'分类颜色',
style: TextStyle(
fontSize: 12,
color: context.jz.text2,
fontWeight: FontWeight.w700,
),
),
),
SizedBox(height: 9),
SizedBox(
height: 48,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: categoryColorCatalog.length,
separatorBuilder: (_, __) => SizedBox(width: 9),
itemBuilder: (_, index) {
final color = categoryColorCatalog[index];
final selected = color.key == colorKey;
return Semantics(
button: true,
selected: selected,
label: color.label,
child: InkWell(
onTap: () =>
setSheetState(() => colorKey = color.key),
borderRadius: BorderRadius.circular(15),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 48,
decoration: BoxDecoration(
color: color.background,
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: selected
? color.foreground
: Colors.transparent,
width: 2,
),
),
child: selected
? Icon(
Icons.check_rounded,
size: 21,
color: color.foreground,
)
: null,
),
),
);
},
),
),
SizedBox(height: 14),
Align(
alignment: Alignment.centerLeft,
child: Text(
'分类图标',
style: TextStyle(
fontSize: 12,
color: context.jz.text2,
fontWeight: FontWeight.w700,
),
),
),
SizedBox(height: 2),
Expanded(
child: ListView(
children: _iconGroups.entries.map((entry) {
return Padding(
padding: const EdgeInsets.only(
top: 12,
bottom: 6,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.key,
style: TextStyle(
fontSize: 11,
color: context.jz.text3,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 9),
GridView.builder(
shrinkWrap: true,
physics:
const NeverScrollableScrollPhysics(),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
mainAxisSpacing: 9,
crossAxisSpacing: 9,
childAspectRatio: 0.82,
),
itemCount: entry.value.length,
itemBuilder: (_, index) {
final icon = entry.value[index];
final selected = icon.key == iconKey;
return Semantics(
selected: selected,
label: icon.label,
button: true,
child: InkWell(
onTap: () => setSheetState(
() => iconKey = icon.key,
),
borderRadius: BorderRadius.circular(
13,
),
child: AnimatedContainer(
duration: const Duration(
milliseconds: 160,
),
decoration: BoxDecoration(
color: selected
? context.jz.primaryBackground
: context.jz.background,
borderRadius:
BorderRadius.circular(13),
border: Border.all(
color: selected
? AppTheme.primary
: context.jz.line,
width: selected ? 1.3 : 1,
),
),
child: Column(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
CategoryIconBox(
iconKey: icon.key,
colorKey: colorKey,
size: 32,
),
SizedBox(height: 5),
Text(
icon.label,
maxLines: 1,
overflow:
TextOverflow.ellipsis,
style: TextStyle(
fontSize: 9.5,
color: selected
? AppTheme.primaryDeep
: context.jz.text2,
fontWeight: selected
? FontWeight.w700
: FontWeight.w500,
),
),
],
),
),
),
);
},
),
],
),
);
}).toList(),
),
),
SizedBox(height: 12),
Row(
children: [
Expanded(
child: JzActionButton(
label: '取消',
secondary: true,
onPressed: () => Navigator.pop(sheetContext),
),
),
SizedBox(width: 10),
Expanded(
child: JzActionButton(
label: category == null ? '添加' : '保存',
onPressed: () {
final name = controller.text.trim();
if (name.isEmpty) return;
Navigator.pop(sheetContext, (
name: name,
icon: iconKey,
color: colorKey,
));
},
),
),
],
),
],
),
),
),
),
),
);
controller.dispose();
return result;
}
Map<String, List<CategoryIconMeta>> get _iconGroups {
final result = <String, List<CategoryIconMeta>>{};
for (final icon in AppIcons.categoryCatalog) {
result.putIfAbsent(icon.group, () => []).add(icon);
}
return result;
}
Future<void> _showActions(CategoryItem category) async {
final action = await showJzOptionSheet<String>(
context,
title: category.name,
options: const [
JzOption(value: 'edit', label: '编辑名称和图标'),
JzOption(value: 'delete', label: '删除分类', subtitle: '历史账单仍保留原分类'),
],
);
if (!mounted) return;
if (action == 'edit') await _edit(category);
if (action == 'delete') await _delete(category);
}
Future<void> _delete(CategoryItem category) async {
final confirmed = await showJzConfirmSheet(
context,
title: '删除“' + category.name + '',
message: '删除后将不能再选择此分类,历史账单仍保留原分类。',
confirmLabel: '删除',
destructive: true,
);
if (!confirmed) return;
try {
await CategoryApi.delete(category.id);
await _load();
} catch (error) {
if (mounted) _showError(error);
}
}
void _reorder(int oldIndex, int newIndex) {
final items = _custom;
if (newIndex > oldIndex) newIndex--;
final item = items.removeAt(oldIndex);
items.insert(newIndex, item);
setState(() {
final system = _cats.where((category) => !category.isCustom).toList();
_cats = [...system, ...items];
_orderDirty = true;
});
}
Future<void> _toggleReorder() async {
if (!_reordering) {
setState(() {
_reordering = true;
_orderDirty = false;
});
return;
}
if (!_orderDirty) {
setState(() => _reordering = false);
return;
}
final ids = _custom.map((category) => category.id).toList();
setState(() => _savingOrder = true);
try {
await CategoryApi.reorder(_type, ids);
if (!mounted) return;
setState(() {
_reordering = false;
_orderDirty = false;
});
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('分类顺序已保存')));
} catch (error) {
if (mounted) _showError(error);
} finally {
if (mounted) setState(() => _savingOrder = false);
}
}
void _showError(Object error) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('分类管理'),
actions: [
if (_custom.length > 1)
TextButton(
onPressed: _savingOrder ? null : _toggleReorder,
child: Text(
_savingOrder
? '保存中'
: _reordering
? '完成'
: '调整顺序',
),
),
],
),
body: Column(
children: [
_typeSwitch(),
Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 8),
child: Row(
children: [
Expanded(
child: Text(
_reordering ? '长按右侧把手调整自定义分类顺序' : '系统分类固定显示,自定义分类可编辑和排序',
style: TextStyle(fontSize: 10.5, color: context.jz.text3),
),
),
if (_savingOrder)
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
),
),
Expanded(
child: _loading
? Center(child: CircularProgressIndicator(strokeWidth: 2))
: _reordering
? _reorderList()
: _normalList(),
),
],
),
floatingActionButton: _reordering
? null
: FloatingActionButton.extended(
onPressed: _add,
backgroundColor: AppTheme.primary,
foregroundColor: Colors.white,
elevation: 0,
icon: AppIcons.icon(AppIcons.plus, size: 17, color: Colors.white),
label: Text('自定义分类'),
),
);
}
Widget _typeSwitch() {
return Container(
width: 216,
margin: const EdgeInsets.only(top: 6),
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: context.jz.line,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: ['expense', 'income'].map((type) {
final selected = _type == type;
return Expanded(
child: InkWell(
onTap: _reordering || _savingOrder
? null
: () {
if (_type == type) return;
setState(() => _type = type);
_load();
},
borderRadius: BorderRadius.circular(8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.symmetric(vertical: 7),
decoration: BoxDecoration(
color: selected ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Text(
type == 'expense' ? '支出' : '收入',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: selected ? context.jz.text : context.jz.text2,
),
),
),
),
);
}).toList(),
),
);
}
Widget _normalList() {
return ListView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 90),
children: [
Card(child: Column(children: _cats.map(_categoryRow).toList())),
],
);
}
Widget _categoryRow(CategoryItem category) {
return InkWell(
onTap: category.isCustom ? () => _showActions(category) : null,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
children: [
CategoryIconBox(
iconKey: category.iconKey,
colorKey: category.colorKey,
size: 34,
),
SizedBox(width: 12),
Expanded(
child: Text(
category.name,
style: TextStyle(fontSize: 13.5, fontWeight: FontWeight.w600),
),
),
if (category.isCustom) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: context.jz.primaryBackground,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'自定义',
style: TextStyle(
fontSize: 9,
color: AppTheme.primaryDeep,
fontWeight: FontWeight.w600,
),
),
),
SizedBox(width: 5),
Icon(
Icons.chevron_right_rounded,
size: 19,
color: context.jz.text3,
),
],
],
),
),
);
}
Widget _reorderList() {
final items = _custom;
if (items.isEmpty) {
return Center(
child: Text('还没有自定义分类', style: TextStyle(color: context.jz.text3)),
);
}
return ReorderableListView.builder(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
buildDefaultDragHandles: false,
itemCount: items.length,
onReorder: _reorder,
itemBuilder: (context, index) {
final category = items[index];
return Container(
key: ValueKey(category.id),
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.fromLTRB(13, 9, 8, 9),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: context.jz.line),
),
child: Row(
children: [
CategoryIconBox(
iconKey: category.iconKey,
colorKey: category.colorKey,
size: 34,
),
SizedBox(width: 11),
Expanded(
child: Text(
category.name,
style: TextStyle(fontSize: 13.5, fontWeight: FontWeight.w600),
),
),
ReorderableDragStartListener(
index: index,
child: Padding(
padding: const EdgeInsets.all(10),
child: AppIcons.icon(
AppIcons.drag,
size: 18,
color: context.jz.text3,
),
),
),
],
),
);
},
);
}
}
@@ -0,0 +1,283 @@
import 'package:flutter/material.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/config_api.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
/// AI 性格设置页(P5):形象/性格从后台 API 动态拉取
class CompanionPage extends StatefulWidget {
const CompanionPage({super.key});
@override
State<CompanionPage> createState() => _CompanionPageState();
}
class _CompanionPageState extends State<CompanionPage> {
String _avatar = 'cat', _persona = 'sassy_cat';
double _roast = 60, _sticker = 70, _proactive = 40;
bool _saving = false, _loaded = false;
List<AvatarItem> _avatars = [];
List<PersonaItem> _personas = [];
// 兜底
static const _fallbackAvatars = [
('dog', '阿福汪', AppIcons.dog),
('cat', '小账喵', AppIcons.cat),
('robot', '账小智', AppIcons.robot),
];
static const _fallbackPersonas = [
('sassy_cat', '毒舌猫娘', '乱花钱会被无情吐槽', AppTheme.ai),
('gentle', '温柔小暖', '永远鼓励,温柔提醒', AppTheme.primary),
('strict', '严格管家', '理性专业,数据说话', AppTheme.ai),
('meme', '沙雕损友', '玩梗高手,快乐记账', AppTheme.orange),
];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final p = await AuthApi.me();
if (mounted && p.aiCompanion != null)
setState(() {
_avatar = p.aiCompanion!.avatarKey;
_persona = p.aiCompanion!.personaKey;
_roast = p.aiCompanion!.roastLevel.toDouble();
_sticker = p.aiCompanion!.stickerFrequency.toDouble();
_proactive = p.aiCompanion!.proactiveLevel.toDouble();
});
} catch (_) {}
try {
final av = await PublicConfigApi.avatars();
final ps = await PublicConfigApi.personas();
if (mounted)
setState(() {
_avatars = av;
_personas = ps;
});
} catch (_) {}
if (mounted) setState(() => _loaded = true);
}
Future<void> _save() async {
setState(() => _saving = true);
try {
await AuthApi.updateCompanion(
avatarKey: _avatar,
personaKey: _persona,
roastLevel: _roast.round(),
stickerFrequency: _sticker.round(),
proactiveLevel: _proactive.round(),
);
await PublicConfigApi.refreshCompanion();
if (!mounted) return;
Navigator.pop(context);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('已保存')));
} catch (e) {
if (mounted)
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(e))));
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
if (!_loaded)
return const Scaffold(
body: Center(
child: CircularProgressIndicator(
color: AppTheme.primary,
strokeWidth: 2,
),
),
);
final avatars = _avatars.isNotEmpty
? _avatars
.map(
(a) => (
a.key,
a.defaultName,
a.key == 'dog'
? AppIcons.dog
: a.key == 'robot'
? AppIcons.robot
: AppIcons.cat,
),
)
.toList()
: _fallbackAvatars;
final personas = _personas.isNotEmpty
? _personas
.map((p) => (p.key, p.name, p.description, AppTheme.ai))
.toList()
: _fallbackPersonas;
return Scaffold(
appBar: AppBar(title: Text('AI 性格设置')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: avatars.map((a) {
final on = a.$1 == _avatar;
return GestureDetector(
onTap: () => setState(() => _avatar = a.$1),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 11),
child: Column(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: on ? 66 : 52,
height: on ? 66 : 52,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: on ? AppTheme.ai : context.jz.aiBackground,
boxShadow: on
? [
BoxShadow(
color: AppTheme.ai.withValues(alpha: 0.3),
blurRadius: 14,
),
]
: null,
),
child: Center(
child: AppIcons.icon(
a.$3,
size: on ? 30 : 24,
color: on ? Colors.white : context.jz.text3,
),
),
),
SizedBox(height: 6),
Text(
a.$2,
style: TextStyle(
fontSize: 10,
color: on ? AppTheme.ai : context.jz.text3,
fontWeight: on ? FontWeight.w600 : null,
),
),
],
),
),
);
}).toList(),
),
SizedBox(height: 16),
GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 2,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 1.9,
children: personas.map((p) {
final on = p.$1 == _persona;
return GestureDetector(
onTap: () => setState(() => _persona = p.$1),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: on ? context.jz.aiBackground : context.jz.card,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: on ? AppTheme.ai : context.jz.line,
width: 1.5,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
p.$2,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 4),
Text(
p.$3,
style: TextStyle(fontSize: 10, color: context.jz.text2),
),
],
),
),
);
}).toList(),
),
SizedBox(height: 14),
Card(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
children: [
_slider('吐槽力度', _roast, (v) => setState(() => _roast = v)),
_slider(
'表情包频率',
_sticker,
(v) => setState(() => _sticker = v),
),
],
),
),
),
SizedBox(height: 16),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.ai),
onPressed: _saving ? null : _save,
child: _saving
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text('保存设置'),
),
],
),
);
}
Widget _slider(String label, double value, ValueChanged<double> onChanged) =>
Column(
children: [
Row(
children: [
Text(
label,
style: TextStyle(fontSize: 12, color: context.jz.text2),
),
Spacer(),
Text(
'${value.round()}%',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
],
),
JzSlider(
value: value,
min: 0,
max: 100,
color: AppTheme.ai,
onChanged: onChanged,
),
],
);
}
@@ -0,0 +1,192 @@
import 'package:flutter/material.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
enum LegalDocumentKind {
privacy('privacy', '隐私政策'),
terms('terms', '用户协议'),
permissions('permissions', '权限用途说明'),
sdk('sdk', '第三方 SDK 清单');
final String key;
final String title;
const LegalDocumentKind(this.key, this.title);
static LegalDocumentKind fromKey(String? key) =>
values.firstWhere((item) => item.key == key, orElse: () => privacy);
}
class LegalDocumentPage extends StatelessWidget {
final LegalDocumentKind kind;
const LegalDocumentPage({super.key, required this.kind});
static const _effectiveDate = '2026 年 7 月 21 日';
@override
Widget build(BuildContext context) {
final sections = _sections(kind);
return Scaffold(
appBar: AppBar(title: Text(kind.title)),
body: SelectionArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(18, 8, 18, 32),
children: [
Text(
'更新及生效日期:$_effectiveDate',
style: TextStyle(fontSize: 12, color: context.jz.text3),
),
SizedBox(height: 14),
...sections.map(
(section) => Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
section.title,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: context.jz.text,
),
),
SizedBox(height: 8),
Text(
section.body,
style: TextStyle(
fontSize: 13.5,
height: 1.75,
color: context.jz.text2,
),
),
],
),
),
),
],
),
),
);
}
static List<_LegalSection> _sections(LegalDocumentKind kind) =>
switch (kind) {
LegalDocumentKind.privacy => _privacy,
LegalDocumentKind.terms => _terms,
LegalDocumentKind.permissions => _permissions,
LegalDocumentKind.sdk => _sdk,
};
static const _privacy = <_LegalSection>[
_LegalSection(
'一、我们如何处理信息',
'记之由个人开发者丁伊文运营。我们重视你的个人信息与财务隐私。为提供账号、记账、统计、预算、AI 对话及数据导出功能,我们会处理你主动提交的账号名称、昵称、账本、分类、账单、预算、聊天文本,以及你主动选择的图片或截屏。密码仅以不可逆哈希形式保存在服务器,不保存明文密码。',
),
_LegalSection(
'二、语音、图片与 AI 数据',
'使用语音记账时,麦克风音频由设备系统语音识别能力处理,记之接收识别后的文字;使用拍照或相册识别时,只有你主动选择的图片会用于本次识别。开启无障碍事件识别后,记之会在微信、支付宝疑似支付流程结束时按需截取当前页面,并由设备内置 OCR 在内存中识别,图片不落盘且处理后立即释放。只有你另行开启 AI 截图补全时,当前支付页图片才会发送至我们配置的火山方舟大模型服务。我们不会将完整账单历史无差别发送给模型,也不会把这些数据用于广告画像。',
),
_LegalSection(
'三、设备、网络与日志',
'为保障登录和接口安全,服务器可能记录访问时间、网络地址、请求结果、应用版本及去标识化故障信息。故障日志不得记录密码、JWT、API Key、完整图片 Base64 或完整隐私内容。',
),
_LegalSection(
'四、存储期限与安全',
'账号数据在你使用服务期间保存。你删除的账单进入 30 天回收站;截屏识别文件在完成、取消或失败后清理,遗留文件会在超过 24 小时后清理。账号注销进入 15 天后悔期,到期后永久删除账号关联数据。导出文件由你主动分享,应用会在分享完成或失败后清理临时副本。',
),
_LegalSection(
'五、你的权利',
'你可以在应用内查询、更正和删除账单,导出数据,修改昵称或密码,撤回非必要权限,并申请注销账号。关闭麦克风、通知、相册选择、无障碍或截屏授权不会影响基础手工记账,但对应功能将无法使用。',
),
_LegalSection(
'六、未成年人、变更与联系',
'未满 14 周岁的未成年人应在监护人同意和指导下使用。政策发生重要变化时,我们会通过应用内显著方式提示并重新征得必要同意。如对本政策、个人信息处理或账号注销有疑问,可发送邮件至 nanxun@nxsir.cn,或拨打 13607268374 联系运营者丁伊文。',
),
];
static const _terms = <_LegalSection>[
_LegalSection(
'一、协议范围',
'本协议是你与记之运营者丁伊文之间关于使用记之记账、统计、预算、AI 助手及相关服务的约定。注册或继续使用前,请完整阅读本协议与隐私政策。',
),
_LegalSection(
'二、账号使用',
'你应提供真实、合法且不侵害他人权益的账号信息,妥善保管登录凭据,并对账号内操作负责。发现账号异常时应及时修改密码。修改密码会使其他设备的既有登录状态失效。',
),
_LegalSection(
'三、AI 功能说明',
'AI 识别和建议可能存在误差。涉及金额、收支类型、分类、时间和预算时,请在确认页面核对。AI 不构成投资、税务、法律或其他专业建议,最终记账和资金决策由你自行确认。',
),
_LegalSection(
'四、使用规范',
'不得利用服务上传违法内容、攻击系统、窃取他人数据、绕过安全截屏限制或干扰服务运行。因系统维护、网络、上游模型或设备权限导致的暂时不可用,我们会尽力恢复并提供明确错误提示。',
),
_LegalSection(
'五、数据与注销',
'你保留对自己输入数据的权利,并可通过应用导出。申请注销后账号进入 15 天后悔期,期间重新登录可取消;等待期结束后数据将按规则永久删除且无法恢复。',
),
];
static const _permissions = <_LegalSection>[
_LegalSection('麦克风', '仅在你主动长按语音输入或发起语音记账时使用,用于系统语音识别。拒绝后仍可使用键盘记账和文字聊天。'),
_LegalSection(
'相册与照片选择',
'仅在你主动选择账单截图或图片识别时读取所选文件。Android 新版本优先使用系统照片选择器,不会扫描整套相册。',
),
_LegalSection(
'通知与振动',
'用于展示 AI 图片识别进度、智能识别候选、自动入账结果以及撤销入口。拒绝后本地识别仍可运行,但后台结果和确认提示可能不可见。',
),
_LegalSection(
'无障碍服务',
'可选权限。用于快捷磁贴静默截屏,以及在你主动开启“无障碍事件识别”后,仅处理微信、支付宝的支付流程事件、当前页面可见文字和按需本地截图 OCR。不会监听或拦截音量键,不会保存完整控件树或本地 OCR 截图;关闭后基础手工记账仍可使用。',
),
_LegalSection(
'屏幕录制 / 截屏授权',
'当无障碍截图不可用且你主动点击截屏记账时,系统会显示一次性授权。应用只截取一帧用于本次识别,完成后立即释放投屏会话;受保护页面不会尝试绕过系统限制。',
),
_LegalSection('网络', '用于登录、同步账本、调用 AI 识别和获取配置。所有生产数据应通过受信任的 HTTPS 服务传输。'),
];
static const _sdk = <_LegalSection>[
_LegalSection(
'FlutterGoogle',
'用于构建应用界面和跨平台运行。基础运行可能处理设备系统版本、界面状态及崩溃上下文;本项目未集成 Flutter 广告或行为分析 SDK。',
),
_LegalSection(
'Dio(开源网络库)',
'用于与记之后端进行 HTTPS 通信,传输登录凭据、账本数据、聊天文本和你主动提交的识别内容。',
),
_LegalSection(
'flutter_secure_storage',
'用于在设备安全存储中保存登录 Token。Android 使用系统加密存储能力,iOS 使用 Keychain。',
),
_LegalSection(
'sqlite3 / SQLCipher',
'用于在本机加密保存游客或账号的账本、分类、账单、预算及待同步操作。数据库密钥保存在系统安全存储中,本地数据库不会由该组件自行上传。',
),
_LegalSection(
'archive',
'用于在设备内存中生成 CSV 与 JSON 数据导出 ZIP,不连接网络、不收集数据;分享完成或失败后应用会清理临时 ZIP 文件。',
),
_LegalSection('image_picker', '用于调用系统相册或照片选择器,只返回你主动选中的图片。'),
_LegalSection('share_plus', '用于调用系统分享面板导出 ZIP。接收方由你选择;分享流程结束后应用清理临时导出文件。'),
_LegalSection(
'ML Kit 中文文字识别(Google',
'用于在 Android 设备本地识别微信、支付宝支付结果页中的成功状态和金额。模型随应用安装,处理过程不需要登录或联网,不会由该 SDK 自行上传截图;图片仅在内存中使用并在完成后释放。',
),
_LegalSection(
'火山方舟大模型服务(字节跳动)',
'用于 AI 对话、账单文本解析、图片识别及预算草稿调整。会处理完成对应请求所需的文本、图片和最小化财务上下文,不接收密码、JWT 或 API Key。',
),
_LegalSection(
'Android / iOS 系统能力',
'系统语音识别、照片选择器、通知、无障碍截图、MediaProjection 和系统分享属于操作系统能力,并非记之植入的广告或统计 SDK。',
),
];
}
class _LegalSection {
final String title;
final String body;
const _LegalSection(this.title, this.body);
}
+558
View File
@@ -0,0 +1,558 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.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/config_api.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
import 'package:miaoji_zhang/shared/services/sync_service.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/update/update_coordinator.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
import 'package:miaoji_zhang/shared/version.dart';
/// 我的页(P6):模式切换 + 设置入口 + 退出登录
class MePage extends StatefulWidget {
const MePage({super.key});
@override
State<MePage> createState() => _MePageState();
}
class _MePageState extends State<MePage> {
UserProfile? _profile;
@override
void initState() {
super.initState();
PublicConfigApi.companionNotifier.addListener(_refreshCompanion);
SessionStore.instance.addListener(_refreshSession);
SyncService.instance.refreshLocalStatus();
_load();
}
void _refreshCompanion() {
if (mounted) setState(() {});
}
void _refreshSession() {
if (mounted) setState(() {});
}
@override
void dispose() {
PublicConfigApi.companionNotifier.removeListener(_refreshCompanion);
SessionStore.instance.removeListener(_refreshSession);
super.dispose();
}
Future<void> _load() async {
try {
final p = await AuthApi.me();
if (mounted) setState(() => _profile = p);
} catch (_) {}
}
Future<void> _checkUpdate() =>
UpdateCoordinator.instance.checkManually(context);
Future<void> _switchMode(String mode) async {
if (_profile?.appMode == mode) return;
try {
final p = await AuthApi.switchMode(mode);
if (!mounted) return;
setState(() => _profile = p);
if (mode == 'ai') context.go('/ai-mode');
} catch (e) {
if (mounted)
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(e))));
}
}
Future<void> _logout() async {
final guest = SessionStore.instance.isGuest;
final ok = await showJzConfirmSheet(
context,
title: guest ? '退出游客模式' : '退出登录',
message: guest
? '游客数据会继续加密保存在本机,下次进入游客模式仍可使用。'
: '账号本地数据会继续加密保存在本机,切换账号不会混用。',
confirmLabel: '退出',
destructive: true,
);
if (!ok) return;
await AuthApi.logout();
if (mounted) context.go('/login');
}
@override
Widget build(BuildContext context) {
final p = _profile;
final session = SessionStore.instance;
return Scaffold(
body: SafeArea(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
// 用户头
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Row(
children: [
CircleAvatar(
radius: 27,
backgroundColor: context.jz.card,
child: AppIcons.icon(
AppIcons.user,
size: 26,
color: context.jz.text2,
),
),
SizedBox(width: 13),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
session.nickname ??
p?.nickname ??
p?.username ??
(session.isGuest ? '游客' : ''),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 3),
Text(
'@${p?.username ?? ''}',
style: TextStyle(fontSize: 11, color: context.jz.text3),
),
],
),
],
),
),
Card(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 15,
vertical: 8,
),
child: session.isGuest
? const GuestLocalStatusCard()
: Column(
children: [
AnimatedBuilder(
animation: SyncService.instance,
builder: (context, _) {
final sync = SyncService.instance;
return JzSwitchTile(
value: session.cloudSyncEnabled,
title: '云同步',
subtitle: session.cloudSyncEnabled
? sync.statusLabel
: '本地模式:数据不会发送到服务端',
onChanged: (value) async {
await session.setCloudSyncEnabled(value);
if (value) {
await sync.run();
} else {
sync.refreshLocalStatus();
}
if (mounted) setState(() {});
},
);
},
),
if (session.needsReauth)
InkWell(
onTap: () => context.go('/login'),
borderRadius: BorderRadius.circular(10),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: context.jz.warningBackground,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'云同步登录已过期,点击重新登录;本地记账不受影响',
style: TextStyle(
fontSize: 10.5,
color: context.jz.text2,
),
),
),
),
],
),
),
),
SizedBox(height: 12),
// 模式切换
if (!session.isGuest && session.aiEnabled) ...[
Card(
child: Padding(
padding: const EdgeInsets.all(15),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'App 模式',
style: TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 3),
Text(
'选择你喜欢的使用方式,可随时切换',
style: TextStyle(
fontSize: 10.5,
color: context.jz.text3,
),
),
SizedBox(height: 11),
Row(
children: [
_ModeOpt(
title: '普通记账模式',
desc: '经典账本界面',
iconAsset: AppIcons.wallet,
color: AppTheme.primary,
selected: session.appMode != 'ai',
onTap: () => _switchMode('normal'),
),
SizedBox(width: 10),
_ModeOpt(
title: '全 AI 模式',
desc: '对话作为主界面',
iconAsset: AppIcons.sparkle,
color: AppTheme.ai,
selected: session.appMode == 'ai',
onTap: () => _switchMode('ai'),
),
],
),
],
),
),
),
SizedBox(height: 12),
], // 设置入口
Card(
child: Column(
children: [
if (!session.isGuest && session.aiEnabled)
_row(
AppIcons.avatarAsset(PublicConfigApi.companionAvatarKey),
context.jz.aiBackground,
AppTheme.ai,
'${PublicConfigApi.companionName} 性格设置',
trailing: p?.aiCompanion?.personaKey,
onTap: () => context.push('/companion'),
),
_row(
AppIcons.target,
context.jz.primaryBackground,
AppTheme.primary,
'预算管理',
onTap: () => context.push('/budget'),
),
_row(
AppIcons.avatarAsset(PublicConfigApi.companionAvatarKey),
context.jz.aiBackground,
AppTheme.ai,
session.aiEnabled ? 'AI 报告' : '报告',
onTap: () => context.push('/report'),
),
_row(
AppIcons.tag,
context.jz.primaryBackground,
AppTheme.primary,
'分类管理',
onTap: () => context.push('/categories'),
),
if (!session.isGuest && PublicConfigApi.screenshotEnabled)
_row(
AppIcons.camera,
context.jz.primaryBackground,
AppTheme.red,
'智能识别',
trailing: '设置',
onTap: () => context.push('/screenshot-settings'),
),
_row(
AppIcons.user,
context.jz.background,
context.jz.text2,
session.isGuest ? '本地数据' : '账号与数据',
onTap: () async {
await context.push('/account-data');
_load();
},
),
_row(
AppIcons.gear,
context.jz.background,
context.jz.text2,
'外观设置',
onTap: () => context.push('/appearance'),
),
if (session.isAccount &&
SyncService.instance.conflictCount > 0)
_row(
AppIcons.offline,
context.jz.warningBackground,
AppTheme.orange,
'同步冲突',
trailing: '${SyncService.instance.conflictCount} 项待处理',
onTap: () async {
await context.push('/sync-conflicts');
if (mounted) setState(() {});
},
),
AnimatedBuilder(
animation: UpdateCoordinator.instance,
builder: (context, _) => _row(
AppIcons.cloud,
context.jz.primaryBackground,
AppTheme.primary,
'检查更新',
trailing: UpdateCoordinator.instance.checking
? '正在检查…'
: AppVersion.display,
onTap: UpdateCoordinator.instance.checking
? () {}
: _checkUpdate,
),
),
_row(
AppIcons.trash,
context.jz.expenseBackground,
AppTheme.red,
'回收站',
trailing: '保留 30 天',
onTap: () => context.push('/recycle-bin'),
),
],
),
),
SizedBox(height: 12),
Card(
child: _row(
AppIcons.exportIcon,
context.jz.expenseBackground,
AppTheme.red,
session.isGuest ? '退出游客模式' : '退出登录',
onTap: _logout,
),
),
SizedBox(height: 16),
Center(
child: Text(
AppVersion.display,
style: TextStyle(fontSize: 10, color: context.jz.text3),
),
),
SizedBox(height: 12),
],
),
),
);
}
Widget _row(
String iconAsset,
Color bg,
Color fg,
String label, {
String? trailing,
required VoidCallback onTap,
}) {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 13),
child: Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: AppIcons.icon(iconAsset, size: 15, color: fg),
),
),
SizedBox(width: 12),
Text(label, style: TextStyle(fontSize: 13.5)),
Spacer(),
if (trailing != null)
Text(
trailing,
style: TextStyle(fontSize: 11.5, color: context.jz.text3),
),
AppIcons.icon(
AppIcons.chevronRight,
size: 16,
color: context.jz.text3,
),
],
),
),
);
}
}
class GuestLocalStatusCard extends StatelessWidget {
const GuestLocalStatusCard({super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 7),
child: Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: context.jz.primaryBackground,
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: AppIcons.icon(
AppIcons.check,
size: 19,
color: AppTheme.primaryDeep,
),
),
),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'本机保存中',
style: TextStyle(fontSize: 13.5, fontWeight: FontWeight.w700),
),
SizedBox(height: 3),
Text(
'账单、分类、预算和统计仅保存在本机,不会自动上传',
style: TextStyle(
fontSize: 10.5,
height: 1.4,
color: context.jz.text2,
),
),
],
),
),
SizedBox(width: 8),
DecoratedBox(
decoration: BoxDecoration(
color: context.jz.primaryBackground,
borderRadius: BorderRadius.all(Radius.circular(12)),
),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 9, vertical: 5),
child: Text(
'游客模式',
style: TextStyle(
color: AppTheme.primaryDeep,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
);
}
}
class _ModeOpt extends StatelessWidget {
final String title, desc;
final String iconAsset;
final Color color;
final bool selected;
final VoidCallback onTap;
const _ModeOpt({
required this.title,
required this.desc,
required this.iconAsset,
required this.color,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(11),
decoration: BoxDecoration(
color: selected ? context.jz.primaryBackground : context.jz.card,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: selected ? AppTheme.primary : context.jz.line,
width: 1.5,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
AppIcons.icon(iconAsset, size: 20, color: color),
Spacer(),
Container(
width: 15,
height: 15,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: selected ? AppTheme.primary : context.jz.line,
width: 1.5,
),
color: selected ? AppTheme.primary : null,
),
child: selected
? AppIcons.icon(
AppIcons.check,
size: 10,
color: Colors.white,
)
: null,
),
],
),
SizedBox(height: 7),
Text(
title,
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700),
),
SizedBox(height: 3),
Text(
desc,
style: TextStyle(fontSize: 9, color: context.jz.text2),
),
],
),
),
),
);
}
}
@@ -0,0 +1,153 @@
import 'package:flutter/material.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/business_api.dart';
import 'package:miaoji_zhang/shared/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/category_icon.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
class RecycleBinPage extends StatefulWidget {
const RecycleBinPage({super.key});
@override
State<RecycleBinPage> createState() => _RecycleBinPageState();
}
class _RecycleBinPageState extends State<RecycleBinPage> {
List<TxItem> _items = const [];
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _loading = true);
try {
await CurrentLedgerStore.instance.ensureLoaded();
final items = await TxApi.recycleBin();
if (mounted) setState(() => _items = items);
} catch (error) {
if (mounted) _showError(error);
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _restore(TxItem item) async {
try {
await TxApi.restore(item.id);
TransactionEvents.notifyChanged();
await _load();
} catch (error) {
if (mounted) _showError(error);
}
}
Future<void> _permanentDelete(TxItem item) async {
final confirmed = await _confirm('永久删除', '永久删除后无法恢复,聊天中的账单卡片会显示为已删除。');
if (!confirmed) return;
try {
await TxApi.permanentlyDelete(item.id);
await _load();
} catch (error) {
if (mounted) _showError(error);
}
}
Future<void> _clear() async {
if (_items.isEmpty) return;
final confirmed = await _confirm('清空回收站', '将永久删除当前账本回收站中的全部账单,无法恢复。');
if (!confirmed) return;
try {
await TxApi.clearRecycleBin();
await _load();
} catch (error) {
if (mounted) _showError(error);
}
}
Future<bool> _confirm(String title, String message) => showJzConfirmSheet(
context,
title: title,
message: message,
confirmLabel: '永久删除',
destructive: true,
);
Future<void> _showActions(TxItem item) async {
final action = await showJzOptionSheet<String>(
context,
title: item.note ?? item.categoryName,
options: const [
JzOption(value: 'restore', label: '恢复账单'),
JzOption(value: 'delete', label: '永久删除', subtitle: '删除后无法恢复'),
],
);
if (action == 'restore') await _restore(item);
if (action == 'delete') await _permanentDelete(item);
}
void _showError(Object error) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('${CurrentLedgerStore.instance.currentName} · 回收站'),
actions: [
TextButton(
onPressed: _items.isEmpty ? null : _clear,
child: Text('清空'),
),
],
),
body: _loading
? Center(
child: CircularProgressIndicator(
color: AppTheme.primary,
strokeWidth: 2,
),
)
: _items.isEmpty
? Center(
child: Text(
'回收站暂无账单\n删除的账单会保留 30 天',
textAlign: TextAlign.center,
style: TextStyle(color: context.jz.text3, height: 1.6),
),
)
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _items.length,
itemBuilder: (context, index) {
final item = _items[index];
return Card(
child: ListTile(
leading: CategoryIconBox(
iconKey: item.categoryIcon,
colorKey: item.categoryColor,
),
title: Text(item.note ?? item.categoryName),
subtitle: Text(
'${item.occurredAt.year}-${item.occurredAt.month.toString().padLeft(2, '0')}-${item.occurredAt.day.toString().padLeft(2, '0')}',
),
trailing: IconButton(
tooltip: '账单操作',
onPressed: () => _showActions(item),
icon: Icon(Icons.more_horiz_rounded),
),
),
);
},
),
);
}
}
@@ -0,0 +1,993 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miaoji_zhang/shared/services/screenshot_channel.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
import 'package:miaoji_zhang/shared/services/recognition_diagnostic_formatter.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
class ScreenshotSettingsPage extends StatefulWidget {
const ScreenshotSettingsPage({super.key});
@override
State<ScreenshotSettingsPage> createState() => _ScreenshotSettingsPageState();
}
class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
with WidgetsBindingObserver {
RecognitionStatus? _status;
bool _checking = true;
bool _authorizing = false;
String? _pendingAuthorizationKey;
String? _error;
Timer? _diagnosticRefreshTimer;
Timer? _previewExpiryTimer;
bool _diagnosticRefreshInFlight = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_check();
_diagnosticRefreshTimer = Timer.periodic(
const Duration(seconds: 2),
(_) => _refreshRunningDiagnostic(),
);
}
@override
void dispose() {
_diagnosticRefreshTimer?.cancel();
_previewExpiryTimer?.cancel();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
Future<void>.delayed(
const Duration(milliseconds: 500),
_resumeAuthorization,
);
}
}
void _refreshRunningDiagnostic() {
if (!mounted ||
_diagnosticRefreshInFlight ||
_status?.latestDiagnostic?.result != 'started') {
return;
}
_diagnosticRefreshInFlight = true;
_check().whenComplete(() => _diagnosticRefreshInFlight = false);
}
Future<void> _check() async {
try {
var status = await ScreenshotChannel.recognitionStatus();
final invalid = <String>[
if (status.accessibilityEvents &&
(!status.accessibilityAuthorized ||
!status.postNotificationsGranted))
'accessibility_events',
if (status.notificationEvents &&
(!status.notificationAuthorized ||
!status.postNotificationsGranted))
'notification_events',
if (status.aiScreenshot &&
(!status.accessibilityAuthorized ||
!status.postNotificationsGranted))
'ai_screenshot',
];
for (final key in invalid) {
await ScreenshotChannel.setRecognitionToggle(key, false);
}
if (invalid.isNotEmpty) {
status = await ScreenshotChannel.recognitionStatus();
}
if (!mounted) return;
setState(() {
_status = status;
_checking = false;
_error = null;
});
_schedulePreviewExpiry(status);
} catch (_) {
if (!mounted) return;
setState(() {
_checking = false;
_error = '状态读取失败,请重试';
});
}
}
void _schedulePreviewExpiry(RecognitionStatus status) {
_previewExpiryTimer?.cancel();
final expiresAt = status.ocrDiagnosticPreviewExpiresAt;
if (!status.ocrDiagnosticPreview || expiresAt == null) return;
final remaining = expiresAt.difference(DateTime.now());
if (remaining <= Duration.zero) {
unawaited(_check());
return;
}
_previewExpiryTimer = Timer(remaining, _check);
}
bool _hasSystemAuthorization(String key, RecognitionStatus status) {
return switch (key) {
'notification_events' => status.notificationAuthorized,
'accessibility_events' ||
'ai_screenshot' => status.accessibilityAuthorized,
_ => true,
};
}
Future<void> _toggle(String key, bool enabled) async {
if (_authorizing) return;
if (key == 'ocr_diagnostic_preview') {
final changed = await ScreenshotChannel.setRecognitionToggle(
key,
enabled,
);
if (!mounted) return;
if (!changed) {
_showMessage('设置保存失败,请重试');
} else if (enabled) {
_showMessage('脱敏预览已开启,将在 10 分钟后自动关闭');
}
await _check();
return;
}
if (enabled &&
key == 'accessibility_events' &&
!await _confirmLocalOcrConsent()) {
return;
}
if (!enabled) {
_pendingAuthorizationKey = null;
final changed = await ScreenshotChannel.setRecognitionToggle(key, false);
if (!mounted) return;
if (!changed) {
_showMessage('设置保存失败,请重试');
}
await _check();
return;
}
final status = _status ?? await ScreenshotChannel.recognitionStatus();
if (!_hasSystemAuthorization(key, status)) {
await ScreenshotChannel.setRecognitionToggle(key, false);
_pendingAuthorizationKey = key;
await _check();
if (!mounted) return;
if (key == 'notification_events') {
await ScreenshotChannel.openNotificationAccessSettings();
} else {
await ScreenshotChannel.openAccessibilitySettings();
}
return;
}
await _enableAfterAuthorization(key);
}
Future<void> _resumeAuthorization() async {
if (_authorizing) return;
await _check();
final key = _pendingAuthorizationKey;
final status = _status;
if (key == null || status == null) return;
if (!_hasSystemAuthorization(key, status)) {
_pendingAuthorizationKey = null;
await ScreenshotChannel.setRecognitionToggle(key, false);
await _check();
if (mounted) _showMessage('未完成系统授权,识别开关仍保持关闭');
return;
}
await _enableAfterAuthorization(key);
}
Future<void> _enableAfterAuthorization(String key) async {
_authorizing = true;
try {
final notificationsAllowed =
await ScreenshotChannel.requestNotificationPermission();
if (!notificationsAllowed) {
await ScreenshotChannel.setRecognitionToggle(key, false);
if (mounted) {
_showMessage('未允许结果通知,识别开关仍保持关闭');
}
return;
}
final changed = await ScreenshotChannel.setRecognitionToggle(key, true);
if (!changed && mounted) {
_showMessage('设置保存失败,请重试');
}
} finally {
_pendingAuthorizationKey = null;
_authorizing = false;
await _check();
}
}
Future<bool> _confirmLocalOcrConsent() async {
final preferences = await SharedPreferences.getInstance();
if (preferences.getBool('local_ocr_consent_v1') == true) return true;
if (!mounted) return false;
final accepted = await showModalBottomSheet<bool>(
context: context,
useSafeArea: true,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) => Container(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
decoration: BoxDecoration(
color: sheetContext.jz.card,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const JzSheetHeader(title: '启用本地视觉识别', subtitle: '请确认无障碍事件识别的截屏用途'),
const SizedBox(height: 12),
const _ConsentPoint(
icon: Icons.visibility_outlined,
text: '仅在微信、支付宝疑似支付流程结束时截取当前页面。',
),
const _ConsentPoint(
icon: Icons.memory_rounded,
text: '图片只在内存中由本地 OCR 处理,完成后立即释放。',
),
const _ConsentPoint(
icon: Icons.cloud_off_outlined,
text: '默认不会上传;只有你另行开启 AI 截图补全时才允许在线分析。',
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: JzActionButton(
label: '暂不开启',
secondary: true,
onPressed: () => Navigator.pop(sheetContext, false),
),
),
const SizedBox(width: 10),
Expanded(
child: JzActionButton(
label: '同意并继续',
onPressed: () => Navigator.pop(sheetContext, true),
),
),
],
),
],
),
),
);
if (accepted == true) {
await preferences.setBool('local_ocr_consent_v1', true);
return true;
}
return false;
}
void _showMessage(String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
final palette = context.jz;
final status = _status;
final aiAvailable =
SessionStore.instance.isAccount && SessionStore.instance.aiEnabled;
return Scaffold(
appBar: AppBar(title: const Text('智能识别')),
body: RefreshIndicator(
onRefresh: _check,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 28),
children: [
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: palette.card,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: palette.line),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: palette.primaryBackground,
borderRadius: BorderRadius.circular(14),
),
child: const Icon(
Icons.auto_awesome_motion_rounded,
color: AppTheme.primary,
),
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'微信、支付宝账单自动识别',
style: TextStyle(
color: palette.text,
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
'两路结果会先在本机合并去重。金额、方向和来源明确时自动入账,其他情况只通知你确认。',
style: TextStyle(
color: palette.text2,
fontSize: 12,
height: 1.55,
),
),
],
),
),
],
),
),
const SizedBox(height: 12),
if (_checking)
const Center(
child: Padding(
padding: EdgeInsets.all(30),
child: CircularProgressIndicator(),
),
)
else if (_error != null)
_ErrorCard(message: _error!, onRetry: _check)
else ...[
_RecognitionCard(
icon: Icons.accessibility_new_rounded,
title: '无障碍事件识别',
description:
'仅在微信和支付宝疑似支付流程中读取可见文字,并按需在内存中进行本地截图 OCR;不保存图片、完整控件树,也不监听按键。',
authorized: status!.accessibilityAuthorized,
connected: status.accessibilityConnected,
onOpenSettings: ScreenshotChannel.openAccessibilitySettings,
child: Column(
children: [
JzSwitchTile(
value: status.accessibilityEvents,
title: '识别开关',
subtitle: !status.accessibilityAuthorized
? '开启后需要前往系统设置授权'
: !status.postNotificationsGranted
? '识别已开启,还需允许记之显示结果通知'
: '系统权限已授权',
onChanged: (value) =>
_toggle('accessibility_events', value),
),
Divider(height: 1, color: context.jz.line),
JzSwitchTile(
value: status.ocrDiagnosticPreview,
title: 'OCR 诊断预览',
subtitle: '仅保留脱敏文字,10 分钟后自动关闭,不上传、不保存原图',
onChanged: status.accessibilityEvents
? (value) => _toggle('ocr_diagnostic_preview', value)
: null,
),
],
),
),
const SizedBox(height: 10),
_RecognitionCard(
icon: Icons.notifications_active_outlined,
title: '通知识别',
description: '只处理开启后新产生的微信、支付宝通知,不读取通知历史;关闭识别不会撤销系统授权。',
authorized: status.notificationAuthorized,
connected: status.notificationConnected,
onOpenSettings:
ScreenshotChannel.openNotificationAccessSettings,
child: JzSwitchTile(
value: status.notificationEvents,
title: '识别开关',
subtitle: !status.notificationAuthorized
? '开启后需要前往系统设置授权'
: !status.postNotificationsGranted
? '已允许读取,还需允许记之显示结果通知'
: '通知访问权限已授权',
onChanged: (value) => _toggle('notification_events', value),
),
),
const SizedBox(height: 10),
_RecognitionCard(
icon: Icons.document_scanner_outlined,
title: 'AI 截图补全',
description: '本地 OCR 已确认支付成功但字段仍不足时才在线分析。截图完成、失败或超时后立即释放。',
authorized: aiAvailable,
connected: aiAvailable && status.accessibilityConnected,
statusLabel: !aiAvailable ? 'AI 不可用' : null,
onOpenSettings: status.accessibilityAuthorized
? null
: ScreenshotChannel.openAccessibilitySettings,
child: JzSwitchTile(
value: status.aiScreenshot,
title: '补全开关',
subtitle: !aiAvailable
? '需要登录且账号具备 AI 权限'
: !status.accessibilityAuthorized
? '需要先授权无障碍截屏能力'
: '默认关闭,仅在支付应用前台运行',
onChanged: !aiAvailable
? null
: (value) => _toggle('ai_screenshot', value),
),
),
if (status.latestDiagnostic != null) ...[
const SizedBox(height: 10),
_RecognitionDiagnosticCard(
diagnostic: status.latestDiagnostic!,
onClear: () async {
await ScreenshotChannel.clearRecognitionDiagnostic();
await _check();
},
),
],
if (status.accessibilityEvents ||
status.notificationEvents ||
status.aiScreenshot) ...[
const SizedBox(height: 10),
_BackgroundKeepAliveCard(status: status),
],
const SizedBox(height: 10),
_RecognitionCard(
icon: Icons.grid_view_rounded,
title: '快捷磁贴截屏',
description:
'无障碍已连接时静默截屏;未连接时每次弹出 Android 一次性投屏授权。两种方式都不会占用音量键。',
authorized: status.accessibilityAuthorized,
connected: status.accessibilityConnected,
statusLabel: status.accessibilityConnected ? '免授权截屏' : '每次系统授权',
child: SizedBox(
width: double.infinity,
child: JzActionButton(
label: '添加截屏记账磁贴',
secondary: true,
icon: const Icon(Icons.tune_rounded, size: 18),
onPressed: ScreenshotChannel.openQuickSettings,
),
),
),
],
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: palette.warningBackground,
borderRadius: BorderRadius.circular(14),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.shield_outlined,
color: AppTheme.orange,
size: 20,
),
const SizedBox(width: 9),
Expanded(
child: Text(
'银行密码页等安全窗口由系统禁止截屏,记之不会绕过限制。本地 OCR 图片仅在内存中处理;只有你开启 AI 截图补全时才会上传当前支付页。',
style: TextStyle(
color: palette.text2,
fontSize: 11.5,
height: 1.55,
),
),
),
],
),
),
],
),
),
);
}
}
class _ConsentPoint extends StatelessWidget {
final IconData icon;
final String text;
const _ConsentPoint({required this.icon, required this.text});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 7),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 20, color: AppTheme.primary),
const SizedBox(width: 10),
Expanded(
child: Text(
text,
style: TextStyle(color: context.jz.text2, height: 1.5),
),
),
],
),
);
}
}
class _RecognitionDiagnosticCard extends StatelessWidget {
final RecognitionDiagnostic diagnostic;
final VoidCallback onClear;
const _RecognitionDiagnosticCard({
required this.diagnostic,
required this.onClear,
});
@override
Widget build(BuildContext context) {
final civil = ShanghaiTime.toCivil(diagnostic.at.toUtc());
final time =
'${civil.month.toString().padLeft(2, '0')}-'
'${civil.day.toString().padLeft(2, '0')} '
'${civil.hour.toString().padLeft(2, '0')}:'
'${civil.minute.toString().padLeft(2, '0')}';
final display = RecognitionDiagnosticDisplay.from(diagnostic);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: context.jz.line),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.fact_check_outlined, color: AppTheme.primary),
const SizedBox(width: 9),
Expanded(
child: Text(
'最近一次识别',
style: TextStyle(
color: context.jz.text,
fontWeight: FontWeight.w800,
),
),
),
Semantics(
button: true,
label: '清除识别诊断',
child: InkWell(
onTap: onClear,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 7,
),
child: Text(
'清除',
style: TextStyle(
color: AppTheme.primary,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
),
),
],
),
const SizedBox(height: 8),
Text(
'${diagnostic.appName} · ${display.stageLabel} · ${display.summaryLabel}',
style: TextStyle(
color: context.jz.text,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 5),
Text(
display.reasonLabel,
style: TextStyle(color: context.jz.text2, height: 1.45),
),
if (display.recognitionKindLabel != null ||
display.amountSourceLabel != null ||
display.statusStrengthLabel != null ||
diagnostic.expectedAmountMatched != null ||
diagnostic.resultTransitionObserved != null) ...[
const SizedBox(height: 9),
Wrap(
spacing: 7,
runSpacing: 7,
children: [
_EvidencePill(
label: display.resultLabel,
positive:
diagnostic.result == 'matched' ||
diagnostic.result == 'auto_ready',
),
if (display.recognitionKindLabel != null)
_EvidencePill(
label: display.recognitionKindLabel!,
positive: true,
),
if (display.amountSourceLabel != null)
_EvidencePill(
label: display.amountSourceLabel!,
positive: true,
),
if (diagnostic.resultFingerprint != null)
_EvidencePill(
label: '结果指纹 ${diagnostic.resultFingerprint}',
positive: true,
),
if (display.statusStrengthLabel != null)
_EvidencePill(
label: display.statusStrengthLabel!,
positive: display.statusStrengthPositive,
),
if (diagnostic.expectedAmountMatched != null)
_EvidencePill(
label: diagnostic.expectedAmountMatched! ? '金额匹配' : '金额不匹配',
positive: diagnostic.expectedAmountMatched!,
),
if (diagnostic.resultTransitionObserved != null)
_EvidencePill(
label: diagnostic.resultTransitionObserved!
? '已观察到页面跳转'
: '未观察到页面跳转',
positive: diagnostic.resultTransitionObserved!,
),
],
),
],
if (diagnostic.ocrPreview.isNotEmpty) ...[
const SizedBox(height: 10),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: context.jz.background,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: context.jz.line),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'OCR 脱敏预览',
style: TextStyle(
color: context.jz.text,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
...diagnostic.ocrPreview.map(
(line) => Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
line,
style: TextStyle(
color: context.jz.text2,
fontSize: 12,
height: 1.35,
),
),
),
),
],
),
),
],
const SizedBox(height: 7),
Text(
'$time · 节点 ${diagnostic.nodeCount}'
'${diagnostic.ocrMs == null ? '' : ' · OCR ${diagnostic.ocrMs}ms'}'
'${diagnostic.amountCandidates == 0 ? '' : ' · 金额候选 ${diagnostic.amountCandidates}'}',
style: TextStyle(color: context.jz.text3, fontSize: 11),
),
],
),
);
}
}
class _EvidencePill extends StatelessWidget {
final String label;
final bool positive;
const _EvidencePill({required this.label, required this.positive});
@override
Widget build(BuildContext context) {
final color = positive ? AppTheme.primary : context.jz.text3;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
decoration: BoxDecoration(
color: positive ? context.jz.primaryBackground : context.jz.background,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: positive ? AppTheme.primary : context.jz.line,
),
),
child: Text(
label,
style: TextStyle(
color: color,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
);
}
}
class _BackgroundKeepAliveCard extends StatelessWidget {
final RecognitionStatus status;
const _BackgroundKeepAliveCard({required this.status});
@override
Widget build(BuildContext context) {
final isVivo = status.manufacturer.toLowerCase().contains('vivo');
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: context.jz.line),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: context.jz.warningBackground,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.battery_saver_outlined,
color: AppTheme.orange,
size: 21,
),
),
const SizedBox(width: 11),
const Expanded(
child: Text(
'后台识别保活',
style: TextStyle(fontWeight: FontWeight.w800),
),
),
Text(
status.batteryOptimizationIgnored ? '后台限制较少' : '需要设置',
style: TextStyle(
color: status.batteryOptimizationIgnored
? AppTheme.primaryDeep
: AppTheme.orange,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 10),
Text(
'无障碍和通知监听由 Android 独立轻量进程运行。请允许记之后台活动,'
'${isVivo ? '并在 OriginOS 中开启自启动,' : ''}'
'否则系统清理进程后可能暂时收不到支付事件。',
style: TextStyle(
color: context.jz.text2,
fontSize: 11.5,
height: 1.55,
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: JzActionButton(
label: '电池优化',
secondary: true,
onPressed: ScreenshotChannel.openBatteryOptimizationSettings,
),
),
const SizedBox(width: 10),
Expanded(
child: JzActionButton(
label: isVivo ? '自启动管理' : '后台设置',
secondary: true,
onPressed: ScreenshotChannel.openBackgroundStartupSettings,
),
),
],
),
],
),
);
}
}
class _RecognitionCard extends StatelessWidget {
final IconData icon;
final String title;
final String description;
final bool authorized;
final bool connected;
final String? statusLabel;
final Widget child;
final VoidCallback? onOpenSettings;
const _RecognitionCard({
required this.icon,
required this.title,
required this.description,
required this.authorized,
required this.connected,
required this.child,
this.statusLabel,
this.onOpenSettings,
});
@override
Widget build(BuildContext context) {
final palette = context.jz;
final color = connected ? AppTheme.primary : AppTheme.ai;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: palette.card,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: palette.line),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: connected
? palette.primaryBackground
: palette.aiBackground,
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: color, size: 21),
),
const SizedBox(width: 11),
Expanded(
child: Text(
title,
style: TextStyle(
color: palette.text,
fontWeight: FontWeight.w800,
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
decoration: BoxDecoration(
color: connected
? palette.primaryBackground
: palette.aiBackground,
borderRadius: BorderRadius.circular(20),
),
child: Text(
statusLabel ??
(connected
? '已连接'
: authorized
? '等待连接'
: '未授权'),
style: TextStyle(
color: color,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
),
],
),
const SizedBox(height: 10),
Text(
description,
style: TextStyle(color: palette.text2, fontSize: 12, height: 1.55),
),
const SizedBox(height: 6),
child,
if (onOpenSettings != null) ...[
const SizedBox(height: 6),
Align(
alignment: Alignment.centerLeft,
child: Semantics(
button: true,
label: '打开系统权限设置',
child: InkWell(
onTap: onOpenSettings,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 8,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.settings_outlined,
size: 17,
color: AppTheme.primary,
),
const SizedBox(width: 6),
Text(
'系统权限设置',
style: TextStyle(
color: AppTheme.primary,
fontWeight: FontWeight.w700,
),
),
],
),
),
),
),
),
],
],
),
);
}
}
class _ErrorCard extends StatelessWidget {
final String message;
final VoidCallback onRetry;
const _ErrorCard({required this.message, required this.onRetry});
@override
Widget build(BuildContext context) {
final palette = context.jz;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: palette.card,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: palette.line),
),
child: Column(
children: [
Text(message, style: TextStyle(color: palette.text2)),
const SizedBox(height: 10),
JzActionButton(label: '重新读取', secondary: true, onPressed: onRetry),
],
),
);
}
}
@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/services/local_database.dart';
import 'package:miaoji_zhang/shared/services/sync_service.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
class SyncConflictsPage extends StatefulWidget {
const SyncConflictsPage({super.key});
@override
State<SyncConflictsPage> createState() => _SyncConflictsPageState();
}
class _SyncConflictsPageState extends State<SyncConflictsPage> {
List<Map<String, dynamic>> _items = const [];
int? _resolvingId;
@override
void initState() {
super.initState();
_load();
}
void _load() {
setState(() => _items = LocalDatabase.instance.conflicts());
}
Future<void> _resolve(
Map<String, dynamic> item, {
required bool keepLocal,
}) async {
setState(() => _resolvingId = item['id'] as int);
try {
await SyncService.instance.resolveConflict(item, keepLocal: keepLocal);
if (mounted) _load();
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
}
} finally {
if (mounted) setState(() => _resolvingId = null);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('同步冲突')),
body: _items.isEmpty
? Center(
child: Text(
'没有需要处理的同步冲突',
style: TextStyle(color: context.jz.text3),
),
)
: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: _items.length,
separatorBuilder: (_, __) => SizedBox(height: 10),
itemBuilder: (context, index) => _conflictCard(_items[index]),
),
);
}
Widget _conflictCard(Map<String, dynamic> item) {
final local = item['local'] as Map<String, dynamic>;
final remote = item['remote'] as Map<String, dynamic>;
final resolving = _resolvingId == item['id'];
return Card(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.sync_problem_rounded, color: AppTheme.orange),
SizedBox(width: 8),
Expanded(
child: Text(
_operationLabel(item['operation'] as String),
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w800),
),
),
],
),
SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _version('本地版本', local, context.jz.primaryBackground),
),
SizedBox(width: 9),
Expanded(
child: _version('云端版本', remote, context.jz.aiBackground),
),
],
),
SizedBox(height: 12),
Row(
children: [
Expanded(
child: JzActionButton(
label: '使用云端',
secondary: true,
loading: resolving,
onPressed: resolving
? null
: () => _resolve(item, keepLocal: false),
),
),
SizedBox(width: 9),
Expanded(
child: JzActionButton(
label: '保留本地',
loading: resolving,
onPressed: resolving
? null
: () => _resolve(item, keepLocal: true),
),
),
],
),
],
),
),
);
}
Widget _version(String title, Map<String, dynamic> value, Color background) {
final amount = value['amount'];
return Container(
constraints: const BoxConstraints(minHeight: 92),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(fontSize: 10.5, color: context.jz.text3),
),
SizedBox(height: 6),
if (amount != null)
Text(
'¥${(amount as num).toStringAsFixed(2)}',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w800),
),
SizedBox(height: 3),
Text(
(value['note'] ?? value['categoryName'] ?? '无可展示内容').toString(),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 10.5, color: context.jz.text2),
),
],
),
);
}
String _operationLabel(String operation) => switch (operation) {
'delete' => '同一账单在云端有新修改,是否仍删除?',
'restore' => '同一账单在云端有新修改,是否仍恢复?',
_ => '本地与云端都修改了同一账单',
};
}
@@ -0,0 +1,564 @@
import 'package:flutter/material.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/business_api.dart';
import 'package:miaoji_zhang/shared/api/config_api.dart';
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
import 'package:miaoji_zhang/shared/widgets/async_error_view.dart';
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
enum _ReportKind { weekly, monthly, yearly }
class ReportPage extends StatefulWidget {
const ReportPage({super.key});
@override
State<ReportPage> createState() => _ReportPageState();
}
class _ReportPageState extends State<ReportPage> {
PeriodReport? _report;
bool _loading = true;
String? _error;
DateTime _anchor = ShanghaiTime.now;
_ReportKind _kind = _ReportKind.monthly;
@override
void initState() {
super.initState();
PublicConfigApi.companionNotifier.addListener(_refreshCompanion);
CurrentLedgerStore.instance.addListener(_load);
_load();
}
@override
void dispose() {
PublicConfigApi.companionNotifier.removeListener(_refreshCompanion);
CurrentLedgerStore.instance.removeListener(_load);
super.dispose();
}
void _refreshCompanion() {
if (mounted) setState(() {});
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
final report = switch (_kind) {
_ReportKind.weekly => await ReportApi.weekly(_anchor),
_ReportKind.monthly => await ReportApi.monthlyPeriod(
_anchor.year,
_anchor.month,
),
_ReportKind.yearly => await ReportApi.yearly(_anchor.year),
};
if (mounted) setState(() => _report = report);
} catch (error) {
if (mounted) setState(() => _error = apiErrorMessage(error));
} finally {
if (mounted) setState(() => _loading = false);
}
}
void _selectKind(_ReportKind kind) {
if (_kind == kind) return;
setState(() {
_kind = kind;
_anchor = ShanghaiTime.now;
_report = null;
});
_load();
}
void _move(int direction) {
final next = switch (_kind) {
_ReportKind.weekly => _anchor.add(Duration(days: 7 * direction)),
_ReportKind.monthly => DateTime(_anchor.year, _anchor.month + direction),
_ReportKind.yearly => DateTime(_anchor.year + direction),
};
if (direction > 0 && _isFuture(next)) return;
setState(() {
_anchor = next;
_report = null;
});
_load();
}
bool _isFuture(DateTime value) {
final now = ShanghaiTime.now;
return switch (_kind) {
_ReportKind.weekly => value.isAfter(now),
_ReportKind.monthly =>
value.year > now.year ||
(value.year == now.year && value.month > now.month),
_ReportKind.yearly => value.year > now.year,
};
}
@override
Widget build(BuildContext context) {
final report = _report;
return Scaffold(
appBar: AppBar(
title: Text(SessionStore.instance.aiEnabled ? 'AI 报告' : '报告'),
),
body: Column(
children: [
_periodTabs(),
_periodNavigator(),
Expanded(
child: !_loading && report == null && _error != null
? AsyncErrorView(message: _error!, onRetry: _load)
: _loading && report == null
? Center(
child: CircularProgressIndicator(
color: AppTheme.primary,
strokeWidth: 2,
),
)
: report == null
? Center(
child: Text(
'暂无数据',
style: TextStyle(color: context.jz.text3),
),
)
: _reportBody(report),
),
],
),
);
}
Widget _periodTabs() {
const labels = {
_ReportKind.weekly: '周报',
_ReportKind.monthly: '月报',
_ReportKind.yearly: '年报',
};
return Container(
margin: const EdgeInsets.fromLTRB(16, 4, 16, 8),
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: context.jz.line,
borderRadius: BorderRadius.circular(11),
),
child: Row(
children: labels.entries.map((entry) {
final selected = entry.key == _kind;
return Expanded(
child: InkWell(
onTap: () => _selectKind(entry.key),
borderRadius: BorderRadius.circular(9),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: selected ? context.jz.card : Colors.transparent,
borderRadius: BorderRadius.circular(9),
),
child: Text(
entry.value,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12.5,
fontWeight: selected ? FontWeight.w800 : FontWeight.w500,
color: selected ? AppTheme.ai : context.jz.text2,
),
),
),
),
);
}).toList(),
),
);
}
Widget _periodNavigator() {
final label =
_report?.periodLabel ??
switch (_kind) {
_ReportKind.weekly => '本周',
_ReportKind.monthly =>
_anchor.year.toString() + '' + _anchor.month.toString() + '',
_ReportKind.yearly => _anchor.year.toString() + '',
};
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
tooltip: '上一个周期',
onPressed: () => _move(-1),
icon: Icon(Icons.chevron_left_rounded),
),
Container(
constraints: const BoxConstraints(minWidth: 150),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: context.jz.line),
),
child: Text(
label,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700),
),
),
IconButton(
tooltip: '下一个周期',
onPressed: _isFuture(_nextAnchor()) ? null : () => _move(1),
icon: Icon(Icons.chevron_right_rounded),
),
],
),
);
}
DateTime _nextAnchor() => switch (_kind) {
_ReportKind.weekly => _anchor.add(const Duration(days: 7)),
_ReportKind.monthly => DateTime(_anchor.year, _anchor.month + 1),
_ReportKind.yearly => DateTime(_anchor.year + 1),
};
Widget _reportBody(PeriodReport report) {
return RefreshIndicator(
onRefresh: _load,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 24),
children: [
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppTheme.ai,
borderRadius: BorderRadius.circular(18),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'' + PublicConfigApi.companionName + _kindLabel + '报告',
style: TextStyle(
fontSize: 11,
color: Colors.white.withValues(alpha: 0.85),
),
),
SizedBox(height: 8),
Text(
report.periodLabel +
',一共支出 ¥' +
report.expense.toStringAsFixed(0),
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
color: Colors.white,
),
),
SizedBox(height: 16),
Row(
children: [
_num('收入', report.income),
_num('支出', report.expense),
_num('结余', report.balance),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'笔数',
style: TextStyle(
fontSize: 10,
color: Colors.white.withValues(alpha: 0.8),
),
),
SizedBox(height: 3),
Text(
report.count.toString(),
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
],
),
),
],
),
],
),
),
SizedBox(height: 10),
Card(
child: Column(
children: [
if (report.peakLabel != null)
_highlight(
AppIcons.fire,
context.jz.expenseBackground,
AppTheme.red,
(_kind == _ReportKind.yearly ? '支出最高的月份:' : '最烧钱的一天:') +
report.peakLabel!,
'支出 ¥' +
report.peakAmount.toStringAsFixed(0) +
(report.peakNote != null
? ',最大一笔是“' + report.peakNote! + ''
: ''),
),
if (SessionStore.instance.aiEnabled)
_highlight(
AppIcons.chat,
context.jz.aiBackground,
AppTheme.ai,
PublicConfigApi.companionName +
'记账占比 ' +
report.aiRatio.toStringAsFixed(0) +
'%',
report.aiRatio > 50 ? '大部分账单都通过 AI 完成' : '可以试试用一句话完成记账',
),
],
),
),
if (report.categoryRanking.isNotEmpty) ...[
SizedBox(height: 10),
_categoryRanking(report.categoryRanking),
],
SizedBox(height: 10),
if (SessionStore.instance.aiEnabled)
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: context.jz.aiBackground,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppTheme.ai.withValues(alpha: 0.12)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
AppIcons.icon(
AppIcons.avatarAsset(
PublicConfigApi.companionAvatarKey,
),
size: 15,
color: AppTheme.ai,
),
SizedBox(width: 7),
Text(
PublicConfigApi.companionName + '点评',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: AppTheme.ai,
),
),
],
),
SizedBox(height: 8),
Text(
'' + report.commentary + '',
style: TextStyle(
fontSize: 12.5,
height: 1.75,
color: context.jz.text2,
),
),
],
),
),
],
),
);
}
Widget _categoryRanking(List<ReportCategoryRank> ranking) {
return Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'支出分类排行',
style: TextStyle(fontSize: 13.5, fontWeight: FontWeight.w800),
),
SizedBox(height: 12),
...ranking.indexed.map((entry) {
final index = entry.$1;
final item = entry.$2;
return Padding(
padding: const EdgeInsets.only(bottom: 11),
child: Row(
children: [
SizedBox(
width: 22,
child: Text(
(index + 1).toString().padLeft(2, '0'),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
color: index == 0
? AppTheme.primary
: context.jz.text3,
),
),
),
CategoryIconBox(
iconKey: item.iconKey,
colorKey: item.colorKey,
size: 32,
),
SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
item.name,
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
),
),
),
Text(
'¥' + item.amount.toStringAsFixed(0),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
SizedBox(width: 7),
SizedBox(
width: 38,
child: Text(
item.percent.toStringAsFixed(0) + '%',
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 10.5,
color: context.jz.text3,
),
),
),
],
),
SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
minHeight: 5,
value: (item.percent / 100).clamp(0.0, 1.0),
backgroundColor: context.jz.line,
color: index == 0
? AppTheme.primary
: AppTheme.primaryLight,
),
),
],
),
),
],
),
);
}),
],
),
),
);
}
String get _kindLabel => switch (_kind) {
_ReportKind.weekly => '',
_ReportKind.monthly => '',
_ReportKind.yearly => '',
};
Widget _num(String label, double value) {
return Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 10,
color: Colors.white.withValues(alpha: 0.8),
),
),
SizedBox(height: 3),
Text(
'¥' + value.toStringAsFixed(0),
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
],
),
);
}
Widget _highlight(
String iconAsset,
Color background,
Color foreground,
String title,
String description,
) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(9),
),
child: Center(
child: AppIcons.icon(iconAsset, size: 16, color: foreground),
),
),
SizedBox(width: 11),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(fontSize: 12.5, fontWeight: FontWeight.w600),
),
SizedBox(height: 3),
Text(
description,
style: TextStyle(
fontSize: 11,
color: context.jz.text2,
height: 1.55,
),
),
],
),
),
],
),
);
}
}
+605
View File
@@ -0,0 +1,605 @@
import 'dart:math' as math;
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
import 'package:flutter/material.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/business_api.dart';
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
import 'package:miaoji_zhang/shared/widgets/async_error_view.dart';
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
class StatsPage extends StatefulWidget {
const StatsPage({super.key});
@override
State<StatsPage> createState() => _StatsPageState();
}
class _StatsPageState extends State<StatsPage> {
String _period = 'month';
DateTime _anchor = ShanghaiTime.now;
PeriodStats? _stats;
String? _error;
bool _loading = true;
@override
void initState() {
super.initState();
CurrentLedgerStore.instance.addListener(_load);
_load();
}
@override
void dispose() {
CurrentLedgerStore.instance.removeListener(_load);
super.dispose();
}
Future<void> _load() async {
if (mounted) {
setState(() {
_loading = true;
_error = null;
});
}
try {
final stats = await TxApi.periodStats(_period, _anchor);
if (mounted) setState(() => _stats = stats);
} catch (error) {
if (mounted) setState(() => _error = apiErrorMessage(error));
} finally {
if (mounted) setState(() => _loading = false);
}
}
void _selectPeriod(String value) {
if (_period == value) return;
setState(() {
_period = value;
_anchor = ShanghaiTime.now;
_stats = null;
});
_load();
}
void _shift(int direction) {
final next = switch (_period) {
'week' => _anchor.add(Duration(days: 7 * direction)),
'year' => DateTime(_anchor.year + direction, 1, 1),
_ => DateTime(_anchor.year, _anchor.month + direction, 1),
};
if (direction > 0 && _isAfterCurrentPeriod(next)) return;
setState(() {
_anchor = next;
_stats = null;
});
_load();
}
bool _isAfterCurrentPeriod(DateTime value) {
final now = ShanghaiTime.now;
return switch (_period) {
'week' => value.isAfter(now),
'year' => value.year > now.year,
_ =>
value.year > now.year ||
(value.year == now.year && value.month > now.month),
};
}
@override
Widget build(BuildContext context) {
final stats = _stats;
return Scaffold(
appBar: AppBar(title: Text('统计')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: JzSegmentedControl<String>(
value: _period,
options: const [
JzOption(value: 'week', label: ''),
JzOption(value: 'month', label: ''),
JzOption(value: 'year', label: ''),
],
onChanged: _loading ? null : _selectPeriod,
),
),
Expanded(
child: _loading && stats == null
? Center(
child: CircularProgressIndicator(
color: AppTheme.primary,
strokeWidth: 2,
),
)
: stats == null && _error != null
? AsyncErrorView(message: _error!, onRetry: _load)
: RefreshIndicator(
onRefresh: _load,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
children: [
_periodNavigator(stats?.periodLabel ?? ''),
if (_loading)
LinearProgressIndicator(
minHeight: 2,
color: AppTheme.primary,
backgroundColor: context.jz.primaryBackground,
),
if (_error != null) _refreshNotice(_error!),
if (stats != null) ...[
SizedBox(height: 10),
_summaryCard(stats),
SizedBox(height: 10),
if (stats.totalExpense == 0 && stats.totalIncome == 0)
_emptyCard()
else ...[
_categoryCard(stats),
SizedBox(height: 10),
if (stats.analysis case final analysis?)
_analysisCard(analysis),
SizedBox(height: 10),
_trendCard(stats),
if (stats.byCategory.isNotEmpty) ...[
SizedBox(height: 10),
_rankingCard(stats),
],
],
],
],
),
),
),
],
),
);
}
Widget _periodNavigator(String label) {
final nextAnchor = switch (_period) {
'week' => _anchor.add(const Duration(days: 7)),
'year' => DateTime(_anchor.year + 1, 1, 1),
_ => DateTime(_anchor.year, _anchor.month + 1, 1),
};
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
tooltip: '上一个周期',
onPressed: _loading ? null : () => _shift(-1),
icon: Icon(Icons.chevron_left_rounded),
),
SizedBox(
width: 170,
child: Text(
label,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
),
),
IconButton(
tooltip: '下一个周期',
onPressed: _loading || _isAfterCurrentPeriod(nextAnchor)
? null
: () => _shift(1),
icon: Icon(Icons.chevron_right_rounded),
),
],
);
}
Widget _summaryCard(PeriodStats stats) {
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 17),
child: Row(
children: [
_summaryValue('支出', stats.totalExpense, AppTheme.red),
_divider(),
_summaryValue('收入', stats.totalIncome, AppTheme.primaryDeep),
_divider(),
_summaryValue(
'结余',
stats.balance,
stats.balance < 0 ? AppTheme.red : context.jz.text,
),
],
),
),
);
}
Widget _summaryValue(String label, double amount, Color color) {
return Expanded(
child: Column(
children: [
Text(
label,
style: TextStyle(fontSize: 10.5, color: context.jz.text3),
),
SizedBox(height: 5),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'¥${amount.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
color: color,
),
),
),
],
),
);
}
Widget _divider() => Container(width: 1, height: 34, color: context.jz.line);
Widget _emptyCard() {
return Card(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 48),
child: Center(
child: Text(
'这个周期还没有账单记录',
style: TextStyle(fontSize: 13, color: context.jz.text3),
),
),
),
);
}
Widget _categoryCard(PeriodStats stats) {
return Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 18, 16, 16),
child: Column(
children: [
SizedBox(
height: 178,
child: _CategoryDonut(
categories: stats.byCategory,
total: stats.totalExpense,
),
),
SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 8,
children: stats.byCategory.take(8).map((category) {
final color = categoryColorMeta(category.colorKey).foreground;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 9,
height: 9,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(3),
),
),
SizedBox(width: 5),
Text(
'${category.name} ${category.percent.toStringAsFixed(0)}%',
style: TextStyle(fontSize: 10.5, color: context.jz.text2),
),
],
);
}).toList(),
),
],
),
),
);
}
Widget _analysisCard(String text) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: context.jz.aiBackground,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppTheme.ai.withValues(alpha: 0.12)),
),
child: Row(
children: [
Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Icons.auto_awesome_rounded,
size: 16,
color: AppTheme.ai,
),
),
SizedBox(width: 10),
Expanded(
child: Text(
text,
style: TextStyle(
fontSize: 12,
color: context.jz.text2,
height: 1.5,
),
),
),
],
),
);
}
Widget _trendCard(PeriodStats stats) {
final title = switch (_period) {
'week' => '每日支出趋势',
'year' => '每月支出趋势',
_ => '每日支出趋势',
};
return Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700),
),
SizedBox(height: 16),
SizedBox(
height: 116,
child: _TrendBars(points: stats.trend, period: _period),
),
],
),
),
);
}
Widget _rankingCard(PeriodStats stats) {
final maxAmount = stats.byCategory.first.amount;
return Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 8, 14, 8),
child: Column(
children: stats.byCategory.take(8).map((category) {
final color = categoryColorMeta(category.colorKey).foreground;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
CategoryIconBox(
iconKey: category.iconKey,
colorKey: category.colorKey,
size: 34,
),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
category.name,
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
),
),
Spacer(),
Text(
'¥${category.amount.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
),
),
],
),
SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
value: maxAmount == 0
? 0
: category.amount / maxAmount,
minHeight: 4,
backgroundColor: context.jz.background,
color: color,
),
),
],
),
),
],
),
);
}).toList(),
),
),
);
}
Widget _refreshNotice(String message) {
return Container(
margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
decoration: BoxDecoration(
color: context.jz.warningBackground,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.info_outline_rounded, size: 16, color: AppTheme.orange),
SizedBox(width: 8),
Expanded(
child: Text(
message,
style: TextStyle(fontSize: 11, color: context.jz.text2),
),
),
TextButton(onPressed: _load, child: Text('重试')),
],
),
);
}
}
class _CategoryDonut extends StatelessWidget {
final List<CategoryStat> categories;
final double total;
const _CategoryDonut({required this.categories, required this.total});
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: _DonutPainter(categories.take(8).toList(), context.jz.line),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'周期支出',
style: TextStyle(fontSize: 10.5, color: context.jz.text3),
),
SizedBox(height: 3),
Text(
'¥${total.toStringAsFixed(0)}',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800),
),
],
),
),
);
}
}
class _DonutPainter extends CustomPainter {
final List<CategoryStat> categories;
final Color lineColor;
_DonutPainter(this.categories, this.lineColor);
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = math.min(size.width, size.height) / 2 - 12;
const strokeWidth = 22.0;
final rect = Rect.fromCircle(center: center, radius: radius);
canvas.drawCircle(
center,
radius,
Paint()
..color = lineColor
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth,
);
final total = categories.fold<double>(0, (sum, item) => sum + item.amount);
if (total == 0) return;
var start = -math.pi / 2;
for (final category in categories) {
final sweep = category.amount / total * math.pi * 2;
final gap = math.min(0.025, sweep / 4);
canvas.drawArc(
rect,
start + gap,
math.max(0, sweep - gap * 2),
false,
Paint()
..color = categoryColorMeta(category.colorKey).foreground
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round,
);
start += sweep;
}
}
@override
bool shouldRepaint(covariant _DonutPainter oldDelegate) => true;
}
class _TrendBars extends StatelessWidget {
final List<PeriodTrendPoint> points;
final String period;
const _TrendBars({required this.points, required this.period});
@override
Widget build(BuildContext context) {
final maxValue = points.fold<double>(
0,
(maximum, point) => math.max(maximum, point.expense),
);
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: points.indexed.map((entry) {
final index = entry.$1;
final point = entry.$2;
final height = maxValue == 0
? 2.0
: math.max(2.0, point.expense / maxValue * 78).toDouble();
final showLabel =
period != 'month' ||
index == 0 ||
index == points.length - 1 ||
(index + 1) % 5 == 0;
return Expanded(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: period == 'month' ? 1 : 3,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Tooltip(
message:
'${point.label} ¥${point.expense.toStringAsFixed(2)}',
child: Container(
height: height,
decoration: BoxDecoration(
color: point.expense == maxValue && maxValue > 0
? AppTheme.primary
: context.jz.primaryBackground,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(4),
),
),
),
),
SizedBox(height: 6),
SizedBox(
height: 14,
child: showLabel
? FittedBox(
fit: BoxFit.scaleDown,
child: Text(
point.label,
style: TextStyle(
fontSize: 9,
color: context.jz.text3,
),
),
)
: null,
),
],
),
),
);
}).toList(),
);
}
}