Flutter for OpenHarmony 个人理财管理App实战 - 分类分析页面
分类分析功能通过交互式饼图和排行榜帮助用户直观了解支出分布。核心功能包括月份选择器、分类排行榜和联动高亮效果。技术实现上使用StatefulWidget管理月份和分类状态,通过数据处理逻辑汇总各分类支出金额,并采用响应式设计确保适配不同屏幕。页面结构分为月份选择器、饼图和排行榜三部分,无数据时显示友好提示。该功能让用户清晰掌握消费去向,辅助优化财务决策。
分类分析是统计模块的核心功能之一,帮助用户深入了解各个分类的支出占比。通过交互式饼图和分类排行榜,用户可以直观地看到钱都花在了哪些地方,从而做出更合理的消费决策。
功能需求分析
分类分析页面需要回答用户一个核心问题:我的钱都花在哪了?为了回答这个问题,我们需要提供以下功能:
- 月份选择器,支持查看历史月份的数据
- 交互式饼图,点击可以选中某个分类
- 分类排行榜,按金额从高到低排列
- 饼图和排行榜联动,点击任意一个都会高亮对应项
- 空状态处理,没有数据时给出友好提示
页面状态设计
分类分析页面需要管理两个状态:当前选中的月份和当前选中的分类。我们使用 StatefulWidget 来管理这些状态:
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/category_service.dart';
import '../../core/services/storage_service.dart';
import '../../data/models/transaction_model.dart';
const _textSecondary = Color(0xFF757575);
导入了 fl_chart 包用于绑制饼图,intl 包用于日期格式化。_textSecondary 是次要文字的颜色,用于标签和辅助信息。
定义页面类和状态:
class CategoryAnalysisPage extends StatefulWidget {
const CategoryAnalysisPage({super.key});
State<CategoryAnalysisPage> createState() => _CategoryAnalysisPageState();
}
class _CategoryAnalysisPageState extends State<CategoryAnalysisPage> {
final _transactionService = Get.find<TransactionService>();
final _categoryService = Get.find<CategoryService>();
final _storage = Get.find<StorageService>();
DateTime _selectedMonth = DateTime.now();
String? _selectedCategoryId;
_selectedMonth 存储当前选中的月份,默认是当前月。_selectedCategoryId 存储当前选中的分类 ID,用于饼图和排行榜的联动高亮。初始值为 null 表示没有选中任何分类。
通过 Get.find 获取三个服务:TransactionService 用于获取交易数据,CategoryService 用于获取分类信息,StorageService 用于获取货币符号。这种依赖注入的方式让代码更加解耦,方便测试和维护。
数据处理逻辑
在 build 方法中,我们需要对原始交易数据进行处理,计算出各分类的支出金额:
Widget build(BuildContext context) {
final start = DateTime(_selectedMonth.year, _selectedMonth.month, 1);
final end = DateTime(_selectedMonth.year, _selectedMonth.month + 1, 0);
final transactions = _transactionService
.getTransactionsByDateRange(start, end)
.where((t) => t.type == TransactionType.expense)
.toList();
首先计算当月的起止日期。start 是当月第一天,end 是当月最后一天。DateTime 构造函数中月份加 1、日期设为 0,会自动计算出上个月的最后一天,这是一个常用的技巧。
然后获取这个日期范围内的所有交易,并筛选出支出类型的交易。分类分析主要关注支出,收入分析在另一个页面。where 方法返回一个惰性迭代器,toList 将其转换为列表。
接下来按分类汇总金额:
final byCategory = <String, double>{};
for (var t in transactions) {
byCategory[t.categoryId] = (byCategory[t.categoryId] ?? 0) + t.amount;
}
final sortedCategories = byCategory.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
final totalExpense = transactions.fold(0.0, (sum, t) => sum + t.amount);
byCategory 是一个 Map,key 是分类 ID,value 是该分类的总金额。遍历所有交易,累加到对应分类上。?? 0 处理首次遇到某分类的情况,如果 Map 中还没有这个 key,就用 0 作为初始值。
sortedCategories 是按金额降序排列的分类列表,金额最大的排在前面。级联操作符 … 让我们可以在同一行完成转换和排序。totalExpense 是总支出,用于计算各分类的占比。fold 方法从初始值 0.0 开始,依次累加每笔交易的金额。
页面主体结构
return Scaffold(
appBar: AppBar(title: const Text('分类分析')),
body: SingleChildScrollView(
padding: EdgeInsets.all(16.w),
child: Column(
children: [
_buildMonthSelector(),
SizedBox(height: 16.h),
if (sortedCategories.isNotEmpty) ...[
_buildPieChart(sortedCategories, totalExpense),
SizedBox(height: 16.h),
_buildRankingList(sortedCategories, totalExpense),
] else
_buildEmptyState(),
],
),
),
);
}
}
页面分为三个部分:月份选择器、饼图、排行榜。如果没有数据,显示空状态提示。使用展开运算符 … 配合 if 语句,可以条件性地添加多个 Widget。SingleChildScrollView 确保内容超出屏幕时可以滚动。
月份选择器
月份选择器让用户可以切换查看不同月份的数据:
Widget _buildMonthSelector() {
return Card(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: () => setState(() {
_selectedMonth = DateTime(
_selectedMonth.year,
_selectedMonth.month - 1
);
_selectedCategoryId = null;
})
),
点击左箭头切换到上个月,同时清空选中的分类。这样做是因为切换月份后数据变了,之前选中的分类可能不存在于新数据中。setState 触发页面重建,新的月份数据会自动加载。
中间显示当前选中的月份:
Text(
DateFormat('yyyy年MM月').format(_selectedMonth),
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600
)
),
DateFormat 来自 intl 包,‘yyyy年MM月’ 格式化为类似 “2024年01月” 的形式。fontWeight 设为 w600 让文字稍微粗一点,更醒目。16.sp 是响应式字号,会根据屏幕尺寸自动调整。
右箭头切换到下个月:
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: () => setState(() {
_selectedMonth = DateTime(
_selectedMonth.year,
_selectedMonth.month + 1
);
_selectedCategoryId = null;
})
),
],
),
),
);
}
DateTime 构造函数会自动处理跨年的情况,比如从 12 月切换到下一年的 1 月。这是 Dart DateTime 类的便利之处,不需要手动处理边界条件。
交互式饼图
饼图是分类分析的核心可视化组件,使用 fl_chart 包实现:
Widget _buildPieChart(
List<MapEntry<String, double>> sortedCategories,
double totalExpense
) {
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),
卡片顶部显示标题"支出分布",让用户知道这个图表展示的是什么。crossAxisAlignment 设为 start 让标题左对齐。
饼图的核心配置:
SizedBox(
height: 200.h,
child: PieChart(
PieChartData(
pieTouchData: PieTouchData(
touchCallback: (event, response) {
if (response?.touchedSection != null) {
final index = response!
.touchedSection!
.touchedSectionIndex;
if (index >= 0 && index < sortedCategories.length) {
setState(() {
_selectedCategoryId = sortedCategories[index].key;
});
}
}
}
),
pieTouchData 配置触摸交互。当用户点击某个扇区时,touchCallback 会被调用,我们从 response 中获取被点击的扇区索引,然后更新 _selectedCategoryId。这样就实现了点击饼图选中分类的功能。
索引检查 index >= 0 && index < sortedCategories.length 是必要的,因为点击饼图中心空白区域时索引可能是 -1。这种防御性编程可以避免数组越界错误。
扇区数据的生成:
sections: sortedCategories.asMap().entries.map((e) {
final category = _categoryService
.getCategoryById(e.value.key);
final percentage = totalExpense > 0
? e.value.value / totalExpense * 100
: 0;
final isSelected = _selectedCategoryId == e.value.key;
return PieChartSectionData(
value: e.value.value,
title: '${percentage.toStringAsFixed(0)}%',
color: category?.color ?? Colors.grey,
radius: isSelected ? 70.r : 60.r,
titleStyle: TextStyle(
fontSize: 10.sp,
color: Colors.white,
fontWeight: FontWeight.bold
),
);
}).toList(),
遍历所有分类,为每个分类创建一个扇区。asMap 将列表转换为索引到元素的 Map,这样我们可以同时获取索引和值。value 是金额,决定扇区的大小。title 显示百分比,toStringAsFixed(0) 保留整数。
color 使用分类的颜色,如果分类不存在就用灰色作为后备。radius 是扇区的半径,选中的扇区半径更大(70 vs 60),形成突出效果。这是一种常见的交互反馈方式,让用户知道哪个扇区被选中了。
饼图的其他配置:
sectionsSpace: 2,
centerSpaceRadius: 40.r,
)
),
),
],
),
),
);
}
sectionsSpace 是扇区之间的间隙,设为 2 像素,让各扇区有明显的分隔。centerSpaceRadius 是中心空白区域的半径,设为 40 形成环形图效果,比实心饼图更现代,也更容易阅读。
分类排行榜
排行榜以列表形式展示各分类的详细数据:
Widget _buildRankingList(
List<MapEntry<String, double>> sortedCategories,
double totalExpense
) {
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),
排行榜也放在卡片中,和饼图保持视觉一致。标题"分类排行"明确告诉用户这个列表的内容。
遍历生成每个分类的行:
...sortedCategories.asMap().entries.map((e) {
final category = _categoryService.getCategoryById(e.value.key);
final percentage = totalExpense > 0
? e.value.value / totalExpense
: 0.0;
final isSelected = _selectedCategoryId == e.value.key;
return GestureDetector(
onTap: () => setState(() {
_selectedCategoryId = e.value.key;
}),
child: Container(
padding: EdgeInsets.symmetric(vertical: 12.h),
decoration: BoxDecoration(
color: isSelected
? (category?.color ?? Colors.grey).withOpacity(0.1)
: null,
borderRadius: BorderRadius.circular(8.r),
),
点击排行榜中的某一行,也会更新 _selectedCategoryId,实现和饼图的联动。选中的行有浅色背景,使用分类颜色的 10% 透明度版本,既能突出显示又不会太刺眼。
行内容的布局:
child: Row(
children: [
SizedBox(
width: 24.w,
child: Text(
'${e.key + 1}',
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.bold,
color: _textSecondary
)
)
),
最左边显示排名数字,e.key 是索引(从 0 开始),加 1 变成从 1 开始的排名。固定宽度 24 保证对齐,即使是两位数的排名也不会错位。
分类图标:
CircleAvatar(
radius: 16.r,
backgroundColor: (category?.color ?? Colors.grey)
.withOpacity(0.2),
child: Icon(
category?.icon ?? Icons.help,
size: 16.sp,
color: category?.color ?? Colors.grey
)
),
SizedBox(width: 12.w),
用 CircleAvatar 包裹图标,背景是分类颜色的浅色版本。如果分类不存在(比如被删除了),显示问号图标和灰色。这种容错处理保证页面不会因为数据不一致而崩溃。
分类名称和金额:
Expanded(
child: Text(
category?.name ?? '未知',
style: TextStyle(fontSize: 14.sp)
)
),
Text(
'${_storage.currency}${e.value.value.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w500
)
),
SizedBox(width: 8.w),
分类名称用 Expanded 占据剩余空间,金额右对齐。金额前面加上货币符号,保留两位小数。fontWeight 设为 w500 让金额稍微突出一点。
百分比显示:
SizedBox(
width: 50.w,
child: Text(
'${(percentage * 100).toStringAsFixed(1)}%',
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12.sp,
color: _textSecondary
)
)
),
],
),
),
);
}),
],
),
),
);
}
百分比固定宽度 50,右对齐,用次要颜色显示。保留一位小数,比饼图上的整数百分比更精确。这种设计让用户可以看到更详细的数据。
空状态处理
当没有支出数据时,显示友好的空状态提示:
Widget _buildEmptyState() {
return Card(
child: Padding(
padding: EdgeInsets.all(32.w),
child: Center(
child: Column(
children: [
Icon(
Icons.pie_chart_outline,
size: 64.sp,
color: Colors.grey[300],
),
SizedBox(height: 16.h),
Text(
'暂无支出数据',
style: TextStyle(
color: _textSecondary,
fontSize: 16.sp
)
),
SizedBox(height: 8.h),
Text(
'记录一笔支出后即可查看分析',
style: TextStyle(
color: _textSecondary,
fontSize: 14.sp,
),
),
],
),
),
),
);
}
空状态包含一个图标、主提示文字和辅助说明。图标用饼图轮廓,和页面主题呼应。辅助说明告诉用户如何产生数据,引导下一步操作。这种设计比简单的"暂无数据"更友好。
图例说明
为了让用户更好地理解饼图,可以添加图例说明:
Widget _buildLegend(List<MapEntry<String, double>> sortedCategories) {
return Wrap(
spacing: 16.w,
runSpacing: 8.h,
children: sortedCategories.take(6).map((e) {
final category = _categoryService.getCategoryById(e.key);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 12.w,
height: 12.w,
decoration: BoxDecoration(
color: category?.color ?? Colors.grey,
borderRadius: BorderRadius.circular(2.r),
),
),
SizedBox(width: 4.w),
Text(
category?.name ?? '未知',
style: TextStyle(fontSize: 12.sp, color: _textSecondary),
),
],
);
}).toList(),
);
}
图例用小色块加分类名称的形式,最多显示 6 个,避免占用太多空间。Wrap 布局让图例自动换行,适应不同屏幕宽度。mainAxisSize.min 让每个图例项只占用必要的宽度。
数据刷新机制
当用户在其他页面添加或删除交易后,回到分类分析页面应该能看到最新数据。由于我们在 build 方法中直接从服务获取数据,每次页面重建都会获取最新数据,所以不需要额外的刷新逻辑。
如果想要手动刷新,可以添加下拉刷新功能:
body: RefreshIndicator(
onRefresh: () async {
setState(() {});
},
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
// ...
),
),
AlwaysScrollableScrollPhysics 确保即使内容不足以滚动,也能触发下拉刷新。onRefresh 中调用 setState 触发重建,就会重新获取数据。async 关键字是必须的,因为 onRefresh 期望返回 Future。
分类详情弹窗
点击某个分类时,除了高亮显示,还可以弹出详情弹窗展示更多信息:
void _showCategoryDetail(String categoryId, double amount, double percentage) {
final category = _categoryService.getCategoryById(categoryId);
final transactions = _transactionService
.getTransactionsByMonth(_selectedMonth.year, _selectedMonth.month)
.where((t) => t.categoryId == categoryId && t.type == TransactionType.expense)
.toList();
Get.bottomSheet(
Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(16.r)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40.w,
height: 4.h,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2.r),
),
),
SizedBox(height: 16.h),
CircleAvatar(
radius: 24.r,
backgroundColor: (category?.color ?? Colors.grey).withOpacity(0.2),
child: Icon(
category?.icon ?? Icons.help,
color: category?.color ?? Colors.grey,
size: 24.sp,
),
),
SizedBox(height: 12.h),
Text(
category?.name ?? '未知分类',
style: TextStyle(fontSize: 18.sp, fontWeight: FontWeight.bold),
),
SizedBox(height: 8.h),
Text(
'${_storage.currency}${amount.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 24.sp,
fontWeight: FontWeight.bold,
color: category?.color ?? Colors.grey,
),
),
Text(
'占总支出 ${(percentage * 100).toStringAsFixed(1)}%',
style: TextStyle(fontSize: 14.sp, color: _textSecondary),
),
SizedBox(height: 16.h),
Text(
'本月共 ${transactions.length} 笔交易',
style: TextStyle(fontSize: 14.sp, color: _textSecondary),
),
SizedBox(height: 16.h),
],
),
),
);
}
底部弹窗显示分类图标、名称、金额、占比和交易笔数。顶部的小横条是标准的底部弹窗设计,提示用户可以下拉关闭。mainAxisSize.min 让弹窗高度自适应内容。
性能优化考虑
当分类数量很多时,饼图可能会变得拥挤。可以考虑以下优化:
- 只显示前 N 个分类,其余合并为"其他"
- 占比太小的分类(如小于 2%)合并显示
- 使用缓存避免重复计算
合并小分类的实现思路:
List<MapEntry<String, double>> _mergeSmallCategories(
List<MapEntry<String, double>> categories,
double total,
double threshold,
) {
final result = <MapEntry<String, double>>[];
double otherAmount = 0;
for (var entry in categories) {
if (entry.value / total >= threshold) {
result.add(entry);
} else {
otherAmount += entry.value;
}
}
if (otherAmount > 0) {
result.add(MapEntry('other', otherAmount));
}
return result;
}
threshold 设为 0.02 表示占比小于 2% 的分类会被合并。这样饼图最多显示 50 个扇区,实际上通常不会超过 10 个。合并后的"其他"分类需要特殊处理,显示时用灰色和问号图标。
动画效果增强
为了提升用户体验,可以给饼图添加入场动画:
class _CategoryAnalysisPageState extends State<CategoryAnalysisPage>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _animation;
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
);
_animation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutCubic,
);
_animationController.forward();
}
void dispose() {
_animationController.dispose();
super.dispose();
}
动画控制器在页面初始化时启动,800 毫秒的时长比较舒适。easeOutCubic 曲线让动画开始快、结束慢,感觉更自然。
在饼图中使用动画值:
PieChartSectionData(
value: e.value.value * _animation.value,
// ...
)
这样饼图会从 0 逐渐展开到完整状态,视觉效果更好。
导出功能
用户可能想把分析结果分享出去,可以添加截图导出功能:
final _screenshotKey = GlobalKey();
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('分类分析'),
actions: [
IconButton(
icon: const Icon(Icons.share),
onPressed: _shareAnalysis,
),
],
),
body: RepaintBoundary(
key: _screenshotKey,
child: // ... 页面内容
),
);
}
Future<void> _shareAnalysis() async {
final boundary = _screenshotKey.currentContext!
.findRenderObject() as RenderRepaintBoundary;
final image = await boundary.toImage(pixelRatio: 2.0);
final byteData = await image.toByteData(format: ImageByteFormat.png);
// 保存或分享图片
}
RepaintBoundary 包裹需要截图的区域,toImage 方法将其转换为图片。pixelRatio 设为 2.0 可以得到更清晰的图片。
小结
分类分析页面通过饼图和排行榜两种形式展示支出分布,帮助用户了解自己的消费结构。核心要点包括:
- 饼图支持点击交互,选中的扇区会放大
- 排行榜和饼图联动,点击任意一个都会高亮对应项
- 月份选择器支持查看历史数据
- 空状态给出友好提示和引导
- 可以添加详情弹窗展示更多信息
- 动画效果提升用户体验
这种可视化分析能帮助用户发现消费中的问题,比如某个分类占比过高,从而做出调整。下一篇将实现趋势分析页面,展示收支随时间的变化趋势。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐


所有评论(0)