Flutter for OpenHarmony 个人理财管理App实战 - 趋势分析页面
趋势分析页面通过可视化图表展示用户近6/12个月的收支变化,包含以下功能: 时间范围选择器(6个月/12个月切换) 双色柱状图直观对比各月收入(绿色)和支出(红色) 趋势指标分析(最高/最低收支、结余情况) 月度明细列表展示具体数据 页面采用卡片式布局,使用Flutter的fl_chart库实现交互式图表,帮助用户: 发现收支的季节性规律 判断收入稳定性 评估储蓄能力变化 为财务规划提供数据支持
趋势分析页面展示近几个月的收支变化趋势,帮助用户了解自己的财务走向。通过可视化图表,用户可以直观地看到收入和支出的变化规律,为财务规划提供数据支持。
功能设计
趋势分析页面包含:
- 时间范围选择(近6个月/近12个月)
- 近几个月收支柱状图
- 图例说明
- 月度明细列表
- 趋势指标分析
设计思路
趋势分析的核心价值在于帮助用户发现规律:
- 哪些月份支出较高?是否有季节性规律?
- 收入是否稳定?有无增长趋势?
- 结余情况如何?储蓄能力是否在提升?
页面实现
创建 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
)
),
),
],
),
)),
],
),
),
);
}
设置结余列样式并完成表格。结余列的文字加粗,让这个关键数据更醒目。颜色根据正负值动态选择:正数用绿色表示盈余,负数用红色表示亏损。每行数据上下添加边距,让表格不会太拥挤,提升可读性。
小结
趋势分析帮助用户了解财务变化趋势,为财务规划提供参考。通过可视化图表和趋势指标,用户可以:
- 发现收支的季节性规律
- 评估储蓄能力的变化
- 及时调整消费习惯
- 制定更合理的财务计划
下一篇将实现分类分析页面。
欢迎加入 OpenHarmony 跨平台开发社区,获取更多技术资源和交流机会:
https://openharmonycrossplatform.csdn.net
更多推荐


所有评论(0)