在这里插入图片描述

前面我们已经把加油记录的新增/编辑/删除链路打通了。
但当记录一多,用户的需求就会非常现实:

  • 我只想看“加满”的记录(因为它影响油耗统计)。
  • 我想按“花费/油量/里程”把记录快速排个序。

这一篇我们把“筛选 + 排序”做成一个完整闭环:

  • 记录页顶部有一个快捷筛选按钮
  • 记录页列表使用 displayEntries() 自动应用筛选与排序
  • 还有一个独立的“筛选/排序设置页”,用于更细的设置

所有代码均来自 lib/app/fillup_app.dart
每段代码后面紧跟解释。


1. 把筛选/排序状态放在 controller:onlyFullTank + sortMode

筛选/排序不是某个页面的临时状态。
它应该在“记录页/设置页”之间共享。

项目里把它们放在 FillUpsController

class FillUpsController extends GetxController {
  final RxList<FillUpEntry> fillups = <FillUpEntry>[].obs;
  final RxBool onlyFullTank = false.obs;
  final RxString sortMode = 'date_desc'.obs;

解释:

  • onlyFullTank:是否仅显示“加满”记录
  • sortMode:排序策略字符串(默认 date_desc

这种设计的好处是:

  • 你在设置页修改后,记录页能立刻生效
  • 页面只关注展示,不在 UI 层散落一堆排序逻辑

2. setSortMode:把排序切换收敛成一个入口

排序策略是字符串,但不要在 UI 里直接写 sortMode.value = ...
项目里提供了一个显式方法:

  void setSortMode(String mode) {
    sortMode.value = mode;
  }

解释:

  • 现在方法很简单,但它是“收敛点”。
  • 以后如果你要加埋点/持久化/提示文案,只改这里。

3. displayEntries:筛选 + 排序的唯一入口

记录页真正使用的数据列表不是 fillups 原始 RxList。
而是通过 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 'cost_asc':
        list.sort((a, b) => cmpNum(a.totalCost, b.totalCost));
        break;
      case 'liters_desc':
        list.sort((a, b) => cmpNum(b.liters, a.liters));
        break;
      case 'liters_asc':
        list.sort((a, b) => cmpNum(a.liters, b.liters));
        break;
      case 'odo_desc':
        list.sort((a, b) => cmpNum(b.odometer, a.odometer));
        break;
      case 'odo_asc':
        list.sort((a, b) => cmpNum(a.odometer, b.odometer));
        break;
      case 'date_desc':
      default:
        list.sort((a, b) => b.dateMs.compareTo(a.dateMs));
        break;
    }

    return list;
  }

这一段你可以拆成两个阶段:

3.1 先筛选

    if (onlyFullTank.value) {
      list.removeWhere((e) => !e.isFullTank);
    }

解释:

  • 先按车辆过滤,再根据 onlyFullTank 做二次过滤。
  • 使用 removeWhere 能让筛选条件非常清晰。

这里也隐含了一个产品取舍:

  • “仅看加满”是一个全局开关
  • 它不会影响数据库,只影响显示

3.2 再排序

    int cmpNum(num a, num b) => a == b ? 0 : (a < b ? -1 : 1);

解释:

  • cmpNum 用来减少重复代码。
  • num 做比较时比直接写 a.compareTo(b) 更通用。

接下来是 switch(sortMode.value)

  • date_asc/date_descdateMs
  • cost_*totalCost
  • liters_*liters
  • odo_*odometer

这样一来,你的 UI 只要改一个字符串,就能切换整个列表排序。


4. 记录页顶部:一个 IconButton 作为“仅看加满”的快捷入口

筛选功能不能藏太深。
项目在记录页 appBar 直接放了一个切换按钮。

真实代码如下:

        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,
                );
              },
            );
          }),

解释:

  • Obx 让按钮图标与 tooltip 随状态变化。
  • 这个按钮只做一件事:切换 onlyFullTank
  • 切换后用 Get.snackbar 给即时反馈。

这里的文案还有一个小细节:

  • 当列表为空时 subtitle 会提示“或取消筛选”。
  • 这能减少用户困惑:
    “我明明有记录,怎么啥都没了?”

5. 进入筛选/排序设置页:一个 tune 图标入口

当用户需要更复杂的排序策略时,
就进入专门的设置页。

真实代码如下:

          IconButton(
            icon: const Icon(Icons.tune),
            onPressed: () => Get.toNamed('/feature/filter_sort'),
          ),

解释:

  • 顶部按钮只负责“开关”。
  • 复杂配置放在单独页面,避免 appBar 被塞爆。

