Flutter实战:打造习惯打卡应用,连续天数统计与热力图展示

养成一个好习惯需要21天,坚持一个好习惯需要90天。本文将用Flutter实现一款习惯打卡应用,支持多习惯管理、连续打卡统计、目标进度追踪和热力图展示。

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

功能特性

  • 多习惯管理:添加、编辑、删除多个习惯
  • 🔥 连续打卡:自动计算连续打卡天数
  • 🎯 目标追踪:设定目标天数,查看完成进度
  • 📅 日期选择:查看和补打历史记录
  • 📊 热力图:直观展示打卡记录
  • 🎨 个性化:自定义图标和颜色

应用架构

UI层

业务逻辑

数据层

Habit模型

SharedPreferences

HabitPresets

图标/颜色

currentStreak

连续天数计算

completionRate

完成率计算

toggleHabit

打卡/取消

HabitTrackerApp

顶部统计

日期选择器

习惯卡片列表

HabitDetail

统计数据

热力图

数据模型

习惯模型

class Habit {
  final String id;
  String name;
  String icon;
  Color color;
  List<String> completedDates;  // 完成的日期列表 'yyyy-MM-dd'
  int targetDays;               // 目标天数
  DateTime createdAt;

  Habit({
    required this.id,
    required this.name,
    required this.icon,
    required this.color,
    List<String>? completedDates,
    this.targetDays = 21,
    DateTime? createdAt,
  })  : completedDates = completedDates ?? [],
        createdAt = createdAt ?? DateTime.now();
}

日期格式化

统一使用 yyyy-MM-dd 格式存储日期:

static String _dateToString(DateTime date) {
  return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}

核心算法

连续打卡天数计算

连续天数的计算逻辑:从今天或昨天开始,向前检查每一天是否有打卡记录。

获取打卡记录

按日期降序排序

最近打卡是今天或昨天?

返回0

从最近打卡日开始

当天有打卡?

streak++

检查前一天

返回streak

int get currentStreak {
  if (completedDates.isEmpty) return 0;
  
  // 按日期降序排序
  final sorted = List<String>.from(completedDates)..sort((a, b) => b.compareTo(a));
  final today = _dateToString(DateTime.now());
  final yesterday = _dateToString(DateTime.now().subtract(const Duration(days: 1)));
  
  // 如果最近一次打卡不是今天或昨天,连续天数为0
  if (sorted.first != today && sorted.first != yesterday) return 0;
  
  int streak = 0;
  DateTime checkDate = sorted.first == today 
      ? DateTime.now() 
      : DateTime.now().subtract(const Duration(days: 1));
  
  // 从最近打卡日向前检查
  for (int i = 0; i < sorted.length; i++) {
    final dateStr = _dateToString(checkDate.subtract(Duration(days: i)));
    if (sorted.contains(dateStr)) {
      streak++;
    } else {
      break;  // 断了就停止
    }
  }
  return streak;
}

完成率计算

完成率 = 打卡天数 / 创建以来的天数

完成率=打卡天数创建至今天数×100% 完成率 = \frac{打卡天数}{创建至今天数} \times 100\% 完成率=创建至今天数打卡天数×100%

double get completionRate {
  final daysSinceCreated = DateTime.now().difference(createdAt).inDays + 1;
  return totalDays / daysSinceCreated;
}

打卡/取消打卡

void _toggleHabit(Habit habit, DateTime date) {
  final dateStr = Habit._dateToString(date);
  setState(() {
    if (habit.completedDates.contains(dateStr)) {
      habit.completedDates.remove(dateStr);  // 取消打卡
    } else {
      habit.completedDates.add(dateStr);     // 打卡
    }
  });
  _saveHabits();
}

UI组件实现

日期选择器

显示最近7天,支持查看历史打卡记录:

Widget _buildDateSelector() {
  final today = DateTime.now();
  final dates = List.generate(7, (i) => today.subtract(Duration(days: 6 - i)));
  final weekdays = ['一', '二', '三', '四', '五', '六', '日'];

  return Row(
    mainAxisAlignment: MainAxisAlignment.spaceEvenly,
    children: dates.map((date) {
      final isSelected = _dateToString(date) == _dateToString(_selectedDate);
      final isToday = _dateToString(date) == _dateToString(today);

      return GestureDetector(
        onTap: () => setState(() => _selectedDate = date),
        child: AnimatedContainer(
          duration: const Duration(milliseconds: 200),
          width: 44,
          padding: const EdgeInsets.symmetric(vertical: 8),
          decoration: BoxDecoration(
            color: isSelected ? Theme.of(context).colorScheme.primary : Colors.transparent,
            borderRadius: BorderRadius.circular(12),
          ),
          child: Column(
            children: [
              Text(weekdays[date.weekday - 1],
                   style: TextStyle(color: isSelected ? Colors.white : Colors.grey)),
              Text('${date.day}',
                   style: TextStyle(
                     fontWeight: isToday ? FontWeight.bold : FontWeight.normal,
                     color: isSelected ? Colors.white : Colors.black,
                   )),
            ],
          ),
        ),
      );
    }).toList(),
  );
}

习惯卡片

包含打卡按钮、习惯信息和进度环:

Widget _buildHabitCard(Habit habit) {
  final isCompleted = habit.completedDates.contains(_dateToString(_selectedDate));

  return Card(
    child: Row(
      children: [
        // 打卡按钮
        GestureDetector(
          onTap: () => _toggleHabit(habit, _selectedDate),
          child: AnimatedContainer(
            duration: const Duration(milliseconds: 300),
            width: 56,
            height: 56,
            decoration: BoxDecoration(
              color: isCompleted ? habit.color : habit.color.withValues(alpha: 0.1),
              borderRadius: BorderRadius.circular(16),
            ),
            child: Center(
              child: isCompleted
                  ? const Icon(Icons.check, color: Colors.white)
                  : Text(habit.icon, style: const TextStyle(fontSize: 28)),
            ),
          ),
        ),
        // 习惯信息
        Expanded(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(habit.name, style: const TextStyle(fontWeight: FontWeight.w600)),
              Row(
                children: [
                  Icon(Icons.local_fire_department, color: Colors.orange),
                  Text('连续 ${habit.currentStreak} 天'),
                  Text('共 ${habit.totalDays} 天'),
                ],
              ),
            ],
          ),
        ),
        // 进度环
        CircularProgressIndicator(
          value: habit.totalDays / habit.targetDays,
          valueColor: AlwaysStoppedAnimation(habit.color),
        ),
      ],
    ),
  );
}

热力图

类似GitHub贡献图的打卡热力图:

Widget _buildCalendarHeatmap(Habit habit) {
  final today = DateTime.now();
  final startDate = DateTime(today.year, today.month - 2, 1);  // 最近3个月
  final days = today.difference(startDate).inDays + 1;

  return Wrap(
    spacing: 4,
    runSpacing: 4,
    children: List.generate(days, (i) {
      final date = startDate.add(Duration(days: i));
      final dateStr = Habit._dateToString(date);
      final isCompleted = habit.completedDates.contains(dateStr);
      final isToday = dateStr == Habit._dateToString(today);

      return Tooltip(
        message: '$dateStr ${isCompleted ? "✓" : ""}',
        child: Container(
          width: 16,
          height: 16,
          decoration: BoxDecoration(
            color: isCompleted ? habit.color : Colors.grey.shade200,
            borderRadius: BorderRadius.circular(3),
            border: isToday ? Border.all(color: Colors.black) : null,
          ),
        ),
      );
    }),
  );
}

添加/编辑习惯表单

支持自定义图标、颜色和目标天数:

void _showAddHabitDialog([Habit? editHabit]) {
  String selectedIcon = editHabit?.icon ?? HabitPresets.icons[0];
  Color selectedColor = editHabit?.color ?? HabitPresets.colors[0];
  int targetDays = editHabit?.targetDays ?? 21;

  showModalBottomSheet(
    context: context,
    builder: (context) {
      return StatefulBuilder(
        builder: (context, setModalState) {
          return Column(
            children: [
              // 习惯名称输入
              TextField(decoration: InputDecoration(labelText: '习惯名称')),
              
              // 图标选择
              Wrap(
                children: HabitPresets.icons.map((icon) {
                  return GestureDetector(
                    onTap: () => setModalState(() => selectedIcon = icon),
                    child: Container(
                      decoration: BoxDecoration(
                        color: selectedIcon == icon 
                            ? selectedColor.withValues(alpha: 0.2) 
                            : Colors.grey.shade100,
                        border: Border.all(
                          color: selectedIcon == icon ? selectedColor : Colors.transparent,
                        ),
                      ),
                      child: Text(icon),
                    ),
                  );
                }).toList(),
              ),
              
              // 颜色选择
              Wrap(
                children: HabitPresets.colors.map((color) {
                  return GestureDetector(
                    onTap: () => setModalState(() => selectedColor = color),
                    child: Container(
                      decoration: BoxDecoration(
                        color: color,
                        shape: BoxShape.circle,
                      ),
                      child: selectedColor == color 
                          ? Icon(Icons.check, color: Colors.white) 
                          : null,
                    ),
                  );
                }).toList(),
              ),
              
              // 目标天数
              DropdownButton<int>(
                value: targetDays,
                items: [7, 14, 21, 30, 60, 90, 100, 365].map((d) {
                  return DropdownMenuItem(value: d, child: Text('$d 天'));
                }).toList(),
                onChanged: (v) => setModalState(() => targetDays = v!),
              ),
            ],
          );
        },
      );
    },
  );
}

预设数据

图标预设

static const List<String> icons = [
  '💪', '📚', '🏃', '💧', '🧘', '✍️', '🎯', '⏰',
  '🍎', '😴', '🎵', '💊', '🚭', '💰', '🧹', '📱',
];
图标 适用习惯
💪 健身、锻炼
📚 阅读、学习
🏃 跑步、运动
💧 喝水
🧘 冥想、瑜伽
✍️ 写作、日记
🎯 目标、计划
早起、作息

数据持久化

使用 SharedPreferences 存储习惯数据:

Future<void> _loadHabits() async {
  final prefs = await SharedPreferences.getInstance();
  final habitsJson = prefs.getString('habits');
  if (habitsJson != null) {
    final List<dynamic> list = jsonDecode(habitsJson);
    _habits = list.map((e) => Habit.fromJson(e)).toList();
  }
}

Future<void> _saveHabits() async {
  final prefs = await SharedPreferences.getInstance();
  await prefs.setString('habits', jsonEncode(_habits.map((e) => e.toJson()).toList()));
}

状态流程

SharedPreferences State 习惯卡片 用户 SharedPreferences State 习惯卡片 用户 点击打卡按钮 _toggleHabit() 添加/移除日期 重新计算streak _saveHabits() setState() 更新UI

扩展建议

  1. 提醒功能:设置每日打卡提醒
  2. 数据统计:周/月/年统计报表
  3. 成就系统:连续打卡奖章
  4. 社交分享:分享打卡成就
  5. 数据导出:导出打卡记录
  6. 云同步:多设备数据同步

项目结构

lib/
└── main.dart
    ├── Habit                 # 习惯模型
    ├── HabitPresets          # 预设图标/颜色
    └── HabitTrackerApp       # 主应用
        ├── _buildDateSelector()    # 日期选择器
        ├── _buildHabitCard()       # 习惯卡片
        ├── _showHabitDetail()      # 习惯详情
        ├── _buildCalendarHeatmap() # 热力图
        └── _showAddHabitDialog()   # 添加/编辑表单

总结

这个习惯打卡应用展示了几个实用的开发技巧:

  1. 连续天数算法:从最近打卡日向前遍历,遇到断档即停止
  2. 日期处理:统一格式化为字符串,便于存储和比较
  3. 热力图:使用Wrap+Container实现类GitHub贡献图
  4. StatefulBuilder:在BottomSheet中管理局部状态
  5. 动画反馈:AnimatedContainer实现打卡动画

习惯养成的关键在于坚持,这个应用通过连续天数、进度环、热力图等可视化手段,帮助用户直观感受自己的坚持成果,从而增强继续打卡的动力。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

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

更多推荐