在这里插入图片描述

到目前为止,你已经能新增一条加油记录。
但只要你做过任何“和车相关”的工具类 App,就一定会遇到一个问题:
如果没有“车辆”这个主实体,一切记录都会失去归属。

这一篇我们把 车辆管理 做成一个可长期使用的模块。
它不是单独的一页 UI,它应该同时解决:

  • 数据层:车的数据如何存,如何删,怎么和加油记录建立关系。
  • 状态层:当前选择哪辆车,切换后其他页面如何自动刷新。
  • 页面层:列表、空态、添加、详情、删除这些高频操作。

说明:本文所有代码片段都来自项目文件 lib/app/fillup_app.dart
我会把代码拆成小块,并在每块后面紧跟解释。


1. 路由层:车辆相关页面必须是一组完整闭环

车辆管理至少需要 3 个页面:

  • 车辆列表:作为“管理入口”。
  • 添加车辆:第一次使用时必须能快速创建。
  • 车辆详情:承载“下钻动作”,把车辆当作一个实体来展示。

项目里这些页面的路由在 FillUpApp.getPages 里定义:

getPages: <GetPage<dynamic>>[
  GetPage(name: '/', page: () => const FillUpShell()),
  GetPage(name: '/vehicles', page: () => const VehiclesPage()),
  GetPage(name: '/vehicle/add', page: () => const VehicleEditorPage()),
  GetPage(name: '/vehicle/detail', page: () => const VehicleDetailPage()),
  GetPage(name: '/fillups', page: () => const FillUpsPage()),
  // ...
],

这里我有一个很明确的命名习惯:

  • 管理列表用复数:/vehicles
  • 单体详情用单数:/vehicle/detail

它不是为了“好看”,而是为了后续扩展时更不容易写错:

  • 你要加编辑页时,路径很自然就是 /vehicle/edit
  • 你要加“车辆选择器”时,也更容易把它当作 /vehicles 的子能力。

2. 依赖注入:VehiclesController 必须在启动时就存在

车辆是全局状态。
当你在车辆管理页新增一辆车:

  • 记录页应该立刻从“未选择车辆”变成可用。
  • 新增加油页应该能立刻读到 activeVehicle

所以我在 FillUpBindings 里注入 VehiclesController

class FillUpBindings extends Bindings {
  
  void dependencies() {
    Get.put(FillUpShellController());
    Get.put(FillUpSettingsController());
    Get.put(VehiclesController());
    Get.put(FillUpsController());
  }
}

这段代码决定了一件事:

  • 不管你从哪个页面进入车辆管理,拿到的都是同一个 controller。

它是 GetX 项目里非常关键的工程化细节。


3. Vehicle 模型:最小字段集 + create/fromMap/toMap 三件套

车辆模型在 fillup_app.dart 中定义为:

class Vehicle {
  final String id;
  final String name;
  final String plateNo;
  final int createdAtMs;

  const Vehicle({
    required this.id,
    required this.name,
    required this.plateNo,
    required this.createdAtMs,
  });

  factory Vehicle.create({required String name, required String plateNo}) {
    return Vehicle(
      id: const Uuid().v4(),
      name: name,
      plateNo: plateNo,
      createdAtMs: DateTime.now().millisecondsSinceEpoch,
    );
  }

  factory Vehicle.fromMap(Map<String, Object?> map) {
    return Vehicle(
      id: (map['id'] as String?) ?? '',
      name: (map['name'] as String?) ?? '',
      plateNo: (map['plate_no'] as String?) ?? '',
      createdAtMs: (map['created_at_ms'] as int?) ?? 0,
    );
  }

  Map<String, Object?> toMap() {
    return <String, Object?>{
      'id': id,
      'name': name,
      'plate_no': plateNo,
      'created_at_ms': createdAtMs,
    };
  }
}

这里我只强调两个工程要点:

  • Vehicle.create() 统一生成 id/createdAtMs
  • fromMap() 对缺字段做兜底。

4. vehicles 表结构:字段名要和 toMap/fromMap 严格一致

车辆数据使用 sqflite 落库。
表结构在 FillUpDb._open()onCreate 里创建:

await db.execute(
  'CREATE TABLE IF NOT EXISTS vehicles ('
  'id TEXT PRIMARY KEY, '
  'name TEXT, '
  'plate_no TEXT, '
  'created_at_ms INTEGER'
  ')',
);

