在这里插入图片描述

说起记账,大多数人都只关注支出,觉得知道钱花哪儿了就够了。但我自己用了一段时间记账App后发现,收入分析同样重要。看着收入曲线一点点上升,那种成就感比看支出账单舒服多了。而且通过分析收入来源,能更清楚地知道哪些渠道能带来收益,对个人财务规划很有帮助。

为什么要做收入分析

在设计这个功能之前,我先想明白了几个问题。

第一个是动力问题。记账这件事,如果只看支出,会越记越沮丧。每天看着钱往外流,谁都不开心。但如果能看到收入的增长趋势,就会有正向激励。我自己就是这样,每次看到本月收入比上月多了几百块,就觉得努力没白费。

第二个是规划问题。知道自己每个月大概能收入多少,才能合理安排支出。比如这个月收入8000,那支出最好控制在6000以内,剩下2000可以存起来或者投资。如果不清楚收入情况,很容易花超。

第三个是来源分析。现在很多人不只有工资这一项收入,可能还有兼职、投资、奖金等。通过分析各个来源的占比,能知道哪些渠道值得继续投入。比如发现兼职收入占比越来越高,说明这条路走对了。

页面整体结构

先看页面的基本框架:

class IncomeAnalysisPage extends StatelessWidget {
  const IncomeAnalysisPage({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('收入分析'),
      ),
      body: SingleChildScrollView(
        padding: EdgeInsets.all(16.w),
        child: Column(
          children: [
            _buildTotalIncome(),
            SizedBox(height: 24.h),
            _buildMonthlyChart(),
            SizedBox(height: 24.h),
            _buildIncomeList(),
          ],
        ),
      ),
    );
  }
}

为什么用StatelessWidget

你可能会问,这里为什么不用StatefulWidget?因为目前页面只是展示数据,没有用户交互导致的状态变化。如果后续要加筛选、排序等功能,再改成StatefulWidget也不迟。

页面布局的三个部分

整个页面分成三块:顶部的总收入卡片、中间的趋势图表、底部的收入明细列表。这个顺序是经过考虑的,用户最关心的是总数,其次是趋势,最后才是明细。

SingleChildScrollView包裹,是因为内容可能超出一屏。Column里的三个组件用SizedBox隔开,间距设置为24,看起来不会太挤。

顶部总收入卡片的设计

这个卡片是整个页面的视觉焦点,我花了不少心思:

Widget _buildTotalIncome() {
  return Container(
    padding: EdgeInsets.all(20.w),
    decoration: BoxDecoration(
      gradient: const LinearGradient(
        colors: [Colors.green, Colors.lightGreen],
      ),
      borderRadius: BorderRadius.circular(16.r),
    ),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          '本月总收入',
          style: TextStyle(color: Colors.white70, fontSize: 14.sp),
        ),
        SizedBox(height: 8.h),
        Text(
          '¥ 8,500.00',
          style: TextStyle(
            color: Colors.white,
            fontSize: 36.sp,
            fontWeight: FontWeight.bold,
          ),
        ),
        SizedBox(height: 8.h),
        Text(
          '较上月增长 5%',
          style: TextStyle(color: Colors.white70, fontSize: 14.sp),
        ),
      ],
    ),
  );
}

绿色渐变的选择

收入用绿色,这是约定俗成的。绿色代表增长、正向、健康。我用了从Colors.greenColors.lightGreen的渐变,比纯色更有层次感。这个渐变是从上到下的,给人一种向上生长的感觉。

金额的视觉层级

注意看三行文字的样式:

  • 第一行"本月总收入"用了white70,半透明的白色,作为标签
  • 第二行金额用了36.sp的大字号,fontWeight.bold加粗,纯白色,这是视觉焦点
  • 第三行"较上月增长5%"又回到white70,作为辅助信息

这种层级关系很重要。用户的视线会自然地被大号加粗的金额吸引,然后再看上下的说明文字。

增长率的激励作用

"较上月增长5%"这行字别小看,它能给用户正向反馈。看到收入在增长,会有成就感。如果是负增长,可以改成红色,提醒用户注意。

实际项目中,这个增长率应该是动态计算的:

final lastMonthIncome = 8095.0;
final currentMonthIncome = 8500.0;
final growthRate = ((currentMonthIncome - lastMonthIncome) / lastMonthIncome * 100).toStringAsFixed(1);

收入趋势图表的实现

图表是这个页面的核心,我用了fl_chart包的柱状图:

