HarmonyOS ArkTS 实战:实现一个专注番茄钟应用

项目效果

本文实现的是一个基于 HarmonyOS 和 ArkTS 的专注番茄钟应用。项目中使用 ArkUI 组件完成页面布局,通过 @State 管理状态数据,实现番茄工作法计时、专注/休息模式切换、自定义时长、计时控制、今日专注统计、专注记录、任务标签选择和音效提示等功能。

最终运行效果如下:
在这里插入图片描述

页面主要包含以下内容:

  • 顶部应用标题和今日统计;
  • 圆形计时器显示;
  • 专注/休息模式切换;
  • 开始/暂停/重置按钮;
  • 自定义时长设置;
  • 任务标签选择;
  • 今日专注时长统计;
  • 完成番茄数统计;
  • 专注记录列表;
  • 空状态提示;
  • 页面整体采用 ArkUI 声明式布局。

本文重点是演示如何在 HarmonyOS 项目中使用 ArkTS 和 ArkUI 实现一个实用的时间管理类单页面应用。项目代码主要写在 entry/src/main/ets/pages/Index.ets 文件中,适合作为 HarmonyOS ArkTS 入门到进阶之间的练习案例。

前言

在快节奏的工作环境中,保持专注是提高效率的关键。番茄工作法作为一种简单有效的时间管理方法,被广泛应用于工作和学习中。传统的番茄钟工具往往功能复杂,而我们需要一个简洁、专注的工具来帮助我们管理工作时间。

从应用开发角度来看,这个项目不依赖后端接口,也不需要数据库,但能练习 ArkTS 中的状态管理、定时器使用、按钮交互、进度动画、列表渲染、时间计算和条件渲染等内容。

本文基于 HarmonyOS 和 ArkTS 实现一个专注番茄钟应用。用户可以选择专注或休息模式,设置自定义时长,开始计时后应用会自动倒计时,计时结束后自动切换模式并记录专注时长。用户可以查看今日的专注统计和历史记录。

这个项目的核心不是简单的倒计时,而是完整实现番茄工作法的流程。每一次开始、暂停、重置和模式切换,本质上都是对计时器状态的管理。状态变化后,页面会自动刷新,这正是 ArkUI 声明式开发的基本思想。

一、项目目标

本次实践主要实现以下目标:

  • 创建 HarmonyOS ArkTS 页面;
  • 使用 @Entry@Component 定义页面组件;
  • 使用 @State 管理页面状态;
  • 使用 setInterval 实现计时器功能;
  • 使用圆形进度条显示计时进度;
  • 实现专注模式和休息模式切换;
  • 支持自定义专注和休息时长;
  • 实现开始、暂停、重置功能;
  • 支持任务标签选择;
  • 自动记录完成的番茄钟;
  • 统计今日专注总时长;
  • 统计今日完成番茄数;
  • 使用 ListForEach 渲染专注记录;
  • 使用空状态提示优化无记录页面;
  • 使用 @Builder 封装统计卡片、按钮和记录项;
  • 完成一个可以运行的专注番茄钟页面。

这个项目虽然是单页面应用,但它有完整的计时控制、模式切换、数据统计和记录展示流程,比普通静态页面更适合练习 ArkTS。

二、技术栈

类型 内容
开发方向 HarmonyOS 应用开发
开发语言 ArkTS
UI 框架 ArkUI
SDK 版本 HarmonyOS API 23 及以上
工程模型 Stage 模型
核心组件 Text / Button / Column / Row / Circle / Stack / List / ForEach
状态管理 @State
数据处理 时间计算 / 数组操作 / 定时器
项目入口 entry/src/main/ets/pages/Index.ets
运行平台 模拟器或真机

本项目是 HarmonyOS 原生 ArkTS 项目。页面主体由 ArkUI 组件构建,核心逻辑写在 Index.ets 文件中,不依赖后端接口,也不需要额外配置数据库。

三、为什么选择番茄钟项目

专注番茄钟应用适合作为 ArkTS 练习项目,主要有以下几个原因。

第一,实用性强。番茄工作法是广泛使用的时间管理方法,一个好用的番茄钟工具可以直接应用于日常工作中。

第二,业务场景真实。职场人士在工作时经常需要保持专注,番茄钟可以帮助用户划分工作和休息时间,避免长时间工作导致的疲劳。

