日历视图提供了一种直观的方式查看每日的收支记录。用户可以通过日历选择日期,查看当天的交易明细,快速定位到特定日期的账单。
请添加图片描述

功能设计

日历视图页面包含:

  1. 可交互的日历组件
  2. 有记录的日期显示标记
  3. 选中日期的收支汇总
  4. 当日交易记录列表
  5. 月度收支统计
  6. 快速跳转到今天

设计思路

日历视图的核心价值:

  • 提供时间维度的账单浏览方式
  • 快速定位到特定日期
  • 直观展示哪些日期有记录
  • 方便回顾历史消费

依赖配置

使用 table_calendar 包实现日历功能:

dependencies:
  table_calendar: ^3.0.9

页面实现

创建 calendar_page.dart

import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:table_calendar/table_calendar.dart';
import 'package:intl/intl.dart';
import '../../core/services/transaction_service.dart';
import '../../core/services/category_service.dart';
import '../../core/services/storage_service.dart';
import '../../data/models/transaction_model.dart';
import '../../routes/app_pages.dart';

导入必要的包和服务。table_calendar 是第三方日历组件库,提供了丰富的日历功能。intl 用于日期格式化。导入三个核心服务用于获取交易数据、分类信息和存储配置。TransactionModel 是交易数据模型,app_pages 包含路由定义。

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

class CalendarPage extends StatefulWidget {
  const CalendarPage({super.key});
  
  State<CalendarPage> createState() => _CalendarPageState();
}

定义颜色常量保持视觉一致性。主题色用于强调元素,收入用绿色,支出用红色,次要文字用灰色。CalendarPage 使用 StatefulWidget 因为需要管理选中日期、焦点日期等状态。这些状态会随用户交互而变化,需要触发页面重建。

class _CalendarPageState extends State<CalendarPage> {
  final _transactionService = Get.find<TransactionService>();
  final _categoryService = Get.find<CategoryService>();
  final _storage = Get.find<StorageService>();
  DateTime _focusedDay = DateTime.now();
  DateTime? _selectedDay;
  CalendarFormat _calendarFormat = CalendarFormat.month;

  Map<DateTime, List<TransactionModel>> _transactionsByDay = {};

初始化服务和状态变量。_focusedDay 是日历当前显示的月份,_selectedDay 是用户选中的日期。_calendarFormat 控制日历显示格式(月/周/双周)。_transactionsByDay 是缓存,key 是日期,value 是该日期的交易列表,避免重复查询提升性能。

  
  void initState() {
    super.initState();
    _selectedDay = DateTime.now();
    _loadTransactions();
  }

  void _loadTransactions() {
    _transactionsByDay.clear();
    for (var t in _transactionService.allTransactions) {

页面初始化时加载交易数据。默认选中今天的日期,然后调用 _loadTransactions 构建缓存。_loadTransactions 方法遍历所有交易记录,按日期归类存储到 Map 中。先清空缓存确保数据是最新的,避免旧数据残留。

      final day = DateTime(t.date.year, t.date.month, t.date.day);
      _transactionsByDay[day] ??= [];
      _transactionsByDay[day]!.add(t);
    }
  }

  List<TransactionModel> _getTransactionsForDay(DateTime day) {
    final normalizedDay = DateTime(day.year, day.month, day.day);
    return _transactionsByDay[normalizedDay] ?? [];
  }

将交易日期标准化为零点时刻,去掉时分秒。使用 ??= 操作符,如果该日期还没有列表就创建一个空列表。_getTransactionsForDay 方法根据日期获取交易列表,同样需要标准化日期。如果该日期没有交易返回空列表,避免空指针异常。

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('日历账单'),
        actions: [
          IconButton(
            icon: const Icon(Icons.today),
            onPressed: () => setState(() {

构建页面主体结构。AppBar 标题显示"日历账单",右侧添加一个"回到今天"的按钮。点击按钮时调用 setState 更新选中日期和焦点日期为今天,触发页面重建。这个功能方便用户快速回到当前日期,提升使用体验。

              _selectedDay = DateTime.now();
              _focusedDay = DateTime.now();
            }),
            tooltip: '回到今天',
          ),
        ],
      ),
      body: Column(
        children: [
          _buildCalendar(),

设置 tooltip 提示文字,鼠标悬停时显示。body 使用 Column 布局,从上到下依次放置日历组件、月度汇总和交易列表。_buildCalendar 方法构建日历组件,是页面的核心部分,用户通过它选择日期查看交易记录。

          _buildMonthSummary(),
          Expanded(child: _buildTransactionList()),
        ],
      ),
    );
  }
}

