趋势分析页面展示近几个月的收支变化趋势,帮助用户了解自己的财务走向。通过可视化图表,用户可以直观地看到收入和支出的变化规律,为财务规划提供数据支持。
请添加图片描述

功能设计

趋势分析页面包含:

  1. 时间范围选择(近6个月/近12个月)
  2. 近几个月收支柱状图
  3. 图例说明
  4. 月度明细列表
  5. 趋势指标分析

设计思路

趋势分析的核心价值在于帮助用户发现规律:

  • 哪些月份支出较高?是否有季节性规律?
  • 收入是否稳定?有无增长趋势?
  • 结余情况如何?储蓄能力是否在提升?

页面实现

创建 trend_analysis_page.dart

import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:intl/intl.dart';
import '../../core/services/transaction_service.dart';
import '../../core/services/storage_service.dart';

const _primaryColor = Color(0xFF2E7D32);
const _incomeColor = Color(0xFF4CAF50);

导入必要的包和定义颜色常量。使用 fl_chart 库来绘制图表,intl 用于日期格式化。定义了主题色、收入色等常量,保持整个页面的视觉风格统一。这些颜色会在图表、卡片等多个地方使用,统一管理便于维护。

const _expenseColor = Color(0xFFE53935);
const _textSecondary = Color(0xFF757575);

class TrendAnalysisPage extends StatefulWidget {
  const TrendAnalysisPage({super.key});

  
  State<TrendAnalysisPage> createState() => _TrendAnalysisPageState();
}

定义页面类为 StatefulWidget,因为需要管理月份数量的状态。支出用红色强调警示性,次要文字用灰色降低视觉权重。通过状态管理实现用户切换6个月或12个月的数据展示,提供灵活的时间范围选择。

class _TrendAnalysisPageState extends State<TrendAnalysisPage> {
  final _transactionService = Get.find<TransactionService>();
  final _storage = Get.find<StorageService>();
  int _monthCount = 6;

  
  Widget build(BuildContext context) {
    final now = DateTime.now();
    final monthlyData = <Map<String, dynamic>>[];

初始化服务依赖和状态变量。_monthCount 默认为6,表示显示近6个月的数据。通过 GetX 的依赖注入获取交易服务和存储服务实例。build 方法中首先获取当前时间,用于计算月份范围,确保数据的时效性。

    for (int i = _monthCount - 1; i >= 0; i--) {
      final month = DateTime(now.year, now.month - i, 1);
      final end = DateTime(now.year, now.month - i + 1, 0);
      final income = _transactionService.getTotalIncome(start: month, end: end);
      final expense = _transactionService.getTotalExpense(start: month, end: end);
      monthlyData.add({
        'month': month,
        'income': income,
        'expense': expense,

循环获取近N个月的数据。从最早的月份开始遍历到当前月份,计算每个月的起止日期。调用交易服务获取该月的总收入和总支出。将数据存储在 Map 中,包含月份、收入等信息,方便后续图表展示和数据分析。

        'balance': income - expense
      });
    }
    final maxValue = monthlyData.fold(0.0, (max, d) =>
      [d['income'] as double, d['expense'] as double, max].reduce((a, b) => a > b ? a : b));
    return Scaffold(
      appBar: AppBar(title: const Text('趋势分析')),
      body: SingleChildScrollView(
        padding: EdgeInsets.all(16.w),

计算结余并找出最大值。结余等于收入减支出,用于判断该月是盈余还是亏损。maxValue 通过 fold 和 reduce 找出所有月份中收入和支出的最大值,用于设置图表的Y轴范围,确保所有数据都能完整显示在图表中。

        child: Column(
          children: [
            _buildTimeRangeSelector(),
            SizedBox(height: 16.h),
            _buildChartCard(monthlyData, maxValue),
            SizedBox(height: 16.h),
            _buildTrendIndicators(monthlyData),
            SizedBox(height: 16.h),

构建页面主体结构。使用 SingleChildScrollView 支持滚动,内容超出屏幕时可以查看。Column 中依次放置时间范围选择器、图表卡片、趋势指标和明细列表。各组件之间用 SizedBox 添加间距,保持视觉上的层次感和呼吸感。

            _buildDetailCard(monthlyData),
          ],
        ),
      ),
    );
  }
}

