在这里插入图片描述

这篇文章我们把 FillUp - 油耗追踪器 的“概览”做成一个真正能用的入口页:

  • 进入应用后第一眼能看到“当前车辆”的关键指标
  • 支持在概览页快速切换车辆
  • 支持一键跳转到新增记录、车辆管理、统计图表、导出/分享等关键功能
  • 支持“最近加油记录”快速回看

说明:本文所有代码均来自项目文件 lib/app/fillup_app.dart,为了保证可读性,我只截取必要的真实片段。每段代码后都会紧跟一段解释。


1. 概览页放在哪里:4 个 Tab + IndexedStack

概览页并不是一个单独路由的页面,它属于底部 Tab 的第一个 Tab。

我们用 ConvexAppBar 做底部导航,用 IndexedStack 保存四个 Tab 的状态,这样切换 Tab 时不会丢失滚动位置与页面状态。

class FillUpShell extends StatelessWidget {
  const FillUpShell({super.key});

  
  Widget build(BuildContext context) {
    final shell = Get.find<FillUpShellController>();
    final tabs = <Widget>[
      const DashboardTab(),
      const LogTab(),
      const StatsTab(),
      const SettingsTab(),
    ];

    return Obx(() {
      final idx = shell.index.value;
      return Scaffold(
        body: IndexedStack(index: idx, children: tabs),
        bottomNavigationBar: ConvexAppBar(
          style: TabStyle.reactCircle,
          items: const <TabItem>[
            TabItem(icon: Icons.dashboard, title: '概览'),
            TabItem(icon: Icons.local_gas_station, title: '记录'),
            TabItem(icon: Icons.insights, title: '统计'),
            TabItem(icon: Icons.settings, title: '设置'),
          ],
          initialActiveIndex: idx,
          onTap: (i) => shell.index.value = i,
        ),
      );
    });
  }
}

解释:

  • 这里 shell.index 是一个 RxInt,用 Obx 监听即可刷新 UI。
  • IndexedStack 的好处是:Tab 切换时不会销毁子页面,尤其适合“概览页 + 列表页 + 图表页”这种结构。
  • 概览页对应的 Widget 是 DashboardTab,这就是我们本篇要实现的重点。

2. Tab 状态如何管理:一个极简 ShellController

底部 Tab 的状态我没有用复杂的状态机,而是用一个最简单的 GetX Controller。

class FillUpShellController extends GetxController {
  final RxInt index = 0.obs;
}

解释:

  • Tab 这种 UI 状态本质上就是一个数字。
  • 只要保证 FillUpShellController 被注入(Get.put)即可。

3. 概览页的基本骨架:SafeArea + ListView

概览页的内容通常会比较长(车辆信息卡、统计卡、快捷入口、最近记录列表)。
所以我选择 整体用一个 ListView,避免嵌套滚动带来的体验问题。

class DashboardTab extends StatelessWidget {
  const DashboardTab({super.key});

  
  Widget build(BuildContext context) {
    final vehiclesCtl = Get.find<VehiclesController>();
    final fillupsCtl = Get.find<FillUpsController>();
    final cs = Theme.of(context).colorScheme;

    return SafeArea(
      child: ListView(
        padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h),
        children: <Widget>[
          Text('FillUp', style: Theme.of(context).textTheme.headlineSmall),
          SizedBox(height: 6.h),
          Text(
            '油耗 / 花费 / 里程一站式追踪',
            style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: cs.onSurfaceVariant),
          ),
          SizedBox(height: 12.h),
          // ... 下面开始放核心卡片
        ],
      ),
    );
  }
}

解释:

  • SafeArea 用于避免顶部/底部系统区域遮挡。
  • flutter_screenutil.w .h 让 padding/间距在不同屏幕尺寸下更稳定。
  • 这里通过 Get.find<...>() 直接拿 Controller:
    • VehiclesController 提供车辆列表与当前车辆
    • FillUpsController 提供记录数据与统计计算

4. 依赖注入:FillUpBindings

为了让 DashboardTabGet.find() 有东西可找,我们在应用启动时通过 initialBinding 统一注入依赖。

class FillUpBindings extends Bindings {
  
  void dependencies() {
    Get.put(FillUpShellController());
    Get.put(FillUpSettingsController());
    Get.put(VehiclesController());
    Get.put(FillUpsController());
  }
}

解释:

  • GetMaterialApp(initialBinding: ...) 是一个很干净的入口组织方式。
  • 把 Controller 的构建集中在 Binding 里,页面就只负责展示与调用,不会出现“页面里到处 new Controller”的情况。

5. 概览页第一张卡:当前车辆选择 + 切换