_buildMonthSummary 显示当前月份的收支汇总,让用户了解整体情况。_buildTransactionList 显示选中日期的交易明细,用 Expanded 包裹让它占据剩余空间。这种布局让日历固定在顶部,交易列表可以滚动查看,符合用户的使用习惯。

日历组件

使用 TableCalendar 实现日历功能:

Widget _buildCalendar() {
  return Card(
    margin: EdgeInsets.all(8.w),
    child: TableCalendar(
      firstDay: DateTime(2020),
      lastDay: DateTime(2030),
      focusedDay: _focusedDay,
      selectedDayPredicate: (day) => isSameDay(_selectedDay, day),
      calendarFormat: _calendarFormat,

创建日历组件并设置基本参数。Card 包裹提供阴影和圆角效果。firstDay 和 lastDay 限制日历的可选范围为2020-2030年。focusedDay 控制当前显示的月份。selectedDayPredicate 判断某个日期是否被选中,用于高亮显示。calendarFormat 控制显示格式。

      onFormatChanged: (format) => setState(() => _calendarFormat = format),
      onDaySelected: (selectedDay, focusedDay) => setState(() { 
        _selectedDay = selectedDay; 
        _focusedDay = focusedDay; 
      }),
      onPageChanged: (focusedDay) => _focusedDay = focusedDay,
      eventLoader: _getTransactionsForDay,
      calendarStyle: CalendarStyle(

配置日历的交互回调。onFormatChanged 在用户切换月/周视图时触发。onDaySelected 在点击日期时触发,更新选中日期和焦点日期。onPageChanged 在切换月份时触发。eventLoader 为每个日期加载事件数据,这里返回该日期的交易列表,用于显示标记点。

        todayDecoration: BoxDecoration(
          color: _primaryColor.withOpacity(0.5), 
          shape: BoxShape.circle
        ),
        selectedDecoration: BoxDecoration(
          color: _primaryColor, 
          shape: BoxShape.circle
        ),
        markerDecoration: BoxDecoration(
          color: _expenseColor, 

配置日历样式。todayDecoration 设置今天日期的样式,用半透明的主题色圆形背景。selectedDecoration 设置选中日期的样式,用完全不透明的主题色,比今天更醒目。markerDecoration 设置标记点的样式,用红色表示有支出记录。这些视觉区分让用户快速识别不同状态的日期。

          shape: BoxShape.circle
        ),
        markerSize: 6.w,
        markersMaxCount: 3,
        outsideDaysVisible: false,
      ),
      headerStyle: HeaderStyle(
        formatButtonVisible: true,
        titleCentered: true,

设置标记点大小为6,最多显示3个标记点。outsideDaysVisible 设为 false 隐藏非当月日期,让日历更简洁。headerStyle 配置头部样式,formatButtonVisible 显示格式切换按钮,titleCentered 让标题居中显示。这些配置让日历既美观又实用。

        formatButtonDecoration: BoxDecoration(
          border: Border.all(color: _primaryColor), 
          borderRadius: BorderRadius.circular(12.r)
        ),
        formatButtonTextStyle: TextStyle(color: _primaryColor),
        leftChevronIcon: Icon(Icons.chevron_left, color: _primaryColor),
        rightChevronIcon: Icon(Icons.chevron_right, color: _primaryColor),
      ),

自定义头部按钮样式。格式切换按钮用主题色边框和圆角,文字也用主题色。左右箭头图标用主题色,保持视觉一致性。这些细节让日历组件与应用整体风格融为一体,提升专业感和品牌识别度。

      calendarBuilders: CalendarBuilders(
        markerBuilder: (context, date, events) {
          if (events.isEmpty) return null;
          
          final transactions = events.cast<TransactionModel>();
          final hasIncome = transactions.any((t) => t.type == TransactionType.income);
          final hasExpense = transactions.any((t) => t.type == TransactionType.expense);
          
          return Positioned(

自定义标记点的构建逻辑。如果某天没有交易记录返回 null 不显示标记。将 events 转换为 TransactionModel 类型。检查该天是否有收入和支出记录。使用 Positioned 定位标记点在日期下方。这样可以区分显示收入和支出,比单一标记更有信息量。

            bottom: 1,
            child: Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                if (hasExpense)
                  Container(
                    width: 6.w,
                    height: 6.w,
                    decoration: BoxDecoration(

标记点定位在底部1像素处。Row 布局横向排列收入和支出标记。如果有支出记录,显示一个6x6的红色圆点。Container 用 BoxDecoration 设置圆形形状和颜色。这种设计让用户一眼就能看出某天有哪些类型的交易。

                      color: _expenseColor,
                      shape: BoxShape.circle,
                    ),
                  ),
                if (hasIncome) ...[
                  SizedBox(width: 2.w),
                  Container(
                    width: 6.w,
                    height: 6.w,
                    decoration: BoxDecoration(

支出标记用红色圆点。如果有收入记录,添加2像素间距后显示绿色圆点。两个圆点并排显示,用户可以同时看到收入和支出信息。展开运算符 … 配合 if 语句,可以条件性地添加多个 Widget,这是 Dart 的语法糖。

                      color: _incomeColor,
                      shape: BoxShape.circle,
                    ),
                  ),
                ],
              ],
            ),
          );
        },
      ),
    ),
  );
}

