📅 Flutter + HarmonyOS 实战:打造一款精美的手机日历应用


运行效果图
在这里插入图片描述
在这里插入图片描述

📋 文章导读

章节 内容概要 预计阅读
日历应用需求分析与设计 3分钟
日期计算核心算法 8分钟
日历网格布局实现 10分钟
日程管理功能开发 8分钟
交互体验优化 5分钟
完整源码与总结 3分钟

💡 写在前面:日历是手机上使用频率最高的工具类应用之一。一个好的日历应用不仅要准确显示日期,还要支持日程管理、节日提醒等功能。本文将带你用Flutter从零实现一款功能完善、界面精美的日历应用,深入讲解日期计算、网格布局、状态管理等核心技术点。


一、需求分析与设计

1.1 功能需求

一款实用的日历应用需要具备以下核心功能:

日历应用

日期展示

月视图

周视图切换

今日高亮

周末标红

日程管理

添加日程

查看日程

颜色标签

时间设置

节日显示

公历节日

农历节日

自定义纪念日

交互体验

月份切换

年月选择器

快速回到今天

1.2 界面设计

整体采用卡片式设计,分为三个主要区域:

区域 功能 设计要点
顶部导航 月份切换、年月选择 蓝色主题,圆角底部
日历网格 日期展示、选择 7列网格,今日/选中高亮
日程列表 当日日程展示 卡片样式,颜色标签

1.3 配色方案

元素 颜色 用途
主题色 blue.shade600 顶部背景、选中日期
今日边框 blue.shade400 今日日期标识
周末文字 red.shade400 周六周日
节日文字 orange.shade600 节日名称
非当月日期 grey.shade400 上/下月日期

二、日期计算核心算法

2.1 日历的数学基础

要正确渲染日历,我们需要解决两个核心问题:

  1. 某月有多少天?
  2. 某月第一天是星期几?
计算某月天数
/// 获取某月的天数
int _daysInMonth(DateTime date) {
  // 下个月的第0天 = 当月的最后一天
  return DateTime(date.year, date.month + 1, 0).day;
}

这里用了一个小技巧:DateTime(year, month, 0) 会返回上个月的最后一天。所以 month + 1 的第0天就是当月的最后一天。

各月天数速查表
月份 天数 备注
1月 31
2月 28/29 闰年29天
3月 31
4月 30
5月 31
6月 30
7月 31
8月 31
9月 30
10月 31
11月 30
12月 31
闰年判断公式

isLeapYear={trueif yearmod  400=0falseif yearmod  100=0trueif yearmod  4=0falseotherwise \text{isLeapYear} = \begin{cases} \text{true} & \text{if } year \mod 400 = 0 \\ \text{false} & \text{if } year \mod 100 = 0 \\ \text{true} & \text{if } year \mod 4 = 0 \\ \text{false} & \text{otherwise} \end{cases} isLeapYear= truefalsetruefalseif yearmod400=0if yearmod100=0if yearmod4=0otherwise

2.2 计算月份第一天的星期

/// 获取某月第一天是星期几(0=周日, 1=周一, ..., 6=周六)
int _firstDayOfMonth(DateTime date) {
  return DateTime(date.year, date.month, 1).weekday % 7;
}

⚠️ 注意:Dart的 weekday 返回 1-7(周一到周日),我们需要转换为 0-6(周日到周六)以匹配中国习惯的日历布局。

2.3 日历网格计算流程

开始渲染日历

计算当月天数

计算第一天是星期几

计算上月需要显示的天数

渲染42个格子

index < firstDay?

显示上月日期

index >= firstDay + daysInMonth?

显示下月日期

显示当月日期

设置灰色样式

设置正常样式

2.4 完整的日期计算代码

Widget _buildCalendarGrid() {
  final daysInMonth = _daysInMonth(_currentMonth);
  final firstDay = _firstDayOfMonth(_currentMonth);
  final prevMonth = DateTime(_currentMonth.year, _currentMonth.month - 1);
  final daysInPrevMonth = _daysInMonth(prevMonth);

  return GridView.builder(
    gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
      crossAxisCount: 7,  // 一周7天
    ),
    itemCount: 42,  // 6行 × 7列 = 42个格子
    itemBuilder: (context, index) {
      DateTime date;
      bool isCurrentMonth = true;

      if (index < firstDay) {
        // 上个月的日期
        final day = daysInPrevMonth - firstDay + index + 1;
        date = DateTime(prevMonth.year, prevMonth.month, day);
        isCurrentMonth = false;
      } else if (index >= firstDay + daysInMonth) {
        // 下个月的日期
        final day = index - firstDay - daysInMonth + 1;
        date = DateTime(_currentMonth.year, _currentMonth.month + 1, day);
        isCurrentMonth = false;
      } else {
        // 当前月的日期
        final day = index - firstDay + 1;
        date = DateTime(_currentMonth.year, _currentMonth.month, day);
      }

      return _buildDayCell(date, isCurrentMonth);
    },
  );
}

