Initial project import
This commit is contained in:
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
'Flutter(Google)',
|
||||
'用于构建应用界面和跨平台运行。基础运行可能处理设备系统版本、界面状态及崩溃上下文;本项目未集成 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);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user