Widget _buildMonthlyChart() {
  return Container(
    padding: EdgeInsets.all(16.w),
    decoration: BoxDecoration(
      color: Colors.white,
      borderRadius: BorderRadius.circular(12.r),
    ),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          '收入趋势',
          style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.bold),
        ),
        SizedBox(height: 20.h),
        SizedBox(
          height: 200.h,
          child: BarChart(
            BarChartData(
              alignment: BarChartAlignment.spaceAround,
              maxY: 10000,
              barTouchData: BarTouchData(enabled: false),
              titlesData: FlTitlesData(
                show: true,
                bottomTitles: AxisTitles(
                  sideTitles: SideTitles(
                    showTitles: true,
                    getTitlesWidget: (value, meta) {
                      const months = ['1月', '2月', '3月', '4月', '5月', '6月'];
                      if (value.toInt() >= 0 && value.toInt() < months.length) {
                        return Text(months[value.toInt()], style: TextStyle(fontSize: 12.sp));
                      }
                      return const Text('');
                    },
                  ),
                ),
                leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
                topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
                rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
              ),
              borderData: FlBorderData(show: false),
              barGroups: [
                BarChartGroupData(x: 0, barRods: [BarChartRodData(toY: 8000, color: Colors.green)]),
                BarChartGroupData(x: 1, barRods: [BarChartRodData(toY: 8200, color: Colors.green)]),
                BarChartGroupData(x: 2, barRods: [BarChartRodData(toY: 8100, color: Colors.green)]),
                BarChartGroupData(x: 3, barRods: [BarChartRodData(toY: 8300, color: Colors.green)]),
                BarChartGroupData(x: 4, barRods: [BarChartRodData(toY: 8400, color: Colors.green)]),
                BarChartGroupData(x: 5, barRods: [BarChartRodData(toY: 8500, color: Colors.green)]),
              ],
            ),
          ),
        ),
      ],
    ),
  );
}

为什么选择柱状图

收入趋势用柱状图比折线图更合适。柱状图能清楚地展示每个月的具体数值,而且柱子的高度对比很直观。看到柱子一个比一个高,就知道收入在增长。

BarChart的关键参数

alignment: BarChartAlignment.spaceAround让柱子均匀分布,两边留白。maxY: 10000设置Y轴最大值,这个值要根据实际数据动态调整,太小会导致柱子顶到天花板,太大会让柱子看起来很矮。

底部标签的处理

getTitlesWidget这个回调很关键,它决定了X轴显示什么文字:

getTitlesWidget: (value, meta) {
  const months = ['1月', '2月', '3月', '4月', '5月', '6月'];
  if (value.toInt() >= 0 && value.toInt() < months.length) {
    return Text(months[value.toInt()], style: TextStyle(fontSize: 12.sp));
  }
  return const Text('');
}

value是柱子的索引,从0开始。我定义了一个月份数组,根据索引取对应的月份名称。这里要做边界检查,避免数组越界。

隐藏其他轴的标签

左、上、右三个轴的标签都设置为不显示,因为不需要。只保留底部的月份标签就够了。borderData: FlBorderData(show: false)隐藏边框,让图表看起来更简洁。

柱子数据的构建

每个柱子用BarChartGroupData表示,x是位置,barRods是柱子的数据。toY是柱子的高度,对应收入金额。所有柱子都用绿色,和顶部卡片的主题色保持一致。

从数据可以看出,收入从8000逐渐增长到8500,这是一个稳定上升的趋势。用户看到这个图表,会很有成就感。

收入明细列表的设计

图表下方是具体的收入明细,让用户知道钱是从哪儿来的:

Widget _buildIncomeList() {
  final incomes = [
    {'source': '工资', 'amount': 8000.0, 'date': '2024-01-05'},
    {'source': '奖金', 'amount': 500.0, 'date': '2024-01-15'},
  ];

  return Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Text(
        '收入明细',
        style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.bold),
      ),
      SizedBox(height: 12.h),
      ...incomes.map((income) => Container(
        margin: EdgeInsets.only(bottom: 12.h),
        padding: EdgeInsets.all(16.w),
        decoration: BoxDecoration(
          color: Colors.white,
          borderRadius: BorderRadius.circular(12.r),
        ),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  income['source'] as String,
                  style: TextStyle(fontSize: 14.sp, fontWeight: FontWeight.bold),
                ),
                SizedBox(height: 4.h),
                Text(
                  income['date'] as String,
                  style: TextStyle(fontSize: 12.sp, color: Colors.grey),
                ),
              ],
            ),
            Text(
              '+¥${income['amount']}',
              style: TextStyle(
                fontSize: 16.sp,
                fontWeight: FontWeight.bold,
                color: Colors.green,
              ),
            ),
          ],
        ),
      )),
    ],
  );
}

数据结构的设计

每条收入记录包含三个字段:来源、金额、日期。这是最基本的信息。实际项目中,还应该加上分类、备注等字段。

