Initial project import

This commit is contained in:
2026-07-24 23:11:20 +08:00
commit 6396eabb87
372 changed files with 49682 additions and 0 deletions
+559
View File
@@ -0,0 +1,559 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/business_api.dart';
import 'package:miaoji_zhang/shared/services/transaction_events.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
class AddPage extends StatefulWidget {
const AddPage({super.key});
@override
State<AddPage> createState() => _AddPageState();
}
class _AddPageState extends State<AddPage> {
final _noteCtrl = TextEditingController();
String _tab = 'expense';
final Map<String, List<CategoryItem>> _categoriesByType = {
'expense': <CategoryItem>[],
'income': <CategoryItem>[],
};
final Map<String, CategoryItem?> _selectedByType = {
'expense': null,
'income': null,
};
String _amount = '0';
String? _paymentMethod;
DateTime _occurredAt = ShanghaiTime.now;
bool _saving = false;
bool _loadingCategories = true;
List<CategoryItem> get _categories =>
_categoriesByType[_tab] ?? const <CategoryItem>[];
CategoryItem? get _selected => _selectedByType[_tab];
Color get _activeColor => _tab == 'income' ? AppTheme.primary : AppTheme.red;
@override
void initState() {
super.initState();
_loadCategories();
}
@override
void dispose() {
_noteCtrl.dispose();
super.dispose();
}
Future<void> _loadCategories() async {
setState(() => _loadingCategories = true);
try {
final results = await Future.wait([
TxApi.categories('expense'),
TxApi.categories('income'),
]);
if (!mounted) return;
setState(() {
_categoriesByType['expense'] = results[0];
_categoriesByType['income'] = results[1];
if (_selectedByType['expense'] == null && results[0].isNotEmpty) {
_selectedByType['expense'] = results[0].first;
}
if (_selectedByType['income'] == null && results[1].isNotEmpty) {
_selectedByType['income'] = results[1].first;
}
});
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
}
} finally {
if (mounted) setState(() => _loadingCategories = false);
}
}
void _switchTab(String tab) {
if (_tab == tab) return;
setState(() => _tab = tab);
}
void _pressKey(String key) {
setState(() {
if (key == 'delete') {
_amount = _amount.length > 1
? _amount.substring(0, _amount.length - 1)
: '0';
return;
}
if (key == '.') {
if (!_amount.contains('.')) _amount += '.';
return;
}
if (_amount.contains('.') && _amount.split('.')[1].length >= 2) return;
_amount = _amount == '0' ? key : '$_amount$key';
if (_amount.length > 9) _amount = _amount.substring(0, 9);
});
}
Future<void> _save() async {
final amount = double.tryParse(_amount) ?? 0;
if (amount <= 0) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('请输入金额')));
return;
}
if (_selected == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('请选择分类')));
return;
}
setState(() => _saving = true);
try {
await TxApi.create(
categoryId: _selected!.id,
type: _tab,
amount: amount,
note: _noteCtrl.text.trim().isEmpty ? null : _noteCtrl.text.trim(),
paymentMethod: _paymentMethod,
occurredAt: _occurredAt,
);
TransactionEvents.notifyChanged();
if (mounted) context.pop(amount);
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _editNote() async {
final value = await showJzTextInputSheet(
context,
title: '备注',
label: '备注内容',
initialValue: _noteCtrl.text,
maxLength: 40,
);
if (value != null) setState(() => _noteCtrl.text = value);
}
Future<void> _pickOccurredAt() async {
final value = await showJzDateTimeSheet(
context,
initial: _occurredAt,
firstDate: DateTime(2000),
lastDate: ShanghaiTime.now.add(const Duration(days: 1)),
title: '选择发生时间',
);
if (value != null && mounted) setState(() => _occurredAt = value);
}
Future<void> _pickPaymentMethod() async {
const methods = ['微信支付', '支付宝', '银行卡', '现金', '其他'];
final options = [
...methods.map((method) => JzOption(value: method, label: method)),
if (_paymentMethod != null) const JzOption(value: '', label: '清除支付方式'),
];
final value = await showJzOptionSheet<String>(
context,
title: '支付方式',
options: options,
selected: _paymentMethod,
);
if (value != null) {
setState(() => _paymentMethod = value.isEmpty ? null : value);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('记一笔'),
leading: IconButton(
icon: AppIcons.icon(
AppIcons.close,
size: 20,
color: context.jz.text2,
),
onPressed: () => context.pop(),
),
),
body: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
final compact = constraints.maxHeight < 650;
return Column(
children: [
_buildTypeSelector(),
SizedBox(height: 4),
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 260),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) {
final offset = _tab == 'expense'
? const Offset(-0.04, 0)
: const Offset(0.04, 0);
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: offset,
end: Offset.zero,
).animate(animation),
child: child,
),
);
},
child: _loadingCategories
? Center(
key: ValueKey('loading'),
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppTheme.primary,
),
)
: _buildCategoryGrid(key: ValueKey(_tab)),
),
),
_buildAmountKeyboard(compact),
],
);
},
),
),
);
}
Widget _buildTypeSelector() {
return Container(
width: 220,
height: 38,
margin: const EdgeInsets.only(top: 4),
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: context.jz.line,
borderRadius: BorderRadius.circular(10),
),
child: LayoutBuilder(
builder: (context, constraints) => Stack(
children: [
AnimatedAlign(
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
alignment: _tab == 'expense'
? Alignment.centerLeft
: Alignment.centerRight,
child: Container(
width: constraints.maxWidth / 2,
height: constraints.maxHeight,
decoration: BoxDecoration(
color: _activeColor,
borderRadius: BorderRadius.circular(7),
),
),
),
Row(
children: [_segment('支出', 'expense'), _segment('收入', 'income')],
),
],
),
),
);
}
Widget _segment(String label, String value) {
final selected = _tab == value;
return Expanded(
child: InkWell(
borderRadius: BorderRadius.circular(7),
onTap: () => _switchTab(value),
child: Center(
child: AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
style: TextStyle(
fontSize: 13,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: selected ? Colors.white : context.jz.text2,
),
child: Text(label),
),
),
),
);
}
Widget _buildCategoryGrid({required Key key}) {
return GridView.builder(
key: key,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
childAspectRatio: 0.92,
),
itemCount: _categories.length,
itemBuilder: (context, index) {
final category = _categories[index];
final selected = category.id == _selected?.id;
return InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => setState(() => _selectedByType[_tab] = category),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 160),
width: 48,
height: 48,
decoration: BoxDecoration(
color: selected ? _activeColor : context.jz.card,
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: selected ? _activeColor : context.jz.line,
width: selected ? 1 : 0.5,
),
),
child: Center(
child: AppIcons.byKey(
category.iconKey,
size: 21,
color: selected ? Colors.white : context.jz.text2,
),
),
),
SizedBox(height: 5),
Text(
category.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
color: selected ? _activeColor : context.jz.text2,
fontWeight: selected ? FontWeight.w600 : null,
),
),
],
),
);
},
);
}
Widget _buildAmountKeyboard(bool compact) {
const keys = [
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'.',
'0',
'delete',
];
final dateLabel =
'${_occurredAt.month}/${_occurredAt.day} '
'${_occurredAt.hour.toString().padLeft(2, '0')}:'
'${_occurredAt.minute.toString().padLeft(2, '0')}';
return Container(
padding: EdgeInsets.fromLTRB(16, compact ? 7 : 10, 16, 10),
decoration: BoxDecoration(
color: context.jz.card,
border: Border(top: BorderSide(color: context.jz.line, width: 0.5)),
),
child: Column(
children: [
Row(
children: [
if (_selected != null) ...[
CategoryIconBox(
iconKey: _selected!.iconKey,
colorKey: _selected!.colorKey,
size: 30,
),
SizedBox(width: 8),
Text(
_selected!.name,
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
],
Spacer(),
AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
style: TextStyle(
fontSize: compact ? 25 : 28,
fontWeight: FontWeight.w800,
color: _activeColor,
),
child: Text('¥ $_amount'),
),
],
),
SizedBox(height: 7),
SizedBox(
height: 34,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
_metaChip(
icon: Icons.edit_note_rounded,
label: _noteCtrl.text.isEmpty ? '备注' : _noteCtrl.text,
onTap: _editNote,
),
_metaChip(
icon: Icons.schedule_rounded,
label: dateLabel,
onTap: _pickOccurredAt,
),
_metaChip(
icon: Icons.account_balance_wallet_outlined,
label: _paymentMethod ?? '支付方式',
onTap: _pickPaymentMethod,
),
],
),
),
SizedBox(height: 8),
SizedBox(
height: compact ? 172 : 196,
child: Row(
children: [
Expanded(
child: GridView.count(
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 3,
mainAxisSpacing: 6,
crossAxisSpacing: 6,
childAspectRatio: compact ? 2.15 : 2.0,
children: keys.map(_buildKey).toList(),
),
),
SizedBox(width: 7),
SizedBox(
width: 74,
child: AnimatedContainer(
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
decoration: BoxDecoration(
color: _saving ? context.jz.line : _activeColor,
borderRadius: BorderRadius.circular(11),
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(11),
child: InkWell(
borderRadius: BorderRadius.circular(11),
onTap: _saving ? null : _save,
child: Center(
child: _saving
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.check_rounded,
color: Colors.white,
size: 24,
),
SizedBox(height: 4),
Text(
'完成',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
],
),
),
),
),
),
),
],
),
),
],
),
);
}
Widget _metaChip({
required IconData icon,
required String label,
required VoidCallback onTap,
}) {
return Padding(
padding: const EdgeInsets.only(right: 7),
child: ActionChip(
avatar: Icon(icon, size: 15, color: context.jz.text2),
label: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 105),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: context.jz.text2),
),
),
side: BorderSide.none,
backgroundColor: context.jz.background,
onPressed: onTap,
),
);
}
Widget _buildKey(String key) {
return Material(
color: context.jz.background,
borderRadius: BorderRadius.circular(9),
child: InkWell(
borderRadius: BorderRadius.circular(9),
onTap: () => _pressKey(key),
child: Center(
child: key == 'delete'
? Icon(
Icons.backspace_outlined,
size: 18,
color: context.jz.text2,
)
: Text(
key,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
),
),
);
}
}
+276
View File
@@ -0,0 +1,276 @@
import 'package:flutter/material.dart';
import 'package:miaoji_zhang/shared/services/transaction_events.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/business_api.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
Future<bool?> showParseSheet(
BuildContext context, {
required String source,
String? initialText,
}) {
return showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => _ParseSheet(source: source, initialText: initialText),
);
}
class _ParseSheet extends StatefulWidget {
final String source;
final String? initialText;
const _ParseSheet({required this.source, this.initialText});
@override
State<_ParseSheet> createState() => _ParseSheetState();
}
class _ParseSheetState extends State<_ParseSheet> {
late final TextEditingController _input;
ParsedDraft? _draft;
bool _parsing = false;
bool _saving = false;
bool get isVoice => widget.source == 'voice';
@override
void initState() {
super.initState();
_input = TextEditingController(text: widget.initialText ?? '');
if ((widget.initialText ?? '').trim().isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) => _parse());
}
}
@override
void dispose() {
_input.dispose();
super.dispose();
}
Future<void> _parse() async {
final text = _input.text.trim();
if (text.isEmpty) return;
setState(() => _parsing = true);
try {
final d = await ParseApi.parse(text, source: widget.source);
if (mounted) setState(() => _draft = d);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(e))));
}
} finally {
if (mounted) setState(() => _parsing = false);
}
}
Future<void> _confirm() async {
final d = _draft;
if (d == null || d.amount <= 0) return;
setState(() => _saving = true);
try {
await ParseApi.confirm(d, widget.source, _input.text.trim());
TransactionEvents.notifyChanged();
if (mounted) Navigator.pop(context, true);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(e))));
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final d = _draft;
return Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.vertical(top: Radius.circular(22)),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(22, 22, 22, 30),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Column(
children: [
Text(
isVoice ? '语音记账' : '文字识别',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700),
),
SizedBox(height: 4),
Text(
isVoice
? '识别到的语音会自动填到这里,你也可以手动补充或修改。'
: '把识别到的文字贴进来,AI 会先帮你解析成账单草稿。',
style: TextStyle(fontSize: 10.5, color: context.jz.text3),
textAlign: TextAlign.center,
),
],
),
),
SizedBox(height: 14),
TextField(
controller: _input,
maxLines: 2,
decoration: InputDecoration(
hintText: isVoice
? '例如:昨天晚上和朋友吃烧烤 88 支付宝付的'
: '例如:永辉超市 合计 128.60 微信支付',
),
onChanged: (_) {
if (_draft != null) setState(() => _draft = null);
},
),
SizedBox(height: 12),
if (d == null)
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.ai),
onPressed: _parsing ? null : _parse,
child: _parsing
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text('开始识别'),
),
)
else ...[
Container(
padding: const EdgeInsets.all(13),
decoration: BoxDecoration(
color: context.jz.aiBackground,
borderRadius: BorderRadius.circular(13),
),
child: Row(
children: [
CategoryIconBox(iconKey: d.categoryIcon, size: 38),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'¥${d.amount.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: d.type == 'income'
? AppTheme.primary
: AppTheme.red,
),
),
SizedBox(height: 3),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color:
(d.type == 'income'
? AppTheme.primary
: AppTheme.red)
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(5),
),
child: Text(
d.type == 'income' ? '收入' : '支出',
style: TextStyle(
fontSize: 9.5,
fontWeight: FontWeight.w700,
color: d.type == 'income'
? AppTheme.primary
: AppTheme.red,
),
),
),
SizedBox(width: 6),
Flexible(
child: Text(
d.categoryName +
(d.paymentMethod == null
? ''
: ' · ' + d.paymentMethod!),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
color: context.jz.text2,
),
),
),
],
),
if (!d.matched)
Text(
'没有识别出有效金额,请修改后重新识别。',
style: TextStyle(
fontSize: 10.5,
color: AppTheme.red,
),
),
],
),
),
],
),
),
SizedBox(height: 12),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => setState(() => _draft = null),
child: Text(
'重新识别',
style: TextStyle(color: context.jz.text2),
),
),
),
SizedBox(width: 10),
Expanded(
child: ElevatedButton(
onPressed: (!d.matched || _saving) ? null : _confirm,
child: _saving
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text('确认入账'),
),
),
],
),
],
],
),
),
);
}
}
File diff suppressed because it is too large Load Diff