你会发现:

  • 数据库列名 plate_no 对应 toMap() 的 key。
  • created_at_ms 对应 createdAtMs

我建议你把这套命名规则固定下来:

  • Dart 字段用 camelCase。
  • SQLite 列名用 snake_case。
  • toMap/fromMap 是唯一的映射层。

5. listVehicles:创建时间倒序,符合“最近添加更重要”的直觉

车辆列表中,用户更可能管理“最近新增的车”。
所以我让查询默认倒序:

Future<List<Vehicle>> listVehicles() async {
  final db = await _open();
  final rows = await db.query('vehicles', orderBy: 'created_at_ms DESC');
  return rows.map(Vehicle.fromMap).toList();
}

这个排序策略带来的体验是:

  • 当用户添加第二辆车时,新车会出现在顶部。
  • 用户不会因为列表很长而找不到刚加的车。

6. upsertVehicle:用 replace 统一新增/编辑写入路径

车辆保存我采用了 upsert 思路:

Future<void> upsertVehicle(Vehicle vehicle) async {
  final db = await _open();
  await db.insert('vehicles', vehicle.toMap(), conflictAlgorithm: ConflictAlgorithm.replace);
}

ConflictAlgorithm.replace 的意义是:

  • 如果 id 已存在,相当于更新。
  • 如果 id 不存在,相当于插入。

这样未来你做“编辑车辆信息”时,不用再补一套 update SQL。


7. deleteVehicle:删除车辆时顺带清理该车的 fillups

车辆是主实体。
记录是从属数据。

如果你只删 vehicles 表的行,不删 fillups 表的记录,就会留下孤儿数据。
它们在 UI 上可能看不见,但会污染统计、导出、容量。

所以数据库层做了级联清理:

Future<void> deleteVehicle(String vehicleId) async {
  final db = await _open();
  await db.delete('vehicles', where: 'id = ?', whereArgs: <Object?>[vehicleId]);
  await db.delete('fillups', where: 'vehicle_id = ?', whereArgs: <Object?>[vehicleId]);
}

这里我希望你记住一个经验:

  • “级联清理”最好放在 DB 层,而不是页面层。

因为页面层太容易漏掉某个入口。
而 DB 层是所有入口共享的唯一真实写入路径。


8. VehiclesController:车辆列表 + 当前选中车辆

controller 中有两份状态:

  • vehicles:全部车辆列表。
  • activeVehicleId:当前选中车辆。

真实实现是:

Future<void> refreshAll() async {
  final list = await FillUpDb.instance.listVehicles();
  vehicles.value = list;
  if (activeVehicleId.value == null && list.isNotEmpty) {
    activeVehicleId.value = list.first.id;
  }
}

Vehicle? get activeVehicle {
  final id = activeVehicleId.value;
  if (id == null) return null;
  return vehicles.firstWhereOrNull((v) => v.id == id);
}

Future<void> remove(String vehicleId) async {
  await FillUpDb.instance.deleteVehicle(vehicleId);
  if (activeVehicleId.value == vehicleId) {
    activeVehicleId.value = null;
  }
  await refreshAll();
}

这段 controller 我盯住三件事就够了:

  • refreshAll() 拉取车辆列表后,必要时自动选中第一辆车。
  • activeVehicle 把“按 id 找车”的细节封装起来。
  • remove() 删除当前车时先把 activeVehicleId 置空,再刷新。

9. VehiclesPage:列表/空态/删除入口

车辆列表页的核心结构是:

  • Obx 监听 ctl.vehicles
  • 空列表时展示 _EmptyState
  • 有数据时用 ListView.separated

真实代码里列表项用了 InkWell + Ink 来做更一致的点击反馈(以下为节选):

