Flutter for OpenHarmony 个人理财管理App实战 - 预算管理页面
本文介绍了预算管理页面的设计与实现,主要功能包括月份选择、预算概览、分类预算展示和超支提醒。采用GetX状态管理,通过BudgetController处理数据逻辑,包括预算计算、月份切换和数据加载。页面使用响应式设计,包含圆形进度指示器和分类预算列表,支持手动刷新和预算编辑。整体架构简洁高效,帮助用户直观掌握预算执行情况。
预算管理是理财应用的核心功能之一,帮助用户控制支出,实现财务目标。本篇将实现预算管理页面,支持设置月度总预算和分类预算,并通过可视化的方式展示预算使用情况。
功能需求分析
预算管理页面需要满足以下需求:
- 月份选择器,查看不同月份的预算情况
- 总预算概览,用圆形进度指示器展示使用进度
- 分类预算列表,用线性进度条展示各分类的使用情况
- 添加预算入口,方便用户设置新预算
- 预算超支提醒,当使用超过一定比例时给出警示
这些功能让用户可以直观地了解自己的预算执行情况,及时调整消费行为。
控制器设计
预算管理页面的数据逻辑用 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 开启动画效果,让进度条有平滑的填充动画。
小结
预算管理页面让用户可以直观地了解预算执行情况,核心要点包括:
- 月份选择器支持查看历史数据
- 圆形进度指示器展示总预算使用进度
- 线性进度条展示分类预算使用情况
- 超支和警告状态有明显的视觉提示
- 空状态给出明确的操作引导
- 下拉刷新支持手动更新数据
这些功能组合在一起,帮助用户有效控制支出,实现财务目标。下一篇将实现预算编辑页面,让用户可以设置和修改预算。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐



所有评论(0)