Flutter for OpenHarmony 习惯养成 App:用打卡机制打造自律生活的可视化引擎

Flutter for OpenHarmony 习惯养成 App:用打卡机制打造自律生活的可视化引擎

在信息过载与注意力稀缺的时代,"坚持"成为最稀缺的能力。而一个优秀的习惯追踪工具 ,不应只是记录打卡的冰冷表格,而应是激发行动、反馈成就、陪伴成长的数字伙伴

🌐 加入社区 欢迎加入 开源鸿蒙跨平台开发者社区 ,获取最新资源与技术支持: 👉 开源鸿蒙跨平台开发者社区


完整效果


一、核心理念:让"坚持"看得见、摸得着

该应用围绕三个关键体验展开:

  1. 可视化打卡:7天网格 + 复选框,每日状态一目了然;
  2. 即时反馈:完成率进度条 + 百分比数字,量化你的努力;
  3. 正向激励:统计面板 + 成就提示("连续7天可获徽章"),强化行为动机。

💡 心理学研究显示:可视化进展能显著提升目标达成率。这个 App 正是这一原理的完美实践。


二、数据模型:简洁而强大的 Habit

dart 复制代码
class Habit {
  final String id;
  final String name;
  final Color color;
  final List<bool> completion; // 最近7天完成状态

  double get completionRate => completed / 7;
  
  Habit copyWith({List<bool>? completion}) { ... }
}
  • 不可变设计 :通过 copyWith 创建新实例,确保状态更新安全;
  • 内聚计算completionRate 作为 getter,自动随数据变化;
  • 色彩编码:每个习惯拥有专属颜色,增强识别度与情感连接。

🎨 颜色不仅是装饰,更是认知锚点------绿色代表"晨间阅读",蓝色代表"冥想",形成条件反射。


三、交互设计亮点

1. 直观的 7 日打卡网格

dart 复制代码
// 每日格子:边框 + 勾选图标 + 星期标签
Container(
  decoration: BoxDecoration(
    color: isCompleted ? habit.color : Colors.transparent,
    border: Border.all(color: isCompleted ? habit.color : Colors.grey),
  ),
  child: isCompleted ? Icon(Icons.check, color: Colors.white) : null,
)
  • 点击即切换:轻触任意日期格子,立即标记/取消完成;
  • 视觉反馈:完成时填充主色 + 白色对勾,未完成为灰色边框;
  • 星期标注:中文"一至日",符合本地用户认知习惯。

2. 完成率进度条 + 文字

  • 使用 LinearProgressIndicator 展示本周完成比例;
  • 数字百分比以习惯主色高亮,强化成就感;
  • 进度条圆角设计(borderRadius),更柔和现代。

3. 撤销删除(Undo Pattern)

dart 复制代码
ScaffoldMessenger.of(context).showSnackBar(
  SnackBar(
    content: Text('已删除:...'),
    action: SnackBarAction(label: '撤销', onPressed: () { ... }),
  ),
);
  • 删除后弹出 Snackbar,提供 5 秒内撤销机会
  • 符合 Material Design 的"临时性操作"最佳实践;
  • 极大降低误操作成本,提升用户安全感。

四、智能细节:超越基础功能的巧思

✅ 随机生成习惯名称(避免空输入)

dart 复制代码
String _getRandomHabitName() {
  final prefixes = ['每日', '坚持', '养成'];
  final actions = ['喝水', '拉伸', '写日记'];
  return '$prefix$action';
}
  • 点击"+"按钮时,自动生成如"每日深呼吸"、"养成感恩习惯"等名称;
  • 降低启动门槛:用户无需思考"该填什么",立刻开始使用;
  • 后续可编辑,兼顾便捷性与灵活性。

✅ 统计概览(Stats Dashboard)

  • 通过 AppBar 右上角 info_outline 图标进入;
  • 展示:
    • 总习惯数
    • 本周总完成次数
    • 平均完成率
  • 底部提示:"连续完成7天可获得成就徽章!"------埋下长期激励钩子

✅ 响应式布局适配小屏设备

dart 复制代码
final _isSmallScreen = _screenWidth < 360;
// 动态调整:字体、间距、FAB 大小、图标尺寸
  • 在 iPhone SE 等小屏设备上自动压缩 padding 和字号;
  • 确保内容不溢出、点击区域足够大;
  • 体现 "移动优先" 的设计哲学。