完成页面布局。最后添加明细卡片,展示每个月的具体数据。整个页面采用卡片式设计,每个功能模块独立成卡片,视觉上清晰分明。通过传递 monthlyData 和 maxValue 给各个组件方法,实现数据的展示和可视化,保持代码的模块化和可维护性。

时间范围选择器

让用户选择查看的时间范围:

Widget _buildTimeRangeSelector() {
  return Card(
    child: Padding(
      padding: EdgeInsets.all(12.w),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          _buildRangeChip('近6个月', 6),

创建时间范围选择器卡片。使用 Card 组件包裹,提供阴影和圆角效果。Row 布局让两个选项横向排列,居中对齐。调用 _buildRangeChip 方法创建第一个选项"近6个月",传入标签文本和对应的月份数量6。

          SizedBox(width: 12.w),
          _buildRangeChip('近12个月', 12),
        ],
      ),
    ),
  );
}

Widget _buildRangeChip(String label, int months) {
  final isSelected = _monthCount == months;

添加间距并创建第二个选项。两个选项之间用 SizedBox 分隔,保持适当间距。_buildRangeChip 方法接收标签和月份数作为参数。通过比较 _monthCount 和传入的 months 判断当前选项是否被选中,用于控制样式。

  return ChoiceChip(
    label: Text(label),
    selected: isSelected,
    onSelected: (_) => setState(() => _monthCount = months),
    selectedColor: _primaryColor.withOpacity(0.2),
    labelStyle: TextStyle(
      color: isSelected ? _primaryColor : _textSecondary,

使用 ChoiceChip 创建可选择的芯片组件。显示传入的标签文本,根据 isSelected 控制选中状态。点击时调用 setState 更新 _monthCount,触发页面重建。选中时背景色为主题色的浅色版本,文字颜色也会相应变化,提供清晰的视觉反馈。

      fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
    ),
  );
}

设置文字粗细。选中状态下文字加粗,未选中时使用正常粗细。这种细节处理让用户能够清楚地识别当前选中的时间范围。ChoiceChip 提供了良好的交互体验,点击时有动画效果,符合 Material Design 规范。

趋势图表卡片

使用柱状图展示收支趋势:

Widget _buildChartCard(List<Map<String, dynamic>> monthlyData, double maxValue) {
  return Card(
    child: Padding(
      padding: EdgeInsets.all(16.w),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text('收支趋势', style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w600)),

创建图表卡片。使用 Card 包裹整个图表区域,内部用 Padding 添加边距。Column 布局从上到下排列标题、图例和图表。标题"收支趋势"使用较大字号和加粗字体,让用户一眼就能看出这个卡片的功能。

          SizedBox(height: 8.h),
          Row(
            children: [
              _buildLegend('收入', _incomeColor),
              SizedBox(width: 16.w),
              _buildLegend('支出', _expenseColor),
            ],
          ),
          SizedBox(height: 16.h),

添加图例说明。Row 布局横向排列收入和支出的图例,用不同颜色标识。图例帮助用户理解图表中的颜色含义,绿色代表收入,红色代表支出。两个图例之间和图例与图表之间都添加了适当的间距,保持视觉上的清晰。

          SizedBox(
            height: 250.h,
            child: BarChart(BarChartData(
              alignment: BarChartAlignment.spaceAround,
              maxY: maxValue * 1.2,
              barGroups: monthlyData.asMap().entries.map((e) => BarChartGroupData(
                x: e.key,
                barRods: [

创建柱状图。设置图表高度为250,使用 BarChart 组件。spaceAround 对齐方式让柱子均匀分布。maxY 设置为最大值的1.2倍,留出顶部空间。遍历月度数据,为每个月创建一组柱子,包含收入和支出两根柱子。

                  BarChartRodData(
                    toY: e.value['income'],
                    color: _incomeColor,
                    width: 12.w,
                    borderRadius: BorderRadius.vertical(top: Radius.circular(4.r))
                  ),
                  BarChartRodData(
                    toY: e.value['expense'],

创建收入柱子。toY 设置柱子的高度为该月的收入金额,颜色使用绿色。柱子宽度为12,顶部添加圆角让视觉效果更柔和。第二根柱子表示支出,使用相同的配置但颜色和数据不同,两根柱子并排显示形成对比。

                    color: _expenseColor,
                    width: 12.w,
                    borderRadius: BorderRadius.vertical(top: Radius.circular(4.r))
                  ),
                ],
              )).toList(),
              titlesData: FlTitlesData(
                leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),

设置支出柱子的样式。使用红色表示支出,宽度和圆角与收入柱子保持一致。配置图表的标题数据,左侧、右侧和顶部的标题都隐藏,只显示底部的月份标签。这样可以让图表更简洁,把注意力集中在数据本身。

                rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
                topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
                bottomTitles: AxisTitles(sideTitles: SideTitles(
                  showTitles: true,
                  getTitlesWidget: (value, meta) {
                    if (value.toInt() < monthlyData.length) {
                      return Text(

配置底部标题显示。只有底部标题设置为显示,用于展示月份信息。getTitlesWidget 回调函数根据索引值返回对应的月份标签。先判断索引是否在数据范围内,避免越界错误。然后创建 Text 组件显示月份。

                        DateFormat('M月').format(monthlyData[value.toInt()]['month']),
                        style: TextStyle(fontSize: 10.sp, color: _textSecondary)
                      );
                    }
                    return const Text('');
                  }
                )),
              ),
              gridData: const FlGridData(show: false),

格式化月份显示。使用 DateFormat 将日期格式化为"M月"的形式,如"1月"、“2月”。文字使用较小的字号和次要颜色,不抢图表主体的风格。如果索引超出范围则返回空文本。隐藏网格线,让图表看起来更简洁清爽。

              borderData: FlBorderData(show: false),
              barTouchData: BarTouchData(
                touchTooltipData: BarTouchTooltipData(
                  getTooltipItem: (group, groupIndex, rod, rodIndex) {
                    final label = rodIndex == 0 ? '收入' : '支出';
                    return BarTooltipItem(
                      '$label: ${_storage.currency}${rod.toY.toStringAsFixed(0)}',

隐藏边框并配置触摸提示。边框隐藏让图表与卡片背景融为一体。配置触摸时显示的提示框,根据柱子索引判断是收入还是支出。提示内容包含标签、货币符号和金额,金额取整显示。这样用户点击柱子时能看到具体数值。

                      TextStyle(color: Colors.white, fontSize: 12.sp),
                    );
                  },
                ),
              ),
            )),
          ),
        ],
      ),
    ),
  );
}

设置提示框样式并完成图表配置。提示文字使用白色,字号适中,确保在深色背景上清晰可读。整个图表配置完成后,用户可以通过点击柱子查看具体金额,通过颜色区分收入支出,通过高度对比看出趋势变化。

Widget _buildLegend(String label, Color color) {
  return Row(
    children: [
      Container(
        width: 12.w,
        height: 12.w,
        decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(2.r))
      ),

创建图例组件。Row 布局横向排列颜色块和文字标签。Container 作为颜色块,宽高都是12,使用传入的颜色填充。添加小圆角让方块看起来更柔和。这个小方块的颜色与图表中的柱子颜色对应,帮助用户理解图表。

      SizedBox(width: 4.w),
      Text(label, style: TextStyle(fontSize: 12.sp, color: _textSecondary)),
    ],
  );
}