6. 记录页列表:只消费 displayEntries 的结果

记录页 body 的关键点是:

  • 永远不要直接展示 controller 的原始列表。
  • 只展示 displayEntries() 的结果。

真实代码如下:

      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),
            );
          },
        );
      }),

解释:

  • v == null 时直接给空态,而不是让列表报错。
  • list.isEmpty 也给空态,并提醒“可能是筛选导致”。
  • 真正的列表展示只看 displayEntries(v.id)

这就是“展示层不做业务”的落地:

  • 业务策略在 controller
  • 页面只负责响应和渲染

7. 筛选/排序设置页:Switch + Dropdown 组合

项目里把“筛选/排序”做成一个设置区块。
它的意义是:

  • 用户不必记住图标含义
  • 可以明确看到当前状态

真实代码如下(完整摘录):

  List<Widget> _buildFilterSort(BuildContext context) {
    final ctl = Get.find<FillUpsController>();
    return <Widget>[
      const _SectionHeader(title: '筛选/排序'),
      SizedBox(height: 10.h),
      Obx(() {
        return SwitchListTile(
          value: ctl.onlyFullTank.value,
          onChanged: (v) => ctl.onlyFullTank.value = v,
          title: const Text('仅显示加满记录'),
          subtitle: const Text('影响“记录”页列表展示'),
        );
      }),
      SizedBox(height: 12.h),
      Obx(() {
        return DropdownButtonFormField<String>(
          value: ctl.sortMode.value,
          decoration: const InputDecoration(labelText: '排序方式'),
          items: const <DropdownMenuItem<String>>[
            DropdownMenuItem(value: 'date_desc', child: Text('日期:新 → 旧')),
            DropdownMenuItem(value: 'date_asc', child: Text('日期:旧 → 新')),
            DropdownMenuItem(value: 'cost_desc', child: Text('花费:高 → 低')),
            DropdownMenuItem(value: 'cost_asc', child: Text('花费:低 → 高')),
            DropdownMenuItem(value: 'liters_desc', child: Text('油量:高 → 低')),
            DropdownMenuItem(value: 'liters_asc', child: Text('油量:低 → 高')),
            DropdownMenuItem(value: 'odo_desc', child: Text('里程:高 → 低')),
            DropdownMenuItem(value: 'odo_asc', child: Text('里程:低 → 高')),
          ],
          onChanged: (v) {
            if (v != null) ctl.setSortMode(v);
          },
        );
      }),
      SizedBox(height: 12.h),
      FilledButton.tonalIcon(
        onPressed: () => Get.toNamed('/fillups'),
        icon: const Icon(Icons.local_gas_station),
        label: const Text('返回记录页查看效果'),
      ),
    ];
  }

这段代码里有三个细节非常值得保留。

7.1 SwitchListTile 的语义非常直观

  • 用户知道“开/关”意味着什么。
  • subtitle 明确告诉用户会影响哪里。

7.2 Dropdown 的 items 与 displayEntries 的 switch 必须一致

你会看到 items 的 value:

  • date_desc/date_asc
  • cost_*totalCost
  • liters_*liters
  • odo_*odometer

这些字符串必须跟 controller 的 switch(sortMode.value) 对齐。
否则你在 UI 选了一个模式,但列表不按预期排序。

7.3 onChanged 走 setSortMode

            if (v != null) ctl.setSortMode(v);

解释:

  • 用方法收敛,后续扩展更容易。
  • 也避免在 UI 里到处写 sortMode.value = ...

8. 体验闭环:为什么设置页要有“返回记录页”按钮

筛选/排序这种功能,用户往往要不断试。
如果你让用户手势返回,体验也还行。
但项目里多做了一步:

      FilledButton.tonalIcon(
        onPressed: () => Get.toNamed('/fillups'),
        icon: const Icon(Icons.local_gas_station),
        label: const Text('返回记录页查看效果'),
      ),

解释:

  • 给“下一步”一个明确按钮,降低学习成本。
  • 用户也不会忘记设置是为了影响记录页。

9. 小结

这一篇我们把“筛选/排序”做成了一个可维护的体系:

  • 状态放在 controller(onlyFullTank/sortMode
  • 列表只消费 displayEntries() 的结果
  • 顶部提供快捷筛选按钮,设置页提供可视化配置
  • 排序策略使用字符串 + switch,扩展成本低

下一篇我们会继续做一个“功能页”的完整实现:维护计划。
它会把“车辆、记录、提醒”三个概念串起来,形成更强的闭环。


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

Logo

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

更多推荐