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
Reference in New Issue
Block a user