添加图例文字。颜色块和文字之间用 SizedBox 分隔,保持小间距。文字使用次要颜色和较小字号,不抢图表主体的风格。整个图例组件简洁明了,让用户快速理解图表中的颜色含义,提升图表的可读性。

趋势指标分析

计算并展示关键趋势指标:

Widget _buildTrendIndicators(List<Map<String, dynamic>> monthlyData) {
  final totalIncome = monthlyData.fold(0.0, (sum, d) => sum + (d['income'] as double));
  final totalExpense = monthlyData.fold(0.0, (sum, d) => sum + (d['expense'] as double));
  final avgIncome = totalIncome / monthlyData.length;
  final avgExpense = totalExpense / monthlyData.length;
  final avgBalance = (totalIncome - totalExpense) / monthlyData.length;
  String incomeTrend = '持平';

计算基础统计指标。使用 fold 方法累加所有月份的收入和支出,得到总收入和总支出。然后除以月份数量计算月均收入、月均支出和月均结余。初始化收入趋势为"持平",后续会根据数据变化更新这个值。

  String expenseTrend = '持平';
  if (monthlyData.length >= 6) {
    final recentIncome = monthlyData.sublist(monthlyData.length - 3)
        .fold(0.0, (sum, d) => sum + (d['income'] as double));
    final previousIncome = monthlyData.sublist(0, 3)
        .fold(0.0, (sum, d) => sum + (d['income'] as double));
    if (previousIncome > 0) {

计算收入趋势。如果数据至少有6个月,则比较最近3个月和之前3个月的收入。使用 sublist 分别提取两个时间段的数据,然后累加计算总收入。判断之前的收入是否大于0,避免除零错误,为后续计算变化百分比做准备。

      final incomeChange = (recentIncome - previousIncome) / previousIncome * 100;
      if (incomeChange > 5) incomeTrend = '上升 ${incomeChange.toStringAsFixed(0)}%';
      else if (incomeChange < -5) incomeTrend = '下降 ${incomeChange.abs().toStringAsFixed(0)}%';
    }
    final recentExpense = monthlyData.sublist(monthlyData.length - 3)
        .fold(0.0, (sum, d) => sum + (d['expense'] as double));
    final previousExpense = monthlyData.sublist(0, 3)

计算收入变化百分比并判断趋势。如果变化超过5%则认为是上升,低于-5%则是下降,否则保持"持平"。百分比取整显示,使用 abs() 确保下降时显示正数。同样的逻辑用于计算支出趋势,提取最近3个月和之前3个月的支出数据。

        .fold(0.0, (sum, d) => sum + (d['expense'] as double));
    if (previousExpense > 0) {
      final expenseChange = (recentExpense - previousExpense) / previousExpense * 100;
      if (expenseChange > 5) expenseTrend = '上升 ${expenseChange.toStringAsFixed(0)}%';
      else if (expenseChange < -5) expenseTrend = '下降 ${expenseChange.abs().toStringAsFixed(0)}%';
    }
  }

计算支出变化趋势。累加之前3个月的支出,判断是否大于0后计算变化百分比。根据变化幅度更新 expenseTrend 的值。这样就得到了收入和支出的趋势描述,可以告诉用户财务状况是在改善还是恶化。

  return Card(
    child: Padding(
      padding: EdgeInsets.all(16.w),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text('趋势指标', style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w600)),
          SizedBox(height: 16.h),

创建趋势指标卡片。使用 Card 包裹,内部添加边距。Column 布局从上到下排列标题和各项指标。标题"趋势指标"使用加粗字体,让用户知道这个区域展示的是统计数据。标题下方添加间距,与内容分隔开。

          Row(
            children: [
              Expanded(child: _buildIndicatorItem('月均收入', avgIncome, _incomeColor)),
              Expanded(child: _buildIndicatorItem('月均支出', avgExpense, _expenseColor)),
              Expanded(child: _buildIndicatorItem('月均结余', avgBalance,
                avgBalance >= 0 ? _incomeColor : _expenseColor)),
            ],
          ),
          SizedBox(height: 16.h),

展示三个关键指标。Row 布局让三个指标横向排列,每个都用 Expanded 包裹实现等宽。月均收入用绿色,月均支出用红色,月均结余根据正负值动态选择颜色。这三个指标是用户最关心的数据,放在最显眼的位置。

          const Divider(),
          SizedBox(height: 12.h),
          _buildTrendRow('收入趋势', incomeTrend, incomeTrend.contains('上升')),
          SizedBox(height: 8.h),
          _buildTrendRow('支出趋势', expenseTrend, !expenseTrend.contains('上升')),
        ],
      ),
    ),
  );
}

