Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 69 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
# Focus Timer

Focus Timer app helps users to stay focused and productive by using timed work intervals with short and long breaks. It
is based on the Pomodoro Technique.
is based on the Pomodoro Technique. The app features session tracking with a local database, daily goals, achievement system, breathing exercises, multiple focus programs, theme customization, and detailed statistics with Canvas-based charts — all optimized for circular wearable displays.

# Preview

<div>
<img src="screenshots/1.PNG" width="24%">
<img src="screenshots/2.PNG" width="24%">
<img src="screenshots/3.PNG" width="24%">
<img src="screenshots/4.PNG" width="24%">
<img src="screenshots/ss1.png" width="24%">
<img src="screenshots/ss2.png" width="24%">
<img src="screenshots/ss3.png" width="24%">
<img src="screenshots/ss4.png" width="24%">
</div>

# Use Cases
Expand All @@ -21,32 +21,85 @@ is based on the Pomodoro Technique.
- Track the active focus session.
- Set default times for focus sessions.

**Newly Added**

- **Onboarding Flow**: Step-by-step welcome experience using Stepper component with goal setting and theme selection on first launch.
- **Session History & Statistics**: All completed sessions are recorded in a relational database (RDB). View daily, weekly, and monthly stats with Canvas-based bar charts and pie charts.
- **Calendar Heatmap**: GitHub-style contribution heatmap showing focus activity over the last 14 weeks, rendered with Canvas API.
- **Daily Focus Goals**: Set a daily target and track progress with a Gauge ring. Celebration animation on goal completion.
- **Achievement & Badge System**: 15 unlockable badges across sessions, minutes, streaks, and exploration categories. Grid gallery with lock/unlock animations and progress bars.
- **Session Tags**: Categorize focus sessions with color-coded Chip components (Work, Study, Coding, Exercise, Reading, Meditation). Filter statistics by category.
- **Focus Programs**: 4 preset templates — Pomodoro (25/5/20), Deep Work (90/20/30), Sprint (15/3/10), Study (50/10/25). Visual cycle dot preview for each program.
- **Breathing Exercise**: Guided 4-7-8 breathing technique with animated expanding/contracting circles using animateTo and Curve.EaseInOut.
- **Theme Customization**: 6 color themes (Purple, Blue, Green, Red, Orange, Cyan) with live preview and persistent selection.
- **Intensity Levels**: Light, Normal, and Intense modes with DataPanel visualization and focus/break time multipliers.
- **Streak Tracking**: Consecutive day counter with current and best streak tracking.
- **Break Activity Suggestions**: Swiper-based activity cards (Walk, Hydrate, Eye Rest, Stretch, Relax) with built-in mini timers.
- **Pomodoro Cycle Visualizer**: Canvas-drawn circular diagram showing all segments in the current cycle with progress overlay on the active segment.
- **Motivational Quotes**: Marquee-scrolling quotes from productivity leaders, displayed during idle state on the timer ring.
- **Settings Page**: Grouped list settings with Toggle switches, Slider for goal adjustment, Navigation links to sub-pages, and theme/intensity pickers.
- **Custom Dialogs**: Session completion summary, achievement unlock celebration, and confirmation dialogs using @CustomDialog with animated transitions and backdrop blur.
- **Router Navigation**: Multi-page navigation with router.pushUrl between Index, Stats, Settings, Achievements, and Programs pages.
- **Persistent Storage**: All user preferences, goals, streaks, and achievement progress are saved via PersistentStorage and restored across app launches.
- **RDB Database**: Full relational database layer using @kit.ArkData relationalStore for session records and daily aggregated statistics.

# Tech Stack

- **Languages**: ArkTS, ArkUI
- **Frameworks**: HarmonyOS SDK 5.0.2(14)
- **Tools**: DevEco Studio Vers 5.1.0.820
- **Libraries**: @kit.ArkUI
- **Libraries**: @kit.ArkUI, @kit.AbilityKit, @kit.ArkData (relationalStore, preferences), @kit.BasicServicesKit, @kit.CoreFileKit, @kit.PerformanceAnalysisKit, @ohos.arkui.advanced.Counter
- **ArkUI Components**: ArcSwiper, Canvas, Gauge, DataPanel, Stepper, Slider, Toggle, Marquee, Swiper, Grid, Flex, CustomDialog, Progress, Scroll, List, ListItemGroup, Circle, SymbolGlyph, animateTo, TransitionEffect, router

