预算管理是理财应用的核心功能之一,帮助用户控制支出,实现财务目标。本篇将实现预算管理页面,支持设置月度总预算和分类预算,并通过可视化的方式展示预算使用情况。
请添加图片描述

功能需求分析

预算管理页面需要满足以下需求:

  1. 月份选择器,查看不同月份的预算情况
  2. 总预算概览,用圆形进度指示器展示使用进度
  3. 分类预算列表,用线性进度条展示各分类的使用情况
  4. 添加预算入口,方便用户设置新预算
  5. 预算超支提醒,当使用超过一定比例时给出警示

这些功能让用户可以直观地了解自己的预算执行情况,及时调整消费行为。

控制器设计

预算管理页面的数据逻辑用 GetX Controller 来管理:

import 'package:get/get.dart';
import '../../core/services/budget_service.dart';
import '../../core/services/storage_service.dart';
import '../../data/models/budget_model.dart';

class BudgetController extends GetxController {
  final _budgetService = Get.find<BudgetService>();
  final _storage = Get.find<StorageService>();
  
  final selectedMonth = DateTime.now().obs;

BudgetController 继承 GetxController 管理预算页面的状态。_budgetService 通过 Get.find 获取预算服务实例,用于读取预算数据。_storage 获取存储服务实例,用于获取货币符号等设置。selectedMonth 是响应式的当前选中月份,默认为当前月份。.obs 让它成为可观察对象,值改变时 UI 会自动更新。

  final totalBudget = 0.0.obs;
  final totalSpent = 0.0.obs;
  final overallBudget = Rxn<BudgetModel>();

  String get currency => _storage.currency;
  double get remaining => totalBudget.value - totalSpent.value;

totalBudget 存储总预算金额,totalSpent 存储已花费金额,都是响应式的 double 值。overallBudget 用 Rxn 包装,表示可能为空的响应式 BudgetModel 对象。currency getter 从存储服务获取货币符号。remaining getter 计算剩余预算,用总预算减去已花费。这些计算属性让 UI 层可以直接使用,不需要重复计算。

  double get percentage => totalBudget.value > 0 
    ? (totalSpent.value / totalBudget.value * 100).clamp(0, 100) 
    : 0;
  
  bool get isOverBudget => totalSpent.value > totalBudget.value;
  bool get isWarning => percentage > 80 && !isOverBudget;
  
  List<BudgetModel> get categoryBudgets => _budgetService.getCategoryBudgets(
    selectedMonth.value.year, 
    selectedMonth.value.month
  );

percentage getter 计算使用百分比,用已花费除以总预算再乘以 100。clamp(0, 100) 把百分比限制在 0 到 100 之间,即使超支了进度条也不会溢出。isOverBudget 判断是否超支,isWarning 判断是否接近超支(超过 80% 但未超支)。categoryBudgets getter 获取当月的所有分类预算,调用服务方法并传入年月参数。

生命周期和数据加载:

  
  void onInit() {
    super.onInit();
    _loadData();
    ever(selectedMonth, (_) => _loadData());
  }
  
  void previousMonth() {
    selectedMonth.value = DateTime(
      selectedMonth.value.year, 
      selectedMonth.value.month - 1
    );
  }

onInit 是 GetX 生命周期方法,在控制器初始化时调用。_loadData 加载数据。ever 监听 selectedMonth 的变化,每次月份改变时自动重新加载数据。previousMonth 方法切换到上一个月,使用 DateTime 构造函数,月份减 1 会自动处理跨年情况(比如 1 月减 1 变成上一年的 12 月)。

  void nextMonth() {
    selectedMonth.value = DateTime(
      selectedMonth.value.year, 
      selectedMonth.value.month + 1
    );
  }
  
  void _loadData() {
    overallBudget.value = _budgetService.getOverallBudget(
      selectedMonth.value.year, 
      selectedMonth.value.month
    );

nextMonth 方法切换到下一个月,月份加 1 也会自动处理跨年。_loadData 方法从服务获取总预算数据,调用 getOverallBudget 并传入年月参数。如果该月没有设置总预算,返回 null。

    if (overallBudget.value != null) {
      totalBudget.value = overallBudget.value!.amount;
      totalSpent.value = overallBudget.value!.spent;
    } else {
      totalBudget.value = 0;
      totalSpent.value = 0;
    }
  }
  
  void refresh() {
    _loadData();
  }
}