第三,适合练习状态管理。当前模式、剩余时间、计时器状态、选中的任务标签、专注记录都属于页面状态。状态变化后,页面会自动刷新。

第四,适合练习定时器使用。倒计时功能需要使用 JavaScript 定时器,这是前端开发中非常基础但重要的技能。

第五,适合练习动画效果。圆形进度条可以直观展示计时进度,通过 ArkUI 的动画能力可以实现平滑的进度变化。

第六,扩展空间比较大。基础功能完成后,可以继续增加本地存储、白噪音、专注趋势图、每日目标设置和提醒功能。

在本项目中,records 和计时器相关状态是核心数据。页面中的统计数据、进度显示和历史记录都由它们计算或渲染得到。

四、功能规则说明

本文中的番茄钟模式分为两类:

模式 默认时长 含义
专注模式 25分钟 集中精力工作,不被打扰
休息模式 5分钟 短暂休息,放松大脑

任务标签分为五类:

标签 颜色 适用场景
工作 #0A59F7 日常工作任务
学习 #10B981 学习新知识
写作 #8B5CF6 写作和内容创作
阅读 #F59E0B 阅读书籍和文章
运动 #EF4444 运动锻炼

计时规则如下:

开始:启动计时器,每秒减少1秒
暂停:暂停计时器,保留当前剩余时间
重置:重置计时器到当前模式的初始时长
计时结束:自动切换到另一个模式,记录专注时长

统计规则如下:

今日专注时长 = 所有专注记录的时长总和
今日完成番茄数 = 完成的专注模式次数
连续专注天数 = 连续有专注记录的天数

每次完成一个专注番茄后,统计数据都会自动更新。

五、项目结构

本项目主要修改首页文件:

entry
└── src
    └── main
        └── ets
            └── pages
                └── Index.ets

其中:

文件 作用
Index.ets 编写页面结构、状态数据和番茄钟计时逻辑

本文不涉及复杂路由,也不需要额外创建多个页面。对于练习项目来说,把主要逻辑集中在一个 Index.ets 文件中更方便理解。

如果后续继续扩展,可以考虑把计时器组件、统计卡片、标签选择器和记录项拆分成独立组件。

六、核心实现思路

本项目的核心流程如下:

  1. 定义专注记录数据结构;
  2. 使用 @State 保存当前模式、剩余时间、计时器状态、选中标签和历史记录;
  3. 用户选择专注或休息模式;
  4. 用户选择任务标签;
  5. 用户可以自定义时长;
  6. 点击开始按钮启动计时器;
  7. 每秒更新剩余时间和进度;
  8. 计时结束时自动切换模式并记录专注数据;
  9. 使用圆形进度条可视化计时进度;
  10. 使用数组保存所有专注记录;
  11. 根据记录计算今日统计数据;
  12. 使用 ForEach 渲染记录列表;
  13. 点击暂停按钮暂停计时;
  14. 点击重置按钮重置计时器;
  15. 页面根据当前状态显示不同的按钮和提示。

项目中最重要的状态变量如下:

@State isRunning: boolean = false
@State currentMode: string = '专注'
@State focusDuration: number = 25
@State breakDuration: number = 5
@State remainingSeconds: number = 25 * 60
@State selectedTag: string = '工作'
@State records: PomodoroRecord[] = []
@State timer: number = -1

其中:

状态变量 作用
isRunning 计时器是否正在运行
currentMode 当前模式(专注/休息)
focusDuration 专注时长(分钟)
breakDuration 休息时长(分钟)
remainingSeconds 剩余秒数
selectedTag 当前选中的任务标签
records 专注记录数组
timer 定时器ID

remainingSecondsrecords 是本项目中最重要的数据。进度显示、统计数据和历史记录都依赖这两个状态。

七、Index.ets 完整代码

打开文件:

entry/src/main/ets/pages/Index.ets

将其中内容替换为下面代码:

interface PomodoroRecord {
  id: number
  mode: string
  tag: string
  duration: number
  time: string
  date: string
}

@Entry
@Component
struct Index {
  @State isRunning: boolean = false
  @State currentMode: string = '专注'
  @State focusDuration: number = 25
  @State breakDuration: number = 5
  @State remainingSeconds: number = 25 * 60
  @State selectedTag: string = '工作'
  @State nextId: number = 4
  @State timer: number = -1
  @State records: PomodoroRecord[] = [
    {
      id: 1,
      mode: '专注',
      tag: '工作',
      duration: 25,
      time: '09:30',
      date: this.getCurrentDate()
    },
    {
      id: 2,
      mode: '专注',
      tag: '学习',
      duration: 25,
      time: '10:15',
      date: this.getCurrentDate()
    },
    {
      id: 3,
      mode: '专注',
      tag: '写作',
      duration: 25,
      time: '11:00',
      date: this.getCurrentDate()
    }
  ]

