flutter_for_openharmonyFillUp -油耗追踪器app实战+记录详情实现
这一篇我们把“记录详情页”做成一个完整的闭环节点。说明:本文所有代码均来自项目文件,我只选取必要片段并紧跟解释。

当你能“新增”和“列表展示”之后,用户下一步一定会做两件事:
- 点开某条记录确认细节
- 在详情页里执行动作:编辑 / 删除 / 分享
这一篇我们把“记录详情页”做成一个完整的闭环节点。
说明:本文所有代码均来自项目文件 lib/app/fillup_app.dart,我只选取必要片段并紧跟解释。
1. 详情页的数据来源:直接接收列表传来的 FillUpEntry
列表页点击某条记录时,会把 FillUpEntry 作为 arguments 传入详情页。详情页开头就做一次兜底:
Widget build(BuildContext context) {
final e = Get.arguments as FillUpEntry?;
if (e == null) {
return const Scaffold(body: Center(child: Text('无记录数据')));
}
final fillups = Get.find<FillUpsController>();
final fmt = DateFormat('yyyy-MM-dd');
return Scaffold(
// ...
);
}
这里的设计取舍很明确:
- 详情页不做二次查库,减少一次 IO
- 如果
arguments丢失(例如错误跳转),页面也不会崩
2. AppBar 的三个高频动作:删除 / 编辑 / 分享
详情页右上角我放了三个 IconButton:
appBar: AppBar(
title: const Text('记录详情'),
actions: <Widget>[
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () async {
final ok = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('确认删除?'),
content: const Text('删除后不可恢复。'),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('删除'),
),
],
);
},
);
if (ok != true) return;
await fillups.remove(e);
Get.back();
Get.snackbar('已删除', '加油记录已删除', snackPosition: SnackPosition.BOTTOM);
},
),
IconButton(
icon: const Icon(Icons.edit),
onPressed: () => Get.toNamed('/fillup/edit', arguments: e),
),
IconButton(
icon: const Icon(Icons.share),
onPressed: () {
Share.share('加油记录:${fmt.format(DateTime.fromMillisecondsSinceEpoch(e.dateMs))},${e.liters}L,${e.totalCost.toStringAsFixed(2)}');
},
),
],
),
这块我重点想强调“删除”的处理:
- 必须二次确认:误删的成本太高。
- 先删再返回:
await fillups.remove(e)成功后再Get.back(),避免页面回去了但数据没删成。 - 删除后提示:
Get.snackbar给用户一个明确的结果反馈。
3. 删除为什么能“自动刷新列表/统计”:因为 controller 保存后会 refresh
删除动作最终调用的是 controller:
Future<void> remove(FillUpEntry entry) async {
await FillUpDb.instance.deleteFillUp(entry.id);
await refreshAll(vehicleId: entry.vehicleId);
}
所以你会发现:
- 记录列表页用
Obx监听 controller 数据,删除后会自动刷新 - 统计页同样依赖
FillUpsController,只要重新进入或触发 rebuild,就会读到新的数据
这就是我一直强调“页面尽量不直接操作数据库”的原因:把刷新策略收敛到 controller,全局体验会更稳定。
4. 详情字段展示:用 _KVRow 做成对齐的“字段列表”
详情页的主体我用一个卡片来展示字段:
body: ListView(
padding: EdgeInsets.all(16.w),
children: <Widget>[
Card(
child: Padding(
padding: EdgeInsets.all(12.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_KVRow(k: '日期', v: fmt.format(DateTime.fromMillisecondsSinceEpoch(e.dateMs))),
_KVRow(k: '里程表', v: e.odometer.toStringAsFixed(1)),
_KVRow(k: '油量', v: e.liters.toStringAsFixed(2)),
_KVRow(k: '单价', v: e.pricePerLiter.toStringAsFixed(2)),
_KVRow(k: '总价', v: e.totalCost.toStringAsFixed(2)),
_KVRow(k: '是否加满', v: e.isFullTank ? '是' : '否'),
_KVRow(k: '加油站', v: e.station.isEmpty ? '--' : e.station),
_KVRow(k: '备注', v: e.note.isEmpty ? '--' : e.note),
],
),
),
),
],
),
我用 _KVRow 做对齐的原因是:
- 字段很多时依然能保持“扫一眼就懂”的结构
--作为空值占位,避免字段缺失时 UI 跳动
5. 分享文本:先保证“能用”,再考虑更精美的分享卡片
详情页里我用 share_plus 直接分享一段简洁文本:
Share.share('加油记录:${fmt.format(DateTime.fromMillisecondsSinceEpoch(e.dateMs))},${e.liters}L,${e.totalCost.toStringAsFixed(2)}');
这一步的目标是:
- 用户可以快速把“这次加了多少、花了多少”发给朋友/同事
- 后续如果要升级成“截图分享”或“导出文件分享”,也可以在 FeaturePage 里扩展
6. 日期格式化:为什么详情页用 yyyy-MM-dd,而列表用 MM-dd
同一份数据,在不同页面需要不同粒度的日期展示:
- 列表页更强调“扫一眼”,用
MM-dd更紧凑 - 详情页更强调“可确认”,用
yyyy-MM-dd更明确
所以详情页在 build() 里先准备一个 formatter:
final fmt = DateFormat('yyyy-MM-dd');
并且在多个地方复用它:
fmt.format(DateTime.fromMillisecondsSinceEpoch(e.dateMs))
这比在每个 _KVRow 里临时 new 一个 DateFormat 更干净,也避免了重复代码。
7. 详情页字段展示为什么用 _KVRow:对齐、可扩展、低维护
当字段数量变多(例如未来加上“油品类型/支付方式/是否高速”等),你会发现“对齐的字段列表”比随便堆 Text 更耐用。
在详情页里,我用 _KVRow(k: ..., v: ...) 来表达每一行字段:
_KVRow(k: '油量', v: e.liters.toStringAsFixed(2)),
_KVRow(k: '单价', v: e.pricePerLiter.toStringAsFixed(2)),
_KVRow(k: '总价', v: e.totalCost.toStringAsFixed(2)),
对应的 _KVRow 组件实现(真实代码,节选)是这样的:
class _KVRow extends StatelessWidget {
final String k;
final String v;
const _KVRow({required this.k, required this.v});
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Padding(
padding: EdgeInsets.only(bottom: 6.h),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SizedBox(
width: 84.w,
child: Text(
k,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: cs.onSurfaceVariant),
),
),
Expanded(child: Text(v)),
],
),
);
}
}
这里面我很在意的点是 SizedBox(width: 84.w):
- 让“key 列”宽度固定
- 不会因为某个 key 特别长导致整列对不齐
如果未来 key 更长,你可以把 84.w 调大,或者把 key 文案做得更短。
8. 删除确认弹窗:为什么我更喜欢 showDialog + bool 返回值
删除是不可逆操作,我不会用 snackbar/底部 sheet 这类“容易误触”的方式,而是用标准 AlertDialog。
代码里我让弹窗返回一个 bool?:
final ok = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('确认删除?'),
content: const Text('删除后不可恢复。'),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('删除'),
),
],
);
},
);
if (ok != true) return;
这个写法的好处是:
ok == true才继续,null/false都会中断- 对“取消/点遮罩关闭/返回键关闭”都天然兼容
9. 删除之后的“正确顺序”:先删,再返回,再提示
很多应用会犯一个体验问题:点击删除后立刻返回列表,但删除还没完成,列表里还在。
项目里删除流程是:
await fillups.remove(e);
Get.back();
Get.snackbar('已删除', '加油记录已删除', snackPosition: SnackPosition.BOTTOM);
这个顺序我认为是合理的:
await保证数据库操作完成- 再返回列表,列表在 controller 刷新后已经是正确状态
- 最后提示用户结果
10. 删除为什么能自动刷新列表:核心在 controller 的 refreshAll(vehicleId)
详情页只调用 controller:
await fillups.remove(e);
controller 里真正做了两件事:删除 + 刷新当前车辆的记录列表。
Future<void> remove(FillUpEntry entry) async {
await FillUpDb.instance.deleteFillUp(entry.id);
await refreshAll(vehicleId: entry.vehicleId);
}
这就是我一直强调的模式:
- 页面负责交互(弹窗/按钮/提示)
- controller 负责数据一致性(删完就刷新)
11. 分享文本怎么写更“像用户说的话”:先短,再完整
目前分享实现是一个短句:
Share.share(
'加油记录:${fmt.format(DateTime.fromMillisecondsSinceEpoch(e.dateMs))},${e.liters}L,${e.totalCost.toStringAsFixed(2)}',
);
我没有在分享里塞太多字段(里程/单价/加油站/备注),原因是:
- 文本太长时,用户在聊天窗口里不愿意发出去
- 更完整的分享,更适合“导出/分享文件”(JSON/CSV/截图)
不过,如果你希望分享更清晰,我更推荐做成两行,仍保持短:
- 第一行:日期 + 油量
- 第二行:总价 + 里程
这部分属于产品取舍,后续可以在“导出/分享”功能里升级。
12. 详情页里的“扩展入口”:票据识别(占位)代表架构预留
你会在详情页底部看到一个按钮:
FilledButton.tonalIcon(
onPressed: () => Get.toNamed('/feature/receipt_scanner'),
icon: const Icon(Icons.receipt_long),
label: const Text('票据识别(占位)'),
),
虽然它目前是占位,但它有两个意义:
- 信息流正确:用户查看一条记录时,最自然的“下一步”就是补充票据/图片信息
- 路由结构统一:所有扩展能力都走
/feature/<id>的体系
当你未来真的接入票据识别时,只需要在 FeatureRegistry 里把占位页替换成真实功能。
13. 实战补充:Get.arguments 的兜底为什么要写在最前面
详情页开头这段判断非常关键:
final e = Get.arguments as FillUpEntry?;
if (e == null) {
return const Scaffold(body: Center(child: Text('无记录数据')));
}
它能帮你挡住很多“非预期入口”导致的崩溃:
- 用户从通知/深链跳进来,但参数没带
- 开发过程中改了路由参数,某个入口没同步
对于工具类应用来说,“不崩”比“功能多”更重要。
14. 小结
记录详情页本质上是“动作枢纽”:
- 进入编辑:带着原记录对象跳转
- 删除:确认弹窗 + 删除后刷新 + 返回列表
- 分享:用最简单的文本先把能力做实
接下来你会发现:有了“详情”的闭环,你的应用会突然变得更像一个真正能长期使用的工具,而不是 demo。
文章底部添加社区引导:https://openharmonycrossplatform.csdn.net
更多推荐


所有评论(0)