如果获取到总预算,将 amount 和 spent 分别赋值给 totalBudget 和 totalSpent。如果没有总预算(为 null),将两者都设为 0。refresh 方法提供手动刷新功能,供下拉刷新或从编辑页面返回时调用。这种数据加载和状态管理的模式是 GetX 的标准做法,简洁高效。

页面主体结构

import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:percent_indicator/percent_indicator.dart';
import '../../core/services/category_service.dart';
import '../../routes/app_pages.dart';
import 'budget_controller.dart';

const _primaryColor = Color(0xFF2E7D32);

导入必要的包:flutter 核心包、flutter_screenutil 屏幕适配、GetX 状态管理、intl 日期格式化、percent_indicator 进度指示器。导入 CategoryService 用于获取分类信息,Routes 用于路由跳转,BudgetController 是页面控制器。定义主题绿色常量。

const _incomeColor = Color(0xFF4CAF50);
const _expenseColor = Color(0xFFE53935);
const _warningColor = Color(0xFFFF9800);
const _textSecondary = Color(0xFF757575);

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

  
  Widget build(BuildContext context) {
    final controller = Get.put(BudgetController());

定义其他颜色常量:收入绿色、支出红色、警告橙色、次要文字灰色。BudgetPage 使用 StatelessWidget 因为状态由 Controller 管理。build 方法中用 Get.put 创建并注册 BudgetController 实例。

    return Scaffold(
      appBar: AppBar(
        title: const Text('预算管理'),
        actions: [
          IconButton(
            icon: const Icon(Icons.add), 
            onPressed: () async {
              await Get.toNamed(Routes.budgetEdit);
              controller.refresh();
            }
          ),
        ],
      ),

Scaffold 提供基本页面结构。AppBar 显示标题"预算管理",actions 中有添加按钮。点击添加按钮跳转到预算编辑页面,await 等待页面返回,然后调用 controller.refresh() 刷新数据。这确保用户添加预算后列表会更新。

      body: RefreshIndicator(
        onRefresh: () async => controller.refresh(),
        child: SingleChildScrollView(
          physics: const AlwaysScrollableScrollPhysics(),
          padding: EdgeInsets.all(16.w),
          child: Column(
            children: [
              _buildMonthSelector(controller),
              SizedBox(height: 16.h),

body 使用 RefreshIndicator 支持下拉刷新,onRefresh 调用 controller.refresh。SingleChildScrollView 让内容可以滚动,physics 设为 AlwaysScrollableScrollPhysics 确保即使内容不足以滚动也能触发下拉刷新。padding 设置 16 的内边距。Column 垂直排列各个组件,首先是月份选择器,然后是 16 高度的间距。

              _buildOverallBudget(controller),
              SizedBox(height: 16.h),
              _buildCategoryBudgets(controller),
            ],
          ),
        ),
      ),
    );
  }
}

接着是总预算概览组件,16 高度间距,最后是分类预算列表组件。这种结构清晰地展示了预算管理的三个主要部分:月份选择、总预算和分类预算。每个部分都是独立的方法,便于维护和复用。

月份选择器

Widget _buildMonthSelector(BudgetController controller) {
  return Obx(() => 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: controller.previousMonth
          ),
          Container(
            padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
            decoration: BoxDecoration(
              color: _primaryColor.withOpacity(0.1),
              borderRadius: BorderRadius.circular(8.r),
            ),
            child: Text(
              DateFormat('yyyy年MM月').format(controller.selectedMonth.value),
              style: TextStyle(
                fontSize: 16.sp, 
                fontWeight: FontWeight.w600,
                color: _primaryColor,
              )
            ),
          ),
          IconButton(
            icon: const Icon(Icons.chevron_right), 
            onPressed: controller.nextMonth
          ),
        ],
      ),
    ),
  ));
}

左右箭头切换上下月,中间显示当前选中的月份。月份文字有浅绿色背景,视觉上更突出。

总预算概览

总预算概览是页面的核心部分,用圆形进度指示器展示使用进度:

Widget _buildOverallBudget(BudgetController controller) {
  return Obx(() {
    final budget = controller.overallBudget.value;
    if (budget == null) {
      return _buildNoBudgetCard(controller);
    }
    return _buildBudgetProgressCard(controller);
  });
}

_buildOverallBudget 方法构建总预算概览。Obx 包裹让组件响应 controller.overallBudget 的变化。获取 budget 对象,如果为 null 说明没有设置预算,显示空状态卡片。如果有预算,显示进度卡片。这种条件渲染让页面可以适应不同状态。

没有预算时显示空状态卡片:

Widget _buildNoBudgetCard(BudgetController controller) {
  return Card(
    child: Padding(
      padding: EdgeInsets.all(32.w),
      child: Column(
        children: [
          Icon(
            Icons.account_balance_wallet, 
            size: 64.sp, 
            color: Colors.grey[300]
          ),

_buildNoBudgetCard 构建空状态卡片。Card 提供阴影和圆角,Padding 设置 32 的内边距让内容不贴边。Column 垂直排列图标、文字和按钮。Icon 使用钱包图标,尺寸 64.sp 比较大,颜色用浅灰色 grey[300] 表示空状态。

          SizedBox(height: 16.h),
          Text(
            '暂未设置月度预算', 
            style: TextStyle(fontSize: 16.sp, color: _textSecondary)
          ),
          SizedBox(height: 8.h),
          Text(
            '设置预算可以帮助你控制支出',
            style: TextStyle(fontSize: 14.sp, color: _textSecondary),
          ),

SizedBox 添加 16 高度的间距。第一个 Text 显示主提示"暂未设置月度预算",字号 16.sp,颜色用次要文字色。第二个 Text 显示辅助说明"设置预算可以帮助你控制支出",字号 14.sp 稍小。两个文字之间用 8 高度的间距分隔。这种双层文字的设计是空状态的标准模式。

          SizedBox(height: 16.h),
          ElevatedButton.icon(
            onPressed: () async {
              await Get.toNamed(
                Routes.budgetEdit, 
                arguments: {'isOverall': true}
              );
              controller.refresh();
            },
            icon: const Icon(Icons.add),
            label: const Text('设置预算'),

SizedBox 添加 16 高度间距。ElevatedButton.icon 同时显示图标和文字。onPressed 跳转到预算编辑页面,arguments 传递 {‘isOverall’: true} 表示要设置总预算。await 等待页面返回,然后调用 refresh 刷新数据。icon 显示加号图标,label 显示"设置预算"文字。

            style: ElevatedButton.styleFrom(
              backgroundColor: _primaryColor,
              foregroundColor: Colors.white,
            ),
          ),
        ],
      ),
    ),
  );
}

style 配置按钮样式,backgroundColor 设为主题绿色,foregroundColor 设为白色。这个按钮是空状态的行动号召(CTA),视觉上要突出,引导用户完成第一次操作。整个空状态卡片通过图标、文字说明和按钮组合,给出明确的操作指引。

有预算时显示进度卡片:

Widget _buildBudgetProgressCard(BudgetController controller) {
  final progressColor = controller.isOverBudget 
    ? _expenseColor 
    : (controller.isWarning ? _warningColor : _primaryColor);
  
  return Card(
    child: Padding(
      padding: EdgeInsets.all(20.w),
      child: Column(
        children: [

_buildBudgetProgressCard 构建进度卡片。首先根据预算状态确定进度条颜色:如果超支用红色,如果警告(超过80%)用橙色,否则用绿色。Card 和 Padding 的用法与空状态卡片一致,内边距 20。Column 垂直排列进度指示器、警告标签和金额信息。

          CircularPercentIndicator(
            radius: 80.r,
            lineWidth: 12.w,
            percent: controller.percentage / 100,
            center: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text(
                  '${controller.percentage.toStringAsFixed(0)}%', 
                  style: TextStyle(
                    fontSize: 28.sp, 
                    fontWeight: FontWeight.bold,

CircularPercentIndicator 来自 percent_indicator 包,展示圆形进度。radius 设为 80 是圆的半径,lineWidth 设为 12 是进度条的宽度。percent 是进度百分比,需要是 0-1 之间的值,所以除以 100。center 是圆形中心显示的内容,使用 Column 垂直排列百分比和文字。第一个 Text 显示百分比,toStringAsFixed(0) 格式化为整数,字号 28.sp 比较大,字重 bold(粗体)。

                    color: progressColor,
                  )
                ),
                Text(
                  '已使用', 
                  style: TextStyle(fontSize: 12.sp, color: _textSecondary)
                ),
              ],
            ),
            progressColor: progressColor,
            backgroundColor: Colors.grey[200]!,
            circularStrokeCap: CircularStrokeCap.round,

百分比文字的颜色使用 progressColor,与进度条颜色一致。第二个 Text 显示"已使用",字号 12.sp,颜色用次要文字色。progressColor 设置进度条颜色,backgroundColor 设置背景轨道颜色为浅灰色。circularStrokeCap 设为 round 让进度条两端是圆角,视觉上更柔和。

            animation: true,
            animationDuration: 800,
          ),
          SizedBox(height: 20.h),
          if (controller.isOverBudget)
            Container(
              padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h),
              decoration: BoxDecoration(
                color: _expenseColor.withOpacity(0.1),

animation 设为 true 开启动画效果,animationDuration 设为 800 毫秒。SizedBox 添加 20 高度间距。如果超支,显示警告标签。Container 是标签容器,padding 设置水平 12、垂直 6 的内边距。decoration 设置浅红色背景(支出红色的 10% 透明度)。

                borderRadius: BorderRadius.circular(4.r),
              ),
              child: Row(
                mainAxisSize: MainAxisSize.min,
                children: [
                  Icon(Icons.warning, size: 16.sp, color: _expenseColor),
                  SizedBox(width: 4.w),
                  Text(
                    '已超出预算',
                    style: TextStyle(
                      fontSize: 12.sp, 
                      color: _expenseColor,

borderRadius 设置 4 的圆角。Row 横向排列图标和文字,mainAxisSize.min 让 Row 宽度自适应内容。Icon 显示警告图标,尺寸 16.sp,颜色红色。SizedBox 添加 4 宽度间距。Text 显示"已超出预算",字号 12.sp,颜色红色。

                      fontWeight: FontWeight.w500,
                    ),
                  ),
                ],
              ),
            ),
          if (controller.isWarning)
            Container(
              padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h),
              decoration: BoxDecoration(
                color: _warningColor.withOpacity(0.1),
                borderRadius: BorderRadius.circular(4.r),
              ),

字重 w500(中等粗体)让文字更醒目。如果是警告状态(超过80%但未超支),显示警告标签。Container 的结构与超支标签类似,背景色改为浅橙色(警告橙色的 10% 透明度)。

              child: Row(
                mainAxisSize: MainAxisSize.min,
                children: [
                  Icon(Icons.info_outline, size: 16.sp, color: _warningColor),
                  SizedBox(width: 4.w),
                  Text(
                    '即将超出预算',
                    style: TextStyle(
                      fontSize: 12.sp, 
                      color: _warningColor,
                      fontWeight: FontWeight.w500,
                    ),
                  ),
                ],
              ),
            ),

Row 横向排列信息图标和文字。Icon 使用信息图标,颜色橙色。Text 显示"即将超出预算",颜色橙色。这种超支和警告状态的明显提示标签帮助用户注意到问题。

底部显示预算、已用、剩余三个金额:

          SizedBox(height: 16.h),
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceAround,
            children: [
              _buildBudgetInfo(
                '预算', 
                controller.totalBudget.value, 
                controller.currency,
                _primaryColor,
              ),
              _buildBudgetInfo(
                '已用', 
                controller.totalSpent.value, 
                controller.currency,
                _expenseColor,
              ),

SizedBox 添加 16 高度间距。Row 横向排列三个金额信息,mainAxisAlignment.spaceAround 让它们均匀分布。_buildBudgetInfo 是辅助方法,构建单个金额信息。第一个显示"预算"和总预算金额,颜色用主题绿色。第二个显示"已用"和已花费金额,颜色用支出红色。

              _buildBudgetInfo(
                '剩余', 
                controller.remaining, 
                controller.currency,
                controller.remaining >= 0 ? _incomeColor : _expenseColor,
              ),
            ],
          ),
        ],
      ),
    ),
  );
}

Widget _buildBudgetInfo(
  String label, 
  double value, 
  String currency,
  Color color,
) {
  return Column(
    children: [
      Text(label, style: TextStyle(fontSize: 12.sp, color: _textSecondary)),
      SizedBox(height: 4.h),
      Text(
        '$currency${value.toStringAsFixed(0)}', 
        style: TextStyle(
          fontSize: 16.sp, 
          fontWeight: FontWeight.bold, 
          color: color
        )
      ),
    ],
  );
}

第三个显示"剩余"和剩余金额,颜色根据剩余金额正负变化:非负用收入绿色,负数用支出红色。_buildBudgetInfo 方法构建单个金额信息,Column 垂直排列标签和金额。标签字号 12.sp,颜色用次要文字色。SizedBox 添加 4 高度间距。金额显示货币符号和数值,toStringAsFixed(0) 格式化为整数,字号 16.sp,字重 bold,颜色使用传入的参数。这种三栏布局清晰地展示了预算的使用情况。

分类预算列表

分类预算用线性进度条展示各分类的使用情况:

Widget _buildCategoryBudgets(BudgetController controller) {
  final categoryService = Get.find<CategoryService>();
  return Obx(() {
    final budgets = controller.categoryBudgets;
    return Card(
      child: Padding(
        padding: EdgeInsets.all(16.w),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text(
                  '分类预算', 
                  style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w600)
                ),
                TextButton.icon(
                  onPressed: () async {
                    await Get.toNamed(Routes.budgetEdit);
                    controller.refresh();
                  },
                  icon: Icon(Icons.add, size: 16.sp),
                  label: const Text('添加'),
                ),
              ],
            ),
            SizedBox(height: 12.h),
            if (budgets.isEmpty)
              _buildEmptyCategoryBudgets()
            else
              ...budgets.map((b) {
                final category = categoryService.getCategoryById(b.categoryId ?? '');
                return _buildCategoryBudgetItem(b, category, controller);
              }),
          ],
        ),
      ),
    );
  });
}

Widget _buildEmptyCategoryBudgets() {
  return Padding(
    padding: EdgeInsets.symmetric(vertical: 24.h),
    child: Center(
      child: Column(
        children: [
          Icon(Icons.category, size: 48.sp, color: Colors.grey[300]),
          SizedBox(height: 12.h),
          Text(
            '暂无分类预算', 
            style: TextStyle(color: _textSecondary, fontSize: 14.sp)
          ),
          SizedBox(height: 4.h),
          Text(
            '为特定分类设置预算,精细管理支出',
            style: TextStyle(color: _textSecondary, fontSize: 12.sp),
          ),
        ],
      ),
    ),
  );
}

标题栏右侧有添加按钮。没有分类预算时显示空状态提示。

分类预算项组件:

Widget _buildCategoryBudgetItem(
  BudgetModel b, 
  category, 
  BudgetController controller
) {
  final progressColor = b.isOverBudget 
    ? _expenseColor 
    : (b.percentage > 80 ? _warningColor : (category?.color ?? _primaryColor));
  
  return Padding(
    padding: EdgeInsets.only(bottom: 16.h),
    child: GestureDetector(
      onTap: () => Get.toNamed(Routes.budgetDetail, arguments: b),
      child: Column(
        children: [
          Row(
            children: [
              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),
              Expanded(
                child: Text(
                  category?.name ?? '未知', 
                  style: TextStyle(fontSize: 14.sp)
                )
              ),
              Column(
                crossAxisAlignment: CrossAxisAlignment.end,
                children: [
                  Text(
                    '${controller.currency}${b.spent.toStringAsFixed(0)}',
                    style: TextStyle(
                      fontSize: 14.sp, 
                      fontWeight: FontWeight.w600,
                      color: b.isOverBudget ? _expenseColor : null,
                    ),
                  ),
                  Text(
                    '/ ${controller.currency}${b.amount.toStringAsFixed(0)}',
                    style: TextStyle(fontSize: 12.sp, color: _textSecondary),
                  ),
                ],
              ),
            ],
          ),
          SizedBox(height: 8.h),
          LinearPercentIndicator(
            padding: EdgeInsets.zero,
            lineHeight: 8.h,
            percent: b.percentage / 100,
            backgroundColor: Colors.grey[200],
            progressColor: progressColor,
            barRadius: Radius.circular(4.r),
            animation: true,
            animationDuration: 500,
          ),
          if (b.isOverBudget) ...[
            SizedBox(height: 4.h),
            Row(
              children: [
                Icon(Icons.warning, size: 12.sp, color: _expenseColor),
                SizedBox(width: 4.w),
                Text(
                  '超出 ${controller.currency}${(b.spent - b.amount).toStringAsFixed(0)}',
                  style: TextStyle(fontSize: 10.sp, color: _expenseColor),
                ),
              ],
            ),
          ],
        ],
      ),
    ),
  );
}

每个分类预算项显示分类图标、名称、已用/预算金额和进度条。进度条颜色根据使用情况变化。超支时显示超出金额的提示。点击可以跳转到预算详情页面。

LinearPercentIndicator 来自 percent_indicator 包,展示线性进度。animation 开启动画效果,让进度条有平滑的填充动画。

小结

预算管理页面让用户可以直观地了解预算执行情况,核心要点包括:

  1. 月份选择器支持查看历史数据
  2. 圆形进度指示器展示总预算使用进度
  3. 线性进度条展示分类预算使用情况
  4. 超支和警告状态有明显的视觉提示
  5. 空状态给出明确的操作引导
  6. 下拉刷新支持手动更新数据

这些功能组合在一起,帮助用户有效控制支出,实现财务目标。下一篇将实现预算编辑页面,让用户可以设置和修改预算。


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

Logo

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

更多推荐