flutter_for_openharmonyFillUp -油耗追踪器app实战+油耗趋势实现
前面两篇我们把“花费趋势”“油价/油量趋势”都画出来了。这一篇我们聚焦统计页里的。本文所有代码来自的。每段代码之后我都会紧跟解释。

前面两篇我们把“花费趋势”“油价/油量趋势”都画出来了。
但很多用户最关心的其实是:
- 这段时间油耗是变高了还是变低了?
这一篇我们聚焦统计页里的 油耗趋势(加满段)。
关键点不是折线图组件怎么用,而是:
- 油耗只能在“加满→加满”的段上才算得准。
- 没有有效段时必须清晰告诉用户为什么。
- tooltip 必须把“这一段”解释清楚(里程、油量、花费)。
本文所有代码来自 lib/app/fillup_app.dart 的 AnalyticsPage。
每段代码之后我都会紧跟解释。
1. 为什么油耗趋势要用“加满段”
如果你直接用“总油量 / 总里程”,
很容易出现两个误导:
- 用户中途多次只加一点点油,油量并不代表真实消耗。
- 用户忘记记录某次加油,导致区间消耗不完整。
项目里采用的策略是:
- 只在
加满 → 加满的区间计算油耗。
这也是为什么加油表单里 isFullTank 不是可有可无的字段。
2. 先把“加满段油耗”准备成图表数据
油耗趋势图的 y 值是 L/100km。
但这个值不是单条记录能算出来的。
它来自“一个区间”。
项目里在统计页里构造了 5 组数组:
consumptionPoints:折线图的点consumptionLabels:x 轴对应的日期标签(段闭合点的日期)consumptionDeltaKm:每一段的里程差consumptionSegLiters:每一段累计油量consumptionSegCost:每一段累计花费
真实代码如下(完整摘录):
final consumptionPoints = <FlSpot>[];
final consumptionLabels = <String>[];
final consumptionDeltaKm = <double>[];
final consumptionSegLiters = <double>[];
final consumptionSegCost = <double>[];
FillUpEntry? lastFull;
double pendingLiters = 0;
double pendingCost = 0;
int segIdx = 0;
for (final e in ordered) {
if (lastFull == null) {
if (e.isFullTank) {
lastFull = e;
pendingLiters = 0;
pendingCost = 0;
}
continue;
}
pendingLiters += e.liters;
pendingCost += e.totalCost;
if (!e.isFullTank) continue;
final delta = e.odometer - lastFull.odometer;
if (delta > 0) {
final c = (pendingLiters / delta) * 100.0;
consumptionPoints.add(FlSpot(segIdx.toDouble(), c));
consumptionLabels.add(df.format(DateTime.fromMillisecondsSinceEpoch(e.dateMs)));
consumptionDeltaKm.add(delta);
consumptionSegLiters.add(pendingLiters);
consumptionSegCost.add(pendingCost);
segIdx += 1;
}
lastFull = e;
pendingLiters = 0;
pendingCost = 0;
}
这段 for 循环是整个油耗趋势的“数据根”。
我按执行顺序解释。
2.1 lastFull:只在遇到第一次加满后开始
你会看到 lastFull == null 时:
- 只有当当前记录
e.isFullTank为 true 才会初始化lastFull。 - 在这之前所有记录都直接
continue。
这样做的好处是:
- 没有起点就不计算,避免假数据。
2.2 pendingLiters/pendingCost:区间累计
一旦进入“已出现 lastFull”的状态:
pendingLiters += e.liters;
pendingCost += e.totalCost;
解释:
- pending 累加的是“上一次加满之后发生的加油”。
- 无论中间是否加满,都先累加。
2.3 只有遇到下一次加满才闭合
if (!e.isFullTank) continue;
解释:
- 油耗段的闭合点必须是“加满”。
- 中间的小加油只是区间的一部分。
2.4 delta 必须大于 0
final delta = e.odometer - lastFull.odometer;
if (delta > 0) {
final c = (pendingLiters / delta) * 100.0;
...
}
这里非常关键。
- 里程不递增(
delta <= 0)时这段数据被忽略。 - 这是对“录入错误”的容错。
2.5 为什么 x 轴是 segIdx
你会注意到:
consumptionPoints的 x 不是记录序号 i。- 而是
segIdx。
原因很简单:
- 并不是每条记录都能形成一段。
- 用
segIdx能保证 x 轴是紧凑的连续序号。
2.6 labels 的日期用“段闭合点”
consumptionLabels.add(df.format(DateTime.fromMillisecondsSinceEpoch(e.dateMs)));
解释:
- 一段油耗用“段结束那次加满”的日期标记。
- 这样用户点击某个点时,直觉上更像“这段油耗最终收敛在这次加满”。
3. 卡片标题:先把规则说清楚
油耗趋势的标题不是一句“油耗趋势”就完事。
项目里在标题下面加了一行规则解释。
真实代码如下:
Text('油耗趋势(加满段)', style: Theme.of(context).textTheme.titleMedium),
SizedBox(height: 8.h),
Text(
'仅在“加满→加满”的有效段上计算(L/100km)',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
),
这里的取舍很现实:
- 你不解释规则,用户就会拿油耗趋势去对照单次加油,必然对不上。
- 你把规则写在图上,后续客服/反馈成本会低很多。
4. 空态分支:consumptionPoints.isEmpty 时直接说原因
油耗趋势这张图最容易出现空数据。
因为它的计算条件更苛刻。
项目里没有强行画一个空图,而是直接显示一行提示:
if (consumptionPoints.isEmpty)
Text(
'暂无可用段:至少需要两次“加满”且里程递增。',
style: Theme.of(context).textTheme.bodyMedium,
)
else
解释:
consumptionPoints.isEmpty就代表“没有形成任何有效段”。- 这里明确把两个条件写出来:
两次加满 + 里程递增。
这段提示文字跟统计页顶部的提示卡是一致的。
一处规则,多处复用。
5. LineChartData:用最少配置把曲线画出来
当 consumptionPoints 不为空时才画图。
项目里这段 chart 配置保持了和前两张趋势图一致的风格。
真实代码如下(从 SizedBox 到 lineBarsData):
SizedBox(
height: 220.h,
child: LineChart(
LineChartData(
minX: 0,
maxX: (consumptionLabels.length - 1).toDouble(),
lineBarsData: <LineChartBarData>[
LineChartBarData(
spots: consumptionPoints,
isCurved: true,
dotData: const FlDotData(show: false),
barWidth: 3,
color: Theme.of(context).colorScheme.tertiary,
),
],
gridData: const FlGridData(show: true),
解释重点:
- y 的单位是
L/100km,但图里不显示 y 刻度。
具体值依赖 tooltip。 tertiary用来区分“油耗曲线”,避免跟油量/花费撞色。
6. bottomTitles:油耗趋势用 localEvery 而不是复用 showEvery
你可能会问:
- 前面花费/油价图不是已经有
showEvery了吗? - 为什么油耗趋势又写一个
localEvery?
原因是:
- 油耗点的数量是“段数”,
通常比记录数少。 - 用
localEvery可以让这一张图自适应段数,不受其它图影响。
真实代码如下(完整摘录):
titlesData: FlTitlesData(
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
interval: 1,
getTitlesWidget: (value, meta) {
final idx = value.round();
if (idx < 0 || idx >= consumptionLabels.length) {
return const SizedBox.shrink();
}
final localEvery = (() {
final s = (consumptionLabels.length / 4).floor();
return s <= 0 ? 1 : s;
})();
if (idx != 0 && idx != consumptionLabels.length - 1 && idx % localEvery != 0) {
return const SizedBox.shrink();
}
return SideTitleWidget(
axisSide: meta.axisSide,
child: Text(consumptionLabels[idx], style: Theme.of(context).textTheme.labelSmall),
);
},
),
),
),
这段代码的“骨架”与之前一致,但细节不同:
- label 用的是
consumptionLabels。 - 稀疏策略用的是
localEvery。
还有一个细节别忽略:
- 同样保留了首尾标签。
7. 触摸指示线与点:沿用统一交互风格
油耗图也支持触摸。
交互策略保持一致:
- 触摸时显示垂直线
- 触摸时显示 dot
真实代码如下:
lineTouchData: LineTouchData(
enabled: true,
handleBuiltInTouches: true,
getTouchedSpotIndicator: (barData, spotIndexes) {
final lineColor = Theme.of(context).colorScheme.outline.withOpacity(0.6);
final dotColor = barData.color ?? Theme.of(context).colorScheme.tertiary;
return spotIndexes
.map(
(i) => TouchedSpotIndicatorData(
FlLine(color: lineColor, strokeWidth: 1),
FlDotData(
show: true,
getDotPainter: (spot, percent, bar, index) {
return FlDotCirclePainter(
radius: 4,
color: dotColor,
strokeWidth: 2,
strokeColor: Theme.of(context).colorScheme.surface,
);
},
),
),
)
.toList();
},
解释:
dotColor用barData.color,保持“线是什么颜色,点就是什么颜色”。- dot 的描边用
surface,避免点在不同背景上失去边界。
8. Tooltip:把一段油耗解释成“可核对的数据”
油耗趋势图的 tooltip 不能只显示一个数。
因为油耗是区间指标。
项目里 tooltip 同时显示:
- 日期(段结束日期)
- 油耗值
- 这段里程、累计油量、累计花费
真实代码如下:
touchTooltipData: LineTouchTooltipData(
tooltipBgColor: Theme.of(context).colorScheme.inverseSurface,
getTooltipItems: (spots) {
return spots.map((s) {
final idx = s.x.round();
final label = (idx >= 0 && idx < consumptionLabels.length) ? consumptionLabels[idx] : '';
final km = (idx >= 0 && idx < consumptionDeltaKm.length) ? consumptionDeltaKm[idx] : 0;
final liters = (idx >= 0 && idx < consumptionSegLiters.length) ? consumptionSegLiters[idx] : 0;
final cost = (idx >= 0 && idx < consumptionSegCost.length) ? consumptionSegCost[idx] : 0;
final ext = (km > 0 || liters > 0)
? '\n${km.toStringAsFixed(0)} km • ${liters.toStringAsFixed(2)} L • ¥${cost.toStringAsFixed(2)}'
: '';
return LineTooltipItem(
'$label\n${s.y.toStringAsFixed(2)} L/100km$ext',
const TextStyle(color: Colors.white),
);
}).toList();
},
),
这段 tooltip 代码有两个“必须保留”的防御点:
8.1 idx 的范围判断
你会看到对每个数组都做了:
idx >= 0 && idx < xxx.length
如果你在 tooltip 里直接索引,
图表组件触摸时很容易因为浮点/四舍五入导致越界。
8.2 ext 的拼接条件
final ext = (km > 0 || liters > 0)
? ...
: '';
解释:
- 如果 km/liters 都是 0,说明 idx 越界或者段数据缺失。
- 这时不拼接扩展信息,tooltip 至少还能显示主值。
另外,ext 使用 “km • L • ¥” 的格式也很实用:
- 一眼能看出这一段是怎么算出来的。
9. 小结
这一篇我们把“油耗趋势(加满段)”打通成可用的产品功能:
- 先在统计页按“加满→加满”构造分段数据(
consumptionPoints等) - 没有有效段就显示明确提示,而不是画空图
- x 轴使用
segIdx,并为油耗趋势单独使用localEvery - tooltip 不止显示油耗值,还把里程/油量/花费一起给出来,方便用户核对
下一篇我们继续写 每公里成本趋势(加满段),
它会复用这一套段数据,但指标从 L/100km 变成 ¥/km。
文章底部添加社区引导:https://openharmonycrossplatform.csdn.net
更多推荐
所有评论(0)