预算详情页面展示单个预算的完整信息,包括使用进度和相关的支出记录。用户可以在这里了解预算的具体使用情况,及时调整消费行为。
请添加图片描述

功能设计

预算详情页面包含:

  1. 预算进度概览(圆形进度指示器)
  2. 预算金额、已用金额、剩余金额统计
  3. 相关支出记录列表
  4. 预算使用趋势图
  5. 编辑和删除预算功能
  6. 预算预警提示

设计思路

预算详情页面的核心目标是帮助用户:

  • 直观了解预算使用进度
  • 查看具体的支出明细
  • 及时发现超支风险
  • 方便地管理预算设置

页面实现

创建 budget_detail_page.dart,首先导入依赖:

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 '../../core/services/transaction_service.dart';
import '../../core/services/budget_service.dart';
import '../../core/services/storage_service.dart';

导入了 Flutter 基础组件、屏幕适配、GetX 状态管理、日期格式化、圆形进度指示器等。这些是实现预算详情页面的基础依赖。

继续导入数据模型和路由:

import '../../data/models/budget_model.dart';
import '../../data/models/transaction_model.dart';
import '../../routes/app_pages.dart';

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

定义了颜色常量,包括主色调、收入色、支出色、警告色和次要文字色。这些颜色会在整个页面中使用,保持视觉一致性。

定义页面类和状态类:

class BudgetDetailPage extends StatefulWidget {
  const BudgetDetailPage({super.key});

  
  State<BudgetDetailPage> createState() => _BudgetDetailPageState();
}

class _BudgetDetailPageState extends State<BudgetDetailPage> {
  late BudgetModel _budget;
  final _categoryService = Get.find<CategoryService>();
  final _transactionService = Get.find<TransactionService>();
  final _budgetService = Get.find<BudgetService>();
  final _storage = Get.find<StorageService>();

使用 StatefulWidget 因为页面需要管理预算数据的状态。通过 Get.find 获取各种服务实例,用于获取分类、交易、预算数据。

初始化方法:

  
  void initState() {
    super.initState();
    _budget = Get.arguments as BudgetModel;
  }

从路由参数中获取预算对象,这是页面展示的核心数据。

构建页面主体结构:

  
  Widget build(BuildContext context) {
    final category = _budget.categoryId != null 
      ? _categoryService.getCategoryById(_budget.categoryId!) 
      : null;
    
    // 获取相关支出记录
    final transactions = _transactionService
      .getTransactionsByMonth(_budget.year, _budget.month)
      .where((t) => t.type == TransactionType.expense && 
        (_budget.isOverall || t.categoryId == _budget.categoryId))
      .toList();
    transactions.sort((a, b) => b.date.compareTo(a.date));

如果是分类预算,获取对应的分类信息。获取该月份的所有支出记录,如果是分类预算则只筛选该分类的支出,按时间倒序排列。

页面布局结构:

    return Scaffold(
      appBar: AppBar(
        title: Text(_budget.isOverall ? '总预算详情' : (category?.name ?? '预算详情')),
        actions: [
          IconButton(
            icon: const Icon(Icons.edit),
            onPressed: () => _navigateToEdit(),
          ),
          IconButton(
            icon: const Icon(Icons.delete),
            onPressed: () => _showDeleteDialog(),
          ),
        ],
      ),
      body: SingleChildScrollView(
        padding: EdgeInsets.all(16.w),
        child: Column(
          children: [
            if (_budget.isOverBudget) _buildWarningBanner(),
            _buildProgressCard(category),
            SizedBox(height: 16.h),
            _buildStatisticsCard(transactions),
            SizedBox(height: 16.h),
            _buildDailyTrendCard(transactions),
            SizedBox(height: 16.h),
            _buildTransactionList(transactions),
          ],
        ),
      ),
    );
  }
}

AppBar 标题根据预算类型显示不同文字,右侧有编辑和删除按钮。如果预算超支,顶部显示警告横幅。页面内容包括进度卡片、统计卡片、趋势图和支出列表。

超支预警横幅

当预算超支时显示醒目的警告:

