在这里插入图片描述

当你能“新增”和“列表展示”之后,用户下一步一定会做两件事:

  • 点开某条记录确认细节
  • 在详情页里执行动作:编辑 / 删除 / 分享

这一篇我们把“记录详情页”做成一个完整的闭环节点。

说明:本文所有代码均来自项目文件 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

Logo

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

更多推荐