三、日历网格布局

3.1 整体布局结构

日历网格

月份选择器

Body - Column

Scaffold

AppBar

Body

FloatingActionButton

月份选择器

星期头部

日历网格

日程列表

左箭头

年月显示

右箭头

GridView 7×6

3.2 星期头部实现

Widget _buildWeekHeader() {
  final weekDays = ['日', '一', '二', '三', '四', '五', '六'];
  
  return Row(
    children: weekDays.asMap().entries.map((entry) {
      final isWeekend = entry.key == 0 || entry.key == 6;
      return Expanded(
        child: Center(
          child: Text(
            entry.value,
            style: TextStyle(
              color: isWeekend ? Colors.red.shade400 : Colors.grey.shade600,
              fontWeight: FontWeight.w600,
            ),
          ),
        ),
      );
    }).toList(),
  );
}

3.3 日期单元格设计

每个日期单元格需要处理多种状态:

状态 样式 优先级
选中日期 蓝色填充背景 1
今天 蓝色边框 2
有日程 底部小圆点 3
节日 橙色小字 4
周末 红色文字 5
非当月 灰色文字 6
Widget _buildDayCell(DateTime date, bool isCurrentMonth) {
  final isToday = _isToday(date);
  final isSelected = _isSelected(date);
  final isWeekend = _isWeekend(date);
  final festival = _getFestival(date);
  final hasEvents = _events[_dateKey(date)]?.isNotEmpty ?? false;

  return GestureDetector(
    onTap: () => setState(() => _selectedDate = date),
    child: Container(
      margin: const EdgeInsets.all(2),
      decoration: BoxDecoration(
        color: isSelected
            ? Colors.blue.shade600
            : isToday ? Colors.blue.shade50 : null,
        borderRadius: BorderRadius.circular(12),
        border: isToday && !isSelected
            ? Border.all(color: Colors.blue.shade400, width: 2)
            : null,
      ),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          // 日期数字
          Text(
            '${date.day}',
            style: TextStyle(
              color: _getDayColor(isSelected, isCurrentMonth, isWeekend),
              fontWeight: isToday || isSelected ? FontWeight.bold : null,
            ),
          ),
          // 节日名称
          if (festival != null)
            Text(festival, style: TextStyle(fontSize: 9, color: Colors.orange)),
          // 日程指示点
          if (hasEvents && festival == null)
            Container(
              width: 6, height: 6,
              decoration: BoxDecoration(
                color: isSelected ? Colors.white : Colors.blue,
                shape: BoxShape.circle,
              ),
            ),
        ],
      ),
    ),
  );
}

四、日程管理功能

4.1 数据模型设计

/// 事件模型
class Event {
  final String title;    // 标题
  final String time;     // 时间
  final Color color;     // 颜色标签
  
  Event(this.title, this.time, this.color);
}

使用 Map<String, List<Event>> 存储日程数据,key 为日期字符串:

final Map<String, List<Event>> _events = {};

String _dateKey(DateTime date) => '${date.year}-${date.month}-${date.day}';

