分类分析是统计模块的核心功能之一,帮助用户深入了解各个分类的支出占比。通过交互式饼图和分类排行榜,用户可以直观地看到钱都花在了哪些地方,从而做出更合理的消费决策。
请添加图片描述

功能需求分析

分类分析页面需要回答用户一个核心问题:我的钱都花在哪了?为了回答这个问题,我们需要提供以下功能:

  1. 月份选择器,支持查看历史月份的数据
  2. 交互式饼图,点击可以选中某个分类
  3. 分类排行榜,按金额从高到低排列
  4. 饼图和排行榜联动,点击任意一个都会高亮对应项
  5. 空状态处理,没有数据时给出友好提示

页面状态设计

分类分析页面需要管理两个状态:当前选中的月份和当前选中的分类。我们使用 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 让弹窗高度自适应内容。

性能优化考虑

当分类数量很多时,饼图可能会变得拥挤。可以考虑以下优化:

  1. 只显示前 N 个分类,其余合并为"其他"
  2. 占比太小的分类(如小于 2%)合并显示
  3. 使用缓存避免重复计算

合并小分类的实现思路:

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 可以得到更清晰的图片。

小结

分类分析页面通过饼图和排行榜两种形式展示支出分布,帮助用户了解自己的消费结构。核心要点包括:

  1. 饼图支持点击交互,选中的扇区会放大
  2. 排行榜和饼图联动,点击任意一个都会高亮对应项
  3. 月份选择器支持查看历史数据
  4. 空状态给出友好提示和引导
  5. 可以添加详情弹窗展示更多信息
  6. 动画效果提升用户体验

这种可视化分析能帮助用户发现消费中的问题,比如某个分类占比过高,从而做出调整。下一篇将实现趋势分析页面,展示收支随时间的变化趋势。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

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

更多推荐