概览页最重要的是“上下文”:你在看的是哪辆车。
因此我用一张 Card 放“车辆名 + 切换入口”。

Card(
  child: Padding(
    padding: EdgeInsets.all(12.w),
    child: Obx(() {
      final list = vehiclesCtl.vehicles;
      if (list.isEmpty) {
        return Row(
          children: <Widget>[
            Expanded(
              child: Text(
                '还没有车辆,先添加一辆吧',
                style: Theme.of(context).textTheme.titleSmall,
              ),
            ),
            FilledButton.tonal(
              onPressed: () => Get.toNamed('/vehicle/add'),
              child: const Text('添加车辆'),
            ),
          ],
        );
      }

      final active = vehiclesCtl.activeVehicle;
      return Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Row(
            children: <Widget>[
              Expanded(
                child: Text(
                  active == null ? '选择车辆' : active.name,
                  style: Theme.of(context).textTheme.titleMedium,
                ),
              ),
              PopupMenuButton<String>(
                onSelected: (id) async {
                  vehiclesCtl.activeVehicleId.value = id;
                  await fillupsCtl.refreshAll(vehicleId: id);
                },
                itemBuilder: (context) {
                  return list
                      .map(
                        (v) => PopupMenuItem<String>(
                          value: v.id,
                          child: Text('${v.name}  ${v.plateNo}'),
                        ),
                      )
                      .toList();
                },
                child: const Row(
                  children: <Widget>[
                    Icon(Icons.swap_vert),
                    SizedBox(width: 6),
                    Text('切换'),
                  ],
                ),
              ),
            ],
          ),
          // ... 下方继续放统计
        ],
      );
    }),
  ),
)