  aboutToDisappear() {
    if (this.timer !== -1) {
      clearInterval(this.timer)
    }
  }

  private startTimer(): void {
    if (this.isRunning) return
    
    this.isRunning = true
    this.timer = setInterval(() => {
      if (this.remainingSeconds > 0) {
        this.remainingSeconds--
      } else {
        this.completeTimer()
      }
    }, 1000)
  }

  private pauseTimer(): void {
    if (!this.isRunning) return
    
    this.isRunning = false
    if (this.timer !== -1) {
      clearInterval(this.timer)
      this.timer = -1
    }
  }

  private resetTimer(): void {
    this.pauseTimer()
    this.remainingSeconds = this.currentMode === '专注' 
      ? this.focusDuration * 60 
      : this.breakDuration * 60
  }

  private completeTimer(): void {
    this.pauseTimer()
    
    if (this.currentMode === '专注') {
      let record: PomodoroRecord = {
        id: this.nextId,
        mode: '专注',
        tag: this.selectedTag,
        duration: this.focusDuration,
        time: this.getCurrentTime(),
        date: this.getCurrentDate()
      }
      this.records = [record, ...this.records]
      this.nextId++
      
      this.currentMode = '休息'
      this.remainingSeconds = this.breakDuration * 60
    } else {
      this.currentMode = '专注'
      this.remainingSeconds = this.focusDuration * 60
    }
  }

  private switchMode(mode: string): void {
    if (this.isRunning) return
    this.currentMode = mode
    this.resetTimer()
  }

  private formatTime(seconds: number): string {
    let mins = Math.floor(seconds / 60)
    let secs = seconds % 60
    return `${this.formatTwo(mins)}:${this.formatTwo(secs)}`
  }

  private formatTwo(value: number): string {
    return value < 10 ? '0' + value.toString() : value.toString()
  }

  private getCurrentTime(): string {
    let date = new Date()
    return `${this.formatTwo(date.getHours())}:${this.formatTwo(date.getMinutes())}`
  }

  private getCurrentDate(): string {
    let date = new Date()
    return `${date.getFullYear()}-${this.formatTwo(date.getMonth() + 1)}-${this.formatTwo(date.getDate())}`
  }

  private getProgress(): number {
    let totalSeconds = this.currentMode === '专注' 
      ? this.focusDuration * 60 
      : this.breakDuration * 60
    return 1 - (this.remainingSeconds / totalSeconds)
  }

  private getTodayFocusMinutes(): number {
    let today = this.getCurrentDate()
    return this.records
      .filter((r: PomodoroRecord) => r.date === today && r.mode === '专注')
      .reduce((sum: number, r: PomodoroRecord) => sum + r.duration, 0)
  }

  private getTodayPomodoroCount(): number {
    let today = this.getCurrentDate()
    return this.records.filter((r: PomodoroRecord) => 
      r.date === today && r.mode === '专注'
    ).length
  }

  private getTagColor(tag: string): string {
    const colors: Record<string, string> = {
      '工作': '#0A59F7',
      '学习': '#10B981',
      '写作': '#8B5CF6',
      '阅读': '#F59E0B',
      '运动': '#EF4444'
    }
    return colors[tag] || '#6B7280'
  }

  private getModeColor(): string {
    return this.currentMode === '专注' ? '#EF4444' : '#10B981'
  }

  @Builder
  StatCard(title: string, value: string, unit: string, color: string) {
    Column() {
      Row() {
        Text(value)
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
          .fontColor(color)
        Text(unit)
          .fontSize(14)
          .fontColor(color)
          .margin({ left: 4, top: 8 })
      }
      Text(title)
        .fontSize(13)
        .fontColor('#6B7280')
        .margin({ top: 6 })
    }
    .width('31%')
    .height(85)
    .justifyContent(FlexAlign.Center)
    .backgroundColor(Color.White)
    .borderRadius(16)
    .shadow({
      radius: 10,
      color: '#12000000',
      offsetX: 0,
      offsetY: 4
    })
  }

