# flutter_for_openharmonyFillUp -油耗追踪器app实战+概览实现
这篇文章我们把说明:本文所有代码均来自项目文件,为了保证可读性,我只截取。每段代码后都会紧跟一段解释。

这篇文章我们把 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
为了让 DashboardTab 的 Get.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 末尾),记住两个要点即可:
- 只做展示:传入
title和value,不在组件里做计算 - 统一视觉:边框/圆角/字号层级固定,让一组指标看起来像“仪表盘”
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'),
),
],
)
解释:
Wrap比Row更适合移动端:屏幕窄时会自动换行。- 每个入口都对应项目里的真实路由。
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
更多推荐



所有评论(0)