五、UI/UX 设计语言:清新治愈的绿色主题

元素 设计说明
主色调 Colors.green ------ 象征成长、健康、希望
背景色 #F8FFF8 ------ 极浅绿,减少视觉疲劳
卡片 圆角 16 + 轻微阴影,层次分明不突兀
空状态 绿色描边圆形图标 + 引导文案,鼓励行动
FAB 按钮 绿底白加号,符合 Material 规范

🌱 整体风格清新、宁静、无压迫感,契合"习惯养成"所需的平和心态。


六、技术实现亮点

技术点 应用说明
ListView.builder 高效渲染习惯列表,支持动态增删
GestureDetector 为打卡格子添加点击区域,比 InkWell 更精准
MediaQuery.of(context).size 实时获取屏幕尺寸,驱动响应式逻辑
DateTime.now().millisecondsSinceEpoch 生成唯一 ID,避免冲突
List.filled(7, false) 初始化7天未完成状态,语义清晰

七、未来扩展方向

当前版本虽已完整,但潜力巨大:

  1. 持久化存储 :接入 hiveshared_preferences 保存习惯数据;
  2. 通知提醒 :使用 flutter_local_notifications 设置每日打卡提醒;
  3. 成就系统:实现"7天连胜"、"30天坚持"等徽章;
  4. 数据图表 :用 fl_chart 展示月度/年度趋势;
  5. 习惯分类:按"健康"、"学习"、"工作"分组管理;
  6. 云同步:通过 Firebase 实现多设备同步。

八、结语:微小习惯,复利人生

这个习惯追踪器的魅力,在于它不追求宏大目标,而专注微小行动的累积

每天打一个勾,看似微不足道,但当7个勾连成一线,当进度条从0%走向100%,当统计面板显示"本周完成21次"------你看到的不是数据,而是自己的改变

完整代码

bash 复制代码
import 'package:flutter/material.dart';
import 'dart:math';

void main() {
  runApp(const HabitTrackerApp());
}

class HabitTrackerApp extends StatelessWidget {
  const HabitTrackerApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: '🌱 习惯养成',
      theme: ThemeData(
        brightness: Brightness.light,
        primarySwatch: Colors.green,
        scaffoldBackgroundColor: const Color(0xFFF8FFF8),
        appBarTheme: const AppBarTheme(
          backgroundColor: Colors.transparent,
          foregroundColor: Colors.green,
          elevation: 0,
        ),
        floatingActionButtonTheme: const FloatingActionButtonThemeData(
          backgroundColor: Colors.green,
          foregroundColor: Colors.white,
        ),
        textTheme: const TextTheme(
          headlineSmall: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
          bodyMedium: TextStyle(fontSize: 16),
        ),
      ),
      home: const HabitTrackerScreen(),
    );
  }
}

// 习惯数据模型
class Habit {
  final String id;
  final String name;
  final Color color;
  final List<bool> completion; // 每日完成状态(最近7天)

  Habit({
    required this.id,
    required this.name,
    required this.color,
    List<bool>? completion,
  }) : completion = completion ?? List.filled(7, false);

  // 计算本周完成率
  double get completionRate {
    if (completion.isEmpty) return 0.0;
    final completed = completion.where((day) => day).length;
    return completed / completion.length;
  }

  // 创建新实例(用于状态更新)
  Habit copyWith({List<bool>? completion}) {
    return Habit(
      id: id,
      name: name,
      color: color,
      completion: completion ?? this.completion,
    );
  }
}

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

  @override
  State<HabitTrackerScreen> createState() => _HabitTrackerScreenState();
}

class _HabitTrackerScreenState extends State<HabitTrackerScreen> {
  // 预设习惯颜色池
  static final List<Color> _colorPool = [
    Colors.green,
    Colors.teal,
    Colors.blue,
    Colors.purple,
    Colors.orange,
    Colors.red,
    Colors.pink,
    Colors.brown,
  ];

  // 屏幕尺寸分类
  late double _screenWidth;
  late bool _isSmallScreen;

