Flutter for OpenHarmony 个人理财管理App实战 - 支出分析页面

支出分析页面专注于分析用户的支出情况,展示本月总支出、各分类的支出占比和消费趋势。帮助用户了解钱都花在了哪里,为优化支出提供数据支持。

功能设计

支出分析页面包含以下功能:

  1. 本月总支出金额展示
  2. 支出构成饼图可视化
  3. 分类支出明细列表
  4. 月份切换查看历史数据
  5. 支出趋势对比
  6. 支出排行榜

控制器实现

创建 expense_analysis_controller.dart 管理页面状态:

dart 复制代码
import 'package:get/get.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';

class ExpenseAnalysisController extends GetxController {
  final _transactionService = Get.find<TransactionService>();
  final _categoryService = Get.find<CategoryService>();
  final _storage = Get.find<StorageService>();

导入必要的依赖包和服务类。ExpenseAnalysisController 继承自 GetxController,负责管理支出分析页面的状态和业务逻辑。通过 Get.find 获取已注册的服务实例,包括交易服务、分类服务和存储服务。这种依赖注入方式让控制器与服务解耦,便于测试和维护。

dart 复制代码
  final selectedMonth = DateTime.now().obs;
  final totalExpense = 0.0.obs;
  final categoryData = <Map<String, dynamic>>[].obs;
  final dailyExpense = <DateTime, double>{}.obs;
  final isLoading = true.obs;

  String get currency => _storage.currency;

  @override
  void onInit() {

定义响应式变量存储页面状态。selectedMonth 存储当前选择的月份,totalExpense 存储总支出金额,categoryData 存储各分类的支出数据,dailyExpense 存储每日支出数据。isLoading 标识数据加载状态。使用 .obs 后缀创建可观察对象,值改变时自动通知 UI 更新。currency 是 getter,从存储服务获取货币设置。

dart 复制代码
    super.onInit();
    _loadData();
    ever(selectedMonth, (_) => _loadData());
  }

  void _loadData() {
    isLoading.value = true;

    final start = DateTime(selectedMonth.value.year, selectedMonth.value.month, 1);
    final end = DateTime(selectedMonth.value.year, selectedMonth.value.month + 1, 0);

onInit 生命周期方法在控制器初始化时调用。首先加载数据,然后使用 ever 监听 selectedMonth 变化,当月份改变时自动重新加载数据。_loadData 方法负责加载支出数据。先设置加载状态为 true,然后计算选中月份的起止日期。start 是月初第一天,end 是月末最后一天。

dart 复制代码
    final transactions = _transactionService
        .getTransactionsByDateRange(start, end)
        .where((t) => t.type == TransactionType.expense)
        .toList();

    totalExpense.value = transactions.fold(0.0, (sum, t) => sum + t.amount);

    // 按分类汇总
    final byCategory = <String, double>{};

从交易服务获取指定日期范围内的交易记录,筛选出支出类型的交易。使用 fold 方法累加所有交易金额得到总支出。创建两个 Map 用于按分类汇总数据:byCategory 存储每个分类的总金额,countByCategory 存储每个分类的交易笔数。这种分组统计是数据分析的基础。

dart 复制代码
    final countByCategory = <String, int>{};
    for (var t in transactions) {
      byCategory[t.categoryId] = (byCategory[t.categoryId] ?? 0) + t.amount;
      countByCategory[t.categoryId] = (countByCategory[t.categoryId] ?? 0) + 1;
    }

    // 排序并构建数据
    final sorted = byCategory.entries.toList()
      ..sort((a, b) => b.value.compareTo(a.value));

遍历所有交易记录,按分类 ID 累加金额和笔数。使用 ?? 运算符处理首次出现的分类,初始值为 0。将 byCategory 的条目转换为列表并按金额降序排序,这样支出最多的分类会排在前面。级联运算符 ... 让排序操作直接在列表上执行并返回列表本身。

dart 复制代码
    categoryData.value = sorted.map((e) {
      final category = _categoryService.getCategoryById(e.key);
      return {
        'categoryId': e.key,
        'category': category,
        'amount': e.value,
        'count': countByCategory[e.key] ?? 0,
        'percentage': totalExpense.value > 0 ? e.value / totalExpense.value : 0.0,
      };
    }).toList();

将排序后的数据转换为包含完整信息的 Map 列表。对每个分类,从分类服务获取分类对象,构建包含分类 ID、分类对象、金额、笔数和占比的 Map。percentage 计算该分类占总支出的百分比,如果总支出为 0 则返回 0 避免除零错误。这个数据结构为 UI 展示提供了所需的全部信息。

dart 复制代码
    // 按日汇总
    final daily = <DateTime, double>{};
    for (var t in transactions) {
      final day = DateTime(t.date.year, t.date.month, t.date.day);
      daily[day] = (daily[day] ?? 0) + t.amount;
    }
    dailyExpense.value = daily;

    isLoading.value = false;

创建 Map 存储每日支出数据。遍历交易记录,将日期时间标准化为只包含年月日的 DateTime 对象,然后累加该日的支出金额。这个数据可用于绘制支出趋势图。将汇总结果赋值给 dailyExpense 响应式变量。最后设置加载状态为 false,通知 UI 数据加载完成。

dart 复制代码
  }