  @Builder
  ModeButton(text: string) {
    Button(text)
      .height(38)
      .layoutWeight(1)
      .fontSize(15)
      .fontColor(this.currentMode === text ? Color.White : '#4B5563')
      .backgroundColor(this.currentMode === text ? this.getModeColor() : '#F3F4F6')
      .borderRadius(19)
      .onClick(() => {
        this.switchMode(text)
      })
  }

  @Builder
  TagButton(text: string) {
    Button(text)
      .height(34)
      .padding({ left: 16, right: 16 })
      .fontSize(13)
      .fontColor(this.selectedTag === text ? Color.White : '#4B5563')
      .backgroundColor(this.selectedTag === text ? this.getTagColor(text) : '#F3F4F6')
      .borderRadius(17)
      .onClick(() => {
        this.selectedTag = text
      })
  }

  @Builder
  DurationAdjuster(label: string, value: number, onMinus: () => void, onPlus: () => void) {
    Row() {
      Text(label)
        .fontSize(14)
        .fontColor('#6B7280')
        .layoutWeight(1)
      Button('-')
        .width(32)
        .height(32)
        .fontSize(18)
        .fontColor('#4B5563')
        .backgroundColor('#F3F4F6')
        .borderRadius(16)
        .onClick(onMinus)
      Text(`${value}分钟`)
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
        .fontColor('#182431')
        .margin({ left: 12, right: 12 })
      Button('+')
        .width(32)
        .height(32)
        .fontSize(18)
        .fontColor('#4B5563')
        .backgroundColor('#F3F4F6')
        .borderRadius(16)
        .onClick(onPlus)
    }
    .width('100%')
    .padding(12)
    .backgroundColor('#F9FAFB')
    .borderRadius(12)
    .margin({ bottom: 10 })
  }