收入标记用绿色圆点。整个 markerBuilder 返回自定义的标记组件。这种设计比默认的单一标记更有表现力,用户可以快速了解每天的交易类型。日历组件配置完成,提供了丰富的交互和视觉反馈,是日历视图页面的核心功能。

月度收支汇总

显示当前月份的收支统计:

Widget _buildMonthSummary() {
  final monthStart = DateTime(_focusedDay.year, _focusedDay.month, 1);
  final monthEnd = DateTime(_focusedDay.year, _focusedDay.month + 1, 0);
  
  double monthIncome = 0;
  double monthExpense = 0;
  
  for (var t in _transactionService.allTransactions) {
    if (t.date.isAfter(monthStart.subtract(const Duration(days: 1))) &&
        t.date.isBefore(monthEnd.add(const Duration(days: 1)))) {
      if (t.type == TransactionType.income) {
        monthIncome += t.amount;
      } else {
        monthExpense += t.amount;
      }
    }
  }

  return Container(
    padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
    decoration: BoxDecoration(
      color: Colors.grey[100],
      border: Border(
        bottom: BorderSide(color: Colors.grey[300]!),
      ),
    ),
    child: Row(
      mainAxisAlignment: MainAxisAlignment.spaceAround,
      children: [
        _buildSummaryItem('本月收入', monthIncome, _incomeColor),
        Container(width: 1, height: 30.h, color: Colors.grey[300]),
        _buildSummaryItem('本月支出', monthExpense, _expenseColor),
        Container(width: 1, height: 30.h, color: Colors.grey[300]),
        _buildSummaryItem('本月结余', monthIncome - monthExpense, 
          monthIncome >= monthExpense ? _incomeColor : _expenseColor),
      ],
    ),
  );
}

Widget _buildSummaryItem(String label, double value, Color color) {
  return Column(
    children: [
      Text(label, style: TextStyle(fontSize: 10.sp, color: _textSecondary)),
      SizedBox(height: 2.h),
      Text(
        '${_storage.currency}${value.toStringAsFixed(0)}',
        style: TextStyle(fontSize: 14.sp, fontWeight: FontWeight.w600, color: color),
      ),
    ],
  );
}

