在这里插入图片描述

前面两篇我们把“花费趋势”“油价/油量趋势”都画出来了。
但很多用户最关心的其实是:

  • 这段时间油耗是变高了还是变低了?

这一篇我们聚焦统计页里的 油耗趋势(加满段)

关键点不是折线图组件怎么用,而是:

  • 油耗只能在“加满→加满”的段上才算得准。
  • 没有有效段时必须清晰告诉用户为什么。
  • tooltip 必须把“这一段”解释清楚(里程、油量、花费)。

本文所有代码来自 lib/app/fillup_app.dartAnalyticsPage
每段代码之后我都会紧跟解释。


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

解释:

  • dotColorbarData.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

Logo

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

更多推荐