body: Obx(() {
  final list = ctl.vehicles;
  if (list.isEmpty) {
    return const _EmptyState(
      title: '暂无车辆',
      subtitle: '点击右下角 + 添加第一辆车',
      icon: Icons.directions_car,
    );
  }
  return ListView.separated(
    padding: EdgeInsets.all(16.w),
    itemCount: list.length,
    separatorBuilder: (_, __) => SizedBox(height: 10.h),
    itemBuilder: (context, i) {
      final v = list[i];
      return InkWell(
        borderRadius: BorderRadius.circular(16.r),
        onTap: () => Get.toNamed('/vehicle/detail', arguments: v),
        child: Ink(
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(16.r),
            border: Border.all(color: Theme.of(context).colorScheme.outlineVariant.withOpacity(0.4)),
          ),
          child: ListTile(
            leading: const Icon(Icons.directions_car),
            title: Text(v.name),
            subtitle: Text(v.plateNo),
            trailing: IconButton(
              icon: const Icon(Icons.delete),
              onPressed: () => ctl.remove(v.id),
            ),
          ),
        ),
      );
    },
  );
}),

这里 UI 只抓两个体验点:

  • 空态给出明确行动提示(去添加第一辆车)。
  • 列表项整体可点,点击反馈一致,进入详情无心理成本。

10. 删除按钮很短,但删除链路要“从 DB 到状态”都兜住

列表里删除按钮的触发只有一行:

onPressed: () => ctl.remove(v.id),

真正的可靠性来自 controller:

  • DB 层会删除 vehicles 表。
  • DB 层会顺带删除 fillups 表中该车的记录。
  • controller 在必要时把 activeVehicleId 置空,并刷新列表。

这就是为什么我说:

  • 页面越薄,越不容易出错。

补充建议:目前这里没有二次确认弹窗。
如果你担心误删,可以在 VehiclesPage 里加一个 showDialog<bool>,再去调用 ctl.remove()
但无论加不加确认,级联删除都应该由 DB 层兜底。


11. VehicleEditorPage:添加车辆表单要做到“输入最少、保存最稳”

添加车辆页面本质上是一个小表单。
项目里的实现非常直接:

onPressed: () async {
  final name = nameCtl.text.trim();
  final plate = plateCtl.text.trim();
  if (name.isEmpty) {
    Get.snackbar('请输入名称', '车辆名称不能为空', snackPosition: SnackPosition.BOTTOM);
    return;
  }
  final v = Vehicle.create(name: name, plateNo: plate);
  await vehicles.addOrUpdate(v);
  Get.back();
},

这里我认为“工具类应用”的表单要满足三个点:

  • 必填尽量少:我只强制 name。
  • 保存反馈明确:name 为空直接 snackbar。
  • 保存后马上返回:减少用户路径。

另外别忽略 dispose():只要你用了 TextEditingController,就应该释放。


12. 新增车辆后为什么能立刻在车辆列表看到:addOrUpdate 内部 refreshAll

页面里保存后只有一句:

await vehicles.addOrUpdate(v);

controller 的实现是:

Future<void> addOrUpdate(Vehicle v) async {
  await FillUpDb.instance.upsertVehicle(v);
  await refreshAll();
}

返回 VehiclesPage 后,Obx 会因为 vehicles.value 更新而自动 rebuild,你不需要再额外做“手动刷新列表”的动作。


13. VehicleDetailPage:车辆详情不是“摆设”,它是下钻入口

我把车辆详情页当作“以车为中心的功能入口”。

真实代码如下:

final vehicle = Get.arguments as Vehicle?;
if (vehicle == null) {
  return const Scaffold(body: Center(child: Text('无车辆数据')));
}

FilledButton.tonalIcon(
  onPressed: () {
    Get.toNamed('/fillups', arguments: vehicle);
  },
  icon: const Icon(Icons.local_gas_station),
  label: const Text('查看该车加油记录'),
),

FilledButton.tonalIcon(
  onPressed: () {
    Get.toNamed('/feature/maintenance_plan', arguments: vehicle);
  },
  icon: const Icon(Icons.build),
  label: const Text('维护计划'),
),

14. 最终小结:车辆管理模块的“闭环标准”

这一篇我们把车辆管理做成了一个闭环:

  • 路由层:/vehicles/vehicle/add/vehicle/detail
  • 数据层:vehicles 表 + upsert/list/delete + 删除时清理 fillups。
  • 状态层:VehiclesController 统一维护车辆列表与当前车辆。
  • 页面层:列表空态、添加表单、详情下钻。

下一篇我们继续做第 07 篇:添加车辆的交互细节与可扩展点。


文章底部添加社区引导:https://openharmonycrossplatform.csdn.net

Logo

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

更多推荐