Flutter for OpenHarmony 跨平台开发:日历打卡功能实战指南

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


一、引言

嘿,亲爱的开发者们~有没有想过,用一套代码就能让你的创意在鸿蒙设备上绽放?今天要和大家分享的,是一个超级实用又可爱的小功能——日历打卡!无论是想养成早起的好习惯,还是坚持每天运动,这个小小的打卡助手都能成为你最好的伙伴呢~

在这个快节奏的时代,养成一个好习惯真的不容易。而日历打卡应用,就像是一个贴心的小伙伴,每天提醒你、鼓励你、陪伴你。当你看到日历上一个个绿色的小圆圈时,那种成就感简直太棒啦!

本文将带领大家使用 Flutter for OpenHarmony 跨平台技术,从零开始实现一个功能完善的日历打卡应用。让我们一起开启这段美妙的开发之旅吧~


二、技术背景

2.1 Flutter for OpenHarmony 简介

Flutter 就像是一个神奇的魔法棒,轻轻一挥,你的应用就能在鸿蒙、Android、iOS 上自由奔跑啦!Flutter 是 Google 推出的开源 UI 框架,以其"一次编写,多处运行"的理念深受开发者喜爱。而 Flutter for OpenHarmony 则是为我们打开了一扇通往鸿蒙生态的大门。

开源鸿蒙(OpenHarmony)是由开放原子开源基金会孵化的开源项目,旨在构建万物智联的操作系统生态。Flutter for OpenHarmony 的出现,让 Flutter 开发者能够无缝地将应用部署到鸿蒙设备上,极大地降低了跨平台开发的门槛。

2.2 跨平台开发的优势

相比传统的原生开发,Flutter 跨平台开发有着独特的魅力:

开发效率提升:一套代码,多端运行,再也不用为不同平台分别开发啦!

热重载体验:修改代码后立即看到效果,开发体验超级流畅~

精美的 UI:Flutter 提供丰富的 Material Design 和 Cupertino 组件,让你的应用颜值在线!

性能优异:Flutter 直接编译为原生代码,运行流畅不卡顿。

2.3 与原生鸿蒙开发的对比

特性 Flutter for OpenHarmony 原生鸿蒙开发
学习曲线 较平缓,Dart语言简洁 ArkTS/ArkUI需要学习
开发效率 一套代码多端运行 仅限鸿蒙平台
UI组件 丰富的跨平台组件 鸿蒙特有组件
性能 接近原生 原生性能
生态 Flutter庞大生态 鸿蒙生态

三、功能设计

3.1 功能概述

我们要实现的日历打卡功能,可不是简单的"点一下就完事"哦!它包含以下贴心的小功能:

多习惯追踪:支持早起、运动、阅读、学习、冥想、喝水等多种习惯,想追踪哪个就选哪个~

日历视图展示:清晰的月历视图,一目了然地看到自己的打卡记录。

打卡统计:本月打卡次数、连续打卡天数、总打卡天数,让成就感看得见!

视觉反馈:已打卡的日期显示绿色圆圈,今天有蓝色边框提示,未来日期则显示为灰色不可点击。

3.2 界面设计

界面从上到下依次为:

  1. 习惯选择器:横向滚动的 FilterChip 列表,选择要追踪的习惯
  2. 统计面板:渐变背景的统计卡片,显示打卡数据
  3. 月份切换:左右箭头切换月份,中间显示年月
  4. 星期标题:日一二三四五六
  5. 日历网格:7列的日期网格,点击即可打卡

四、核心实现

4.1 数据结构设计

首先,我们需要设计数据结构来存储打卡记录:

// 当前显示的月份
DateTime _currentMonth = DateTime.now();

// 当前选中的习惯
String _selectedHabit = '早起';

// 支持的习惯列表
final List<String> _habits = ['早起', '运动', '阅读', '学习', '冥想', '喝水'];

// 打卡记录存储,key为习惯名称,value为打卡日期集合
final Map<String, Set<String>> _habitRecords = {};

这里使用 Map<String, Set<String>> 来存储打卡记录,每个习惯对应一个日期字符串集合。日期字符串格式为 年-月-日,例如 2026-04-29

4.2 打卡状态判断

判断某一天是否已打卡:

bool _isChecked(DateTime date) {
  final key = '${date.year}-${date.month}-${date.day}';
  return (_habitRecords[_selectedHabit] ?? {}).contains(key);
}

4.3 打卡/取消打卡

点击日期时的处理逻辑:

