Flutter for OpenHarmony 跨平台开发:日历打卡功能实战指南
Flutter 就像是一个神奇的魔法棒,轻轻一挥,你的应用就能在鸿蒙、Android、iOS 上自由奔跑啦!Flutter 是 Google 推出的开源 UI 框架,以其"一次编写,多处运行"的理念深受开发者喜爱。而 Flutter for OpenHarmony 则是为我们打开了一扇通往鸿蒙生态的大门。开源鸿蒙(OpenHarmony)是由开放原子开源基金会孵化的开源项目,旨在构建万物智联的操作
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 界面设计
界面从上到下依次为:
- 习惯选择器:横向滚动的 FilterChip 列表,选择要追踪的习惯
- 统计面板:渐变背景的统计卡片,显示打卡数据
- 月份切换:左右箭头切换月份,中间显示年月
- 星期标题:日一二三四五六
- 日历网格: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 应用,需要注意:
- 签名配置:在 DevEco Studio 中配置自动签名
- 权限配置:如需网络请求,需在
module.json5中配置网络权限 - 触摸反馈:使用
InkWell或GestureDetector处理触摸事件
八、总结与展望
通过本文的学习,我们使用 Flutter for OpenHarmony 成功实现了一个功能完善的日历打卡应用。从数据结构设计到 UI 实现,再到统计数据计算,每一步都体现了 Flutter 跨平台开发的便捷与高效。
功能回顾:
- ✅ 多习惯追踪
- ✅ 日历视图展示
- ✅ 打卡统计
- ✅ 视觉反馈
可扩展方向:
- 数据持久化:使用
shared_preferences或hive保存打卡记录 - 提醒功能:添加本地通知提醒
- 数据可视化:添加打卡趋势图表
- 社交分享:分享打卡成就到社交平台
Flutter for OpenHarmony 的生态正在蓬勃发展,越来越多的开发者加入到这个大家庭中。相信在不久的将来,我们会看到更多优秀的跨平台应用诞生!
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)