  void previousMonth() {
    selectedMonth.value = DateTime(
      selectedMonth.value.year,
      selectedMonth.value.month - 1,
    );
  }

  void nextMonth() {

previousMonth 方法切换到上一个月。创建新的 DateTime 对象,月份减 1。DateTime 构造函数会自动处理跨年情况,例如 2024年1月减1会变成2023年12月。selectedMonth 值改变会触发 ever 监听器,自动重新加载数据。nextMonth 方法类似,切换到下一个月。

dart 复制代码
    selectedMonth.value = DateTime(
      selectedMonth.value.year,
      selectedMonth.value.month + 1,
    );
  }

  // 获取上月支出用于对比
  double get lastMonthExpense {
    final lastMonth = DateTime(selectedMonth.value.year, selectedMonth.value.month - 1);
    final start = DateTime(lastMonth.year, lastMonth.month, 1);

nextMonth 将月份加 1,DateTime 自动处理跨年。lastMonthExpense 是计算属性,获取上月总支出用于环比对比。首先计算上月的日期,然后确定上月的起止日期。这个方法每次访问时都会重新计算,确保数据是最新的。

dart 复制代码
    final end = DateTime(lastMonth.year, lastMonth.month + 1, 0);

    return _transactionService
        .getTransactionsByDateRange(start, end)
        .where((t) => t.type == TransactionType.expense)
        .fold(0.0, (sum, t) => sum + t.amount);
  }

  // 环比变化
  double get monthOverMonthChange {

计算上月的结束日期。从交易服务获取上月的支出交易,筛选支出类型,累加金额得到上月总支出。这个值用于计算环比变化。monthOverMonthChange 计算属性返回环比变化百分比,反映支出的增长或下降趋势。

dart 复制代码
    if (lastMonthExpense == 0) return 0;
    return (totalExpense.value - lastMonthExpense) / lastMonthExpense * 100;
  }

  // 日均支出
  double get dailyAverage {
    final daysInMonth = DateTime(
      selectedMonth.value.year,
      selectedMonth.value.month + 1,
      0,

如果上月支出为 0,返回 0 避免除零错误。否则计算环比变化:(本月支出 - 上月支出) / 上月支出 * 100,得到百分比。正值表示支出增加,负值表示支出减少。dailyAverage 计算属性返回日均支出。首先获取选中月份的天数,使用 DateTime 构造函数的技巧:月份加 1,日期设为 0,会得到上月最后一天,其 day 属性就是天数。

dart 复制代码
    ).day;
    final now = DateTime.now();
    final currentDay = selectedMonth.value.year == now.year &&
            selectedMonth.value.month == now.month
        ? now.day
        : daysInMonth;
    return totalExpense.value / currentDay;
  }
}

获取当前日期。如果选中的是当前月份,使用当前日期作为天数(因为本月还没结束);否则使用整月天数。最后用总支出除以天数得到日均支出。这个指标帮助用户了解平均每天花费多少钱,便于控制日常开支。

页面实现

创建 expense_analysis_page.dart

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 'expense_analysis_controller.dart';

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

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

  @override
  Widget build(BuildContext context) {
    final controller = Get.put(ExpenseAnalysisController());

    return Scaffold(
      appBar: AppBar(
        title: const Text('支出分析'),
        centerTitle: true,
      ),
      body: Obx(() {
        if (controller.isLoading.value) {
          return const Center(child: CircularProgressIndicator());
        }
        return SingleChildScrollView(
          padding: EdgeInsets.all(16.w),
          child: Column(
            children: [
              _buildMonthSelector(controller),
              SizedBox(height: 16.h),
              _buildTotalCard(controller),
              SizedBox(height: 16.h),
              _buildStatsRow(controller),
              SizedBox(height: 16.h),
              _buildChartCard(controller),
              SizedBox(height: 16.h),
              _buildCategoryList(controller),
            ],
          ),
        );
      }),
    );
  }
}