void _toggleCheck(DateTime date) {
  final key = '${date.year}-${date.month}-${date.day}';
  setState(() {
    _habitRecords[_selectedHabit] ??= {};
    if (_habitRecords[_selectedHabit]!.contains(key)) {
      _habitRecords[_selectedHabit]!.remove(key);
    } else {
      _habitRecords[_selectedHabit]!.add(key);
    }
  });
}

4.4 统计数据计算

本月打卡次数

int _getMonthCheckCount() {
  return (_habitRecords[_selectedHabit] ?? {})
      .where((d) => d.startsWith('${_currentMonth.year}-${_currentMonth.month}'))
      .length;
}

连续打卡天数

int _getStreak() {
  int streak = 0;
  DateTime checkDate = DateTime.now();
  while (true) {
    final key = '${checkDate.year}-${checkDate.month}-${checkDate.day}';
    if ((_habitRecords[_selectedHabit] ?? {}).contains(key)) {
      streak++;
      checkDate = checkDate.subtract(const Duration(days: 1));
    } else {
      break;
    }
  }
  return streak;
}

五、完整代码实现

5.1 日历打卡功能完整代码

import 'package:flutter/material.dart';

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

  
  State<CalendarFeature> createState() => _CalendarFeatureState();
}

class _CalendarFeatureState extends State<CalendarFeature> {
  DateTime _currentMonth = DateTime.now();
  String _selectedHabit = '早起';
  final List<String> _habits = ['早起', '运动', '阅读', '学习', '冥想', '喝水'];
  final Map<String, Set<String>> _habitRecords = {};

  bool _isChecked(DateTime date) {
    final key = '${date.year}-${date.month}-${date.day}';
    return (_habitRecords[_selectedHabit] ?? {}).contains(key);
  }

  void _toggleCheck(DateTime date) {
    final key = '${date.year}-${date.month}-${date.day}';
    setState(() {
      _habitRecords[_selectedHabit] ??= {};
      if (_habitRecords[_selectedHabit]!.contains(key)) {
        _habitRecords[_selectedHabit]!.remove(key);
      } else {
        _habitRecords[_selectedHabit]!.add(key);
      }
    });
  }

  int _getMonthCheckCount() {
    return (_habitRecords[_selectedHabit] ?? {})
        .where((d) => d.startsWith('${_currentMonth.year}-${_currentMonth.month}'))
        .length;
  }

  int _getStreak() {
    int streak = 0;
    DateTime checkDate = DateTime.now();
    while (true) {
      final key = '${checkDate.year}-${checkDate.month}-${checkDate.day}';
      if ((_habitRecords[_selectedHabit] ?? {}).contains(key)) {
        streak++;
        checkDate = checkDate.subtract(const Duration(days: 1));
      } else {
        break;
      }
    }
    return streak;
  }

  
  Widget build(BuildContext context) {
    return Column(
      children: [
        _buildHabitSelector(),
        _buildStats(),
        _buildMonthHeader(),
        _buildWeekDays(),
        Expanded(child: _buildCalendarGrid()),
      ],
    );
  }

  Widget _buildHabitSelector() {
    return Container(
      padding: const EdgeInsets.all(12),
      child: SingleChildScrollView(
        scrollDirection: Axis.horizontal,
        child: Row(
          children: _habits.map((habit) => Padding(
            padding: const EdgeInsets.only(right: 8),
            child: FilterChip(
              label: Text(habit),
              selected: _selectedHabit == habit,
              onSelected: (selected) {
                setState(() => _selectedHabit = habit);
              },
              selectedColor: Colors.green.shade200,
            ),
          )).toList(),
        ),
      ),
    );
  }

