Flutter for OpenHarmony 个人理财管理App实战 - 预算编辑页面
预算编辑页面支持设置总预算和分类预算,提供预算类型选择、月份设置、分类选择和金额输入功能。页面采用状态管理实现动态交互,包含表单验证确保数据有效性。通过Card组件构建UI元素,包括预算类型切换卡片、月份选择卡片、分类选择器(仅分类预算模式显示)、金额输入卡片和操作按钮。状态类管理预算类型、分类ID、选择月份等关键数据,并处理初始化参数和资源释放。页面布局采用SingleChildScrollVi
预算编辑页面是预算管理模块的核心功能,支持设置总预算和分类预算。用户可以选择预算类型、月份、分类和金额,灵活控制自己的支出计划。
功能需求分析
预算编辑页面需要满足以下需求:
- 支持两种预算类型:总预算控制整体支出,分类预算精细管理各项开支
- 月份选择器,可以为不同月份设置不同的预算
- 分类选择器,选择分类预算时需要指定具体分类
- 金额输入,支持小数输入
- 表单验证,确保输入的数据有效
这些功能让用户可以根据自己的实际情况灵活设置预算,既可以设置一个总的月度预算,也可以为餐饮、交通等具体分类设置独立预算。
页面状态设计
预算编辑页面需要管理多个状态,使用 StatefulWidget 来实现:
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import '../../core/services/budget_service.dart';
import '../../core/services/category_service.dart';
import '../../data/models/category_model.dart';
const _primaryColor = Color(0xFF2E7D32);
const _textSecondary = Color(0xFF757575);
class BudgetEditPage extends StatefulWidget {
const BudgetEditPage({super.key});
State<BudgetEditPage> createState() => _BudgetEditPageState();
}
导入了必要的依赖,包括 BudgetService 用于保存预算数据,CategoryService 用于获取分类列表。定义了两个颜色常量,保持和应用其他页面的视觉一致性。
状态类的定义:
class _BudgetEditPageState extends State<BudgetEditPage> {
final _amountController = TextEditingController();
final _budgetService = Get.find<BudgetService>();
final _categoryService = Get.find<CategoryService>();
bool _isOverall = false;
String? _selectedCategoryId;
DateTime _selectedMonth = DateTime.now();
final _formKey = GlobalKey<FormState>();
_amountController 控制金额输入框。_isOverall 标记是否是总预算,false 表示分类预算。_selectedCategoryId 存储选中的分类 ID,只有分类预算时才会用到。_selectedMonth 存储选中的月份。_formKey 用于表单验证。
初始化和资源释放
void initState() {
super.initState();
final args = Get.arguments as Map<String, dynamic>?;
if (args != null && args['isOverall'] == true) {
_isOverall = true;
}
if (args != null && args['categoryId'] != null) {
_selectedCategoryId = args['categoryId'];
}
if (args != null && args['month'] != null) {
_selectedMonth = args['month'];
}
}
void dispose() {
_amountController.dispose();
super.dispose();
}
initState 中从路由参数获取初始值。如果是从总预算入口进来的,isOverall 会是 true。如果是从某个分类的预算入口进来的,会带上 categoryId。dispose 中释放 controller 资源。
页面主体结构
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('设置预算')),
body: Form(
key: _formKey,
child: SingleChildScrollView(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTypeCard(),
SizedBox(height: 16.h),
_buildMonthCard(),
SizedBox(height: 16.h),
if (!_isOverall) _buildCategorySelector(),
if (!_isOverall) SizedBox(height: 16.h),
_buildAmountCard(),
SizedBox(height: 16.h),
_buildTipsCard(),
SizedBox(height: 32.h),
_buildSaveButton(),
],
),
),
),
);
}
用 Form 包裹整个表单,方便统一验证。SingleChildScrollView 让内容可以滚动,适应不同屏幕高度。分类选择器只在分类预算模式下显示。
预算类型选择
预算类型选择让用户在总预算和分类预算之间切换:
Widget _buildTypeCard() {
return Card(
child: Padding(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.category, size: 20.sp, color: _primaryColor),
SizedBox(width: 8.w),
Text(
'预算类型',
style: TextStyle(fontSize: 14.sp, color: _textSecondary)
),
],
),
SizedBox(height: 12.h),
Row(
children: [
Expanded(child: _buildTypeButton('总预算', true)),
SizedBox(width: 12.w),
Expanded(child: _buildTypeButton('分类预算', false)),
],
),
SizedBox(height: 12.h),
Text(
_isOverall
? '总预算控制当月所有支出的上限'
: '分类预算控制特定分类的支出上限',
style: TextStyle(fontSize: 12.sp, color: _textSecondary),
),
],
),
),
);
}
Widget _buildTypeButton(String label, bool isOverall) {
final isSelected = _isOverall == isOverall;
return GestureDetector(
onTap: () => setState(() {
_isOverall = isOverall;
if (isOverall) {
_selectedCategoryId = null;
}
}),
child: Container(
padding: EdgeInsets.symmetric(vertical: 12.h),
decoration: BoxDecoration(
color: isSelected ? _primaryColor : Colors.grey[200],
borderRadius: BorderRadius.circular(8.r),
border: Border.all(
color: isSelected ? _primaryColor : Colors.transparent,
width: 2,
),
),
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isOverall ? Icons.account_balance_wallet : Icons.category,
size: 16.sp,
color: isSelected ? Colors.white : const Color(0xFF212121),
),
SizedBox(width: 4.w),
Text(
label,
style: TextStyle(
color: isSelected ? Colors.white : const Color(0xFF212121),
fontWeight: FontWeight.w500,
)
),
],
),
),
),
);
}
两个按钮并排显示,选中的有绿色背景和白色文字。切换到总预算时,清空已选的分类。底部有说明文字,帮助用户理解两种预算类型的区别。
月份选择
月份选择器让用户可以为不同月份设置预算:
Widget _buildMonthCard() {
return Card(
child: Padding(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.calendar_month, size: 20.sp, color: _primaryColor),
SizedBox(width: 8.w),
Text(
'预算月份',
style: TextStyle(fontSize: 14.sp, color: _textSecondary)
),
],
),
SizedBox(height: 12.h),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: () => setState(() => _selectedMonth = DateTime(
_selectedMonth.year,
_selectedMonth.month - 1
))
),
GestureDetector(
onTap: _showMonthPicker,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 8.h),
decoration: BoxDecoration(
color: _primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8.r),
),
child: Text(
DateFormat('yyyy年MM月').format(_selectedMonth),
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: _primaryColor,
)
),
),
),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: () => setState(() => _selectedMonth = DateTime(
_selectedMonth.year,
_selectedMonth.month + 1
))
),
],
),
],
),
),
);
}
左右箭头切换上下月,中间显示当前选中的月份。点击月份可以弹出月份选择器快速跳转。
月份选择器弹窗:
void _showMonthPicker() {
final now = DateTime.now();
Get.dialog(
AlertDialog(
title: const Text('选择月份'),
content: SizedBox(
width: 280.w,
height: 200.h,
child: GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
childAspectRatio: 1.5,
crossAxisSpacing: 8.w,
mainAxisSpacing: 8.h,
),
itemCount: 12,
itemBuilder: (_, index) {
final month = DateTime(now.year, index + 1);
final isSelected = _selectedMonth.month == index + 1 &&
_selectedMonth.year == now.year;
return GestureDetector(
onTap: () {
setState(() => _selectedMonth = month);
Get.back();
},
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: isSelected ? _primaryColor : Colors.grey[100],
borderRadius: BorderRadius.circular(8.r),
),
child: Text(
'${index + 1}月',
style: TextStyle(
color: isSelected ? Colors.white : Colors.black87,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
),
);
},
),
),
),
);
}
用 GridView 展示 12 个月份,4 列布局。选中的月份有不同的背景色。
分类选择
当选择分类预算时,显示分类选择器:
Widget _buildCategorySelector() {
final categories = _categoryService.expenseCategories;
return Card(
child: Padding(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.label, size: 20.sp, color: _primaryColor),
SizedBox(width: 8.w),
Text(
'选择分类',
style: TextStyle(fontSize: 14.sp, color: _textSecondary)
),
const Spacer(),
if (_selectedCategoryId != null)
Text(
'已选择',
style: TextStyle(fontSize: 12.sp, color: _primaryColor),
),
],
),
SizedBox(height: 12.h),
Wrap(
spacing: 12.w,
runSpacing: 12.h,
children: categories.map((c) => _buildCategoryChip(c)).toList(),
),
],
),
),
);
}
Widget _buildCategoryChip(CategoryModel category) {
final isSelected = _selectedCategoryId == category.id;
return GestureDetector(
onTap: () => setState(() => _selectedCategoryId = category.id),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
decoration: BoxDecoration(
color: isSelected ? category.color : category.color.withOpacity(0.1),
borderRadius: BorderRadius.circular(20.r),
border: Border.all(
color: isSelected ? category.color : Colors.transparent,
width: 2,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
category.icon,
size: 16.sp,
color: isSelected ? Colors.white : category.color
),
SizedBox(width: 4.w),
Text(
category.name,
style: TextStyle(
fontSize: 12.sp,
color: isSelected ? Colors.white : category.color,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
)
),
],
),
),
);
}
分类选择使用彩色芯片,选中时背景变为分类颜色,文字变白。AnimatedContainer 让选中状态的切换有平滑的动画效果。Wrap 布局让芯片自动换行,适应不同屏幕宽度。
只显示支出分类,因为预算主要是控制支出的。
金额输入
金额输入是预算编辑的核心:
Widget _buildAmountCard() {
return Card(
child: Padding(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.attach_money, size: 20.sp, color: _primaryColor),
SizedBox(width: 8.w),
Text(
'预算金额',
style: TextStyle(fontSize: 14.sp, color: _textSecondary)
),
],
),
SizedBox(height: 12.h),
TextFormField(
controller: _amountController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
style: TextStyle(fontSize: 24.sp, fontWeight: FontWeight.bold),
decoration: InputDecoration(
prefixText: '¥ ',
prefixStyle: TextStyle(
fontSize: 24.sp,
fontWeight: FontWeight.bold,
color: _primaryColor,
),
border: const OutlineInputBorder(),
hintText: '0.00',
hintStyle: TextStyle(color: Colors.grey[400]),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: _primaryColor, width: 2),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '请输入预算金额';
}
final amount = double.tryParse(value);
if (amount == null || amount <= 0) {
return '请输入有效的金额';
}
return null;
},
),
SizedBox(height: 12.h),
Wrap(
spacing: 8.w,
children: [500, 1000, 2000, 5000].map((amount) =>
ActionChip(
label: Text('¥$amount'),
onPressed: () {
_amountController.text = amount.toString();
},
)
).toList(),
),
],
),
),
);
}
金额输入框使用数字键盘,支持小数输入。前缀显示货币符号,字号较大让金额更醒目。validator 做表单验证,确保输入的是有效的正数。
底部提供几个快捷金额按钮,点击可以快速填入常用金额,减少输入操作。
提示信息
添加一个提示卡片,帮助用户理解预算功能:
Widget _buildTipsCard() {
return Card(
color: Colors.amber.withOpacity(0.1),
child: Padding(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.lightbulb_outline, size: 20.sp, color: Colors.amber[700]),
SizedBox(width: 8.w),
Text(
'小贴士',
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: Colors.amber[700],
)
),
],
),
SizedBox(height: 8.h),
Text(
'• 预算会在每月初自动重置\n'
'• 支出超过预算80%时会收到提醒\n'
'• 可以同时设置总预算和分类预算',
style: TextStyle(fontSize: 12.sp, color: _textSecondary, height: 1.5),
),
],
),
),
);
}
用浅黄色背景突出提示信息,列出几个关键点帮助用户理解预算的工作方式。
保存预算
保存按钮和保存逻辑:
Widget _buildSaveButton() {
return SizedBox(
width: double.infinity,
height: 48.h,
child: ElevatedButton(
onPressed: _saveBudget,
style: ElevatedButton.styleFrom(
backgroundColor: _primaryColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.r),
),
),
child: Text(
'保存预算',
style: TextStyle(fontSize: 16.sp, color: Colors.white)
),
),
);
}
void _saveBudget() {
if (!_formKey.currentState!.validate()) {
return;
}
final amount = double.tryParse(_amountController.text);
if (amount == null || amount <= 0) {
Get.snackbar(
'提示',
'请输入有效金额',
snackPosition: SnackPosition.BOTTOM,
);
return;
}
if (!_isOverall && _selectedCategoryId == null) {
Get.snackbar(
'提示',
'请选择分类',
snackPosition: SnackPosition.BOTTOM,
);
return;
}
_budgetService.addBudget(
amount: amount,
categoryId: _isOverall ? null : _selectedCategoryId,
year: _selectedMonth.year,
month: _selectedMonth.month,
isOverall: _isOverall
);
Get.back();
Get.snackbar(
'成功',
'预算已设置',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: _primaryColor.withOpacity(0.9),
colorText: Colors.white,
);
}
保存前先做表单验证,检查金额是否有效,分类预算是否选择了分类。验证通过后调用 BudgetService 保存数据,然后返回上一页并显示成功提示。
snackbar 显示在底部,成功提示用绿色背景,和应用主色调一致。
编辑已有预算
如果是编辑已有预算,需要在初始化时填充数据。在前面的 initState 方法中,我们已经处理了这种情况:
// 在 initState 中添加对已有预算的处理
if (args != null && args['budget'] != null) {
final budget = args['budget'];
_amountController.text = budget.amount.toString();
_isOverall = budget.isOverall;
_selectedCategoryId = budget.categoryId;
_selectedMonth = DateTime(budget.year, budget.month);
}
从路由参数获取已有预算数据,填充到各个表单字段中。这样编辑和新建可以共用同一个页面,减少代码重复。如果 args 中包含 budget 对象,说明是编辑模式,否则是新建模式。
小结
预算编辑页面让用户可以灵活设置预算,核心要点包括:
- 支持总预算和分类预算两种类型
- 月份选择器支持快速切换和跳转
- 分类选择使用彩色芯片,视觉效果好
- 金额输入有表单验证,确保数据有效
- 快捷金额按钮减少输入操作
- 提示信息帮助用户理解功能
这些功能组合在一起,让预算设置变得简单直观。下一篇将实现预算详情页面,展示预算的使用情况和相关交易记录。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐
所有评论(0)