月份选择器

dart 复制代码
Widget _buildMonthSelector(ExpenseAnalysisController controller) {
  return Obx(() => Container(
    padding: EdgeInsets.symmetric(vertical: 8.h),
    child: Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        IconButton(
          icon: const Icon(Icons.chevron_left),
          onPressed: controller.previousMonth,

_buildMonthSelector 方法构建月份选择器组件。Obx 包裹确保月份变化时自动更新显示。Container 添加垂直 8 像素内边距。Row 水平居中排列左箭头、月份显示和右箭头。IconButton 显示左箭头图标,点击时调用 previousMonth 方法切换到上一个月。这种左右箭头的设计符合用户对日期选择的认知习惯。

dart 复制代码
        ),
        GestureDetector(
          onTap: () => _showMonthPicker(controller),
          child: Container(
            padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
            decoration: BoxDecoration(
              color: _expenseColor.withOpacity(0.1),
              borderRadius: BorderRadius.circular(20.r),
            ),

GestureDetector 包裹月份显示区域,点击时弹出月份选择器。Container 添加水平 16 像素、垂直 8 像素的内边距。BoxDecoration 定义样式,背景色为支出红色的 10% 透明度,圆角半径 20 像素。这个圆角矩形背景让月份显示区域更突出,提示用户可以点击。

dart 复制代码
            child: Row(
              children: [
                Icon(Icons.calendar_today, size: 16.sp, color: _expenseColor),
                SizedBox(width: 8.w),
                Text(
                  DateFormat('yyyy年MM月').format(controller.selectedMonth.value),
                  style: TextStyle(
                    fontSize: 16.sp,
                    fontWeight: FontWeight.w600,

Row 水平排列日历图标和月份文字。日历图标大小 16 像素,颜色为支出红色。SizedBox 添加 8 像素间距。Text 使用 DateFormat 格式化显示月份,格式为"yyyy年MM月",例如"2024年01月"。字体大小 16 像素,中等粗细,颜色为支出红色。这种格式化显示让日期更易读。

dart 复制代码
                    color: _expenseColor,
                  ),
                ),
              ],
            ),
          ),
        ),
        IconButton(
          icon: const Icon(Icons.chevron_right),
          onPressed: controller.nextMonth,
        ),
      ],
    ),
  ));
}

完成月份显示区域的构建。IconButton 显示右箭头图标,点击时调用 nextMonth 方法切换到下一个月。整个月份选择器的设计简洁直观,用户可以通过左右箭头快速切换月份,也可以点击中间区域打开日期选择器跳转到任意月份。

dart 复制代码
void _showMonthPicker(ExpenseAnalysisController controller) {
  showDatePicker(
    context: Get.context!,
    initialDate: controller.selectedMonth.value,
    firstDate: DateTime(2020),
    lastDate: DateTime.now(),
    initialDatePickerMode: DatePickerMode.year,
  ).then((date) {

_showMonthPicker 方法显示日期选择器对话框。使用 Flutter 内置的 showDatePicker 函数。context 从 GetX 获取,initialDate 设置为当前选中的月份。firstDate 设置为 2020年,限制最早可选日期。lastDate 设置为当前日期,不能选择未来月份。initialDatePickerMode 设置为 year,让选择器默认显示年份视图,方便快速选择月份。

dart 复制代码
    if (date != null) {
      controller.selectedMonth.value = date;
    }
  });
}

showDatePicker 返回 Future,使用 then 处理选择结果。如果用户选择了日期(不是取消),将选中的日期赋值给 selectedMonth。这会触发 ever 监听器,自动重新加载该月份的数据。日期选择器提供了快速跳转到任意月份的能力,提升用户体验。

总支出卡片