交易记录列表

Widget _buildTransactionList() {
  final transactions = _selectedDay != null 
    ? _getTransactionsForDay(_selectedDay!) 
    : <TransactionModel>[];
  
  // 按时间排序
  transactions.sort((a, b) => b.date.compareTo(a.date));
  
  final income = transactions
    .where((t) => t.type == TransactionType.income)
    .fold(0.0, (sum, t) => sum + t.amount);
  final expense = transactions
    .where((t) => t.type == TransactionType.expense)
    .fold(0.0, (sum, t) => sum + t.amount);

  return Card(
    margin: EdgeInsets.all(8.w),
    child: Column(
      children: [
        if (_selectedDay != null) _buildDaySummary(income, expense),
        Expanded(
          child: transactions.isEmpty
            ? _buildEmptyState()
            : _buildList(transactions),
        ),
      ],
    ),
  );
}

Widget _buildDaySummary(double income, double expense) {
  return Container(
    padding: EdgeInsets.all(16.w),
    decoration: BoxDecoration(
      border: Border(bottom: BorderSide(color: Colors.grey[200]!)),
    ),
    child: Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: [
        Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              DateFormat('MM月dd日 EEEE', 'zh_CN').format(_selectedDay!), 
              style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w600)
            ),
            SizedBox(height: 4.h),
            Text(
              _isToday(_selectedDay!) ? '今天' : _getRelativeDate(_selectedDay!),
              style: TextStyle(fontSize: 12.sp, color: _textSecondary),
            ),
          ],
        ),
        Row(
          children: [
            Column(
              crossAxisAlignment: CrossAxisAlignment.end,
              children: [
                Text(
                  '收入', 
                  style: TextStyle(fontSize: 10.sp, color: _textSecondary)
                ),
                Text(
                  '+${_storage.currency}${income.toStringAsFixed(0)}', 
                  style: TextStyle(fontSize: 12.sp, color: _incomeColor, fontWeight: FontWeight.w500)
                ),
              ],
            ),
            SizedBox(width: 16.w),
            Column(
              crossAxisAlignment: CrossAxisAlignment.end,
              children: [
                Text(
                  '支出', 
                  style: TextStyle(fontSize: 10.sp, color: _textSecondary)
                ),
                Text(
                  '-${_storage.currency}${expense.toStringAsFixed(0)}', 
                  style: TextStyle(fontSize: 12.sp, color: _expenseColor, fontWeight: FontWeight.w500)
                ),
              ],
            ),
          ],
        ),
      ],
    ),
  );
}

bool _isToday(DateTime date) {
  final now = DateTime.now();
  return date.year == now.year && date.month == now.month && date.day == now.day;
}

String _getRelativeDate(DateTime date) {
  final now = DateTime.now();
  final today = DateTime(now.year, now.month, now.day);
  final targetDate = DateTime(date.year, date.month, date.day);
  final difference = today.difference(targetDate).inDays;
  
  if (difference == 1) return '昨天';
  if (difference == 2) return '前天';
  if (difference == -1) return '明天';
  if (difference > 0) return '$difference天前';
  return '${-difference}天后';
}

Widget _buildEmptyState() {
  return Center(
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Icon(Icons.event_note, size: 48.sp, color: Colors.grey[300]),
        SizedBox(height: 12.h),
        Text(
          _selectedDay == null ? '请选择日期' : '当日无记录', 
          style: TextStyle(color: _textSecondary, fontSize: 14.sp)
        ),
        if (_selectedDay != null) ...[
          SizedBox(height: 16.h),
          ElevatedButton.icon(
            onPressed: () => Get.toNamed(Routes.addTransaction, arguments: {'date': _selectedDay}),
            icon: const Icon(Icons.add),
            label: const Text('添加记录'),
            style: ElevatedButton.styleFrom(backgroundColor: _primaryColor),
          ),
        ],
      ],
    ),
  );
}