添加分隔线并展示趋势信息。Divider 将平均值和趋势信息分隔开,让布局更清晰。两行趋势信息分别展示收入和支出的变化方向。收入上升是好事用绿色,支出上升是坏事用红色,通过颜色传达价值判断。

Widget _buildIndicatorItem(String label, double value, Color color) {
  return Column(
    children: [
      Text(label, style: TextStyle(fontSize: 12.sp, color: _textSecondary)),
      SizedBox(height: 4.h),
      Text(
        '${_storage.currency}${value.toStringAsFixed(0)}',
        style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.bold, color: color),
      ),

创建单个指标项。Column 布局上下排列标签和数值。标签使用次要颜色和较小字号,数值使用传入的颜色、较大字号和加粗字体,形成视觉层次。货币符号和金额拼接显示,金额取整让数字更简洁易读。

    ],
  );
}

Widget _buildTrendRow(String label, String trend, bool isPositive) {
  return Row(
    mainAxisAlignment: MainAxisAlignment.spaceBetween,
    children: [
      Text(label, style: TextStyle(fontSize: 14.sp)),
      Row(

创建趋势行组件。Row 布局让标签和趋势值左右分布,中间自动填充空白。标签使用正常字号和颜色。右侧再用一个 Row 包裹图标和趋势文字,让它们紧密排列在一起,形成一个整体。

        children: [
          Icon(
            trend.contains('上升') ? Icons.trending_up :
            trend.contains('下降') ? Icons.trending_down : Icons.trending_flat,
            size: 16.sp,
            color: isPositive ? _incomeColor : _expenseColor,
          ),
          SizedBox(width: 4.w),

添加趋势图标。根据趋势文字选择对应的图标:上升用向上箭头,下降用向下箭头,持平用水平线。图标颜色根据 isPositive 参数决定,好的趋势用绿色,坏的趋势用红色。图标和文字之间添加小间距。

          Text(
            trend,
            style: TextStyle(
              fontSize: 14.sp,
              color: isPositive ? _incomeColor : _expenseColor,
              fontWeight: FontWeight.w500,
            ),
          ),
        ],
      ),
    ],
  );
}

显示趋势文字。文字内容是之前计算好的趋势描述,如"上升 10%“或"持平”。颜色与图标保持一致,字体稍微加粗让数字更醒目。整个趋势行通过图标、颜色和文字三重信息传达,让用户一眼就能看出财务状况的变化方向。

月度明细卡片

