import 'dart:async'; 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/widgets/backend_status_icon.dart'; import 'package:miaoji_zhang/shared/services/shanghai_time.dart'; class AddPage extends StatefulWidget { const AddPage({super.key}); @override State createState() => _AddPageState(); } class _AddPageState extends State { final _noteCtrl = TextEditingController(); final _counterpartyCtrl = TextEditingController(); String _tab = 'expense'; String _transferDirection = 'out'; final Map> _categoriesByType = { 'expense': [], 'income': [], }; final Map _selectedByType = { 'expense': null, 'income': null, 'transfer_out': null, 'transfer_in': null, }; String _amount = '0'; String? _paymentMethod; DateTime _occurredAt = ShanghaiTime.now; bool _saving = false; bool _loadingCategories = true; int _loadRevision = 0; String get _categoryType => _tab == 'transfer' ? _transferDirection == 'in' ? 'income' : 'expense' : _tab; String get _selectionKey => _tab == 'transfer' ? 'transfer_$_transferDirection' : _tab; List get _categories => _categoriesByType[_categoryType] ?? const []; CategoryItem? get _selected => _selectedByType[_selectionKey]; Color get _activeColor => _tab == 'income' || _tab == 'transfer' && _transferDirection == 'in' ? AppTheme.primary : _tab == 'transfer' ? AppTheme.orange : AppTheme.red; @override void initState() { super.initState(); unawaited(_loadCategories()); } @override void dispose() { _noteCtrl.dispose(); _counterpartyCtrl.dispose(); super.dispose(); } Future _loadCategories() async { final revision = ++_loadRevision; final expense = TxApi.categoriesLocal('expense'); final income = TxApi.categoriesLocal('income'); if (mounted) { setState(() { _applyCategories(expense, income); _loadingCategories = false; }); } await Future.wait([ TxApi.categoriesRemote('expense').then((values) { if (!mounted || revision != _loadRevision) return; setState(() { _applyCategories(values, _categoriesByType['income'] ?? const []); }); }, onError: (_) {}), TxApi.categoriesRemote('income').then((values) { if (!mounted || revision != _loadRevision) return; setState(() { _applyCategories(_categoriesByType['expense'] ?? const [], values); }); }, onError: (_) {}), ]); } void _applyCategories(List expense, List income) { _categoriesByType['expense'] = expense; _categoriesByType['income'] = income; _selectedByType['expense'] = _preserveSelection( _selectedByType['expense'], expense, ); _selectedByType['income'] = _preserveSelection( _selectedByType['income'], income, ); _selectedByType['transfer_out'] = _preserveSelection( _selectedByType['transfer_out'], expense, ); _selectedByType['transfer_in'] = _preserveSelection( _selectedByType['transfer_in'], income, ); } CategoryItem? _preserveSelection( CategoryItem? selected, List values, ) { if (values.isEmpty) return null; if (selected == null) return values.first; return values.where((item) => item.id == selected.id).firstOrNull ?? values.first; } void _switchTab(String tab) { if (_tab == tab) return; setState(() => _tab = tab); } void _switchTransferDirection(String direction) { if (_transferDirection == direction) return; setState(() => _transferDirection = direction); } 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 _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, transferDirection: _tab == 'transfer' ? _transferDirection : null, counterparty: _tab == 'transfer' && _counterpartyCtrl.text.trim().isNotEmpty ? _counterpartyCtrl.text.trim() : null, 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 _editNote() async { final value = await showJzTextInputSheet( context, title: '备注', label: '备注内容', initialValue: _noteCtrl.text, maxLength: 40, ); if (value != null) setState(() => _noteCtrl.text = value); } Future _editCounterparty() async { final value = await showJzTextInputSheet( context, title: '转账对方', label: '姓名或备注名', initialValue: _counterpartyCtrl.text, maxLength: 40, ); if (value != null) setState(() => _counterpartyCtrl.text = value); } Future _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 _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( 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(), ), actions: [BackendStatusIcon(onRetry: _loadCategories)], ), body: SafeArea( child: LayoutBuilder( builder: (context, constraints) { final compact = constraints.maxHeight < 650; return Column( children: [ _buildTypeSelector(), if (_tab == 'transfer') ...[ const SizedBox(height: 6), Padding( padding: const EdgeInsets.symmetric(horizontal: 72), child: JzSegmentedControl( value: _transferDirection, options: const [ JzOption(value: 'out', label: '转出'), JzOption(value: 'in', label: '转入'), ], onChanged: _switchTransferDirection, ), ), ], 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) : _tab == 'income' ? const Offset(0.04, 0) : Offset.zero; return FadeTransition( opacity: animation, child: SlideTransition( position: Tween( 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: 300, 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: Alignment( _tab == 'expense' ? -1 : _tab == 'income' ? 1 : 0, 0, ), child: Container( width: constraints.maxWidth / 3, height: constraints.maxHeight, decoration: BoxDecoration( color: _activeColor, borderRadius: BorderRadius.circular(7), ), ), ), Row( children: [ _segment('支出', 'expense'), _segment('转账', 'transfer'), _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[_selectionKey] = 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, ), if (_tab == 'transfer') _metaChip( icon: Icons.person_outline_rounded, label: _counterpartyCtrl.text.isEmpty ? '转账对方' : _counterpartyCtrl.text, onTap: _editCounterparty, ), _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), ), ), ), ); } }