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、测试、元服务和应用上架分发等。

更多推荐