dart 复制代码
Widget _buildTotalCard(ExpenseAnalysisController controller) {
  return Card(
    elevation: 2,
    shape: RoundedRectangleBorder(
      borderRadius: BorderRadius.circular(16.r),
    ),
    child: Container(
      width: double.infinity,
      padding: EdgeInsets.all(24.w),
      decoration: BoxDecoration(

_buildTotalCard 方法构建总支出卡片。Card 组件提供卡片样式,elevation 设置阴影深度为 2。shape 定义圆角矩形边框,圆角半径 16 像素。Container 宽度设置为 double.infinity 占满父容器宽度,padding 添加 24 像素内边距。BoxDecoration 定义容器装饰样式,使用渐变背景增强视觉效果。

dart 复制代码
        gradient: LinearGradient(
          colors: [_expenseColor, _expenseColor.withOpacity(0.8)],
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
        ),
        borderRadius: BorderRadius.circular(16.r),
      ),
      child: Column(
        children: [
          Row(

LinearGradient 创建线性渐变,从左上到右下,颜色从完全不透明的支出红色渐变到 80% 透明度。这种渐变效果让卡片更有层次感和现代感。borderRadius 设置圆角与 Card 保持一致。Column 垂直排列卡片内容,包括标题、金额和笔数。

dart 复制代码
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Icon(Icons.trending_down, color: Colors.white70, size: 20.sp),
              SizedBox(width: 8.w),
              Text(
                '本月总支出',
                style: TextStyle(
                  fontSize: 14.sp,
                  color: Colors.white70,

Row 水平居中排列图标和标题。trending_down 图标表示支出趋势,颜色为白色 70% 透明度,大小 20 像素。SizedBox 添加 8 像素间距。Text 显示"本月总支出"标题,字体大小 14 像素,颜色为白色 70% 透明度。在深色背景上使用半透明白色文字,视觉效果柔和舒适。

dart 复制代码
                ),
              ),
            ],
          ),
          SizedBox(height: 12.h),
          Obx(() => Text(
            '${controller.currency}${controller.totalExpense.value.toStringAsFixed(2)}',
            style: TextStyle(
              fontSize: 36.sp,
              fontWeight: FontWeight.bold,

完成标题行。SizedBox 添加 12 像素垂直间距。Obx 包裹金额文本,确保总支出变化时自动更新。Text 显示货币符号和金额,保留两位小数。字体大小 36 像素,加粗显示,这是卡片的视觉焦点。大号字体让用户一眼就能看到本月花了多少钱。

dart 复制代码
              color: Colors.white,
            ),
          )),
          SizedBox(height: 8.h),
          Obx(() => Text(
            '共 ${controller.categoryData.fold(0, (sum, item) => sum + (item['count'] as int))} 笔支出',
            style: TextStyle(
              fontSize: 12.sp,
              color: Colors.white70,
            ),

金额文字颜色为纯白色,在渐变背景上清晰可见。SizedBox 添加 8 像素间距。Obx 包裹笔数文本,确保数据变化时自动更新。使用 fold 方法累加所有分类的交易笔数,显示"共 X 笔支出"。字体大小 12 像素,颜色为白色 70% 透明度。这个辅助信息让用户了解支出的频率。

dart 复制代码
          )),
        ],
      ),
    ),
  );
}

完成总支出卡片的构建。整个卡片使用红色渐变背景和白色文字,视觉上强调支出的警示性质。大号金额数字是视觉焦点,标题和笔数作为辅助信息。这种设计让用户快速了解本月的支出总额和交易频率,为后续的详细分析提供概览。

统计数据行

dart 复制代码
Widget _buildStatsRow(ExpenseAnalysisController controller) {
  return Row(
    children: [
      Expanded(
        child: _buildStatCard(
          '日均支出',
          controller.dailyAverage,
          Icons.calendar_view_day,
          controller.currency,
        ),

_buildStatsRow 方法构建统计数据行,并排显示两个统计卡片。Row 水平排列子组件。第一个 Expanded 让日均支出卡片占据一半宽度。_buildStatCard 方法创建统计卡片,传入标题"日均支出"、日均金额、日历图标和货币符号。日均支出帮助用户了解平均每天花费多少钱。

dart 复制代码
      ),
      SizedBox(width: 12.w),
      Expanded(
        child: _buildStatCard(
          '环比变化',
          controller.monthOverMonthChange,
          controller.monthOverMonthChange >= 0
              ? Icons.arrow_upward
              : Icons.arrow_downward,
          '%',
          isPercentage: true,

SizedBox 在两个卡片之间添加 12 像素间距。第二个 Expanded 让环比变化卡片占据另一半宽度。环比变化卡片显示与上月的对比,根据值的正负选择向上或向下箭头图标。后缀为百分号,isPercentage 参数设置为 true 表示这是百分比数据。环比变化帮助用户了解支出趋势。

dart 复制代码
        ),
      ),
    ],
  );
}

Widget _buildStatCard(
  String label,
  double value,
  IconData icon,
  String suffix, {
  bool isPercentage = false,
}) {

完成统计数据行的构建。_buildStatCard 方法创建单个统计卡片。接收标题、数值、图标、后缀和是否为百分比五个参数。isPercentage 是可选命名参数,默认为 false。这个方法可以复用来创建不同类型的统计卡片,提高代码复用性。

dart 复制代码
  final color = isPercentage
      ? (value >= 0 ? _expenseColor : Colors.green)
      : _textSecondary;

  return Card(
    shape: RoundedRectangleBorder(
      borderRadius: BorderRadius.circular(12.r),
    ),
    child: Padding(
      padding: EdgeInsets.all(16.w),

根据是否为百分比和值的正负确定颜色。如果是百分比,正值用红色(支出增加是坏事),负值用绿色(支出减少是好事);否则用次要文本灰色。Card 组件提供卡片样式,圆角半径 12 像素。Padding 为卡片内容添加 16 像素内边距。

dart 复制代码
      child: Column(
        children: [
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Icon(icon, size: 16.sp, color: color),
              SizedBox(width: 4.w),
              Text(
                label,
                style: TextStyle(fontSize: 12.sp, color: _textSecondary),

Column 垂直排列标题和数值。Row 水平居中排列图标和标题文字。图标大小 16 像素,颜色根据前面的逻辑确定。SizedBox 添加 4 像素间距。Text 显示标题,字体大小 12 像素,颜色为次要文本灰色。标题和图标组合让用户快速理解这个统计项的含义。

dart 复制代码
              ),
            ],
          ),
          SizedBox(height: 8.h),
          Text(
            isPercentage
                ? '${value >= 0 ? '+' : ''}${value.toStringAsFixed(1)}$suffix'
                : '$suffix${value.toStringAsFixed(2)}',
            style: TextStyle(
              fontSize: 18.sp,
              fontWeight: FontWeight.bold,

完成标题行。SizedBox 添加 8 像素垂直间距。Text 显示数值,根据是否为百分比使用不同格式。百分比格式:正值前加"+"号,保留一位小数,后缀为"%";普通格式:前缀为货币符号,保留两位小数。字体大小 18 像素,加粗显示,这是卡片的重点信息。

dart 复制代码
              color: isPercentage ? color : Colors.black87,
            ),
          ),
        ],
      ),
    ),
  );
}

数值颜色根据类型确定。如果是百分比,使用前面计算的颜色(红色或绿色);否则使用黑色 87% 透明度。完成统计卡片的构建。这个设计让统计数据清晰易读,颜色编码帮助用户快速判断趋势好坏。两个统计卡片并排显示,充分利用屏幕宽度,提供更多有价值的信息。

支出构成图表

dart 复制代码
Widget _buildChartCard(ExpenseAnalysisController controller) {
  return Obx(() {
    if (controller.categoryData.isEmpty) {
      return Card(
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(12.r),
        ),
        child: Padding(
          padding: EdgeInsets.all(32.w),
          child: Column(

_buildChartCard 方法构建支出构成图表卡片。Obx 包裹确保数据变化时自动更新。首先检查是否有数据,如果 categoryData 为空,显示空状态提示。Card 组件提供卡片样式,圆角半径 12 像素。Padding 添加 32 像素内边距。Column 垂直排列空状态的图标和文字。

dart 复制代码
            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(

显示饼图轮廓图标,大小 64 像素,颜色为浅灰色,表示空状态。SizedBox 添加 16 像素间距。Text 显示"暂无支出数据"提示,字体大小 16 像素,颜色为次要文本灰色。再添加 8 像素间距。第二个 Text 显示引导文字,鼓励用户开始记账。

dart 复制代码
                '记录您的第一笔支出吧',
                style: TextStyle(color: _textSecondary.withOpacity(0.7), fontSize: 12.sp),
              ),
            ],
          ),
        ),
      );
    }

    return Card(
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(12.r),

引导文字字体较小,颜色更淡,视觉上弱化。完成空状态的构建。如果有数据,返回包含饼图的卡片。Card 组件提供卡片样式,圆角半径 12 像素。这种空状态设计友好地引导新用户开始使用应用。

dart 复制代码
      ),
      child: Padding(
        padding: EdgeInsets.all(16.w),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                Icon(Icons.pie_chart, color: _expenseColor, size: 20.sp),
                SizedBox(width: 8.w),

Padding 为卡片内容添加 16 像素内边距。Column 垂直排列标题、图表和图例,crossAxisAlignment 设置为 start 让内容左对齐。Row 水平排列图标和标题。饼图图标大小 20 像素,颜色为支出红色。SizedBox 添加 8 像素间距。

dart 复制代码
                Text(
                  '支出构成',
                  style: TextStyle(
                    fontSize: 16.sp,
                    fontWeight: FontWeight.w600,
                  ),
                ),
              ],
            ),
            SizedBox(height: 24.h),
            SizedBox(

Text 显示"支出构成"标题,字体大小 16 像素,中等粗细。完成标题行。SizedBox 添加 24 像素垂直间距,为图表留出空间。第二个 SizedBox 设置图表容器的高度为 200 像素,确保饼图有足够的显示空间。

dart 复制代码
              height: 200.h,
              child: PieChart(
                PieChartData(
                  sections: controller.categoryData.take(6).map((item) {
                    final category = item['category'];
                    final percentage = item['percentage'] as double;
                    return PieChartSectionData(
                      value: item['amount'] as double,
                      title: percentage >= 0.05
                          ? '${(percentage * 100).toStringAsFixed(0)}%'

PieChart 是 fl_chart 库提供的饼图组件。PieChartData 定义图表数据。sections 从 categoryData 取前 6 个分类(避免图表过于拥挤)。map 方法遍历每个分类,提取分类对象和占比。PieChartSectionData 定义饼图的一个扇区。value 是金额,title 是显示的文字,只有占比大于等于 5% 才显示百分比,避免小扇区文字重叠。

dart 复制代码
                          : '',
                      color: category?.color ?? Colors.grey,
                      radius: 60.r,
                      titleStyle: TextStyle(
                        fontSize: 11.sp,
                        color: Colors.white,
                        fontWeight: FontWeight.bold,
                      ),
                    );
                  }).toList(),

如果占比小于 5%,title 为空字符串不显示。color 使用分类的颜色,如果分类为空则用灰色。radius 设置扇区半径为 60 像素。titleStyle 定义标题样式,字体大小 11 像素,白色,加粗。这些参数共同决定了饼图的视觉效果。

dart 复制代码
                  sectionsSpace: 2,
                  centerSpaceRadius: 50.r,
                  centerSpaceColor: Colors.white,
                ),
              ),
            ),
            SizedBox(height: 16.h),
            _buildLegend(controller),
          ],
        ),
      ),
    );

sectionsSpace 设置扇区之间的间隙为 2 像素,让各扇区分隔更清晰。centerSpaceRadius 设置中心空白区域半径为 50 像素,创建环形图效果。centerSpaceColor 设置中心颜色为白色。完成饼图配置。SizedBox 添加 16 像素间距。_buildLegend 方法创建图例,说明各颜色代表的分类。

dart 复制代码
  });
}

Widget _buildLegend(ExpenseAnalysisController controller) {
  return Wrap(
    spacing: 16.w,
    runSpacing: 8.h,
    children: controller.categoryData.take(6).map((item) {
      final category = item['category'];
      return Row(
        mainAxisSize: MainAxisSize.min,

完成图表卡片的构建。_buildLegend 方法创建图例组件。Wrap 是自动换行的布局组件,spacing 设置水平间距 16 像素,runSpacing 设置垂直间距 8 像素。从 categoryData 取前 6 个分类(与饼图一致)。map 方法遍历每个分类,提取分类对象。Row 的 mainAxisSize 设置为 min,让图例项宽度自适应内容。

dart 复制代码
        children: [
          Container(
            width: 12.w,
            height: 12.w,
            decoration: BoxDecoration(
              color: category?.color ?? Colors.grey,
              shape: BoxShape.circle,
            ),
          ),
          SizedBox(width: 4.w),

Container 创建颜色指示器,宽高都是 12 像素。BoxDecoration 定义装饰,颜色使用分类的颜色,shape 设置为圆形。这个小圆点与饼图中的颜色对应。SizedBox 在圆点和文字之间添加 4 像素间距。

dart 复制代码
          Text(
            category?.name ?? '未知',
            style: TextStyle(fontSize: 12.sp, color: _textSecondary),
          ),
        ],
      );
    }).toList(),
  );
}

Text 显示分类名称,如果分类为空显示"未知"。字体大小 12 像素,颜色为次要文本灰色。完成单个图例项的构建。Wrap 会自动排列所有图例项,必要时换行。图例让用户清楚知道饼图中各颜色代表的分类,提升图表的可读性。整个图表卡片通过饼图和图例的组合,直观展示支出构成。

分类支出列表