void _addEvent(DateTime date, Event event) {
  final key = _dateKey(date);
  _events[key] = [...(_events[key] ?? []), event];
}

4.2 日程列表展示

Widget _buildEventItem(Event event) {
  return Container(
    margin: const EdgeInsets.symmetric(vertical: 4),
    padding: const EdgeInsets.all(12),
    decoration: BoxDecoration(
      color: event.color.withValues(alpha: 0.1),
      borderRadius: BorderRadius.circular(12),
      border: Border(
        left: BorderSide(color: event.color, width: 4),  // 左侧颜色条
      ),
    ),
    child: Row(
      children: [
        // 时间标签
        Container(
          padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
          decoration: BoxDecoration(
            color: event.color.withValues(alpha: 0.2),
            borderRadius: BorderRadius.circular(8),
          ),
          child: Text(event.time, style: TextStyle(color: event.color)),
        ),
        const SizedBox(width: 12),
        // 标题
        Expanded(child: Text(event.title)),
        Icon(Icons.chevron_right, color: Colors.grey),
      ],
    ),
  );
}

4.3 添加日程对话框

状态管理 对话框 用户 状态管理 对话框 用户 点击添加按钮 显示表单 输入标题 选择时间 选择颜色 点击确定 调用 _addEvent() 更新 _events Map setState() 刷新UI 关闭对话框

4.4 颜色标签选择器

Row(
  children: [
    const Text('颜色标签:'),
    ...[Colors.blue, Colors.orange, Colors.green, Colors.pink, Colors.purple]
        .map((color) {
      return GestureDetector(
        onTap: () => setDialogState(() => selectedColor = color),
        child: Container(
          margin: const EdgeInsets.symmetric(horizontal: 4),
          width: 32,
          height: 32,
          decoration: BoxDecoration(
            color: color,
            shape: BoxShape.circle,
            border: selectedColor == color
                ? Border.all(color: Colors.black, width: 3)
                : null,
          ),
        ),
      );
    }),
  ],
)

五、交互体验优化

5.1 月份切换动画

void _changeMonth(int delta) {
  setState(() {
    _currentMonth = DateTime(
      _currentMonth.year,
      _currentMonth.month + delta,
    );
  });
}

5.2 年月选择器

使用 ListWheelScrollView 实现 iOS 风格的滚轮选择器:

void _showYearMonthPicker() {
  showModalBottomSheet(
    context: context,
    shape: const RoundedRectangleBorder(
      borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
    ),
    builder: (context) {
      return Container(
        height: 300,
        child: Row(
          children: [
            // 年份滚轮
            Expanded(
              child: ListWheelScrollView.useDelegate(
                itemExtent: 40,
                perspective: 0.005,
                onSelectedItemChanged: (index) {
                  setState(() {
                    _currentMonth = DateTime(2020 + index, _currentMonth.month);
                  });
                },
                childDelegate: ListWheelChildBuilderDelegate(
                  childCount: 20,
                  builder: (context, index) => Center(
                    child: Text('${2020 + index}年'),
                  ),
                ),
              ),
            ),
            // 月份滚轮
            Expanded(
              child: ListWheelScrollView.useDelegate(
                itemExtent: 40,
                perspective: 0.005,
                onSelectedItemChanged: (index) {
                  setState(() {
                    _currentMonth = DateTime(_currentMonth.year, index + 1);
                  });
                },
                childDelegate: ListWheelChildBuilderDelegate(
                  childCount: 12,
                  builder: (context, index) => Center(
                    child: Text('${index + 1}月'),
                  ),
                ),
              ),
            ),
          ],
        ),
      );
    },
  );
}

5.3 节日数据配置

// 公历节日
final Map<String, String> _solarFestivals = {
  '1-1': '元旦',
  '2-14': '情人节',
  '3-8': '妇女节',
  '4-1': '愚人节',
  '5-1': '劳动节',
  '5-4': '青年节',
  '6-1': '儿童节',
  '7-1': '建党节',
  '8-1': '建军节',
  '9-10': '教师节',
  '10-1': '国庆节',
  '12-25': '圣诞节',
};