Widget _buildWarningBanner() {
  return Container(
    margin: EdgeInsets.only(bottom: 16.h),
    padding: EdgeInsets.all(12.w),
    decoration: BoxDecoration(
      color: _expenseColor.withOpacity(0.1),
      borderRadius: BorderRadius.circular(8.r),
      border: Border.all(color: _expenseColor.withOpacity(0.3)),
    ),
    child: Row(
      children: [
        Icon(Icons.warning_amber_rounded, color: _expenseColor, size: 24.sp),
        SizedBox(width: 12.w),
        Expanded(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(
                '预算已超支',
                style: TextStyle(
                  fontSize: 14.sp,
                  fontWeight: FontWeight.w600,
                  color: _expenseColor,
                ),
              ),
              Text(
                '已超出预算 ${_storage.currency}${(_budget.spent - _budget.amount).toStringAsFixed(2)}',
                style: TextStyle(fontSize: 12.sp, color: _textSecondary),
              ),
            ],
          ),
        ),
      ],
    ),
  );
}

## 进度概览卡片

进度卡片是页面的核心,展示预算使用情况:

```dart
Widget _buildProgressCard(category) {
  // 计算进度颜色
  Color progressColor = _primaryColor;
  if (_budget.percentage >= 100) {
    progressColor = _expenseColor;
  } else if (_budget.percentage >= 80) {
    progressColor = _warningColor;
  }

根据预算使用比例动态计算进度条颜色。正常情况下是绿色,达到 80% 变为橙色警告,超支则变为红色。这种颜色变化能直观提醒用户预算状态。

卡片主体结构:

  return Card(
    child: Padding(
      padding: EdgeInsets.all(24.w),
      child: Column(
        children: [
          if (category != null) ...[
            CircleAvatar(
              radius: 32.r, 
              backgroundColor: category.color.withOpacity(0.2), 
              child: Icon(category.icon, size: 32.sp, color: category.color)
            ),
            SizedBox(height: 12.h),
          ],
          Text(
            DateFormat('yyyy年MM月').format(DateTime(_budget.year, _budget.month)), 
            style: TextStyle(fontSize: 16.sp, color: _textSecondary)
          ),
          SizedBox(height: 16.h),

如果是分类预算,顶部显示分类图标。然后显示预算所属的月份。使用 DateFormat 格式化日期为中文格式。

圆形进度指示器:

          CircularPercentIndicator(
            radius: 80.r, 
            lineWidth: 12.w, 
            percent: (_budget.percentage / 100).clamp(0.0, 1.0),
            center: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text(
                  '${_budget.percentage.toStringAsFixed(0)}%', 
                  style: TextStyle(fontSize: 24.sp, fontWeight: FontWeight.bold)
                ),
                Text('已使用', style: TextStyle(fontSize: 12.sp, color: _textSecondary)),
              ],
            ),
            progressColor: progressColor,
            backgroundColor: Colors.grey[200]!,
            animation: true,
            animationDuration: 800,
          ),
          SizedBox(height: 20.h),

使用 CircularPercentIndicator 显示圆形进度。中心显示百分比数字,进度条颜色根据使用情况变化。clamp 确保百分比在 0-100% 范围内,即使超支也不会超过 100%。动画时长 800ms,让进度显示更流畅。

底部统计信息:

          Row(
            mainAxisAlignment: MainAxisAlignment.spaceAround,
            children: [
              _buildInfo('预算', _budget.amount, _storage.currency),
              _buildInfo('已用', _budget.spent, _storage.currency),
              _buildInfo('剩余', _budget.remaining, _storage.currency, isRemaining: true),
            ],
          ),
        ],
      ),
    ),
  );
}

三列布局显示预算总额、已用金额和剩余金额。spaceAround 让三列均匀分布。

统计信息项构建方法:

Widget _buildInfo(String label, double value, String currency, {bool isRemaining = false}) {
  Color valueColor = const Color(0xFF212121);
  if (isRemaining) valueColor = value >= 0 ? _incomeColor : _expenseColor;
  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: valueColor)
      ),
    ],
  );
}

每个统计项包含标签和数值。剩余金额根据正负显示不同颜色:正数为绿色,负数(超支)为红色。这种颜色编码让用户一眼就能看出预算状态。

统计信息卡片

统计卡片展示预算使用的详细数据:

Widget _buildStatisticsCard(List<TransactionModel> transactions) {
  final daysInMonth = DateTime(_budget.year, _budget.month + 1, 0).day;
  final today = DateTime.now();
  final daysPassed = today.year == _budget.year && today.month == _budget.month
      ? today.day
      : daysInMonth;
  
  final dailyBudget = _budget.amount / daysInMonth;
  final dailySpent = daysPassed > 0 ? _budget.spent / daysPassed : 0.0;
  final transactionCount = transactions.length;
  final avgTransaction = transactionCount > 0 ? _budget.spent / transactionCount : 0.0;

计算各种统计指标。daysInMonth 获取该月总天数,daysPassed 计算已过去的天数。dailyBudget 是日均预算,dailySpent 是日均支出,avgTransaction 是笔均金额。这些指标帮助用户了解消费习惯。

卡片布局:

  return Card(
    child: Padding(
      padding: EdgeInsets.all(16.w),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text('统计数据', style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w600)),
          SizedBox(height: 16.h),
          Row(
            children: [
              Expanded(child: _buildStatItem('日均预算', dailyBudget)),
              Expanded(child: _buildStatItem('日均支出', dailySpent)),
            ],
          ),
          SizedBox(height: 12.h),
          Row(
            children: [
              Expanded(child: _buildStatItem('交易笔数', transactionCount.toDouble(), isCount: true)),
              Expanded(child: _buildStatItem('笔均金额', avgTransaction)),
            ],
          ),
        ],
      ),
    ),
  );
}

两行两列的网格布局,展示四个统计指标。第一行是日均预算和日均支出,第二行是交易笔数和笔均金额。这种布局紧凑且易读。

统计项构建方法:

Widget _buildStatItem(String label, double value, {bool isCount = false}) {
  return Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Text(label, style: TextStyle(fontSize: 12.sp, color: _textSecondary)),
      SizedBox(height: 4.h),
      Text(
        isCount ? '${value.toInt()} 笔' : '${_storage.currency}${value.toStringAsFixed(2)}',
        style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w600),
      ),
    ],
  );
}

每个统计项包含标签和数值。isCount 参数区分笔数和金额,笔数显示为整数加"笔"单位,金额显示为货币格式。

每日支出趋势图

趋势图让用户直观看到每天的支出情况:

Widget _buildDailyTrendCard(List<TransactionModel> transactions) {
  // 按日期汇总支出
  final dailyExpense = <int, double>{};
  for (var t in transactions) {
    final day = t.date.day;
    dailyExpense[day] = (dailyExpense[day] ?? 0) + t.amount;
  }
  
  final daysInMonth = DateTime(_budget.year, _budget.month + 1, 0).day;
  final dailyBudget = _budget.amount / daysInMonth;

遍历所有交易记录,按日期汇总每天的支出金额。dailyExpense 是一个 Map,key 是日期(1-31),value 是当天的总支出。计算日均预算作为参考线。

卡片头部:

  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)),
              Text(
                '日均预算: ${_storage.currency}${dailyBudget.toStringAsFixed(0)}',
                style: TextStyle(fontSize: 12.sp, color: _textSecondary),
              ),
            ],
          ),
          SizedBox(height: 16.h),

标题左侧显示"每日支出",右侧显示日均预算金额,让用户知道每天应该控制在多少以内。

柱状图实现:

          SizedBox(
            height: 120.h,
            child: Row(
              crossAxisAlignment: CrossAxisAlignment.end,
              children: List.generate(daysInMonth, (index) {
                final day = index + 1;
                final expense = dailyExpense[day] ?? 0;
                final maxExpense = dailyExpense.values.isEmpty 
                    ? dailyBudget 
                    : dailyExpense.values.reduce((a, b) => a > b ? a : b);
                final height = maxExpense > 0 ? (expense / maxExpense * 80).h : 0.0;
                final isOverBudget = expense > dailyBudget;

用 Row 和 List.generate 生成每天的柱子。找出最大支出金额,用于计算每个柱子的相对高度。判断当天支出是否超过日均预算,用于设置柱子颜色。

柱子渲染:

                return Expanded(
                  child: Padding(
                    padding: EdgeInsets.symmetric(horizontal: 1.w),
                    child: Column(
                      mainAxisAlignment: MainAxisAlignment.end,
                      children: [
                        Container(
                          height: height.clamp(2.0, 80.h),
                          decoration: BoxDecoration(
                            color: isOverBudget ? _expenseColor : _primaryColor,
                            borderRadius: BorderRadius.vertical(top: Radius.circular(2.r)),
                          ),
                        ),
                        SizedBox(height: 4.h),
                        if (day % 5 == 1 || day == daysInMonth)
                          Text(
                            '$day',
                            style: TextStyle(fontSize: 8.sp, color: _textSecondary),
                          )
                        else
                          SizedBox(height: 10.h),
                      ],
                    ),
                  ),
                );
              }),
            ),
          ),
        ],
      ),
    ),
  );
}

每个柱子用 Expanded 平均分配宽度。柱子高度根据支出金额按比例计算,最小 2 像素确保有支出的日子能看到柱子。超过日均预算的柱子显示为红色,正常的为绿色。底部每隔 5 天显示一次日期标签,避免太拥挤。

相关支出列表

支出列表展示与该预算相关的所有交易记录:

Widget _buildTransactionList(List<TransactionModel> transactions) {
  return Card(
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Padding(
          padding: EdgeInsets.all(16.w),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              Text('相关支出', style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w600)),
              Text(
                '共 ${transactions.length} 笔',
                style: TextStyle(fontSize: 12.sp, color: _textSecondary),
              ),
            ],
          ),
        ),

卡片头部显示标题和交易总笔数,让用户快速了解支出记录的数量。

空状态和列表项:

        if (transactions.isEmpty)
          Padding(
            padding: EdgeInsets.all(24.w), 
            child: Center(
              child: Text('暂无支出记录', style: TextStyle(color: _textSecondary, fontSize: 14.sp))
            )
          )
        else
          ...transactions.take(20).map((t) {
            final cat = _categoryService.getCategoryById(t.categoryId);
            return ListTile(
              leading: CircleAvatar(
                backgroundColor: (cat?.color ?? Colors.grey).withOpacity(0.2), 
                child: Icon(cat?.icon ?? Icons.help, color: cat?.color ?? Colors.grey, size: 20.sp)
              ),
              title: Text(cat?.name ?? '未知'),
              subtitle: Text(DateFormat('MM-dd HH:mm').format(t.date)),

如果没有支出记录,显示空状态提示。否则显示交易列表,最多显示 20 条。每个列表项包含分类图标、分类名称和交易时间。

列表项尾部和点击事件:

              trailing: Text(
                '-${_storage.currency}${t.amount.toStringAsFixed(2)}', 
                style: TextStyle(color: _expenseColor, fontWeight: FontWeight.w600)
              ),
              onTap: () => Get.toNamed(Routes.transactionDetail, arguments: t),
            );
          }),

右侧显示支出金额,用红色表示支出。点击列表项可以跳转到交易详情页面查看更多信息。

查看更多按钮:

        if (transactions.length > 20)
          Padding(
            padding: EdgeInsets.all(16.w),
            child: Center(
              child: TextButton(
                onPressed: () {
                  // 查看更多
                },
                child: Text('查看全部 ${transactions.length} 笔记录'),
              ),
            ),
          ),
      ],
    ),
  );
}

如果交易记录超过 20 条,底部显示"查看更多"按钮,让用户可以查看完整列表。这种分页加载方式避免一次性渲染太多列表项影响性能。

编辑和删除功能

编辑预算功能:

void _navigateToEdit() {
  Get.toNamed(Routes.budgetEdit, arguments: _budget)?.then((result) {
    if (result != null && result is BudgetModel) {
      setState(() => _budget = result);
    }
  });
}

点击编辑按钮跳转到预算编辑页面,传入当前预算对象。编辑完成后,如果返回了新的预算对象,更新当前页面的数据并刷新界面。

删除预算功能:

void _showDeleteDialog() {
  Get.dialog(AlertDialog(
    title: const Text('删除预算'),
    content: const Text('确定要删除这个预算吗?此操作不可恢复。'),
    actions: [
      TextButton(onPressed: () => Get.back(), child: const Text('取消')),
      TextButton(
        onPressed: () {
          _budgetService.deleteBudget(_budget.id);
          Get.back();
          Get.back();
          Get.snackbar('成功', '预算已删除');
        },
        child: Text('删除', style: TextStyle(color: _expenseColor)),
      ),
    ],
  ));
}

点击删除按钮弹出确认对话框,避免误操作。确认后调用 BudgetService 删除预算,关闭对话框和详情页面,返回上一页并显示成功提示。删除按钮用红色文字突出显示,提醒用户这是危险操作。

BudgetModel 数据模型

预算数据模型定义:

class BudgetModel {
  final String id;
  final double amount;
  final double spent;
  final String? categoryId;
  final int year;
  final int month;
  final bool isOverall;
  
  BudgetModel({
    required this.id,
    required this.amount,
    this.spent = 0,
    this.categoryId,
    required this.year,
    required this.month,
    this.isOverall = false,
  });

id 是预算的唯一标识,amount 是预算金额,spent 是已用金额。categoryId 是分类 ID,总预算时为 null。year 和 month 指定预算所属的月份。isOverall 标记是否是总预算。

计算属性:

  double get remaining => amount - spent;
  double get percentage => amount > 0 ? (spent / amount * 100) : 0;
  bool get isOverBudget => spent > amount;

remaining 计算剩余金额,percentage 计算使用百分比,isOverBudget 判断是否超支。这些计算属性让 UI 代码更简洁。

复制方法:

  BudgetModel copyWith({
    double? amount,
    double? spent,
    String? categoryId,
    int? year,
    int? month,
    bool? isOverall,
  }) {
    return BudgetModel(
      id: id,
      amount: amount ?? this.amount,
      spent: spent ?? this.spent,
      categoryId: categoryId ?? this.categoryId,
      year: year ?? this.year,
      month: month ?? this.month,
      isOverall: isOverall ?? this.isOverall,
    );
  }
}

copyWith 方法用于创建修改后的副本,保持数据不可变性。这是 Flutter 中常用的模式,便于状态管理。

预算提醒功能

预算提醒帮助用户及时了解预算状态:

void _checkBudgetAlert() {
  if (_budget.percentage >= 80 && _budget.percentage < 100) {
    // 显示警告提示
    Get.snackbar(
      '预算提醒',
      '您的预算已使用 ${_budget.percentage.toStringAsFixed(0)}%,请注意控制支出',
      backgroundColor: _warningColor.withOpacity(0.9),
      colorText: Colors.white,
      duration: const Duration(seconds: 4),
    );

当预算使用达到 80% 但未超支时,显示橙色警告提示。提醒用户注意控制支出,避免超支。

超支提醒:

  } else if (_budget.isOverBudget) {
    Get.snackbar(
      '预算超支',
      '您的预算已超支 ${_storage.currency}${(_budget.spent - _budget.amount).toStringAsFixed(2)}',
      backgroundColor: _expenseColor.withOpacity(0.9),
      colorText: Colors.white,
      duration: const Duration(seconds: 4),
    );
  }
}

当预算超支时,显示红色提示,告知用户具体超支金额。这种分级提醒机制帮助用户更好地管理预算。可以在页面初始化时调用这个方法,让用户进入页面就能看到提醒。

小结

预算详情页面让用户可以:

  1. 直观查看预算使用进度
  2. 了解具体的支出明细
  3. 通过趋势图发现消费规律
  4. 及时发现超支情况
  5. 方便地编辑和管理预算

下一篇将实现统计分析页面。


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

https://openharmonycrossplatform.csdn.net

Logo

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

更多推荐