# Directory Structure

```
entry/src/main/ets/
├───components
│ ProgressController.ets
│ ProgressRing.ets
│ ProgressTimes.ets
├───entryability
│ EntryAbility.ets
├───entrybackupability
│ EntryBackupAbility.ets
├───components
│ BadgeCard.ets
│ BreakSuggestions.ets
│ BreathingExercise.ets
│ CalendarHeatmap.ets
│ CycleVisualizer.ets
│ FocusDialog.ets
│ GoalRingView.ets
│ IntensitySelector.ets
│ ProgressController.ets
│ ProgressRing.ets
│ ProgressTimes.ets
│ QuotesProvider.ets
│ StatsChart.ets
│ TagSelector.ets
│ ThemePickerView.ets
├───constants
│ SizeConstants.ets
├───entryability
│ EntryAbility.ets
├───entrybackupability
│ EntryBackupAbility.ets
├───model
│ Achievement.ets
│ Focus.ets
│ FocusType.ets
│ GoalModel.ets
│ IntensityModel.ets
│ Program.ets
│ ProgramTemplate.ets
│ SessionRecord.ets
│ TagModel.ets
│ ThemeModel.ets
│ Timer.ets
└───pages
Index.ets
├───pages
│ AchievementsPage.ets
│ Index.ets
│ OnboardingPage.ets
│ ProgramListPage.ets
│ SettingsPage.ets
│ StatsPage.ets
└───service
AchievementService.ets
DbService.ets
StreakTracker.ets
```

# Constraints and Restrictions
Expand Down
3 changes: 2 additions & 1 deletion build-profile.json5
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
{
"name": "default",
"signingConfig": "default",
"targetSdkVersion": "5.0.2(14)",
"compatibleSdkVersion": "5.0.2(14)",
"runtimeOS": "HarmonyOS",
"buildOption": {
Expand Down Expand Up @@ -40,4 +41,4 @@
]
}
]
}
}
65 changes: 65 additions & 0 deletions entry/src/main/ets/components/BadgeCard.ets
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Achievement, AchievementProgress } from '../model/Achievement'

@Component
export default struct BadgeCard {
@Prop achievement: Achievement
@Prop progress: AchievementProgress
@State private scaleVal: number = 1.0

build() {
Column({ space: 4 }) {
Stack() {
Circle()
.width(44)
.height(44)
.fill(this.progress.unlocked ? '#552586' : '#2A2A2A')
.scale({ x: this.scaleVal, y: this.scaleVal })

SymbolGlyph(this.achievement.icon)
.fontSize(20)
.fontColor([this.progress.unlocked ? Color.White : '#666666'])

if (!this.progress.unlocked) {
Circle()
.width(44)
.height(44)
.fill('#00000088')

SymbolGlyph($r('sys.symbol.lock_fill'))
.fontSize(14)
.fontColor(['#888888'])
}
}

Text(this.achievement.name)
.fontSize(10)
.fontColor(this.progress.unlocked ? Color.White : '#888888')
.textAlign(TextAlign.Center)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })

if (!this.progress.unlocked) {
Progress({ value: this.progress.currentValue, total: this.achievement.targetValue })
.width(44)
.height(4)
.color('#552586')
.backgroundColor('#333333')
.style({ strokeWidth: 4 })
}
}
.width(60)
.padding(4)
.onClick(() => {
if (this.progress.unlocked) {
this.getUIContext()?.animateTo({ duration: 300, curve: Curve.EaseInOut }, () => {
this.scaleVal = 1.2
})
setTimeout(() => {
this.getUIContext()?.animateTo({ duration: 300, curve: Curve.EaseInOut }, () => {
this.scaleVal = 1.0
})
}, 300)
}
})
}
}
124 changes: 124 additions & 0 deletions entry/src/main/ets/components/BreakSuggestions.ets
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { SizeConstants } from '../constants/SizeConstants'

