
养猫的开销不小,猫粮、猫砂、玩具、医疗...每一笔都值得记录。今天我们来实现添加支出记录的功能,帮助铲屎官们管理养猫的花费。
功能规划
添加支出页面需要支持:
- 选择支出类别
- 输入支出名称和金额
- 选择日期
- 关联猫咪(可选)
- 添加备注
这些功能组合起来,就能完整记录每一笔养猫支出。
依赖引入
导入需要的包:
dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:intl/intl.dart';
import '../../providers/cat_provider.dart';
import '../../models/expense_record.dart';
Provider管理数据状态,screenutil处理屏幕适配。
intl用于日期格式化。
有状态组件
支出页面需要维护表单状态:
dart
class AddExpenseScreen extends StatefulWidget {
const AddExpenseScreen({super.key});
@override
State<AddExpenseScreen> createState() => _AddExpenseScreenState();
}
不需要传入catId,可以在页面内选择。
StatefulWidget用于管理输入状态。
状态变量定义
State类中声明变量:
dart
class _AddExpenseScreenState extends State<AddExpenseScreen> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _amountController = TextEditingController();
final _notesController = TextEditingController();
ExpenseCategory _category = ExpenseCategory.food;
DateTime _date = DateTime.now();
String? _selectedCatId;
默认类别是食品,日期是今天。
_selectedCatId为null表示不关联猫咪。
资源清理
dispose中释放控制器:
dart
@override
void dispose() {
_titleController.dispose();
_amountController.dispose();
_notesController.dispose();
super.dispose();
}
三个TextEditingController都要释放。
这是Flutter开发的基本规范。
页面结构
build方法构建UI:
dart
@override
Widget build(BuildContext context) {
final cats = context.watch<CatProvider>().cats;
return Scaffold(
appBar: AppBar(title: const Text('添加支出')),
body: Form(
key: _formKey,
child: SingleChildScrollView(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
context.watch监听猫咪列表变化。
Form包裹表单用于统一验证。
类别选择
用ChoiceChip展示类别选项:
dart
Text('支出类别', style: TextStyle(fontSize: 14.sp, fontWeight: FontWeight.w500)),
SizedBox(height: 8.h),
Wrap(
spacing: 8.w,
runSpacing: 8.h,
children: ExpenseCategory.values.map((cat) {
return ChoiceChip(
label: Text(_getCategoryString(cat)),
selected: _category == cat,
onSelected: (selected) {
if (selected) setState(() => _category = cat);
},
selectedColor: Colors.orange[100],
);
}).toList(),
),
SizedBox(height: 16.h),
Wrap让Chip自动换行排列。
选中的Chip用橙色背景突出显示。
名称输入
必填的支出名称:
dart
TextFormField(
controller: _titleController,
decoration: const InputDecoration(
labelText: '支出名称 *',
hintText: '如:猫粮',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.title),
),
validator: (value) => value?.isEmpty ?? true ? '请输入名称' : null,
),
SizedBox(height: 16.h),
hintText给出输入示例。
validator验证非空。
金额输入
带货币符号的金额输入:
dart
TextFormField(
controller: _amountController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '金额 *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.attach_money),
prefixText: '¥ ',
),
validator: (value) {
if (value?.isEmpty ?? true) return '请输入金额';
if (double.tryParse(value!) == null) return '请输入有效金额';
return null;
},
),
SizedBox(height: 16.h),
keyboardType设为number弹出数字键盘。
prefixText显示人民币符号。
日期选择
点击选择支出日期:
dart
InkWell(
onTap: () => _selectDate(context),
child: InputDecorator(
decoration: const InputDecoration(
labelText: '日期',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.calendar_today),
),
child: Text(DateFormat('yyyy-MM-dd').format(_date)),
),
),
SizedBox(height: 16.h),
InputDecorator让显示样式与输入框一致。
点击整个区域触发日期选择。
猫咪关联
下拉选择关联的猫咪:
dart
if (cats.isNotEmpty) ...[
DropdownButtonFormField<String?>(
value: _selectedCatId,
decoration: const InputDecoration(
labelText: '关联猫咪 (选填)',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.pets),
),
items: [
const DropdownMenuItem(value: null, child: Text('不关联')),
...cats.map((cat) => DropdownMenuItem(value: cat.id, child: Text(cat.name))),
],
onChanged: (value) => setState(() => _selectedCatId = value),
),
SizedBox(height: 16.h),
],
只有当有猫咪时才显示这个选项。
可以选择不关联任何猫咪。
备注输入
可选的备注字段:
dart
TextFormField(
controller: _notesController,
maxLines: 2,
decoration: const InputDecoration(
labelText: '备注 (选填)',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.note),
),
),
SizedBox(height: 32.h),
maxLines: 2让输入框有两行高度。
备注是选填的,不需要验证。
保存按钮
底部的提交按钮:
dart
SizedBox(
width: double.infinity,
height: 48.h,
child: ElevatedButton(
onPressed: _saveExpense,
child: const Text('保存'),
),
),
],
),
),
),
);
}
按钮撑满宽度,视觉上更突出。
点击触发保存逻辑。
类别转换方法
枚举转中文:
dart
String _getCategoryString(ExpenseCategory category) {
switch (category) {
case ExpenseCategory.food: return '食品';
case ExpenseCategory.medical: return '医疗';
case ExpenseCategory.grooming: return '美容';
case ExpenseCategory.toys: return '玩具';
case ExpenseCategory.supplies: return '用品';
case ExpenseCategory.other: return '其他';
}
}
switch语句处理每种类别的显示文本。
六种类别覆盖常见的养猫支出。
日期选择方法
弹出日期选择器:
dart
Future<void> _selectDate(BuildContext context) async {
final picked = await showDatePicker(
context: context,
initialDate: _date,
firstDate: DateTime(2020),
lastDate: DateTime.now(),
);
if (picked != null) setState(() => _date = picked);
}
lastDate设为今天,不能选择未来的日期。
选择后更新状态刷新UI。
保存支出逻辑
验证并保存数据:
dart
void _saveExpense() {
if (_formKey.currentState!.validate()) {
final record = ExpenseRecord(
catId: _selectedCatId,
category: _category,
title: _titleController.text,
amount: double.parse(_amountController.text),
date: _date,
notes: _notesController.text.isEmpty ? null : _notesController.text,
);
context.read<CatProvider>().addExpenseRecord(record);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('支出记录添加成功!')),
);
}
}
}
validate触发表单验证,通过后才保存。
SnackBar给用户一个成功的反馈。
数据模型
ExpenseRecord的结构:
dart
class ExpenseRecord {
final String id;
final String? catId;
final ExpenseCategory category;
final String title;
final double amount;
final DateTime date;
final String? notes;
}
catId和notes是可选字段。
amount用double存储金额。
类别枚举:
dart
enum ExpenseCategory {
food,
medical,
grooming,
toys,
supplies,
other,
}
六种类别覆盖养猫的主要支出。
枚举比字符串更类型安全。
金额验证详解
两步验证逻辑:
dart
validator: (value) {
if (value?.isEmpty ?? true) return '请输入金额';
if (double.tryParse(value!) == null) return '请输入有效金额';
return null;
},
先检查是否为空。
再检查是否是有效的数字。
tryParse的作用:
dart
double.tryParse(value!)
尝试将字符串转为double。
转换失败返回null,不会抛异常。
InputDecoration详解
输入框装饰的各个属性:
dart
InputDecoration(
labelText: '金额 *', // 标签文字
border: OutlineInputBorder(), // 边框样式
prefixIcon: Icon(...), // 前缀图标
prefixText: '¥ ', // 前缀文字
hintText: '如:猫粮', // 提示文字
)
labelText在输入时会浮动到上方。
prefixText显示在输入内容前面。
ChoiceChip使用
选择类型的Chip:
dart
ChoiceChip(
label: Text(_getCategoryString(cat)),
selected: _category == cat,
onSelected: (selected) {
if (selected) setState(() => _category = cat);
},
selectedColor: Colors.orange[100],
)
selected控制是否选中状态。
onSelected在点击时触发。
Wrap布局
让子组件自动换行:
dart
Wrap(
spacing: 8.w, // 水平间距
runSpacing: 8.h, // 垂直间距
children: [...],
)
spacing是同一行内的间距。
runSpacing是行与行之间的间距。
条件渲染
只在有猫咪时显示选择框:
dart
if (cats.isNotEmpty) ...[
DropdownButtonFormField(...),
SizedBox(height: 16.h),
],
展开运算符...让条件渲染更简洁。
没有猫咪时这部分不会渲染。
Provider读写
读取猫咪列表:
dart
final cats = context.watch<CatProvider>().cats;
watch会在数据变化时触发重建。
保存支出记录:
dart
context.read<CatProvider>().addExpenseRecord(record);
read不会触发重建,适合在回调中使用。
表单验证流程
触发验证:
dart
if (_formKey.currentState!.validate()) {
// 验证通过,执行保存
}
validate方法会触发所有字段的验证。
只有全部通过才返回true。
小结
添加支出页面涵盖了这些知识点:
- 表单验证和数据保存
- ChoiceChip类别选择
- 金额输入和验证
- 日期选择器使用
这些技巧在其他表单页面也能复用,是Flutter开发的基础技能。
欢迎加入OpenHarmony跨平台开发社区,一起交流Flutter开发经验: