flutter_for_openharmonyFillUp -油耗追踪器app实战+记录列表实现
这一篇我们把说明:本文所有代码均来自项目文件。为了保证阅读体验,我只截取,并在每段代码后紧跟解释。

这一篇我们把 “记录列表” 做成一个真正能高频使用的页面:
- 进入记录页能看到当前车辆的加油记录
- 支持“仅看加满”一键筛选(影响列表展示)
- 支持跳转到筛选/排序面板(影响列表展示)
- 支持从列表进入详情页,形成“列表 -> 详情 -> 编辑/删除”闭环
说明:本文所有代码均来自项目文件 lib/app/fillup_app.dart。为了保证阅读体验,我只截取必要的真实片段,并在每段代码后紧跟解释。
1. 记录页在哪里:Tab 里的 LogTab
记录页并不是单独的页面入口,它挂在底部四个 Tab 中的第二个 Tab。
class LogTab extends StatelessWidget {
const LogTab({super.key});
Widget build(BuildContext context) {
return const FillUpsPage();
}
}
这里我只保留了一个非常薄的 LogTab,让真正的业务页面收敛到 FillUpsPage。这样后续如果要把“记录页”变成带二级导航(例如 日历/列表 两种视图),也更容易扩展。
2. 路由约定:列表页、详情页、编辑页的串联
列表页只是入口之一,更重要的是它要能顺滑地串起新增、详情、编辑。
项目里通过 GetX 的 getPages 统一声明路由:
getPages: <GetPage<dynamic>>[
GetPage(name: '/fillups', page: () => const FillUpsPage()),
GetPage(name: '/fillup/add', page: () => const FillUpEditorPage()),
GetPage(name: '/fillup/edit', page: () => const FillUpEditorPage()),
GetPage(name: '/fillup/detail', page: () => const FillUpDetailPage()),
],
我刻意把“新增”和“编辑”都指向同一个 FillUpEditorPage,再通过 Get.arguments 区分编辑模式。这样 UI/校验/保存逻辑不需要重复写两套。
3. 列表数据从哪来:VehiclesController + FillUpsController
记录列表一定要有一个“当前车辆”的概念:同一个人可能记录多辆车(家庭用车、公司车等)。
在 FillUpsPage 里,页面直接读取两个 controller:
Widget build(BuildContext context) {
final vehicles = Get.find<VehiclesController>();
final fillups = Get.find<FillUpsController>();
// ...
}
VehiclesController 提供“当前车辆”,FillUpsController 负责“记录数据 + 展示策略(筛选/排序)”。这会让页面逻辑保持清爽:
- 页面关注“怎么展示”
- controller 关注“怎么组织数据、怎么刷新”
4. AppBar 上的“仅看加满”筛选:一键影响列表
实际用起来,“仅看加满”非常关键:
- 统计油耗/分段逻辑只认可“加满段”
- 用户经常想快速定位那些“可参与计算”的记录
对应实现是在 FillUpsPage 的 AppBar actions 里用 Obx 绑定:
appBar: AppBar(
title: const Text('加油记录'),
actions: <Widget>[
Obx(() {
final onlyFull = fillups.onlyFullTank.value;
return IconButton(
tooltip: onlyFull ? '仅看加满' : '查看全部',
icon: Icon(onlyFull ? Icons.local_gas_station : Icons.filter_alt_off),
onPressed: () {
fillups.onlyFullTank.value = !onlyFull;
Get.snackbar(
'筛选已更新',
fillups.onlyFullTank.value ? '仅显示“加满”记录' : '显示全部记录',
snackPosition: SnackPosition.BOTTOM,
);
},
);
}),
IconButton(
icon: const Icon(Icons.tune),
onPressed: () => Get.toNamed('/feature/filter_sort'),
),
],
),
这里有两个细节我很喜欢:
- 视觉状态明确:图标会随着
onlyFullTank的变化而变化,用户一眼能知道当前处于“仅看加满”还是“全部”。 - 交互有反馈:每次切换都用
Get.snackbar给出结果提示,避免用户怀疑“点了没反应”。
另外,/feature/filter_sort 这条路由会进入“筛选/排序”面板(我们后续文章会专门展开),它也会影响 FillUpsController.displayEntries() 的返回顺序。
5. 列表主体:根据当前车辆 + 展示策略得到最终列表
页面 body 同样用 Obx 监听变化:车辆切换、记录新增/删除、筛选/排序改变,都能触发刷新。
body: Obx(() {
final v = vehicles.activeVehicle;
final list = v == null ? <FillUpEntry>[] : fillups.displayEntries(v.id);
if (v == null) {
return const _EmptyState(
title: '未选择车辆',
subtitle: '先去“车辆管理”添加/选择车辆',
icon: Icons.directions_car,
);
}
if (list.isEmpty) {
return const _EmptyState(
title: '暂无加油记录',
subtitle: '点击右下角 + 新增第一条记录(或取消筛选)',
icon: Icons.local_gas_station,
);
}
return ListView.separated(
padding: EdgeInsets.all(16.w),
itemCount: list.length,
separatorBuilder: (_, __) => SizedBox(height: 10.h),
itemBuilder: (context, index) {
final e = list[index];
return _FillUpTile(
entry: e,
onTap: () => Get.toNamed('/fillup/detail', arguments: e),
);
},
);
}),
这段代码把“页面状态”拆成了三种:
- 未选车辆:提示先去车辆管理
- 有车辆但无记录:提示新增记录或取消筛选
- 有记录:正常渲染列表
我会优先把“空状态”写得清晰,这是移动端体验里非常重要的一点:用户不会面对空白屏。
6. 展示策略:displayEntries() 负责筛选 + 排序
页面里并没有直接操作 fillups.fillups,而是通过 displayEntries(vehicleId) 得到“可展示”的列表。
List<FillUpEntry> displayEntries(String vehicleId) {
final list = fillups.where((e) => e.vehicleId == vehicleId).toList();
if (onlyFullTank.value) {
list.removeWhere((e) => !e.isFullTank);
}
int cmpNum(num a, num b) => a == b ? 0 : (a < b ? -1 : 1);
switch (sortMode.value) {
case 'date_asc':
list.sort((a, b) => a.dateMs.compareTo(b.dateMs));
break;
case 'cost_desc':
list.sort((a, b) => cmpNum(b.totalCost, a.totalCost));
break;
case 'liters_desc':
list.sort((a, b) => cmpNum(b.liters, a.liters));
break;
case 'odo_desc':
list.sort((a, b) => cmpNum(b.odometer, a.odometer));
break;
case 'date_desc':
default:
list.sort((a, b) => b.dateMs.compareTo(a.dateMs));
break;
}
return list;
}
这里的设计点是:
- 筛选在前:先过滤掉无关数据,再排序更省成本。
- 排序模式可扩展:
sortMode是字符串枚举,后续添加新的排序方式不需要改页面。
你会注意到:我在这里没有做“数据库层面 ORDER BY”,而是把排序留在内存里做。对这个体量的加油记录(通常几十到几百条),内存排序足够快,并且实现简单。
7. 列表项怎么做得“信息密度高但不乱”:_FillUpTile
记录列表最怕两件事:
- 信息太少:用户得点进详情才知道关键数据
- 信息太多:列表一屏只能看到两条
我用 _FillUpTile 做了一种“可快速扫读”的布局:日期 + 油量在第一行,金额 + 里程在第二行,右侧用“加满/未加满”tag 强调。
class _FillUpTile extends StatelessWidget {
final FillUpEntry entry;
final VoidCallback onTap;
const _FillUpTile({required this.entry, required this.onTap});
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final date = DateFormat('MM-dd').format(DateTime.fromMillisecondsSinceEpoch(entry.dateMs));
final tagBg = entry.isFullTank ? cs.primaryContainer : cs.surfaceContainerHighest;
final tagFg = entry.isFullTank ? cs.onPrimaryContainer : cs.onSurfaceVariant;
final tagText = entry.isFullTank ? '加满' : '未加满';
return InkWell(
borderRadius: BorderRadius.circular(16.r),
onTap: onTap,
child: Ink(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16.r),
color: cs.surface,
border: Border.all(color: cs.outlineVariant.withOpacity(0.4)),
),
child: Padding(
padding: EdgeInsets.all(12.w),
child: Row(
children: <Widget>[
CircleAvatar(
backgroundColor: cs.secondaryContainer,
child: Icon(Icons.local_gas_station, color: cs.onSecondaryContainer),
),
SizedBox(width: 12.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: Text(
'$date ${entry.liters.toStringAsFixed(2)} L',
style: Theme.of(context).textTheme.titleSmall,
),
),
Container(
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h),
decoration: BoxDecoration(
color: tagBg,
borderRadius: BorderRadius.circular(999),
),
child: Text(
tagText,
style: Theme.of(context).textTheme.labelSmall?.copyWith(color: tagFg),
),
),
],
),
SizedBox(height: 4.h),
Text(
'¥${entry.totalCost.toStringAsFixed(2)} • ${entry.odometer.toStringAsFixed(1)} km',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
),
if (entry.station.isNotEmpty) ...<Widget>[
SizedBox(height: 2.h),
Text(
entry.station,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
),
],
],
),
),
Icon(Icons.chevron_right, color: cs.onSurfaceVariant),
],
),
),
),
);
}
}
这段 tile 里还有几个我会刻意保留的“体验点”:
- tag 用主题色容器色:
primaryContainer能保证在浅色/深色主题里都可读。 - 加油站可选显示:有填就展示一行,没有填就不占空间。
- InkWell + Ink:保证点击水波纹和圆角一致。
8. 一条记录的数据结构:FillUpEntry 里哪些字段是“列表必需”
记录列表的显示,其实完全依赖 FillUpEntry 这个数据结构。你会发现列表里展示的每一项信息(日期、油量、总价、里程、加满标签、加油站)都能直接从这个对象拿到。
FillUpEntry 的核心字段(节选)如下:
class FillUpEntry {
final int dateMs;
final double odometer;
final double liters;
final double pricePerLiter;
final double totalCost;
final bool isFullTank;
final String station;
final String note;
}
我建议你在写 UI 之前先把“列表需要展示什么”想清楚,然后对照模型看是否都有字段承载。
比如:
- 日期:列表里展示
MM-dd,但保存时用dateMs这种整数更适合持久化。 - 总价:列表里展示的是
totalCost而不是liters * pricePerLiter的临时计算,这样可以保证导入/编辑后逻辑一致。
9. “加满”默认值的细节:fromMap 里把缺省当作加满
很多人第一次做“旧数据兼容”会踩坑:当你后来才新增 is_full 字段时,旧数据里是没有这个字段的。
项目里在 FillUpEntry.fromMap() 做了一个很实用的兼容策略(节选):
factory FillUpEntry.fromMap(Map<String, Object?> map) {
final rawFull = map['is_full'];
final isFull = rawFull == null ? true : ((rawFull as int?) ?? 1) == 1;
return FillUpEntry(
// ...
isFullTank: isFull,
);
}
解释一下这里的“默认加满”选择:
- rawFull 为 null 直接当作 true:避免升级版本后旧记录全部变成“未加满”,影响统计。
- 使用
int存布尔:SQLite 里常见做法,1/0更直观。
这类兼容策略写在 model 层,一次到位,页面和 controller 就都能受益。
10. 排序模式:用 sortMode + setSortMode 把列表策略收敛
你在 displayEntries() 里看到的排序,依赖于一个可响应的 sortMode:
final RxString sortMode = 'date_desc'.obs;
void setSortMode(String mode) {
sortMode.value = mode;
}
我更喜欢把“排序模式”作为 controller 的公开状态,而不是把排序逻辑散落在页面里,原因是:
- 可复用:同一个排序策略可以同时影响“记录列表”和“概览页最近记录”。
- 可持久化:未来你想把 sortMode 存到
SharedPreferences,也更容易做。
/feature/filter_sort 进入的设置页,本质上就是在更改 onlyFullTank 和 sortMode 这两组状态。
11. 新增入口:FloatingActionButton
列表页右下角的 + 入口用于新增加油记录:
floatingActionButton: FloatingActionButton(
onPressed: () => Get.toNamed('/fillup/add'),
child: const Icon(Icons.add),
),
我更喜欢把新增入口放在列表页而不是概览页:因为大多数用户的操作路径是“我刚加完油 -> 直接记一条”,列表页点击成本最低。
小结
到这里,我们已经把记录列表的“日常使用路径”打通:
FillUpsPage根据“当前车辆”展示数据FillUpsController.displayEntries()统一承载筛选/排序策略_FillUpTile提供高信息密度的列表项- 点击列表项进入详情,为后续编辑/删除埋好入口
下一篇我们会开始做“新增加油”,重点会放在:表单校验、日期选择、以及如何把数据保存到数据库。
文章底部添加社区引导:https://openharmonycrossplatform.csdn.net
更多推荐
所有评论(0)