展开运算符的妙用

注意看...incomes.map()这行代码,三个点是Dart的展开运算符。map返回的是一个Iterable,展开运算符把它拆成一个个Widget,直接放进Column的children里。

这样写比用ListView.builder简洁,因为数据量不大,不需要懒加载。

卡片布局的细节

每条记录用一个白色卡片展示,左边是来源和日期,右边是金额。mainAxisAlignment: MainAxisAlignment.spaceBetween让左右两边分别靠边,中间自动留白。

金额的正向标识

金额前面加了个加号,表示这是收入。颜色用绿色,和支出的红色形成对比。这种视觉区分很重要,用户一眼就能看出是收入还是支出。

实际使用中的改进

这个页面目前是静态数据,实际使用时需要做一些改进。

数据来源的处理

收入数据应该从数据库读取,而不是硬编码。可以用FutureBuilder来处理异步数据加载:

FutureBuilder<List<Income>>(
  future: _loadIncomeData(),
  builder: (context, snapshot) {
    if (snapshot.hasData) {
      return _buildIncomeList(snapshot.data!);
    }
    return const CircularProgressIndicator();
  },
)

时间范围的筛选

用户可能想看不同时间段的收入,比如本周、本月、本年。可以在AppBar加个下拉菜单,让用户选择时间范围。

收入来源的分类统计

可以加个饼图,展示各个来源的占比。比如工资占80%,奖金占10%,兼职占10%。这样能更清楚地看出收入结构。

同比环比的对比

除了"较上月增长5%“,还可以加上同比数据,比如"较去年同期增长15%”。这样能看出长期趋势。

图表库的选择

Flutter有很多图表库,我选择fl_chart是因为它功能强大、文档完善、社区活跃。

fl_chart的优点

  • 支持多种图表类型:折线图、柱状图、饼图、雷达图等
  • 高度可定制:颜色、样式、动画都能自定义
  • 性能不错:即使数据量大也能流畅渲染
  • 文档详细:官方示例很全面,遇到问题容易找到解决方案

其他可选的图表库

如果fl_chart不满足需求,还可以试试:

  • charts_flutter:Google官方的图表库,但已经不维护了
  • syncfusion_flutter_charts:功能最强大,但是商业使用要付费
  • graphic:基于Grammar of Graphics理论,适合复杂的数据可视化

性能优化的考虑

虽然这个页面数据量不大,但还是要考虑性能。

图表的渲染优化

fl_chart的图表是用Canvas绘制的,性能已经很好了。但如果数据点特别多,可以考虑降采样,只显示关键的数据点。

列表的懒加载

如果收入明细很多,应该用ListView.builder代替Column+map的方式。ListView.builder只会构建可见的item,内存占用更少。

数据缓存

从数据库读取的数据可以缓存起来,避免每次进入页面都重新查询。可以用ProviderGetX来管理状态。

用户体验的细节

除了功能实现,用户体验也很重要。

加载状态的提示

数据加载时,应该显示一个loading指示器,让用户知道正在加载。不要让页面空白,用户会以为卡住了。

空状态的处理

如果用户还没有收入记录,应该显示一个友好的空状态页面,引导用户添加第一条记录。可以放个插图,配上"还没有收入记录,快去添加吧"这样的文字。

错误处理

如果数据加载失败,要给用户明确的提示,并提供重试按钮。不要只是打印错误日志,用户看不到。

动画效果

图表可以加个入场动画,柱子从下往上长出来,会更有趣。fl_chart支持动画,只需要设置animate: true

数据统计的扩展

收入分析还可以做得更深入。

收入预测

根据历史数据,预测下个月的收入。可以用简单的线性回归,或者更复杂的时间序列模型。

收入目标设定

用户可以设定月收入目标,比如10000元。页面上显示当前进度,激励用户努力达成目标。

收入来源的趋势分析

不仅看总收入的趋势,还要看各个来源的趋势。比如工资稳定,但兼职收入在增长,说明副业做得不错。

收支对比

把收入和支出放在一起对比,看看每个月能存下多少钱。如果支出大于收入,要及时调整。

小结

今天实现了收入分析统计功能,用到了渐变卡片、柱状图、列表展示等组件。核心是用fl_chart绘制收入趋势图,让用户直观地看到收入变化。

这个功能虽然看起来简单,但对用户的价值很大。看着收入曲线上升,会有成就感和动力。通过分析收入来源,能更好地规划财务。

在实现过程中,我特别注重视觉设计。绿色渐变的卡片、清晰的图表、简洁的列表,这些细节都是为了让用户用得舒服。毕竟,一个好看又好用的工具,才能让用户坚持记账。

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

Logo

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

更多推荐