interface BreakActivity {
icon: Resource
name: Resource
description: Resource
durationSeconds: number
color: string
}

@Component
export default struct BreakSuggestions {
@State private currentIndex: number = 0
@State private miniTimer: number = 0
@State private miniRunning: boolean = false
private miniIntervalId: number = -1
private activities: BreakActivity[] = [
{ icon: $r('sys.symbol.figure_walk'), name: $r('app.string.break_walk'), description: $r('app.string.break_walk_desc'), durationSeconds: 120, color: '#4CAF50' },
{ icon: $r('sys.symbol.drop_fill'), name: $r('app.string.break_water'), description: $r('app.string.break_water_desc'), durationSeconds: 30, color: '#2196F3' },
{ icon: $r('sys.symbol.moon_fill'), name: $r('app.string.break_eyes'), description: $r('app.string.break_eyes_desc'), durationSeconds: 60, color: '#FF9800' },
{ icon: $r('sys.symbol.figure_walk'), name: $r('app.string.break_stretch'), description: $r('app.string.break_stretch_desc'), durationSeconds: 90, color: '#E91E63' },
{ icon: $r('sys.symbol.heart_fill'), name: $r('app.string.break_relax'), description: $r('app.string.break_relax_desc'), durationSeconds: 60, color: '#9C27B0' },
{ icon: $r('sys.symbol.face_smiling'), name: $r('app.string.break_smile'), description: $r('app.string.break_smile_desc'), durationSeconds: 30, color: '#00BCD4' },
]

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

build() {
Column() {
Swiper() {
ForEach(this.activities, (activity: BreakActivity, index: number) => {
Column({ space: 6 }) {
SymbolGlyph(activity.icon)
.fontSize(28)
.fontColor([activity.color])

Text(activity.name)
.fontSize(13)
.fontColor(Color.White)
.fontWeight(FontWeight.Medium)

Text(activity.description)
.fontSize(10)
.fontColor('#AAAAAA')
.textAlign(TextAlign.Center)
.maxLines(2)
.padding({ left: 24, right: 24 })

if (this.miniRunning && this.currentIndex === index) {
Text(this.formatMiniTime())
.fontSize(18)
.fontColor(activity.color)
.fontWeight(FontWeight.Bold)
.margin({ top: 4 })
} else {
Button() {
Text(`${activity.durationSeconds}s`)
.fontColor(Color.White)
.fontSize(11)
}
.height(32)
.padding({ left: 16, right: 16 })
.backgroundColor(activity.color)
.borderRadius(16)
.margin({ top: 4 })
.onClick(() => {
this.currentIndex = index
this.startMiniTimer(activity.durationSeconds)
})
}
}
.width(SizeConstants.FULL_WIDTH_PERCENT)
.height(SizeConstants.FULL_HEIGHT_PERCENT)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}, (activity: BreakActivity, index: number) => `${index}`)
}
.width(SizeConstants.FULL_WIDTH_PERCENT)
.height(SizeConstants.FULL_HEIGHT_PERCENT)
.index(this.currentIndex)
.indicator(
new DotIndicator()
.color('#444444')
.selectedColor('#FFFFFF')
.itemWidth(4)
.itemHeight(4)
.selectedItemWidth(8)
.selectedItemHeight(4)
)
.onChange((index: number) => {
this.currentIndex = index
})
}
.width(SizeConstants.FULL_WIDTH_PERCENT)
.height(SizeConstants.FULL_HEIGHT_PERCENT)
.justifyContent(FlexAlign.Center)
}

private startMiniTimer(duration: number) {
this.miniTimer = duration
this.miniRunning = true
if (this.miniIntervalId !== -1) {
clearInterval(this.miniIntervalId)
}
this.miniIntervalId = setInterval(() => {
this.miniTimer--
if (this.miniTimer <= 0) {
clearInterval(this.miniIntervalId)
this.miniIntervalId = -1
this.miniRunning = false
}
}, 1000)
}

private formatMiniTime(): string {
const m = Math.floor(this.miniTimer / 60)
const s = this.miniTimer % 60
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
}
Loading