String? _getFestival(DateTime date) {
  final key = '${date.month}-${date.day}';
  return _solarFestivals[key];
}

六、完整源码与运行

6.1 项目结构

flutter_calendar/
├── lib/
│   └── main.dart       # 日历应用主代码(约450行)
├── ohos/               # 鸿蒙平台配置
├── pubspec.yaml        # 依赖配置
└── README.md           # 项目说明

6.2 运行命令

# 获取依赖
flutter pub get

# 运行到模拟器/设备
flutter run

# 运行到鸿蒙设备
flutter run -d ohos

6.3 功能清单

功能 状态 说明
月视图展示 完整的日历网格
月份切换 左右箭头切换
年月选择器 滚轮式选择
今日高亮 边框+背景
日期选择 点击选中
周末标红 周六周日红色
节日显示 公历节日
日程添加 标题+时间+颜色
日程列表 卡片式展示
快速回今天 顶部按钮

七、扩展方向

7.1 功能扩展建议

日历应用

农历支持

周视图

日程提醒

云端同步

Widget小组件

农历转换算法

农历节日

本地通知

定时提醒

账号登录

数据备份

7.2 农历转换思路

农历转换是一个复杂的问题,可以使用现成的库或查表法:

// 推荐使用 lunar 包
// pubspec.yaml: lunar: ^1.3.0

import 'package:lunar/lunar.dart';

String getLunarDate(DateTime date) {
  final lunar = Lunar.fromDate(date);
  return '${lunar.getMonthInChinese()}${lunar.getDayInChinese()}';
}

7.3 本地通知实现

// 使用 flutter_local_notifications 包
// 在添加日程时设置提醒

Future<void> scheduleNotification(Event event, DateTime date) async {
  await flutterLocalNotificationsPlugin.zonedSchedule(
    0,
    '日程提醒',
    event.title,
    tz.TZDateTime.from(date, tz.local),
    const NotificationDetails(
      android: AndroidNotificationDetails('calendar', '日历提醒'),
    ),
    androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
    uiLocalNotificationDateInterpretation:
        UILocalNotificationDateInterpretation.absoluteTime,
  );
}

八、常见问题

Q1: 为什么日历要显示42个格子?

日历网格固定为6行7列=42个格子,这是因为:

  • 一个月最多31天
  • 如果1号是周六,那么需要6行才能显示完整
  • 固定行数可以避免月份切换时布局跳动
Q2: 如何处理跨年的月份切换?

Dart的DateTime会自动处理跨年情况:

// 2026年1月 - 1个月 = 2025年12月
DateTime(2026, 0, 1)  // 自动变成 2025-12-01

// 2025年12月 + 1个月 = 2026年1月
DateTime(2025, 13, 1)  // 自动变成 2026-01-01
Q3: 如何实现日程数据持久化?

可以使用以下方案:

  1. SharedPreferences:适合简单数据
  2. SQLite (sqflite):适合复杂查询
  3. Hive:高性能NoSQL数据库
// 使用 shared_preferences 示例
Future<void> saveEvents() async {
  final prefs = await SharedPreferences.getInstance();
  final json = jsonEncode(_events.map((k, v) => 
    MapEntry(k, v.map((e) => e.toJson()).toList())
  ));
  await prefs.setString('events', json);
}

九、总结

本文从零开始,实现了一款功能完善的Flutter日历应用。核心技术点包括:

  1. 日期计算:月份天数、星期计算、闰年判断
  2. 网格布局:GridView实现7×6日历网格
  3. 状态管理:日期选择、月份切换、日程数据
  4. 交互设计:滚轮选择器、添加对话框、颜色标签
  5. 视觉设计:今日高亮、周末标红、节日显示

日历看似简单,实则涉及不少细节。希望这篇文章能帮你理解日历应用的开发思路,也欢迎在此基础上继续扩展更多功能!


📅 完整源码已上传,欢迎Star支持!


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

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