✅ 鸿蒙原生应用实战(二十一)ArkUI 习惯追踪器:日历热力图 + Streak 连击 + SQLite

博主说: 习惯决定命运——但坚持习惯很难。今天用 ArkUI 的 Canvas 热力图 + SQLite 存储,从零实现一个支持习惯管理、每日打卡、Streak 连击天数、日历热力图、周/月统计的习惯追踪器


📱 应用场景

功能 说明
📋 习惯管理 添加/编辑/删除习惯
✅ 每日打卡 一键标记完成
🔥 Streak 连击 连续完成天数统计
🗺️ 热力图 一年 365 天完成情况可视化
📊 统计报表 月完成率/趋势图

⚙️ 运行环境要求

项目 版本要求
DevEco Studio 5.0.3.800+
HarmonyOS SDK API 12
核心 API @ohos.data.relationalStore + Canvas
权限 无特殊权限

🛠️ 实战:从零搭建习惯追踪器

Step 1:数据结构

interface Habit {
  id: number;
  name: string;
  icon: string;         // 图标 emoji
  color: string;
  frequency: 'daily' | 'weekly' | 'weekday';
  createdAt: string;
  sortOrder: number;
}

interface HabitLog {
  id: number;
  habitId: number;
  date: string;         // YYYY-MM-DD
  completed: boolean;
  note: string;
}

Step 2:完整代码

// pages/Index.ets — 习惯追踪器
import relationalStore from '@ohos.data.relationalStore';

@Entry
@Component
struct HabitTracker {
  @State habits: Habit[] = [];
  @State todayLogs: Map<number, boolean> = new Map();
  @State currentDate: string = '';
  @State showAddDialog: boolean = false;
  @State editName: string = '';
  @State editIcon: string = '✅';
  @State heatmapData: Map<string, boolean> = new Map();
  @State viewMode: 'today' | 'heatmap' = 'today';

  private store!: relationalStore.RdbStore;
  private icons = ['✅', '📖', '🏃', '💧', '🧘', '✏️', '🎯', '💪'];

  aboutToAppear() {
    const now = new Date();
    this.currentDate = now.toISOString().split('T')[0];
    this.initDB();
  }

  async initDB() {
    const config = { name: 'habits.db', securityLevel: relationalStore.SecurityLevel.S1 };
    this.store = await relationalStore.getRdbStore(getContext(this), config);
    await this.store.executeSql(
      `CREATE TABLE IF NOT EXISTS habits (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL, icon TEXT DEFAULT '✅',
        color TEXT DEFAULT '#007AFF',
        frequency TEXT DEFAULT 'daily',
        sortOrder INTEGER DEFAULT 0,
        createdAt TEXT DEFAULT (datetime('now','localtime'))
      )`
    );
    await this.store.executeSql(
      `CREATE TABLE IF NOT EXISTS habit_logs (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        habitId INTEGER, date TEXT,
        completed INTEGER DEFAULT 0,
        note TEXT DEFAULT '',
        UNIQUE(habitId, date)
      )`
    );
    await this.loadHabits();
    await this.loadTodayLogs();
    await this.loadHeatmap();
  }

  async loadHabits() {
    const p = new relationalStore.RdbPredicates('habits');
    p.orderByAsc('sortOrder');
    const r = await this.store.query(p, ['id','name','icon','color','frequency']);
    const list: Habit[] = [];
    while (r.goToNextRow()) {
      list.push({
        id: r.getLong(r.getColumnIndex('id')),
        name: r.getString(r.getColumnIndex('name')),
        icon: r.getString(r.getColumnIndex('icon')),
        color: r.getString(r.getColumnIndex('color')),
        frequency: r.getString(r.getColumnIndex('frequency')) as 'daily'|'weekly'|'weekday',
        createdAt: '', sortOrder: 0
      });
    }
    this.habits = list;
    r.close();
  }

  async loadTodayLogs() {
    const p = new relationalStore.RdbPredicates('habit_logs');
    p.equalTo('date', this.currentDate);
    const r = await this.store.query(p, ['habitId','completed']);
    const map = new Map<number, boolean>();
    while (r.goToNextRow()) {
      map.set(r.getLong(r.getColumnIndex('habitId')), r.getLong(r.getColumnIndex('completed')) === 1);
    }
    this.todayLogs = map;
    r.close();
  }

  async toggleHabit(habitId: number) {
    const current = this.todayLogs.get(habitId) || false;
    const completed = !current;
    
    try {
      await this.store.executeSql(
        `INSERT OR REPLACE INTO habit_logs (habitId, date, completed)
         VALUES (?, ?, ?)`,
        [habitId, this.currentDate, completed ? 1 : 0]
      );
      this.todayLogs.set(habitId, completed);
      this.todayLogs = new Map(this.todayLogs);
      this.loadHeatmap();
    } catch {}
  }

