flutter_for_openharmonyFillUp -油耗追踪器app实战+新增加油实现
如果你确实想在新增页强制递增,我更推荐把逻辑放在 controller 层(能拿到同车历史数据),而不是散落在 UI 里。这样你在“新增/编辑/导入”时,只要统一走这套规则,就不会出现同一条数据在不同入口算出来不一样。在个人工具类应用里,我更倾向于这种“路径尽量少”的实现方式:正确率更高,维护成本更低。目前新增页只做了“必须大于 0”的校验,并没有强制里程递增。另一个“新增体验”的关键细节是:新增

这一篇我们把“新增加油”做成真正能落地的表单流程:
- 从记录列表一键进入新增页
- 选择日期、填写里程/油量/单价/加油站/备注
- 支持“本次加满油箱”开关(影响后续油耗统计)
- 表单校验 + 保存到数据库 + 返回列表
1. 入口:从记录列表右下角 + 进入新增页
新增记录最常见的动作是“刚加完油,马上记录”,所以我把入口放在 FillUpsPage 的右下角:
floatingActionButton: FloatingActionButton(
onPressed: () => Get.toNamed('/fillup/add'),
child: const Icon(Icons.add),
),
这段路由跳转非常直接:只要你在 getPages 里声明了 /fillup/add 指向 FillUpEditorPage,就能做到“列表 -> 新增页”的固定路径。
这里有个小习惯:我不在列表页直接拼装
FillUpEntry的默认值,而是让编辑页自己决定默认日期、默认是否加满等,这样入口越简单越不容易出错。
2. 同一个编辑页兼顾“新增/编辑”:先把状态结构搭好
新增页和编辑页我复用同一个页面 FillUpEditorPage,新增时 _editing == null。
class FillUpEditorPage extends StatefulWidget {
const FillUpEditorPage({super.key});
State<FillUpEditorPage> createState() => _FillUpEditorPageState();
}
class _FillUpEditorPageState extends State<FillUpEditorPage> {
DateTime _date = DateTime.now();
bool _isFullTank = true;
FillUpEntry? _editing;
bool _didInitFromArgs = false;
final odometerCtl = TextEditingController();
final litersCtl = TextEditingController();
final priceCtl = TextEditingController();
final stationCtl = TextEditingController();
final noteCtl = TextEditingController();
void initState() {
super.initState();
final arg = Get.arguments;
if (arg is FillUpEntry) {
_editing = arg;
}
}
}
这里我把状态分成三类:
- 日期/是否加满:属于“非文本输入”,直接用变量持有。
- 输入框:用
TextEditingController管理。 - 编辑模式:用
_editing决定是否是编辑;新增时保持null。
这套结构的好处是:新增、编辑、详情跳转过来都能复用同一个页面,长期维护成本更低。
3. 日期选择:用 ListTile + DatePicker 做成“可点击的字段”
我不直接用 TextField 让用户手输日期,而是用一个 ListTile 做成“字段样式”,点击后弹出日期选择器:
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.calendar_month),
title: const Text('日期'),
subtitle: Text(DateFormat('yyyy-MM-dd').format(_date)),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
final picked = await showDatePicker(
context: context,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
initialDate: _date,
);
if (picked != null) setState(() => _date = picked);
},
),
这一段的关键是:
subtitle展示格式化后的日期,用户能明确看到当前值。picked != null才更新,取消选择不会污染原本的数据。
4. 关键字段:里程/油量/单价的输入与校验
三个字段决定了最重要的计算:totalCost = liters * price。因此我在保存前做了一个非常硬的校验:只要其中一个不大于 0,就不允许保存。
FilledButton.icon(
onPressed: () async {
final odo = double.tryParse(odometerCtl.text.trim()) ?? 0;
final liters = double.tryParse(litersCtl.text.trim()) ?? 0;
final price = double.tryParse(priceCtl.text.trim()) ?? 0;
if (odo <= 0 || liters <= 0 || price <= 0) {
Get.snackbar('数据不完整', '里程/油量/单价必须大于 0', snackPosition: SnackPosition.BOTTOM);
return;
}
// ... 继续构建 entry 并保存
},
icon: const Icon(Icons.save),
label: Text(isEdit ? '保存修改' : '保存'),
),
这里我没有做“更复杂的合法性判断”(例如里程必须递增),原因是:
- 首先把 “能用” 做到位;
- 更强的校验可以放在后续文章(例如编辑/导入时的异常处理);
- 但“不能为 0”这条底线必须守住,否则统计图表会被脏数据拖垮。
5. “是否加满”开关:决定它能否参与油耗分段统计
这个开关不是装饰,它会影响后续“油耗趋势”和“加满段算法”。
SwitchListTile(
value: _isFullTank,
onChanged: (v) => setState(() => _isFullTank = v),
title: const Text('本次加满油箱'),
subtitle: const Text('仅“加满”的两次记录之间参与油耗计算'),
),
你可以把它理解成一个“数据质量标记”:
- 加满:我认可这条记录可作为分段边界
- 未加满:仍然记录花费/油量,但它不会作为油耗分段的边界
6. 构建并保存:使用 FillUpEntry.create() 统一计算 totalCost
新增时(isEdit == false),我用 FillUpEntry.create() 统一生成 id、dateMs,并计算 totalCost:
final vehicleId = isEdit ? _editing!.vehicleId : vehicles.activeVehicle?.id;
if (vehicleId == null) {
Get.snackbar('请先添加车辆', '没有车辆无法记录加油', snackPosition: SnackPosition.BOTTOM);
return;
}
final entry = isEdit
? FillUpEntry(
id: _editing!.id,
vehicleId: vehicleId,
dateMs: _date.millisecondsSinceEpoch,
odometer: odo,
liters: liters,
pricePerLiter: price,
totalCost: liters * price,
isFullTank: _isFullTank,
station: stationCtl.text.trim(),
note: noteCtl.text.trim(),
)
: FillUpEntry.create(
vehicleId: vehicleId,
date: _date,
odometer: odo,
liters: liters,
pricePerLiter: price,
isFullTank: _isFullTank,
station: stationCtl.text.trim(),
note: noteCtl.text.trim(),
);
await fillups.addOrUpdate(entry);
Get.back();
这一段里,“新增”的核心点在于:
- vehicleId 必须存在:没有车辆就不允许创建记录。
- 创建逻辑收敛:
FillUpEntry.create()负责 id/totalCost 这些“必须一致的规则”。 - 保存后返回:
Get.back()让用户回到列表,感知到“我刚刚新增了一条”。
7. 为什么我坚持用 FillUpEntry.create():把“必然一致的规则”放到 model 层
做表单时最容易出现的 bug 是:
- A 页面计算
totalCost = liters * price,B 页面也算一次 - 某次改动只改了其中一个页面
- 导致同一条记录在列表/详情/导出里显示不一致
所以我在 model 层提供了一个 FillUpEntry.create(),把生成 id、dateMs、以及 totalCost 的计算统一收敛:
factory FillUpEntry.create({
required String vehicleId,
required DateTime date,
required double odometer,
required double liters,
required double pricePerLiter,
required bool isFullTank,
required String station,
required String note,
}) {
final total = liters * pricePerLiter;
return FillUpEntry(
id: const Uuid().v4(),
vehicleId: vehicleId,
dateMs: date.millisecondsSinceEpoch,
odometer: odometer,
liters: liters,
pricePerLiter: pricePerLiter,
totalCost: total,
isFullTank: isFullTank,
station: station,
note: note,
);
}
这样你在“新增/编辑/导入”时,只要统一走这套规则,就不会出现同一条数据在不同入口算出来不一样。
8. totalCost 为什么要落库:不是为了省计算,而是为了“保证数据不可变的一致性”
有人会问:总价不是 liters * pricePerLiter 吗?为什么还要存一份 total_cost?
从工程角度,我存 totalCost 有两个目的:
- 导入/兼容:如果未来导入 CSV/JSON 时只提供了总价(或存在舍入差异),你仍然可以把它作为事实存下来。
- 展示一致:列表/详情/统计都统一使用
totalCost,不会因为浮点舍入导致“0.01 元”级别的不一致。
这个原则在 toMap() 里也能看出来:
Map<String, Object?> toMap() {
return <String, Object?>{
'id': id,
'vehicle_id': vehicleId,
'date_ms': dateMs,
'odometer': odometer,
'liters': liters,
'price_per_liter': pricePerLiter,
'total_cost': totalCost,
'is_full': isFullTank ? 1 : 0,
'station': station,
'note': note,
};
}
你可以理解为:
liters、pricePerLiter是“输入事实”totalCost是“当时结算结果”(也应视为事实)
9. 新增保存链路:页面只负责组装 entry,controller 负责落库与刷新
在 FillUpEditorPage 里保存时,最后只做两件事:
- 调
fillups.addOrUpdate(entry) Get.back()
await fillups.addOrUpdate(entry);
Get.back();
真正的落库发生在 FillUpsController.addOrUpdate():
Future<void> addOrUpdate(FillUpEntry entry) async {
await FillUpDb.instance.upsertFillUp(entry);
await refreshAll(vehicleId: entry.vehicleId);
}
这一段我认为是“新增流程”的关键收敛点:
- upsert:不关心插入还是更新,统一 replace
- refreshAll:保存后立刻刷新当前车辆的列表数据
所以你从新增页返回列表时,列表不会出现“还需要手动下拉刷新”的体验问题。
10. 数据库层的 upsert:用 ConflictAlgorithm.replace 让新增/编辑共用一条写入路径
controller 调用的 FillUpDb.upsertFillUp() 内部实现也很简单:
Future<void> upsertFillUp(FillUpEntry entry) async {
final db = await _open();
await db.insert('fillups', entry.toMap(), conflictAlgorithm: ConflictAlgorithm.replace);
}
这个设计对“编辑保存”尤其友好:
- 编辑保存时
id不变,所以 insert 会触发 replace - 你不需要写两套 SQL(insert/update)
在个人工具类应用里,我更倾向于这种“路径尽量少”的实现方式:正确率更高,维护成本更低。
11. 表结构与版本升级:is_full 的兼容策略决定了历史数据是否会被“误判”
新增页里我们加入了“本次加满油箱”开关,对应数据库里就是 is_full 字段。
在 FillUpDb._open() 里,表结构(节选)是这样创建的:
await db.execute(
'CREATE TABLE IF NOT EXISTS fillups ('
'id TEXT PRIMARY KEY, '
'vehicle_id TEXT, '
'date_ms INTEGER, '
'odometer REAL, '
'liters REAL, '
'price_per_liter REAL, '
'total_cost REAL, '
'is_full INTEGER, '
'station TEXT, '
'note TEXT'
')',
);
真正的“坑点”在升级逻辑:如果旧版本没有 is_full,升级时必须补字段,并给旧数据一个合理默认值:
onUpgrade: (db, oldVersion, newVersion) async {
if (oldVersion < 2) {
await db.execute('ALTER TABLE fillups ADD COLUMN is_full INTEGER');
await db.execute('UPDATE fillups SET is_full = 1 WHERE is_full IS NULL');
}
},
我把旧数据默认设为 1(加满)的原因是:
- 没有这个字段的历史数据,你很难判断它到底是不是加满
- 但如果默认成 0,会导致历史记录突然“无法参与统计”,用户会认为是 bug
这个默认值策略和我们在 FillUpEntry.fromMap() 里的策略是一致的。
12. 列表默认倒序:为什么新增后你能立刻在顶部看到最新记录
另一个“新增体验”的关键细节是:新增完回列表,用户应该第一眼看到刚刚那条。
项目里列表查询是按 date_ms DESC 返回:
final rows = await db.query(
'fillups',
where: vehicleId == null ? null : 'vehicle_id = ?',
whereArgs: vehicleId == null ? null : <Object?>[vehicleId],
orderBy: 'date_ms DESC',
);
所以在默认排序(date_desc)下,你新增一条记录会立刻排在最上面。
这也是为什么我在新增页保存后直接
Get.back():因为列表页的数据已经刷新,用户回去就能看到结果。
13. 实战补充:里程递增的约束放在哪里更合适?
目前新增页只做了“必须大于 0”的校验,并没有强制里程递增。这是一个刻意的取舍:
- 业务上:有些用户可能录入了历史补账数据,日期/里程不一定严格递增
- 统计上:加满段算法会在遇到
delta <= 0时忽略该段(避免统计污染)
如果你确实想在新增页强制递增,我更推荐把逻辑放在 controller 层(能拿到同车历史数据),而不是散落在 UI 里。
14. 小结:新增流程为什么要做得“短而硬”
我对新增页的目标是:
- 让用户 10 秒内完成一次记录
- 把最低限度的数据质量兜住(关键字段必须 > 0)
- 把“加满”作为强语义字段存下来,为后续统计做准备
下一篇我们会专门讲“编辑加油”:重点是如何从详情页进入编辑、如何预填表单、以及如何保证 id 不变。
文章底部添加社区引导:https://openharmonycrossplatform.csdn.net
更多推荐


所有评论(0)