dart 复制代码
Widget _buildCategoryList(ExpenseAnalysisController controller) {
  return Obx(() {
    if (controller.categoryData.isEmpty) {
      return const SizedBox.shrink();
    }

    return Card(
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(12.r),
      ),

_buildCategoryList 方法构建分类支出列表。Obx 包裹确保数据变化时自动更新。首先检查是否有数据,如果为空返回 SizedBox.shrink(),这是一个零尺寸的占位组件,不占用任何空间。如果有数据,返回包含列表的卡片。Card 组件提供卡片样式,圆角半径 12 像素。

dart 复制代码
      child: Padding(
        padding: EdgeInsets.all(16.w),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Row(
                  children: [

Padding 为卡片内容添加 16 像素内边距。Column 垂直排列标题和列表项,crossAxisAlignment 设置为 start 让内容左对齐。第一个 Row 在标题和分类数量之间留出空间,mainAxisAlignment 设置为 spaceBetween。第二个 Row 水平排列图标和标题文字。

dart 复制代码
                    Icon(Icons.list, color: _expenseColor, size: 20.sp),
                    SizedBox(width: 8.w),
                    Text(
                      '支出明细',
                      style: TextStyle(
                        fontSize: 16.sp,
                        fontWeight: FontWeight.w600,
                      ),
                    ),
                  ],

列表图标大小 20 像素,颜色为支出红色。SizedBox 添加 8 像素间距。Text 显示"支出明细"标题,字体大小 16 像素,中等粗细。完成标题部分。这个标题让用户知道下面是详细的分类支出列表。

dart 复制代码
                ),
                Text(
                  '共 ${controller.categoryData.length} 个分类',
                  style: TextStyle(fontSize: 12.sp, color: _textSecondary),
                ),
              ],
            ),
            SizedBox(height: 16.h),
            ...controller.categoryData.asMap().entries.map((entry) {
              final index = entry.key;

Text 显示分类总数,例如"共 5 个分类",字体大小 12 像素,颜色为次要文本灰色。这个统计信息让用户了解支出涉及多少个分类。SizedBox 添加 16 像素间距。使用扩展运算符 ... 将列表项展开到 Column 的 children 中。asMap().entries 将列表转换为带索引的条目,提取索引和数据项。

dart 复制代码
              final item = entry.value;
              final category = item['category'];
              final amount = item['amount'] as double;
              final count = item['count'] as int;
              final percentage = item['percentage'] as double;

              return Column(
                children: [
                  _buildCategoryItem(
                    index + 1,

从数据项中提取各个字段:分类对象、金额、笔数和占比。这些数据将传递给 _buildCategoryItem 方法构建列表项。Column 包裹列表项和分隔线。_buildCategoryItem 方法创建单个分类项,第一个参数是排名(索引加 1,因为索引从 0 开始)。

dart 复制代码
                    category,
                    amount,
                    count,
                    percentage,
                    controller.currency,
                  ),
                  if (index < controller.categoryData.length - 1)
                    Divider(height: 24.h),
                ],
              );
            }),

传递分类对象、金额、笔数、占比和货币符号给 _buildCategoryItem。使用 if 条件判断,只在非最后一项后面添加分隔线。Divider 是水平分隔线,height 设置为 24 像素(包含上下间距)。这样列表项之间有清晰的分隔,但最后一项后面没有多余的分隔线。

dart 复制代码
          ],
        ),
      ),
    );
  });
}