  Widget _buildStats() {
    return Container(
      margin: const EdgeInsets.symmetric(horizontal: 12),
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        gradient: LinearGradient(
          colors: [Colors.green.shade400, Colors.green.shade600],
          begin: Alignment.centerLeft,
          end: Alignment.centerRight,
        ),
        borderRadius: BorderRadius.circular(12),
      ),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceAround,
        children: [
          _buildStatItem('本月打卡', _getMonthCheckCount(), Icons.calendar_today),
          _buildStatItem('连续天数', _getStreak(), Icons.local_fire_department),
          _buildStatItem('总天数', (_habitRecords[_selectedHabit] ?? {}).length, Icons.star),
        ],
      ),
    );
  }

  Widget _buildStatItem(String label, int value, IconData icon) {
    return Column(
      children: [
        Icon(icon, color: Colors.white, size: 24),
        const SizedBox(height: 4),
        Text('$value', style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.white)),
        Text(label, style: const TextStyle(fontSize: 12, color: Colors.white70)),
      ],
    );
  }

  Widget _buildMonthHeader() {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          IconButton(
            icon: const Icon(Icons.chevron_left, size: 28),
            onPressed: () => setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1)),
          ),
          Text(
            '${_currentMonth.year}${_currentMonth.month}月',
            style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
          ),
          IconButton(
            icon: const Icon(Icons.chevron_right, size: 28),
            onPressed: () => setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month + 1)),
          ),
        ],
      ),
    );
  }

  Widget _buildWeekDays() {
    const weekDays = ['日', '一', '二', '三', '四', '五', '六'];
    return Container(
      padding: const EdgeInsets.symmetric(vertical: 8),
      child: Row(
        children: weekDays.map((d) => Expanded(
          child: Center(
            child: Text(d, style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.grey)),
          ),
        )).toList(),
      ),
    );
  }

  Widget _buildCalendarGrid() {
    final firstDay = DateTime(_currentMonth.year, _currentMonth.month, 1);
    final lastDay = DateTime(_currentMonth.year, _currentMonth.month + 1, 0);
    final startWeekday = firstDay.weekday % 7;
    final totalDays = lastDay.day + startWeekday;

    return GridView.builder(
      padding: const EdgeInsets.symmetric(horizontal: 8),
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 7,
        childAspectRatio: 1,
      ),
      itemCount: totalDays,
      itemBuilder: (context, index) {
        if (index < startWeekday) return const SizedBox();

        final day = index - startWeekday + 1;
        final date = DateTime(_currentMonth.year, _currentMonth.month, day);
        final checked = _isChecked(date);
        final isToday = _isToday(date);
        final isFuture = date.isAfter(DateTime.now());

        return GestureDetector(
          onTap: isFuture ? null : () => _toggleCheck(date),
          child: Container(
            margin: const EdgeInsets.all(4),
            decoration: BoxDecoration(
              color: checked ? Colors.green : (isToday ? Colors.blue.shade50 : null),
              shape: BoxShape.circle,
              border: isToday && !checked ? Border.all(color: Colors.blue, width: 2) : null,
            ),
            child: Stack(
              alignment: Alignment.center,
              children: [
                Text(
                  '$day',
                  style: TextStyle(
                    color: checked ? Colors.white : (isFuture ? Colors.grey.shade300 : null),
                    fontWeight: isToday ? FontWeight.bold : null,
                  ),
                ),
                if (checked)
                  const Positioned(
                    bottom: 2,
                    child: Icon(Icons.check, size: 12, color: Colors.white),
                  ),
              ],
            ),
          ),
        );
      },
    );
  }

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

六、运行效果

在这里插入图片描述

运行效果展示:

  • 习惯选择器可以横向滚动选择不同习惯
  • 统计面板显示渐变绿色背景,数据清晰
  • 日历网格中已打卡日期显示绿色圆圈
  • 今天有蓝色边框高亮提示
  • 点击日期即可完成打卡/取消打卡

七、关键技术点解析

7.1 GridView 构建日历网格

使用 GridView.builder 构建日历网格,关键点在于计算起始位置:

final firstDay = DateTime(_currentMonth.year, _currentMonth.month, 1);
final startWeekday = firstDay.weekday % 7;

weekday % 7 是为了将周一为1的星期转换为周日为0的格式。

7.2 FilterChip 实现习惯选择

FilterChip 是 Material Design 3 的组件,非常适合做这种可选择的标签:

FilterChip(
  label: Text(habit),
  selected: _selectedHabit == habit,
  onSelected: (selected) {
    setState(() => _selectedHabit = habit);
  },
  selectedColor: Colors.green.shade200,
)

7.3 鸿蒙适配要点

在鸿蒙设备上运行 Flutter 应用,需要注意:

  1. 签名配置:在 DevEco Studio 中配置自动签名
  2. 权限配置:如需网络请求,需在 module.json5 中配置网络权限
  3. 触摸反馈:使用 InkWellGestureDetector 处理触摸事件

八、总结与展望

通过本文的学习,我们使用 Flutter for OpenHarmony 成功实现了一个功能完善的日历打卡应用。从数据结构设计到 UI 实现,再到统计数据计算,每一步都体现了 Flutter 跨平台开发的便捷与高效。

功能回顾

  • ✅ 多习惯追踪
  • ✅ 日历视图展示
  • ✅ 打卡统计
  • ✅ 视觉反馈

可扩展方向

  • 数据持久化:使用 shared_preferenceshive 保存打卡记录
  • 提醒功能:添加本地通知提醒
  • 数据可视化:添加打卡趋势图表
  • 社交分享:分享打卡成就到社交平台

Flutter for OpenHarmony 的生态正在蓬勃发展,越来越多的开发者加入到这个大家庭中。相信在不久的将来,我们会看到更多优秀的跨平台应用诞生!

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