在这里插入图片描述

这一篇我们把 “记录列表” 做成一个真正能高频使用的页面:

  • 进入记录页能看到当前车辆的加油记录
  • 支持“仅看加满”一键筛选(影响列表展示)
  • 支持跳转到筛选/排序面板(影响列表展示)
  • 支持从列表进入详情页,形成“列表 -> 详情 -> 编辑/删除”闭环

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

这里有两个细节我很喜欢:

  1. 视觉状态明确:图标会随着 onlyFullTank 的变化而变化,用户一眼能知道当前处于“仅看加满”还是“全部”。
  2. 交互有反馈:每次切换都用 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 进入的设置页,本质上就是在更改 onlyFullTanksortMode 这两组状态。


11. 新增入口:FloatingActionButton

列表页右下角的 + 入口用于新增加油记录:

floatingActionButton: FloatingActionButton(
  onPressed: () => Get.toNamed('/fillup/add'),
  child: const Icon(Icons.add),
),

我更喜欢把新增入口放在列表页而不是概览页:因为大多数用户的操作路径是“我刚加完油 -> 直接记一条”,列表页点击成本最低。


小结

到这里,我们已经把记录列表的“日常使用路径”打通:

  • FillUpsPage 根据“当前车辆”展示数据
  • FillUpsController.displayEntries() 统一承载筛选/排序策略
  • _FillUpTile 提供高信息密度的列表项
  • 点击列表项进入详情,为后续编辑/删除埋好入口

下一篇我们会开始做“新增加油”,重点会放在:表单校验、日期选择、以及如何把数据保存到数据库。


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

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