解释:

  • vehiclesCtl.vehicles 是一个 RxList,所以用 Obx 包起来。
  • 没有车辆时直接引导去 /vehicle/add,避免用户陷入“空白页”。
  • 有车辆时用 PopupMenuButton 切换:
    • 更新 activeVehicleId
    • 刷新当前车辆的加油记录(fillupsCtl.refreshAll(vehicleId: id)

这里有一个小细节:概览页切换车辆后,概览上的统计值也会自动变化,因为统计值计算依赖 FillUpsController.fillups


6. 概览页关键指标:用小组件 _StatChip 统一视觉

概览页的一组指标(花费、记录数、有效里程、平均油耗、加满次数、有效段数)如果直接用 Text 拼,会很散。
我用一个很简单的 _StatChip 统一样式。

这里我就不再把 _StatChip 的组件源码完整贴出来了(它在 fillup_app.dart 末尾),记住两个要点即可:

  • 只做展示:传入 titlevalue,不在组件里做计算
  • 统一视觉:边框/圆角/字号层级固定,让一组指标看起来像“仪表盘”

7. 指标怎么计算:直接依赖 FillUpsController.stats()

概览页的指标不是硬编码,而是通过 FillUpsController.stats(vehicleId) 计算得到。

下面是概览页里几个典型的取值方式(注意:每个值都做了空车/无数据保护)。

_StatChip(title: '总花费', value: () {
  final id = vehiclesCtl.activeVehicleId.value;
  if (id == null) return '--';
  final s = fillupsCtl.stats(id);
  return s.totalCost.toStringAsFixed(2);
}())

解释:

  • id == null 直接返回 --,避免空指针。
  • stats() 返回的 FuelStats.totalCost 是“全量花费”,便于用户理解“总共花了多少”。

再看“有效里程”(我们走的是“加满段”算法,因此这里更准确的说法是有效里程):

_StatChip(title: '有效里程', value: (() {
  final id = vehiclesCtl.activeVehicleId.value;
  if (id == null) return '--';
  final s = fillupsCtl.stats(id);
  return s.totalDistance <= 0 ? '--' : s.totalDistance.toStringAsFixed(1);
})())

解释:

  • totalDistance 在当前项目中代表“可用于计算加满段统计的有效里程”。
  • 没有有效段(比如只有一条加满记录)就显示 --

再看“加满次数”和“有效段数”:

_StatChip(title: '加满次数', value: (() {
  final id = vehiclesCtl.activeVehicleId.value;
  if (id == null) return '0';
  return fillupsCtl.stats(id).fullTankCount.toString();
})())

_StatChip(title: '有效段数', value: (() {
  final id = vehiclesCtl.activeVehicleId.value;
  if (id == null) return '0';
  return fillupsCtl.stats(id).segmentCount.toString();
})())

解释:

  • “加满次数”用于告诉用户:你到底标记了多少次“加满”。
  • “有效段数”用于告诉用户:有多少个“加满→加满”的区间可以参与油耗计算。

8. 为什么油耗可能显示 --:在概览页给出解释提示

如果用户没按“加满段”录入(比如只加了两次油但都没标记加满,或者里程没有递增),平均油耗自然算不出来。

概览页这里我加了一个“信息提示行”,只在 segmentCount == 0 时出现。

Builder(
  builder: (context) {
    final id = vehiclesCtl.activeVehicleId.value;
    if (id == null) return const SizedBox.shrink();
    final s = fillupsCtl.stats(id);
    if (s.segmentCount > 0) return const SizedBox.shrink();
    return Row(
      children: <Widget>[
        Icon(Icons.info_outline, size: 18, color: Theme.of(context).colorScheme.primary),
        SizedBox(width: 8.w),
        Expanded(
          child: Text(
            '油耗暂不可算:至少需要两次“加满”记录且里程递增。',
            style: Theme.of(context).textTheme.bodySmall?.copyWith(
              color: Theme.of(context).colorScheme.onSurfaceVariant,
            ),
          ),
        ),
      ],
    );
  },
)

解释:

  • 这条提示是“可解释性”的关键。
  • 用户看到 -- 时不会困惑,也不会把问题归咎于 App。

9. 快捷功能:用 _QuickActionCard 统一入口

概览页通常要承担“导航中枢”的职责。
我用一个小卡片组件 _QuickActionCard 把几个高频入口放成一排。

Wrap(
  runSpacing: 10.h,
  spacing: 10.w,
  children: <Widget>[
    _QuickActionCard(
      title: '新增加油',
      icon: Icons.add,
      onTap: () => Get.toNamed('/fillup/add'),
    ),
    _QuickActionCard(
      title: '车辆管理',
      icon: Icons.directions_car,
      onTap: () => Get.toNamed('/vehicles'),
    ),
    _QuickActionCard(
      title: '统计图表',
      icon: Icons.show_chart,
      onTap: () => Get.toNamed('/analytics'),
    ),
    _QuickActionCard(
      title: '导出/分享',
      icon: Icons.share,
      onTap: () => Get.toNamed('/feature/export_share'),
    ),
  ],
)

解释:

  • WrapRow 更适合移动端:屏幕窄时会自动换行。
  • 每个入口都对应项目里的真实路由。

10. 最近加油记录:复用 _FillUpTile

概览页最后一块是“最近记录”。
我直接复用了记录页的 _FillUpTile(避免写两套 UI)。

const _SectionHeader(title: '最近加油记录'),
SizedBox(height: 8.h),
Obx(() {
  final items = fillupsCtl.fillups;
  if (items.isEmpty) {
    return const _EmptyState(
      title: '暂无记录',
      subtitle: '去“记录”页新增第一条加油信息',
      icon: Icons.local_gas_station,
    );
  }
  return ListView.separated(
    shrinkWrap: true,
    physics: const NeverScrollableScrollPhysics(),
    itemCount: items.take(5).length,
    separatorBuilder: (_, __) => SizedBox(height: 10.h),
    itemBuilder: (context, index) {
      final e = items[index];
      return _FillUpTile(
        entry: e,
        onTap: () => Get.toNamed('/fillup/detail', arguments: e),
      );
    },
  );
})

解释:

  • 外层已经是 ListView,这里内部再用 ListView.separated 时要 shrinkWrap: true 并禁用滚动,避免冲突。
  • 点击最近记录跳转到 /fillup/detail,并通过 arguments 传递记录对象。

11. 空态统一:_EmptyState

概览页有两类常见空态:

  • 没有车辆
  • 有车辆但没有记录

空态我用统一组件 _EmptyState 展示,避免每个页面都写一套。这里就不再把组件源码完整贴出来了(它在 fillup_app.dart 末尾),你只需要记住:

  • 页面逻辑负责决定显示哪种空态(没车/没记录)
  • 空态组件负责统一视觉(图标、标题、引导文案)

12. 小结:概览页的设计取舍

我在实现概览页时主要遵循几个原则:

  • 先上下文:优先展示当前车辆是谁,切换入口放在最显眼的卡片顶部。
  • 先解释再指标:当油耗算不出来时,不要只给 --,要给解释。
  • 高频入口一屏可达:新增、车辆、统计、导出等入口放在概览页中段。
  • 最近记录不要做太重:最多展示 5 条,避免概览页变成“另一个列表页”。

如果你准备继续写下一篇(记录列表实现),重点就会从“仪表盘展示”转向“筛选/排序/加满标签/删除编辑”的完整链路。


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

Logo

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

更多推荐