Widget _buildDetailCard(List<Map<String, dynamic>> monthlyData) {
  return Card(
    child: Padding(
      padding: EdgeInsets.all(16.w),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text('月度明细', style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w600)),
          SizedBox(height: 12.h),

创建月度明细卡片。使用 Card 包裹整个表格区域,内部添加边距。Column 布局从上到下排列标题、表头和数据行。标题"月度明细"使用加粗字体,下方添加间距。这个卡片以表格形式展示每个月的详细数据。

          Padding(
            padding: EdgeInsets.only(bottom: 8.h),
            child: Row(
              children: [
                Expanded(flex: 2, child: Text('月份', style: TextStyle(fontSize: 12.sp, color: _textSecondary))),
                Expanded(child: Text('收入', textAlign: TextAlign.right, style: TextStyle(fontSize: 12.sp, color: _textSecondary))),
                Expanded(child: Text('支出', textAlign: TextAlign.right, style: TextStyle(fontSize: 12.sp, color: _textSecondary))),

创建表头。Row 布局横向排列四列:月份、收入、支出、结余。月份列占2份宽度,其他列各占1份。文字使用次要颜色和较小字号,右对齐让数字整齐排列。表头下方添加底部边距,与数据行分隔开。

                Expanded(child: Text('结余', textAlign: TextAlign.right, style: TextStyle(fontSize: 12.sp, color: _textSecondary))),
              ],
            ),
          ),
          const Divider(),
          ...monthlyData.reversed.map((d) => Padding(
            padding: EdgeInsets.symmetric(vertical: 8.h),
            child: Row(

完成表头并开始数据行。最后一列是结余,同样右对齐。表头下方添加分隔线。使用 reversed 将数据倒序,让最新的月份显示在最上面。展开运算符 … 将 map 返回的多个 Widget 展开到 Column 的 children 中。

              children: [
                Expanded(
                  flex: 2,
                  child: Text(
                    DateFormat('yyyy年M月').format(d['month']),
                    style: TextStyle(fontSize: 14.sp)
                  ),
                ),
                Expanded(
                  child: Text(

创建月份列。占2份宽度,使用 DateFormat 格式化日期为"yyyy年M月"的形式。字号适中,颜色使用默认的深色。月份列宽度较大,因为日期文字较长,需要更多空间来显示完整信息。

                    '${_storage.currency}${(d['income'] as double).toStringAsFixed(0)}',
                    textAlign: TextAlign.right,
                    style: TextStyle(fontSize: 12.sp, color: _incomeColor)
                  ),
                ),
                Expanded(
                  child: Text(
                    '${_storage.currency}${(d['expense'] as double).toStringAsFixed(0)}',
                    textAlign: TextAlign.right,

创建收入和支出列。拼接货币符号和金额,金额取整显示。文字右对齐让数字纵向对齐,方便比较。收入用绿色,支出用红色,通过颜色快速区分。字号略小于月份列,突出月份信息。

                    style: TextStyle(fontSize: 12.sp, color: _expenseColor)
                  ),
                ),
                Expanded(
                  child: Text(
                    '${_storage.currency}${(d['balance'] as double).toStringAsFixed(0)}',
                    textAlign: TextAlign.right,
                    style: TextStyle(
                      fontSize: 12.sp,

创建结余列。同样拼接货币符号和金额,右对齐显示。结余是收入减支出的结果,是用户最关心的数据之一。字号与收入支出列保持一致,保持表格的整齐美观。

                      fontWeight: FontWeight.w600,
                      color: (d['balance'] as double) >= 0 ? _incomeColor : _expenseColor
                    )
                  ),
                ),
              ],
            ),
          )),
        ],
      ),
    ),
  );
}

设置结余列样式并完成表格。结余列的文字加粗,让这个关键数据更醒目。颜色根据正负值动态选择:正数用绿色表示盈余,负数用红色表示亏损。每行数据上下添加边距,让表格不会太拥挤,提升可读性。

小结

趋势分析帮助用户了解财务变化趋势,为财务规划提供参考。通过可视化图表和趋势指标,用户可以:

  1. 发现收支的季节性规律
  2. 评估储蓄能力的变化
  3. 及时调整消费习惯
  4. 制定更合理的财务计划

下一篇将实现分类分析页面。


欢迎加入 OpenHarmony 跨平台开发社区,获取更多技术资源和交流机会:

https://openharmonycrossplatform.csdn.net

Logo

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

更多推荐