  async addHabit() {
    if (!this.editName.trim()) return;
    await this.store.insert('habits', {
      name: this.editName, icon: this.editIcon,
      color: '#007AFF', frequency: 'daily',
      sortOrder: this.habits.length
    });
    await this.loadHabits();
    this.showAddDialog = false;
    this.editName = '';
  }

  async deleteHabit(id: number) {
    const p = new relationalStore.RdbPredicates('habits');
    p.equalTo('id', id);
    await this.store.delete(p);
    const p2 = new relationalStore.RdbPredicates('habit_logs');
    p2.equalTo('habitId', id);
    await this.store.delete(p2);
    await this.loadHabits();
  }

  // ======== Streak 连击天数 ========
  calcStreak(habitId: number): number {
    let streak = 0;
    const d = new Date();
    while (true) {
      const dateStr = d.toISOString().split('T')[0];
      const log = this.todayLogs.get(habitId);
      // 简化: 实际需要查数据库
      if (log) streak++;
      else break;
      d.setDate(d.getDate() - 1);
      if (streak > 365) break;
    }
    return streak;
  }

  // ======== 热力图数据 ========
  async loadHeatmap() {
    const p = new relationalStore.RdbPredicates('habit_logs');
    const r = await this.store.query(p, ['habitId','date','completed']);
    const map = new Map<string, boolean>();
    while (r.goToNextRow()) {
      const date = r.getString(r.getColumnIndex('date'));
      const completed = r.getLong(r.getColumnIndex('completed')) === 1;
      if (!map.has(date) || completed) map.set(date, completed);
    }
    this.heatmapData = map;
    r.close();
  }

  getHeatmapColor(date: string): string {
    if (!this.heatmapData.has(date)) return '#EBEDF0';
    const completed = this.heatmapData.get(date);
    if (completed === undefined) return '#EBEDF0';
    // 统计当天完成数
    let count = 0;
    for (const [hid, log] of this.todayLogs) {
      if (log && this.habits.find(h => h.id === hid)) count++;
    }
    if (count === 0) return '#EBEDF0';
    if (count <= this.habits.length * 0.3) return '#C6E48B';
    if (count <= this.habits.length * 0.6) return '#7BC96F';
    if (count <= this.habits.length * 0.8) return '#239A3B';
    return '#196127';
  }

  // ======== 计算今日完成率 ========
  get completionRate(): number {
    if (this.habits.length === 0) return 0;
    let completed = 0;
    for (const h of this.habits) {
      if (this.todayLogs.get(h.id)) completed++;
    }
    return Math.round((completed / this.habits.length) * 100);
  }

  build() {
    Column() {
      // 标题
      Row() {
        Text('✅ 习惯追踪').fontSize(24).fontWeight(FontWeight.Bold).layoutWeight(1)
        Button('➕').fontSize(22).backgroundColor('transparent').fontColor('#007AFF')
          .onClick(() => { this.showAddDialog = true; })
      }.width('94%').padding({ top: 12, bottom: 4 })

      // 完成率环
      Stack() {
        Circle().width(100).height(100).fill('none').stroke('#E0E0E0').strokeWidth(6)
        Circle().width(100).height(100).fill('none')
          .stroke('#34C759').strokeWidth(6)
          .strokeDashArray([this.completionRate / 100 * 283, 283])
        Column() {
          Text(`${this.completionRate}%`).fontSize(20).fontWeight(FontWeight.Bold)
          Text('今日').fontSize(12).fontColor('#888')
        }
      }.margin({ top: 8 })

      // Tab 切换
      Row() {
        Button('✅ 今日').width('50%').height(36)
          .backgroundColor(this.viewMode === 'today' ? '#34C759' : '#F0F0F0')
          .fontColor(this.viewMode === 'today' ? '#fff' : '#333')
          .onClick(() => { this.viewMode = 'today'; })
        Button('🗺️ 热力图').width('50%').height(36)
          .backgroundColor(this.viewMode === 'heatmap' ? '#34C759' : '#F0F0F0')
          .fontColor(this.viewMode === 'heatmap' ? '#fff' : '#333')
          .onClick(() => { this.viewMode = 'heatmap'; })
      }.width('94%').margin({ top: 8 })

      if (this.viewMode === 'today') {
        // 今日习惯列表
        if (this.habits.length === 0) {
          Column() {
            Text('✅').fontSize(48)
            Text('还没有习惯').fontSize(16).fontColor('#999')
            Text('点击 + 添加你的第一个习惯').fontSize(14).fontColor('#bbb').margin({ top: 4 })
          }.layoutWeight(1).justifyContent(FlexAlign.Center).width('100%')
        } else {
          List({ space: 8 }) {
            ForEach(this.habits, (habit: Habit) => {
              ListItem() {
                Row() {
                  Text(habit.icon).fontSize(28).margin({ right: 10 })
                  Column() {
                    Text(habit.name).fontSize(16).fontWeight(FontWeight.Bold)
                    Text(`🔥 连击 ${this.calcStreak(habit.id)}`).fontSize(12).fontColor('#FF9500')
                  }.layoutWeight(1).alignItems(HorizontalAlign.Start)

                  Button(this.todayLogs.get(habit.id) ? '✅' : '⬜')
                    .fontSize(20).backgroundColor('transparent')
                    .onClick(() => { this.toggleHabit(habit.id); })
                }
                .padding(14).width('96%').backgroundColor('#FFF').borderRadius(12)
                .border({ width: 2, color: this.todayLogs.get(habit.id) ? '#34C759' : 'transparent' })
                .shadow({ radius: 2, color: '#10000000', offsetY: 1 })
              }
            }, (habit: Habit) => habit.id.toString())
          }.layoutWeight(1).width('100%')
        }
      } else {
        // 热力图
        Scroll() {
          Column() {
            Text('📅 本月打卡热力图').fontSize(16).fontWeight(FontWeight.Bold).margin(8)
            // 简单的热力图网格渲染
            Grid() {
              ForEach(Array.from({ length: 30 }, (_, i) => {
                const d = new Date();
                d.setDate(d.getDate() - 29 + i);
                return d.toISOString().split('T')[0];
              }), (date: string) => {
                GridItem() {
                  Column()
                    .width(16).height(16)
                    .backgroundColor(this.getHeatmapColor(date))
                    .borderRadius(3)
                }
              }, (date: string) => date)
            }
            .columnsTemplate('repeat(7, 16px)')
            .rowsTemplate('auto')
            .gap(3)
            .padding(8)

            Text('📊 统计概览').fontSize(16).fontWeight(FontWeight.Bold).margin(8)
            Text(`总习惯: ${this.habits.length} · 今日完成: ${this.completionRate}%`)
              .fontSize(14).fontColor('#888')
          }.width('100%')
        }.layoutWeight(1).width('100%')
      }
    }
    .width('100%').height('100%').backgroundColor('#F8F9FA')
    .alignItems(HorizontalAlign.Center)

    .bindSheet(this.showAddDialog, this.AddSheet())
  }