Widget _buildCategoryItem(
  int rank,
  dynamic category,
  double amount,
  int count,
  double percentage,
  String currency,
) {

完成分类列表的构建。_buildCategoryItem 方法创建单个分类列表项。接收排名、分类对象、金额、笔数、占比和货币符号六个参数。这个方法将这些数据组织成一个清晰的列表项,展示分类的详细支出信息。

dart 复制代码
  return Row(
    children: [
      // 排名
      Container(
        width: 24.w,
        height: 24.w,
        decoration: BoxDecoration(
          color: rank <= 3 ? _expenseColor : Colors.grey[200],
          shape: BoxShape.circle,
        ),

Row 水平排列排名、图标、名称和金额。Container 创建排名徽章,宽高都是 24 像素。BoxDecoration 定义装饰,前三名使用支出红色背景,其他使用浅灰色背景,shape 设置为圆形。这种颜色区分让前三名更突出,用户一眼就能看到主要支出去向。

dart 复制代码
        child: Center(
          child: Text(
            '$rank',
            style: TextStyle(
              fontSize: 12.sp,
              color: rank <= 3 ? Colors.white : _textSecondary,
              fontWeight: FontWeight.bold,
            ),
          ),
        ),
      ),
      SizedBox(width: 12.w),
      // 分类图标

Center 让排名数字居中显示。Text 显示排名数字,字体大小 12 像素,前三名用白色文字,其他用次要文本灰色,加粗显示。完成排名徽章。SizedBox 添加 12 像素间距。接下来是分类图标部分。

dart 复制代码
      CircleAvatar(
        radius: 18.r,
        backgroundColor: (category?.color ?? Colors.grey).withOpacity(0.2),
        child: Icon(
          category?.icon ?? Icons.help,
          size: 18.sp,
          color: category?.color ?? Colors.grey,
        ),
      ),
      SizedBox(width: 12.w),
      // 分类名称和笔数

CircleAvatar 创建圆形头像,半径 18 像素,背景色为分类颜色的 20% 透明度。Icon 显示分类图标,大小 18 像素,颜色为分类颜色。如果分类为空,使用问号图标和灰色。这个图标让用户快速识别分类。SizedBox 添加 12 像素间距。

dart 复制代码
      Expanded(
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              category?.name ?? '未知',
              style: TextStyle(
                fontSize: 14.sp,
                fontWeight: FontWeight.w500,
              ),

Expanded 让名称区域占据剩余空间。Column 垂直排列分类名称和笔数,crossAxisAlignment 设置为 start 左对齐。Text 显示分类名称,如果为空显示"未知",字体大小 14 像素,中等粗细。分类名称是列表项的主要信息。

dart 复制代码
            ),
            Text(
              '$count 笔',
              style: TextStyle(
                fontSize: 12.sp,
                color: _textSecondary,
              ),
            ),
          ],
        ),
      ),
      // 金额和占比
      Column(

Text 显示交易笔数,例如"5 笔",字体大小 12 像素,颜色为次要文本灰色。这个辅助信息让用户了解该分类的交易频率。完成名称区域。Column 垂直排列金额和占比,右对齐显示。

dart 复制代码
        crossAxisAlignment: CrossAxisAlignment.end,
        children: [
          Text(
            '$currency${amount.toStringAsFixed(2)}',
            style: TextStyle(
              fontSize: 14.sp,
              fontWeight: FontWeight.bold,
              color: _expenseColor,
            ),
          ),
          Text(

crossAxisAlignment 设置为 end 让文字右对齐。Text 显示金额,包含货币符号,保留两位小数。字体大小 14 像素,加粗,颜色为支出红色。金额是列表项的关键数据,使用醒目的颜色和加粗字体突出显示。

dart 复制代码
            '${(percentage * 100).toStringAsFixed(1)}%',
            style: TextStyle(
              fontSize: 12.sp,
              color: _textSecondary,
            ),
          ),
        ],
      ),
    ],
  );
}

Text 显示占比百分比,保留一位小数,例如"25.5%"。字体大小 12 像素,颜色为次要文本灰色。占比让用户了解该分类在总支出中的比重。完成分类列表项的构建。整个列表项通过排名、图标、名称、笔数、金额和占比的组合,全面展示每个分类的支出情况,帮助用户清晰了解钱都花在了哪里。

设计要点

支出分析页面的设计考虑:

  1. 使用红色主题强调支出的警示性质
  2. 日均支出和环比变化帮助用户了解消费趋势
  3. 饼图直观展示各分类占比
  4. 排名显示让用户快速了解主要支出去向
  5. 支持月份切换查看历史数据
  6. 环比上升用红色,下降用绿色(支出减少是好事)

性能优化

  1. 使用 Obx 包裹需要响应式更新的部分
  2. 数据计算在控制器中完成,避免在 build 中重复计算
  3. 图表使用 fl_chart 库,性能优秀
  4. 饼图最多显示6个分类,避免过于拥挤

小结

支出分析页面帮助用户了解钱都花在了哪里,通过直观的图表和详细的列表,用户可以清晰地看到自己的消费结构,为优化支出提供数据支持。下一篇将实现收入分析页面。


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

https://openharmonycrossplatform.csdn.net

相关推荐
qq_417129252 小时前
C++中的桥接模式变体
开发语言·c++·算法
开源技术2 小时前
如何将本地LLM模型与Ollama和Python集成
开发语言·python
Hello World . .2 小时前
数据结构:队列
c语言·开发语言·数据结构·vim
jin1233222 小时前
React Native鸿蒙跨平台完成剧本杀组队详情页面,可以复用桌游、团建、赛事等各类组队详情页开发
javascript·react native·react.js·ecmascript·harmonyos
李白你好2 小时前
Burp Suite插件用于自动检测Web应用程序中的未授权访问漏洞
前端
clever1013 小时前
在QtCreator 4.10.2中调试qt程序qDebug()输出中文为乱码问题的解决
开发语言·qt
测试开发Kevin3 小时前
小tip:换行符CRLF 和 LF 的区别以及二者在实际项目中的影响
java·开发语言·python
经年未远3 小时前
vue3中实现耳机和扬声器切换方案
javascript·学习·vue
松☆4 小时前
Dart 核心语法精讲:从空安全到流程控制(3)
android·java·开发语言