Widget _buildList(List<TransactionModel> transactions) {
  return ListView.separated(
    padding: EdgeInsets.symmetric(vertical: 8.h),
    itemCount: transactions.length,
    separatorBuilder: (_, __) => Divider(height: 1, indent: 72.w),
    itemBuilder: (_, index) {
      final t = transactions[index];
      final category = _categoryService.getCategoryById(t.categoryId);
      return ListTile(
        leading: CircleAvatar(
          backgroundColor: (category?.color ?? Colors.grey).withOpacity(0.2), 
          child: Icon(category?.icon ?? Icons.help, color: category?.color ?? Colors.grey, size: 20.sp)
        ),
        title: Text(category?.name ?? '未知'),
        subtitle: Row(
          children: [
            Text(
              DateFormat('HH:mm').format(t.date),
              style: TextStyle(fontSize: 12.sp, color: _textSecondary),
            ),
            if (t.note != null && t.note!.isNotEmpty) ...[
              SizedBox(width: 8.w),
              Expanded(
                child: Text(
                  t.note!, 
                  maxLines: 1, 
                  overflow: TextOverflow.ellipsis,
                  style: TextStyle(fontSize: 12.sp, color: _textSecondary),
                ),
              ),
            ],
          ],
        ),
        trailing: Text(
          '${t.type == TransactionType.income ? '+' : '-'}${_storage.currency}${t.amount.toStringAsFixed(2)}',
          style: TextStyle(
            color: t.type == TransactionType.income ? _incomeColor : _expenseColor, 
            fontWeight: FontWeight.w600
          ),
        ),
        onTap: () => Get.toNamed(Routes.transactionDetail, arguments: t),
      );
    },
  );
}

快速添加记录

在选中日期时,提供快速添加记录的入口:

Widget _buildQuickAddButton() {
  return FloatingActionButton(
    onPressed: () => Get.toNamed(
      Routes.addTransaction, 
      arguments: {'date': _selectedDay ?? DateTime.now()}
    ),
    backgroundColor: _primaryColor,
    child: const Icon(Icons.add),
  );
}

日期范围快速跳转

提供快速跳转到特定日期的功能:

void _showDatePicker() async {
  final picked = await showDatePicker(
    context: context,
    initialDate: _selectedDay ?? DateTime.now(),
    firstDate: DateTime(2020),
    lastDate: DateTime(2030),
    builder: (context, child) {
      return Theme(
        data: Theme.of(context).copyWith(
          colorScheme: ColorScheme.light(primary: _primaryColor),
        ),
        child: child!,
      );
    },
  );
  
  if (picked != null) {
    setState(() {
      _selectedDay = picked;
      _focusedDay = picked;
    });
  }
}

设计要点

日历视图的设计考虑:

  1. 有记录的日期显示小圆点标记,区分收入和支出
  2. 今天和选中日期有不同的样式
  3. 支持月/双周/周三种视图切换
  4. 选中日期后显示当日收支汇总
  5. 提供快速跳转到今天的功能
  6. 空状态时提供添加记录的入口

性能优化

对于大量交易数据,使用缓存优化性能:

// 在页面初始化时预处理数据
void _loadTransactions() {
  _transactionsByDay.clear();
  for (var t in _transactionService.allTransactions) {
    final day = DateTime(t.date.year, t.date.month, t.date.day);
    _transactionsByDay[day] ??= [];
    _transactionsByDay[day]!.add(t);
  }
}

// 监听数据变化,更新缓存

void didChangeDependencies() {
  super.didChangeDependencies();
  _loadTransactions();
}

小结

日历视图提供了一种直观的方式浏览历史记录,用户可以:

  1. 快速定位到某一天查看详情
  2. 通过标记了解哪些日期有记录
  3. 查看月度收支汇总
  4. 方便地添加指定日期的记录

下一篇将实现搜索功能页面。


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

https://openharmonycrossplatform.csdn.net

Logo

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

更多推荐