  // 初始习惯数据(模拟本地存储)
  List<Habit> _habits = [
    Habit(
      id: '1',
      name: '晨间阅读',
      color: Colors.green,
      completion: [true, true, false, true, true, false, true],
    ),
    Habit(
      id: '2',
      name: '运动30分钟',
      color: Colors.teal,
      completion: [false, true, true, false, true, true, true],
    ),
    Habit(
      id: '3',
      name: '冥想10分钟',
      color: Colors.blue,
      completion: [true, false, false, true, true, true, false],
    ),
  ];

  final Random _random = Random();

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _updateScreenSize();
  }

  void _updateScreenSize() {
    final size = MediaQuery.of(context).size;
    _screenWidth = size.width;
    _isSmallScreen = _screenWidth < 360;
  }

  // 切换某天的完成状态
  void _toggleCompletion(String habitId, int dayIndex) {
    setState(() {
      _habits = _habits.map((habit) {
        if (habit.id == habitId) {
          final newCompletion = List<bool>.from(habit.completion);
          newCompletion[dayIndex] = !newCompletion[dayIndex];
          return habit.copyWith(completion: newCompletion);
        }
        return habit;
      }).toList();
    });
  }

  // 添加新习惯
  void _addNewHabit() {
    final habitName = _getRandomHabitName();
    final color = _colorPool[_random.nextInt(_colorPool.length)];

    final newHabit = Habit(
      id: DateTime.now().millisecondsSinceEpoch.toString(),
      name: habitName,
      color: color,
    );

    setState(() {
      _habits.add(newHabit);
    });

    // 显示添加提示
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(
          '已添加新习惯:$habitName',
          style: TextStyle(fontSize: _isSmallScreen ? 14 : 15),
        ),
        duration: const Duration(seconds: 2),
        behavior: SnackBarBehavior.floating,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(12),
        ),
      ),
    );
  }

  // 随机生成习惯名称(避免空输入)
  String _getRandomHabitName() {
    final prefixes = ['每日', '坚持', '养成', '开始', '继续'];
    final actions = ['喝水', '拉伸', '写日记', '整理桌面', '深呼吸', '散步', '感恩', '计划'];
    final suffixes = ['', '习惯', '练习', '仪式'];

    final prefix = prefixes[_random.nextInt(prefixes.length)];
    final action = actions[_random.nextInt(actions.length)];
    final suffix = suffixes[_random.nextInt(suffixes.length)];

    return '$prefix$action$suffix';
  }

  // 删除习惯
  void _deleteHabit(String habitId) {
    final habitToDelete = _habits.firstWhere((h) => h.id == habitId);
    setState(() {
      _habits.removeWhere((h) => h.id == habitId);
    });

    // 撤销删除
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(
          '已删除:${habitToDelete.name}',
          style: TextStyle(fontSize: _isSmallScreen ? 14 : 15),
        ),
        action: SnackBarAction(
          label: '撤销',
          onPressed: () {
            setState(() {
              _habits.insert(0, habitToDelete);
            });
          },
        ),
        behavior: SnackBarBehavior.floating,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(12),
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    _updateScreenSize();

    final fabSize = _isSmallScreen ? 48.0 : 56.0;
    final iconSize = _isSmallScreen ? 24.0 : 28.0;

    return Scaffold(
      appBar: AppBar(
        title: Text(
          '我的习惯',
          style: TextStyle(
            fontSize: _isSmallScreen ? 20 : 22,
            fontWeight: FontWeight.bold,
          ),
        ),
        centerTitle: true,
        toolbarHeight: _isSmallScreen ? 52 : 56,
        actions: [
          IconButton(
            icon: Icon(Icons.info_outline, size: _isSmallScreen ? 22 : 24),
            onPressed: () {
              _showStatsDialog();
            },
            padding: EdgeInsets.all(_isSmallScreen ? 8 : 12),
            constraints: BoxConstraints(
              minWidth: _isSmallScreen ? 40 : 48,
              minHeight: _isSmallScreen ? 40 : 48,
            ),
          ),
        ],
      ),
      body: _habits.isEmpty
          ? _buildEmptyState()
          : ListView.builder(
              padding: EdgeInsets.only(
                top: 16,
                bottom: _isSmallScreen ? 80 : 90,
              ),
              itemCount: _habits.length,
              itemBuilder: (context, index) {
                final habit = _habits[index];
                return _buildHabitCard(habit, index);
              },
            ),
      floatingActionButton: SizedBox(
        width: fabSize,
        height: fabSize,
        child: FloatingActionButton(
          heroTag: 'add_habit',
          onPressed: _addNewHabit,
          tooltip: '添加新习惯',
          child: Icon(Icons.add, size: iconSize),
        ),
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
    );
  }

  Widget _buildEmptyState() {
    final iconSize = _isSmallScreen ? 56.0 : 64.0;
    final titleSize = _isSmallScreen ? 18.0 : 20.0;
    final padding = _isSmallScreen ? 24.0 : 32.0;

    return Center(
      child: Padding(
        padding: EdgeInsets.all(padding),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Container(
              padding: const EdgeInsets.all(24),
              decoration: BoxDecoration(
                color: Colors.green.withValues(alpha: 0.1),
                shape: BoxShape.circle,
              ),
              child: Icon(
                Icons.checklist,
                size: iconSize,
                color: Colors.green,
              ),
            ),
            SizedBox(height: _isSmallScreen ? 20 : 24),
            Text(
              '还没有习惯记录',
              style: TextStyle(
                fontSize: titleSize,
                fontWeight: FontWeight.bold,
              ),
            ),
            const SizedBox(height: 8),
            Text(
              '点击下方按钮添加你的第一个习惯',
              textAlign: TextAlign.center,
              style: const TextStyle(
                color: Colors.grey,
                height: 1.5,
                fontSize: 14,
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildHabitCard(Habit habit, int index) {
    final horizontalPadding = _isSmallScreen ? 12.0 : 16.0;
    final verticalPadding = _isSmallScreen ? 12.0 : 16.0;
    final cardMargin = _isSmallScreen
        ? const EdgeInsets.symmetric(horizontal: 12, vertical: 6)
        : const EdgeInsets.symmetric(horizontal: 16, vertical: 8);
    final fontSize = _isSmallScreen ? 16.0 : 18.0;
    final iconSize = _isSmallScreen ? 18.0 : 20.0;

    return Card(
      margin: cardMargin,
      elevation: 2,
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
      child: Padding(
        padding: EdgeInsets.symmetric(
            horizontal: horizontalPadding, vertical: verticalPadding),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // 习惯名称 + 删除按钮
            Row(
              children: [
                Expanded(
                  child: Text(
                    habit.name,
                    style: TextStyle(
                        fontSize: fontSize, fontWeight: FontWeight.bold),
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                  ),
                ),
                SizedBox(
                  width: 36,
                  height: 36,
                  child: IconButton(
                    icon: Icon(Icons.delete_outline, size: iconSize),
                    onPressed: () => _deleteHabit(habit.id),
                    color: Colors.grey,
                    splashRadius: 18,
                    padding: EdgeInsets.zero,
                    constraints: const BoxConstraints(),
                  ),
                ),
              ],
            ),

            SizedBox(height: _isSmallScreen ? 8 : 12),

            // 完成率进度条
            LinearProgressIndicator(
              value: habit.completionRate,
              backgroundColor: Colors.grey.shade200,
              color: habit.color,
              minHeight: _isSmallScreen ? 6 : 8,
              borderRadius: BorderRadius.circular(4),
            ),

            SizedBox(height: _isSmallScreen ? 6 : 8),

            // 完成率文本
            Text(
              '本周完成率: ${(habit.completionRate * 100).round()}%',
              style: TextStyle(
                color: habit.color,
                fontWeight: FontWeight.w600,
                fontSize: _isSmallScreen ? 13 : 14,
              ),
            ),

            SizedBox(height: _isSmallScreen ? 12 : 16),

            // 7天打卡网格
            SizedBox(
              height: _isSmallScreen ? 52 : 58,
              child: Row(
                children: List.generate(7, (dayIndex) {
                  final isCompleted = habit.completion[dayIndex];
                  final dayLabel =
                      ['一', '二', '三', '四', '五', '六', '日'][dayIndex];
                  final boxSize = _isSmallScreen ? 32.0 : 36.0;
                  final fontSize = _isSmallScreen ? 11.0 : 12.0;
                  final iconSize = _isSmallScreen ? 16.0 : 18.0;

                  return Expanded(
                    child: GestureDetector(
                      onTap: () => _toggleCompletion(habit.id, dayIndex),
                      child: Column(
                        mainAxisAlignment: MainAxisAlignment.spaceBetween,
                        children: [
                          // 复选框
                          Container(
                            width: boxSize,
                            height: boxSize,
                            decoration: BoxDecoration(
                              color: isCompleted
                                  ? habit.color
                                  : Colors.transparent,
                              border: Border.all(
                                color: isCompleted
                                    ? habit.color
                                    : Colors.grey.shade400,
                                width: 2,
                              ),
                              borderRadius: BorderRadius.circular(8),
                            ),
                            child: isCompleted
                                ? Icon(
                                    Icons.check,
                                    size: iconSize,
                                    color: Colors.white,
                                  )
                                : null,
                          ),
                          // 星期标签
                          Text(
                            dayLabel,
                            style: TextStyle(
                              fontSize: fontSize,
                              color: isCompleted ? habit.color : Colors.grey,
                            ),
                          ),
                        ],
                      ),
                    ),
                  );
                }),
              ),
            ),
          ],
        ),
      ),
    );
  }

  void _showStatsDialog() {
    final totalHabits = _habits.length;
    final totalCompleted = _habits.fold(0, (sum, habit) {
      return sum + habit.completion.where((day) => day).length;
    });
    final avgCompletionRate = totalHabits > 0
        ? (_habits.fold(0.0, (sum, habit) => sum + habit.completionRate) /
                totalHabits *
                100)
            .round()
        : 0;

    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Text(
          '📊 习惯统计',
          style: TextStyle(fontSize: _isSmallScreen ? 18 : 20),
        ),
        content: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            _buildStatRow('总习惯数', '$totalHabits 个'),
            _buildStatRow('本周完成', '$totalCompleted 次'),
            _buildStatRow('平均完成率', '$avgCompletionRate%'),
            SizedBox(height: _isSmallScreen ? 12 : 16),
            Container(
              padding: EdgeInsets.all(_isSmallScreen ? 10 : 12),
              decoration: BoxDecoration(
                color: Colors.green.withValues(alpha: 0.1),
                borderRadius: BorderRadius.circular(8),
              ),
              child: Text(
                '小提示:连续完成7天可获得成就徽章!',
                style: TextStyle(
                  color: Colors.green,
                  fontSize: _isSmallScreen ? 13 : 14,
                ),
              ),
            ),
          ],
        ),
        actions: [
          TextButton(
            onPressed: Navigator.of(context).pop,
            child: const Text('关闭'),
          ),
        ],
      ),
    );
  }

  Widget _buildStatRow(String label, String value) {
    return Padding(
      padding: EdgeInsets.symmetric(vertical: _isSmallScreen ? 3 : 4),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          Text(
            label,
            style: TextStyle(
              fontWeight: FontWeight.bold,
              fontSize: _isSmallScreen ? 14 : 15,
            ),
          ),
          Text(
            value,
            style: TextStyle(
              color: Colors.green,
              fontSize: _isSmallScreen ? 14 : 15,
            ),
          ),
        ],
      ),
    );
  }
}
相关推荐
mCell7 小时前
如何零成本搭建个人站点
前端·程序员·github
mCell8 小时前
为什么 Memo Code 先做 CLI:以及终端输入框到底有多难搞
前端·设计模式·agent
恋猫de小郭8 小时前
AI 在提高你工作效率的同时,也一直在增加你的疲惫和焦虑
前端·人工智能·ai编程
少云清8 小时前
【安全测试】2_客户端脚本安全测试 _XSS和CSRF
前端·xss·csrf
银烛木8 小时前
黑马程序员前端h5+css3
前端·css·css3
m0_607076608 小时前
CSS3 转换,快手前端面试经验,隔壁都馋哭了
前端·面试·css3
HEADKON9 小时前
索托拉西布Sotorasib治疗KRAS G12C突变肺癌的标准口服方案与药物相互作用
生活·健康医疗
听海边涛声9 小时前
CSS3 图片模糊处理
前端·css·css3
IT、木易9 小时前
css3 backdrop-filter 在移动端 Safari 上导致渲染性能急剧下降的优化方案有哪些?
前端·css3·safari
0思必得09 小时前
[Web自动化] Selenium无头模式
前端·爬虫·selenium·自动化·web自动化