预算编辑页面是预算管理模块的核心功能,支持设置总预算和分类预算。用户可以选择预算类型、月份、分类和金额,灵活控制自己的支出计划。
请添加图片描述

功能需求分析

预算编辑页面需要满足以下需求:

  1. 支持两种预算类型:总预算控制整体支出,分类预算精细管理各项开支
  2. 月份选择器,可以为不同月份设置不同的预算
  3. 分类选择器,选择分类预算时需要指定具体分类
  4. 金额输入,支持小数输入
  5. 表单验证,确保输入的数据有效

这些功能让用户可以根据自己的实际情况灵活设置预算,既可以设置一个总的月度预算,也可以为餐饮、交通等具体分类设置独立预算。

页面状态设计

预算编辑页面需要管理多个状态,使用 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 对象,说明是编辑模式,否则是新建模式。

小结

预算编辑页面让用户可以灵活设置预算,核心要点包括:

  1. 支持总预算和分类预算两种类型
  2. 月份选择器支持快速切换和跳转
  3. 分类选择使用彩色芯片,视觉效果好
  4. 金额输入有表单验证,确保数据有效
  5. 快捷金额按钮减少输入操作
  6. 提示信息帮助用户理解功能

这些功能组合在一起,让预算设置变得简单直观。下一篇将实现预算详情页面,展示预算的使用情况和相关交易记录。


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

Logo

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

更多推荐