Files
Aq-Accounting-Flutter/lib/pages/add/widgets/manual_entry_tab.dart
T
2026-09-21 17:37:53 +08:00

248 lines
8.8 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/api_exception.dart';
import '../../../providers/add_bill_provider.dart';
import '../../../providers/category_provider.dart';
import '../../../utils/amount_input_formatter.dart';
import '../../../utils/date_util.dart';
import '../../../utils/toast.dart';
import '../../../widgets/category_picker_grid.dart';
import '../../../widgets/state_views.dart';
/// 手动记账表单(复刻 uniApp add.vue 手动 Tab)
class ManualEntryTab extends ConsumerStatefulWidget {
const ManualEntryTab({super.key});
@override
ConsumerState<ManualEntryTab> createState() => _ManualEntryTabState();
}
class _ManualEntryTabState extends ConsumerState<ManualEntryTab> {
late final TextEditingController _descriptionController = TextEditingController();
late final TextEditingController _amountController = TextEditingController();
@override
void dispose() {
_descriptionController.dispose();
_amountController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final form = ref.watch(addBillFormProvider);
final notifier = ref.read(addBillFormProvider.notifier);
// OCR「选择此条」外部填充时,同步非受控输入框
ref.listen<int>(addBillFormProvider.select((s) => s.revision), (prev, next) {
if (prev == next) return;
final s = ref.read(addBillFormProvider);
_amountController.text = s.amount?.toStringAsFixed(2) ?? '';
_descriptionController.text = s.description;
});
return ListView(
padding: const EdgeInsets.all(16),
children: [
_sectionLabel('账单类型'),
_typeSelector(form, notifier),
const SizedBox(height: 16),
_sectionLabel('分类'),
_categoryGrid(form, notifier),
const SizedBox(height: 16),
_sectionLabel('金额'),
_amountField(form, notifier),
const SizedBox(height: 16),
_sectionLabel('日期'),
_dateField(context, form, notifier),
const SizedBox(height: 16),
_sectionLabel('备注'),
_descriptionField(form, notifier),
const SizedBox(height: 24),
_submitBtn(context, ref, form, notifier),
const SizedBox(height: 90), // 底部导航栏避让
],
);
}
Widget _sectionLabel(String text) => Padding(
padding: const EdgeInsets.only(bottom: 8, left: 4),
child: Text(
text,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037)),
),
);
// ---- 类型选择 ----
Widget _typeSelector(AddBillFormState form, AddBillFormNotifier notifier) {
return Row(
children: [
_typeCard('📤', '支出', 1, form, notifier),
const SizedBox(width: 10),
_typeCard('📥', '收入', 2, form, notifier),
],
);
}
Widget _typeCard(String icon, String label, int type, AddBillFormState form, AddBillFormNotifier notifier) {
final active = form.type == type;
return Expanded(
child: GestureDetector(
onTap: () => notifier.setType(type),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: active ? Colors.white : Colors.white.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: active ? const Color(0xFFC8956E) : Colors.transparent,
width: 2,
),
boxShadow: active
? [BoxShadow(color: const Color(0x33C8956E), blurRadius: 8, offset: const Offset(0, 3))]
: null,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(icon, style: const TextStyle(fontSize: 20)),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: active ? const Color(0xFF5D4037) : const Color(0xFF8D6E63),
),
),
],
),
),
),
);
}
// ---- 分类宫格 ----
Widget _categoryGrid(AddBillFormState form, AddBillFormNotifier notifier) {
return Consumer(builder: (context, ref, _) {
final categoriesAsync = ref.watch(categoriesProvider(form.type));
return categoriesAsync.when(
loading: () => const LoadingView(padding: EdgeInsets.all(20)),
error: (e, _) => ErrorView(
message: '分类加载失败:${asApiException(e).message}',
onRetry: () => ref.invalidate(categoriesProvider),
),
data: (categories) => CategoryPickerGrid(
categories: categories,
selectedId: form.categoryId,
onSelect: notifier.setCategory,
crossAxisCount: 5,
),
);
});
}
// ---- 金额 ----
Widget _amountField(AddBillFormState form, AddBillFormNotifier notifier) {
return TextField(
controller: _amountController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [AmountInputFormatter()],
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Color(0xFF5D4037)),
decoration: InputDecoration(
hintText: '请输入金额',
prefixText: '¥ ',
prefixStyle: const TextStyle(fontSize: 18, color: Color(0xFFC8956E), fontWeight: FontWeight.bold),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(14), borderSide: BorderSide.none),
),
onChanged: (v) => notifier.setAmount(double.tryParse(v)),
);
}
// ---- 日期 ----
Widget _dateField(BuildContext context, AddBillFormState form, AddBillFormNotifier notifier) {
return GestureDetector(
onTap: () async {
final initial = DateTime.tryParse(form.billDate) ?? DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) notifier.setDate(formatDate(picked));
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
Text(form.billDate, style: const TextStyle(fontSize: 15, color: Color(0xFF5D4037))),
const Spacer(),
const Icon(Icons.chevron_right, color: Color(0xFF8D6E63)),
],
),
),
);
}
// ---- 备注 ----
Widget _descriptionField(AddBillFormState form, AddBillFormNotifier notifier) {
return TextField(
maxLines: 3,
maxLength: 200,
controller: _descriptionController,
style: const TextStyle(fontSize: 15, color: Color(0xFF5D4037)),
decoration: InputDecoration(
hintText: '请输入备注(可选)',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(14), borderSide: BorderSide.none),
),
onChanged: notifier.setDescription,
);
}
// ---- 保存 ----
Widget _submitBtn(BuildContext context, WidgetRef ref, AddBillFormState form, AddBillFormNotifier notifier) {
return SizedBox(
width: double.infinity,
height: 50,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFC8956E),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(25)),
),
onPressed: form.submitting
? null
: () async {
try {
await notifier.submit();
_amountController.clear();
_descriptionController.clear();
if (context.mounted) showToast(context, '✅ 记账成功');
} on ApiException catch (e) {
if (context.mounted) showToast(context, e.message, error: true);
} catch (_) {
if (context.mounted) showToast(context, '保存失败,请重试', error: true);
}
},
child: form.submitting
? const SizedBox(
width: 22, height: 22,
child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white),
)
: const Text('保 存', style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold, letterSpacing: 4)),
),
);
}
}