在这里插入图片描述

这一篇我们把“新增加油”做成真正能落地的表单流程:

  • 从记录列表一键进入新增页
  • 选择日期、填写里程/油量/单价/加油站/备注
  • 支持“本次加满油箱”开关(影响后续油耗统计)
  • 表单校验 + 保存到数据库 + 返回列表

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 有两个目的:

  1. 导入/兼容:如果未来导入 CSV/JSON 时只提供了总价(或存在舍入差异),你仍然可以把它作为事实存下来。
  2. 展示一致:列表/详情/统计都统一使用 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,
  };
}

你可以理解为:

  • literspricePerLiter 是“输入事实”
  • 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

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