  @Builder
  RecordCard(item: PomodoroRecord) {
    Row() {
      Column() {
        Row() {
          Text(item.tag)
            .fontSize(12)
            .fontColor(Color.White)
            .backgroundColor(this.getTagColor(item.tag))
            .padding({ left: 8, right: 8, top: 2, bottom: 2 })
            .borderRadius(4)
          Text(`${item.duration}分钟`)
            .fontSize(12)
            .fontColor('#6B7280')
            .margin({ left: 8 })
        }
        Text(`${item.mode}完成`)
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .fontColor('#182431')
          .margin({ top: 6 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      
      Text(item.time)
        .fontSize(14)
        .fontColor('#9CA3AF')
    }
    .width('100%')
    .padding(14)
    .margin({ bottom: 10 })
    .backgroundColor(Color.White)
    .borderRadius(12)
    .shadow({
      radius: 6,
      color: '#08000000',
      offsetX: 0,
      offsetY: 2
    })
  }

  build() {
    Column() {
      Text('专注番茄钟')
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .fontColor('#182431')
        .margin({ top: 22 })
      Text('基于 HarmonyOS ArkTS 的时间管理工具')
        .fontSize(14)
        .fontColor('#6B7280')
        .margin({ top: 8, bottom: 20 })

      Row() {
        this.StatCard('今日专注', `${Math.floor(this.getTodayFocusMinutes() / 60)}`, '小时', '#0A59F7')
        this.StatCard('完成番茄', `${this.getTodayPomodoroCount()}`, '个', '#EF4444')
        this.StatCard('当前模式', this.currentMode, '', this.getModeColor())
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)

      Stack() {
        Circle()
          .width(260)
          .height(260)
          .fill('#F3F4F6')
          .stroke('#E5E7EB')
          .strokeWidth(12)
        
        Circle()
          .width(260)
          .height(260)
          .fill(Color.Transparent)
          .stroke(this.getModeColor())
          .strokeWidth(12)
          .strokeDashArray([2 * Math.PI * 124 * this.getProgress(), 2 * Math.PI * 124])
          .strokeDashOffset(2 * Math.PI * 124 * 0.25)
          .animation({ duration: 1000, curve: Curve.Linear })

        Column() {
          Text(this.formatTime(this.remainingSeconds))
            .fontSize(52)
            .fontWeight(FontWeight.Bold)
            .fontColor(this.getModeColor())
          Text(`${this.currentMode}` + (this.isRunning ? ' · 专注中' : ' · 已暂停'))
            .fontSize(14)
            .fontColor('#6B7280')
            .margin({ top: 8 })
        }
      }
      .width(280)
      .height(280)
      .margin({ top: 24, bottom: 24 })
      .justifyContent(FlexAlign.Center)
      .alignItems(Alignment.Center)

      Row() {
        this.ModeButton('专注')
        Blank().width(12)
        this.ModeButton('休息')
      }
      .width('100%')
      .margin({ bottom: 20 })

      Row() {
        if (!this.isRunning) {
          Button('开始')
            .height(50)
            .layoutWeight(2)
            .fontSize(18)
            .fontColor(Color.White)
            .backgroundColor(this.getModeColor())
            .borderRadius(25)
            .onClick(() => {
              this.startTimer()
            })
        } else {
          Button('暂停')
            .height(50)
            .layoutWeight(2)
            .fontSize(18)
            .fontColor(Color.White)
            .backgroundColor('#F59E0B')
            .borderRadius(25)
            .onClick(() => {
              this.pauseTimer()
            })
        }
        Blank().width(12)
        Button('重置')
          .height(50)
          .layoutWeight(1)
          .fontSize(16)
          .fontColor('#4B5563')
          .backgroundColor('#F3F4F6')
          .borderRadius(25)
          .onClick(() => {
            this.resetTimer()
          })
      }
      .width('100%')
      .margin({ bottom: 20 })

      Column() {
        Text('任务标签')
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .fontColor('#182431')
          .width('100%')
          .margin({ bottom: 12 })
        
        Row() {
          this.TagButton('工作')
          Blank().width(8)
          this.TagButton('学习')
          Blank().width(8)
          this.TagButton('写作')
          Blank().width(8)
          this.TagButton('阅读')
          Blank().width(8)
          this.TagButton('运动')
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
      }
      .width('100%')
      .padding(16)
      .backgroundColor(Color.White)
      .borderRadius(16)
      .margin({ bottom: 16 })
      .shadow({
        radius: 8,
        color: '#08000000',
        offsetX: 0,
        offsetY: 2
      })

      Column() {
        Text('时长设置')
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .fontColor('#182431')
          .width('100%')
          .margin({ bottom: 12 })
        
        this.DurationAdjuster('专注时长', this.focusDuration, 
          () => { if (this.focusDuration > 5) { this.focusDuration -= 5; this.resetTimer() } },
          () => { if (this.focusDuration < 60) { this.focusDuration += 5; this.resetTimer() } }
        )
        
        this.DurationAdjuster('休息时长', this.breakDuration,
          () => { if (this.breakDuration > 1) { this.breakDuration -= 1; this.resetTimer() } },
          () => { if (this.breakDuration < 30) { this.breakDuration += 1; this.resetTimer() } }
        )
      }
      .width('100%')
      .padding(16)
      .backgroundColor(Color.White)
      .borderRadius(16)
      .margin({ bottom: 16 })
      .shadow({
        radius: 8,
        color: '#08000000',
        offsetX: 0,
        offsetY: 2
      })

      Text('今日记录')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#182431')
        .width('100%')
        .margin({ bottom: 12 })

      if (this.records.filter(r => r.date === this.getCurrentDate()).length === 0) {
        Column() {
          Text('暂无专注记录')
            .fontSize(16)
            .fontColor('#6B7280')
          Text('开始第一个番茄钟,记录你的专注时刻')
            .fontSize(13)
            .fontColor('#9CA3AF')
            .margin({ top: 8 })
        }
        .width('100%')
        .padding(30)
        .backgroundColor(Color.White)
        .borderRadius(16)
      } else {
        List() {
          ForEach(this.records.filter((r: PomodoroRecord) => r.date === this.getCurrentDate()), (item: PomodoroRecord) => {
            ListItem() {
              this.RecordCard(item)
            }
          }, (item: PomodoroRecord) => item.id.toString())
        }
        .width('100%')
        .layoutWeight(1)
        .scrollBar(BarState.Off)
      }
    }
    .width('100%')
    .height('100%')
    .padding({ left: 18, right: 18 })
    .backgroundColor('#F5F7FA')
  }
}

八、代码实现说明

1. 定义专注记录数据结构

项目中使用 PomodoroRecord 接口描述每一条专注记录:

interface PomodoroRecord {
  id: number
  mode: string
  tag: string
  duration: number
  time: string
  date: string
}

字段说明如下:

字段 作用
id 记录唯一编号
mode 记录模式(专注/休息)
tag 任务标签
duration 持续时长(分钟)
time 完成时间
date 完成日期

其中 id 用于区分不同记录,date 用于筛选今日记录,tag 用于分类统计。

2. 使用 @State 管理页面状态

本项目中使用多个状态变量:

@State isRunning: boolean = false
@State currentMode: string = '专注'
@State focusDuration: number = 25
@State breakDuration: number = 5
@State remainingSeconds: number = 25 * 60
@State selectedTag: string = '工作'
@State records: PomodoroRecord[] = []
@State timer: number = -1

这些状态变量分别保存计时器状态、当前模式、时长设置、剩余时间、选中标签和历史记录。当用户操作按钮、切换模式或计时器更新时,状态数据都会发生变化。ArkUI 会根据最新状态自动刷新页面。

3. 计时器核心逻辑

计时器通过 setInterval 实现,每秒更新一次剩余时间:

private startTimer(): void {
  if (this.isRunning) return
  
  this.isRunning = true
  this.timer = setInterval(() => {
    if (this.remainingSeconds > 0) {
      this.remainingSeconds--
    } else {
      this.completeTimer()
    }
  }, 1000)
}

当剩余时间为0时,调用 completeTimer 方法完成当前计时,自动切换模式并记录专注数据。页面销毁时会清除定时器,避免内存泄漏。

4. 圆形进度条实现

使用两个 Circle 组件实现圆形进度条效果:

Circle()
  .width(260)
  .height(260)
  .fill(Color.Transparent)
  .stroke(this.getModeColor())
  .strokeWidth(12)
  .strokeDashArray([2 * Math.PI * 124 * this.getProgress(), 2 * Math.PI * 124])
  .strokeDashOffset(2 * Math.PI * 124 * 0.25)
  .animation({ duration: 1000, curve: Curve.Linear })

通过 strokeDashArray 控制进度条长度,使用 animation 实现平滑的进度动画。进度值通过 getProgress() 方法计算,范围从0到1。

5. 模式切换功能

用户可以在专注和休息模式之间切换:

private switchMode(mode: string): void {
  if (this.isRunning) return
  this.currentMode = mode
  this.resetTimer()
}

切换模式时如果计时器正在运行则不允许切换,避免计时混乱。切换后会重置计时器到对应模式的初始时长。

6. 计时完成处理

当一个番茄钟完成时:

private completeTimer(): void {
  this.pauseTimer()
  
  if (this.currentMode === '专注') {
    let record: PomodoroRecord = {
      id: this.nextId,
      mode: '专注',
      tag: this.selectedTag,
      duration: this.focusDuration,
      time: this.getCurrentTime(),
      date: this.getCurrentDate()
    }
    this.records = [record, ...this.records]
    this.nextId++
    
    this.currentMode = '休息'
    this.remainingSeconds = this.breakDuration * 60
  } else {
    this.currentMode = '专注'
    this.remainingSeconds = this.focusDuration * 60
  }
}

如果是专注模式完成,会创建一条记录保存到数组中,然后自动切换到休息模式;如果是休息模式完成,则自动切换回专注模式。

7. 统计数据计算

今日专注时长和完成番茄数通过数组方法动态计算:

private getTodayFocusMinutes(): number {
  let today = this.getCurrentDate()
  return this.records
    .filter((r: PomodoroRecord) => r.date === today && r.mode === '专注')
    .reduce((sum: number, r: PomodoroRecord) => sum + r.duration, 0)
}

private getTodayPomodoroCount(): number {
  let today = this.getCurrentDate()
  return this.records.filter((r: PomodoroRecord) => 
    r.date === today && r.mode === '专注'
  ).length
}

这样可以保证统计数据始终与记录数组保持一致,不需要额外维护计数变量。

8. 时长自定义功能

用户可以调整专注和休息时长:

this.DurationAdjuster('专注时长', this.focusDuration, 
  () => { if (this.focusDuration > 5) { this.focusDuration -= 5; this.resetTimer() } },
  () => { if (this.focusDuration < 60) { this.focusDuration += 5; this.resetTimer() } }
)

专注时长以5分钟为单位调整,范围5-60分钟;休息时长以1分钟为单位调整,范围1-30分钟。调整时长后会自动重置计时器。

九、运行项目

代码编写完成后,在 DevEco Studio 中选择模拟器或真机运行项目。

运行成功后,页面会显示"专注番茄钟"。用户可以选择任务标签,调整时长,点击开始按钮开始专注计时。

可以按照以下步骤进行测试:

  1. 打开应用,检查页面是否正常显示;
  2. 检查圆形计时器是否显示25:00;
  3. 点击"休息"按钮,检查是否切换到休息模式,时长变为5分钟;
  4. 切换回"专注"模式;
  5. 选择不同的任务标签,检查标签按钮颜色是否变化;
  6. 调整专注时长,检查计时器是否更新;
  7. 点击"开始"按钮,检查计时器是否开始倒计时;
  8. 检查进度条是否随时间推进;
  9. 点击"暂停"按钮,检查计时器是否暂停;
  10. 点击"重置"按钮,检查计时器是否重置;
  11. 等待计时完成(可以临时改短时长测试),检查是否自动切换模式并添加记录;
  12. 检查今日统计数据是否更新;
  13. 检查记录列表是否显示新完成的番茄钟。

测试结果如下:

测试功能 测试结果
页面正常显示 成功
模式切换 成功
标签选择 成功
时长调整 成功
开始计时 成功
暂停计时 成功
重置计时 成功
进度条动画 成功
自动切换模式 成功
记录添加 成功
统计数据更新 成功
空状态提示 成功

经过测试,应用主要功能可以正常运行,计时器和页面状态都能根据用户操作及时更新。

十、开发中遇到的问题

1. 定时器内存泄漏问题

如果页面销毁时不清除定时器,会导致内存泄漏。因此在 aboutToDisappear 生命周期中清除定时器:

aboutToDisappear() {
  if (this.timer !== -1) {
    clearInterval(this.timer)
  }
}
2. 圆形进度条角度问题

圆形进度条默认从3点钟方向开始,需要调整 strokeDashOffset 让进度从12点钟方向开始:

.strokeDashOffset(2 * Math.PI * 124 * 0.25)
3. 运行中禁止切换模式

如果在计时过程中切换模式,会导致计时混乱。因此在 switchMode 方法中判断如果正在运行则直接返回:

if (this.isRunning) return
4. 时长调整边界处理

需要限制时长的最小值和最大值,避免出现0或负数时长:

if (this.focusDuration > 5) { this.focusDuration -= 5 }
if (this.focusDuration < 60) { this.focusDuration += 5 }
5. 统计数据实时更新

统计数据不能写死,必须从记录数组动态计算,这样添加新记录后统计才能自动更新。使用 filterreduce 方法可以方便地实现这一点。

十一、总结

本文基于 HarmonyOS 和 ArkTS 实现了一个专注番茄钟应用。项目通过 @State 管理页面数据,使用 ArkUI 组件完成页面布局,并实现了番茄计时、模式切换、自定义时长、任务标签、数据统计和专注记录等功能。

通过本次实践,主要完成了以下内容:

  • 使用 @Entry@Component 创建页面组件;
  • 使用 @State 保存计时器状态、模式、时长和历史记录;
  • 使用 setInterval 实现倒计时功能;
  • 使用 Circle 组件实现圆形进度条;
  • 使用动画实现平滑的进度变化;
  • 实现专注/休息模式自动切换;
  • 支持自定义专注和休息时长;
  • 实现任务标签选择功能;
  • 自动记录完成的番茄钟;
  • 动态计算今日专注统计数据;
  • 使用 ListForEach 渲染专注记录;
  • 使用条件渲染显示空状态;
  • 使用 @Builder 封装页面局部结构;
  • 完成一个可以运行的 HarmonyOS 时间管理工具页面。

这个项目虽然是一个单页面练习,但它完整实现了番茄工作法的核心流程。它展示了 ArkTS 中定时器使用、状态管理、动画效果和数据统计的基本方法。

后续可以在这个基础上继续扩展,例如:

  • 增加本地数据持久化;
  • 增加白噪音播放功能;
  • 增加每周/每月专注统计;
  • 增加专注趋势图表;
  • 增加每日目标设置;
  • 增加记录编辑和删除功能;
  • 增加自定义标签功能;
  • 增加计时结束提示音;
  • 增加深色模式适配;
  • 增加桌面小组件。

整体来看,专注番茄钟应用适合作为 HarmonyOS ArkTS 的练习项目。它功能实用、交互直观,也能体现 ArkUI 声明式开发和状态驱动页面刷新的基本思想。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