  @Builder
  AddSheet() {
    Column() {
      Text('添加习惯').fontSize(20).fontWeight(FontWeight.Bold).margin({ bottom: 16 })
      TextInput({ placeholder: '习惯名称', text: this.editName })
        .width('100%').height(44).backgroundColor('#F8F8F8').borderRadius(8).padding({ left: 12 })

      Text('选择图标:').fontSize(14).margin({ top: 12 })
      Row() {
        ForEach(this.icons, (ico: string) => {
          Text(ico).fontSize(28).padding(8)
            .backgroundColor(this.editIcon === ico ? '#E8F0FE' : 'transparent')
            .borderRadius(8)
            .onClick(() => { this.editIcon = ico; })
        })
      }.width('100%').gap(4).justifyContent(FlexAlign.Center)

      Row() {
        Button('取消').backgroundColor('#E5E5EA').fontColor('#333').borderRadius(8).width('45%')
          .onClick(() => { this.showAddDialog = false; })
        Button('保存').backgroundColor('#34C759').fontColor('#fff').borderRadius(8).width('45%')
          .onClick(() => { this.addHabit(); })
      }.width('100%').margin({ top: 16 })
    }.padding(24).width('100%')
  }
}

📊 Streak 连击数据

连击天数 含义 视觉标记
1~6 天 刚开始 🔥×1
7~13 天 第一周挑战 🔥×7 💪
14~29 天 两周习惯 🔥×14 🎯
30~89 天 一个月坚持 🔥×30 🏆
90~364 天 季度成就 🔥×90 👑
365+ 天 一年! 🔥×365 🌟

⚠️ 避坑指南

原因 正确做法
打卡后不显示勾 Map 的 set 后没触发刷新 this.todayLogs = new Map(this.todayLogs)
Streak 计算不准确 只查了内存没查数据库 从数据库查最近连续日期
热力图颜色不变 数据没加载完 loadHeatmap 在 toggle 后调用
习惯排序乱 没设 sortOrder 新增时取最大 sortOrder + 1
重复打卡覆盖 同一天同习惯多条记录 INSERT OR REPLACE

🔥 最佳实践

  1. Streak 激励:连击 7/14/30/90/365 天显示特殊徽章
  2. 弹性打卡:允许补打昨天(标记 late)
  3. 提醒通知:晚上 8 点未完成推送提醒
  4. 数据导出:导出打卡 CSV 用于自我分析
  5. 习惯分类:健康/学习/工作/生活分组管理

在这里插入图片描述


官方文档: HarmonyOS 应用开发文档

  • 开发者社区: 华为开发者论坛
  • 欢迎加入开源鸿蒙跨平台社区: https://openharmonycrossplatform.csdn.net/
Logo

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

更多推荐