diff --git a/README.md b/README.md
index be96529..4e9618f 100644
--- a/README.md
+++ b/README.md
@@ -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
# Use Cases
@@ -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
diff --git a/build-profile.json5 b/build-profile.json5
index 482f194..5323015 100644
--- a/build-profile.json5
+++ b/build-profile.json5
@@ -7,6 +7,7 @@
{
"name": "default",
"signingConfig": "default",
+ "targetSdkVersion": "5.0.2(14)",
"compatibleSdkVersion": "5.0.2(14)",
"runtimeOS": "HarmonyOS",
"buildOption": {
@@ -40,4 +41,4 @@
]
}
]
-}
\ No newline at end of file
+}
diff --git a/entry/src/main/ets/components/BadgeCard.ets b/entry/src/main/ets/components/BadgeCard.ets
new file mode 100644
index 0000000..78d3a79
--- /dev/null
+++ b/entry/src/main/ets/components/BadgeCard.ets
@@ -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)
+ }
+ })
+ }
+}
diff --git a/entry/src/main/ets/components/BreakSuggestions.ets b/entry/src/main/ets/components/BreakSuggestions.ets
new file mode 100644
index 0000000..f9035d0
--- /dev/null
+++ b/entry/src/main/ets/components/BreakSuggestions.ets
@@ -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')}`
+ }
+}
diff --git a/entry/src/main/ets/components/BreathingExercise.ets b/entry/src/main/ets/components/BreathingExercise.ets
new file mode 100644
index 0000000..ed67a30
--- /dev/null
+++ b/entry/src/main/ets/components/BreathingExercise.ets
@@ -0,0 +1,164 @@
+import { SizeConstants } from '../constants/SizeConstants'
+
+enum BreathPhase {
+ inhale,
+ hold,
+ exhale,
+ rest
+}
+
+@Component
+export default struct BreathingExercise {
+ @State private circleScale: number = 0.5
+ @State private phase: BreathPhase = BreathPhase.inhale
+ @State private phaseText: string = 'Breathe In'
+ @State private secondsLeft: number = 4
+ @State private isRunning: boolean = false
+ @State private circleColor: string = '#552586'
+ @State private cyclesCompleted: number = 0
+ @State private glowOpacity: number = 0.6
+ private intervalId: number = -1
+ // 4-7-8 technique: inhale 4s, hold 7s, exhale 8s
+ private pattern: number[] = [4, 7, 8, 2]
+ private phaseIndex: number = 0
+
+ aboutToDisappear() {
+ this.stopBreathing()
+ }
+
+ build() {
+ Column() {
+ Stack() {
+ // Outer glow ring
+ Circle()
+ .width(118)
+ .height(118)
+ .fill(Color.Transparent)
+ .stroke(this.circleColor)
+ .strokeWidth(2)
+ .opacity(this.glowOpacity)
+
+ // Breathing circle
+ Circle()
+ .width(86)
+ .height(86)
+ .fill(this.circleColor)
+ .opacity(0.3)
+ .scale({ x: this.circleScale, y: this.circleScale })
+
+ // Inner circle
+ Circle()
+ .width(52)
+ .height(52)
+ .fill(this.circleColor)
+ .opacity(0.6)
+ .scale({ x: this.circleScale, y: this.circleScale })
+
+ Column({ space: 2 }) {
+ Text(this.phaseText)
+ .fontSize(11)
+ .fontColor(Color.White)
+ .fontWeight(FontWeight.Medium)
+ Text(`${this.secondsLeft}`)
+ .fontSize(20)
+ .fontColor(Color.White)
+ .fontWeight(FontWeight.Bold)
+ }
+ }
+ .width(126)
+ .height(126)
+
+ Row({ space: 12 }) {
+ Button() {
+ SymbolGlyph(this.isRunning ? $r('sys.symbol.pause') : $r('sys.symbol.play_fill'))
+ .fontColor([Color.White])
+ .fontSize(16)
+ }
+ .width(36)
+ .height(36)
+ .borderRadius(18)
+ .backgroundColor('#552586')
+ .onClick(() => {
+ if (this.isRunning) {
+ this.stopBreathing()
+ } else {
+ this.startBreathing()
+ }
+ })
+
+ Column() {
+ Text($r('app.string.cycles'))
+ .fontSize(10)
+ .fontColor('#888888')
+ Text(`${this.cyclesCompleted}`)
+ .fontSize(14)
+ .fontColor(Color.White)
+ }
+ }
+ .margin({ top: 10 })
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .height(SizeConstants.FULL_HEIGHT_PERCENT)
+ .justifyContent(FlexAlign.Center)
+ .alignItems(HorizontalAlign.Center)
+ }
+
+ private startBreathing() {
+ this.isRunning = true
+ this.phaseIndex = 0
+ this.enterPhase(0)
+
+ this.intervalId = setInterval(() => {
+ this.secondsLeft--
+ if (this.secondsLeft <= 0) {
+ this.phaseIndex = (this.phaseIndex + 1) % 4
+ if (this.phaseIndex === 0) {
+ this.cyclesCompleted++
+ }
+ this.enterPhase(this.phaseIndex)
+ }
+ }, 1000)
+ }
+
+ private stopBreathing() {
+ this.isRunning = false
+ if (this.intervalId !== -1) {
+ clearInterval(this.intervalId)
+ this.intervalId = -1
+ }
+ }
+
+ private enterPhase(index: number) {
+ this.secondsLeft = this.pattern[index]
+ switch (index) {
+ case 0: // Inhale
+ this.phase = BreathPhase.inhale
+ this.phaseText = 'Breathe In'
+ this.circleColor = '#4CAF50'
+ this.getUIContext()?.animateTo({ duration: this.pattern[0] * 1000, curve: Curve.EaseInOut }, () => {
+ this.circleScale = 1.0
+ this.glowOpacity = 1.0
+ })
+ break
+ case 1: // Hold
+ this.phase = BreathPhase.hold
+ this.phaseText = 'Hold'
+ this.circleColor = '#2196F3'
+ break
+ case 2: // Exhale
+ this.phase = BreathPhase.exhale
+ this.phaseText = 'Breathe Out'
+ this.circleColor = '#9C27B0'
+ this.getUIContext()?.animateTo({ duration: this.pattern[2] * 1000, curve: Curve.EaseInOut }, () => {
+ this.circleScale = 0.5
+ this.glowOpacity = 0.6
+ })
+ break
+ case 3: // Rest
+ this.phase = BreathPhase.rest
+ this.phaseText = 'Rest'
+ this.circleColor = '#552586'
+ break
+ }
+ }
+}
diff --git a/entry/src/main/ets/components/CalendarHeatmap.ets b/entry/src/main/ets/components/CalendarHeatmap.ets
new file mode 100644
index 0000000..d605e87
--- /dev/null
+++ b/entry/src/main/ets/components/CalendarHeatmap.ets
@@ -0,0 +1,90 @@
+import { SizeConstants } from '../constants/SizeConstants'
+
+@Component
+export default struct CalendarHeatmap {
+ @Prop dailyData: string[] = [] // "date:minutes" format
+ private canvasCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(new RenderingContextSettings(true))
+ private heatColors: string[] = ['#1A1A1A', '#2D1B4E', '#4A2D7A', '#6A3FA6', '#9969C7', '#B589D6']
+
+ build() {
+ Column({ space: 8 }) {
+ Canvas(this.canvasCtx)
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .height(100)
+ .onReady(() => this.drawHeatmap())
+
+ // Legend row
+ Row({ space: 4 }) {
+ Text($r('app.string.less')).fontSize(9).fontColor('#888888')
+ ForEach(this.heatColors, (color: string) => {
+ Row()
+ .width(10)
+ .height(10)
+ .borderRadius(2)
+ .backgroundColor(color)
+ }, (color: string) => color)
+ Text($r('app.string.more')).fontSize(9).fontColor('#888888')
+ }
+ .justifyContent(FlexAlign.Center)
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ }
+ .padding({ left: 10, right: 10, top: 12, bottom: 12 })
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ }
+
+ private drawHeatmap() {
+ const ctx = this.canvasCtx
+ const cellSize = 10
+ const gap = 2
+ const cols = 14 // 2 weeks
+ const rows = 7 // days of week
+
+ // Parse data into map
+ const dataMap = new Map()
+ let maxMinutes = 1
+ this.dailyData.forEach((entry: string) => {
+ const parts = entry.split(':')
+ if (parts.length === 2) {
+ const minutes = parseInt(parts[1])
+ dataMap.set(parts[0], minutes)
+ if (minutes > maxMinutes) maxMinutes = minutes
+ }
+ })
+
+ ctx.clearRect(0, 0, 200, 100)
+
+ // Draw cells for last 14 days x 1 row compacted into grid
+ const today = new Date()
+ const startDate = new Date(today)
+ startDate.setDate(startDate.getDate() - (cols * rows - 1))
+
+ for (let col = 0; col < cols; col++) {
+ for (let row = 0; row < rows; row++) {
+ const dayIndex = col * rows + row
+ const d = new Date(startDate)
+ d.setDate(d.getDate() + dayIndex)
+ const dateStr = `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`
+
+ const minutes = dataMap.get(dateStr) ?? 0
+ const intensity = Math.min(Math.floor((minutes / maxMinutes) * 5), 5)
+
+ const x = 10 + col * (cellSize + gap)
+ const y = 5 + row * (cellSize + gap)
+
+ ctx.fillStyle = this.heatColors[intensity]
+ ctx.fillRect(x, y, cellSize, cellSize)
+ }
+ }
+
+ // Today marker
+ const totalDays = cols * rows
+ const todayIndex = totalDays - 1
+ const todayCol = Math.floor(todayIndex / rows)
+ const todayRow = todayIndex % rows
+ const tx = 10 + todayCol * (cellSize + gap)
+ const ty = 5 + todayRow * (cellSize + gap)
+ ctx.strokeStyle = '#FFFFFF'
+ ctx.lineWidth = 1
+ ctx.strokeRect(tx, ty, cellSize, cellSize)
+ }
+}
diff --git a/entry/src/main/ets/components/CycleVisualizer.ets b/entry/src/main/ets/components/CycleVisualizer.ets
new file mode 100644
index 0000000..a806e94
--- /dev/null
+++ b/entry/src/main/ets/components/CycleVisualizer.ets
@@ -0,0 +1,122 @@
+import { FocusType } from '../model/FocusType'
+import { SizeConstants } from '../constants/SizeConstants'
+
+@Component
+export default struct CycleVisualizer {
+ @Prop programCycle: number[] = [0, 1, 0, 1, 0, 2] // FocusType values
+ @Prop currentIndex: number = 0
+ @Prop progress: number = 0 // 0-1 for current segment
+ private canvasCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(new RenderingContextSettings(true))
+ private focusColor: string = '#552586'
+ private shortBreakColor: string = '#4CAF50'
+ private longBreakColor: string = '#2196F3'
+
+ build() {
+ Column({ space: 10 }) {
+ Canvas(this.canvasCtx)
+ .width(144)
+ .height(144)
+ .onReady(() => this.drawCycle())
+
+ // Legend
+ Row({ space: 12 }) {
+ this.legendItem(this.focusColor, $r('app.string.focus'))
+ this.legendItem(this.shortBreakColor, $r('app.string.break'))
+ }
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .height(SizeConstants.FULL_HEIGHT_PERCENT)
+ .justifyContent(FlexAlign.Center)
+ .alignItems(HorizontalAlign.Center)
+ }
+
+ @Builder
+ legendItem(color: string, label: Resource) {
+ Row({ space: 4 }) {
+ Circle().width(8).height(8).fill(color)
+ Text(label).fontSize(10).fontColor('#AAAAAA')
+ }
+ }
+
+ private drawCycle() {
+ const ctx = this.canvasCtx
+ const cx = 72
+ const cy = 72
+ const outerR = 58
+ const innerR = 40
+ const total = this.programCycle.length
+
+ ctx.clearRect(0, 0, 144, 144)
+
+ const segmentAngle = (Math.PI * 2) / total
+ const startOffset = -Math.PI / 2
+
+ for (let i = 0; i < total; i++) {
+ const focusType = this.programCycle[i] as FocusType
+ const startAngle = startOffset + i * segmentAngle
+ const endAngle = startAngle + segmentAngle - 0.02 // small gap
+
+ let color: string
+ switch (focusType) {
+ case FocusType.focus:
+ color = this.focusColor
+ break
+ case FocusType.shortBreak:
+ color = this.shortBreakColor
+ break
+ case FocusType.longBreak:
+ color = this.longBreakColor
+ break
+ default:
+ color = this.focusColor
+ }
+
+ // Draw segment
+ ctx.beginPath()
+ ctx.arc(cx, cy, outerR, startAngle, endAngle)
+ ctx.arc(cx, cy, innerR, endAngle, startAngle, true)
+ ctx.closePath()
+
+ if (i < this.currentIndex) {
+ // Completed - full color
+ ctx.fillStyle = color
+ ctx.globalAlpha = 1.0
+ } else if (i === this.currentIndex) {
+ // Current - highlighted
+ ctx.fillStyle = color
+ ctx.globalAlpha = 1.0
+ ctx.fill()
+
+ // Progress overlay
+ const progressAngle = startAngle + segmentAngle * this.progress
+ ctx.beginPath()
+ ctx.arc(cx, cy, outerR + 3, startAngle, progressAngle)
+ ctx.arc(cx, cy, innerR - 3, progressAngle, startAngle, true)
+ ctx.closePath()
+ ctx.fillStyle = '#FFFFFF'
+ ctx.globalAlpha = 0.2
+ ctx.fill()
+ ctx.globalAlpha = 1.0
+ continue
+ } else {
+ // Upcoming - dimmed
+ ctx.fillStyle = color
+ ctx.globalAlpha = 0.3
+ }
+ ctx.fill()
+ ctx.globalAlpha = 1.0
+ }
+
+ // Center text
+ ctx.fillStyle = '#FFFFFF'
+ ctx.font = '12vp sans-serif'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(`${this.currentIndex + 1}/${total}`, cx, cy - 6)
+
+ ctx.fillStyle = '#AAAAAA'
+ ctx.font = '8vp sans-serif'
+ const typeNames = ['Focus', 'Break', 'Break']
+ ctx.fillText(typeNames[this.programCycle[this.currentIndex]] ?? 'Focus', cx, cy + 8)
+ }
+}
diff --git a/entry/src/main/ets/components/FocusDialog.ets b/entry/src/main/ets/components/FocusDialog.ets
new file mode 100644
index 0000000..79da611
--- /dev/null
+++ b/entry/src/main/ets/components/FocusDialog.ets
@@ -0,0 +1,172 @@
+import { FocusType, focusName } from '../model/FocusType'
+
+@CustomDialog
+export struct SessionCompleteDialog {
+ controller: CustomDialogController
+ focusType: FocusType = FocusType.focus
+ duration: number = 0
+ onDismiss: () => void = () => {}
+
+ build() {
+ Column({ space: 12 }) {
+ // Icon
+ SymbolGlyph($r('sys.symbol.checkmark_circle_fill'))
+ .fontSize(36)
+ .fontColor(['#4CAF50'])
+
+ Text($r('app.string.session_complete'))
+ .fontSize(16)
+ .fontColor(Color.White)
+ .fontWeight(FontWeight.Bold)
+
+ // Session info
+ Row({ space: 8 }) {
+ Column() {
+ Text(focusName(this.focusType, true))
+ .fontSize(11)
+ .fontColor('#AAAAAA')
+ Text(`${Math.floor(this.duration / 60)} min`)
+ .fontSize(16)
+ .fontColor(Color.White)
+ }
+ }
+
+ Button() {
+ Text($r('app.string.continue'))
+ .fontColor(Color.White)
+ .fontSize(12)
+ }
+ .width('80%')
+ .height(32)
+ .backgroundColor('#552586')
+ .borderRadius(16)
+ .onClick(() => {
+ this.onDismiss()
+ this.controller.close()
+ })
+ }
+ .padding(14)
+ .width('86%')
+ .alignItems(HorizontalAlign.Center)
+ .backgroundColor('#1A1A1A')
+ .borderRadius(18)
+ }
+}
+
+@CustomDialog
+export struct ConfirmDialog {
+ controller: CustomDialogController
+ title: ResourceStr = ''
+ message: ResourceStr = ''
+ onConfirm: () => void = () => {}
+
+ build() {
+ Column({ space: 12 }) {
+ Text(this.title)
+ .fontSize(14)
+ .fontColor(Color.White)
+ .fontWeight(FontWeight.Bold)
+
+ Text(this.message)
+ .fontSize(11)
+ .fontColor('#AAAAAA')
+ .textAlign(TextAlign.Center)
+
+ Row({ space: 12 }) {
+ Button() {
+ Text($r('app.string.cancel'))
+ .fontColor('#AAAAAA')
+ .fontSize(11)
+ }
+ .layoutWeight(1)
+ .height(28)
+ .backgroundColor('#333333')
+ .borderRadius(14)
+ .onClick(() => this.controller.close())
+
+ Button() {
+ Text($r('app.string.confirm'))
+ .fontColor(Color.White)
+ .fontSize(11)
+ }
+ .layoutWeight(1)
+ .height(28)
+ .backgroundColor('#552586')
+ .borderRadius(14)
+ .onClick(() => {
+ this.onConfirm()
+ this.controller.close()
+ })
+ }
+ .width('100%')
+ }
+ .padding(14)
+ .width('86%')
+ .alignItems(HorizontalAlign.Center)
+ .backgroundColor('#1A1A1A')
+ .borderRadius(18)
+ }
+}
+
+@CustomDialog
+export struct AchievementUnlockDialog {
+ controller: CustomDialogController
+ achievementName: ResourceStr = ''
+ achievementIcon: Resource = $r('sys.symbol.star_fill')
+
+ @State private iconScale: number = 0.0
+
+ aboutToAppear() {
+ setTimeout(() => {
+ this.getUIContext()?.animateTo({
+ duration: 600,
+ curve: Curve.EaseOut,
+ }, () => {
+ this.iconScale = 1.0
+ })
+ }, 100)
+ }
+
+ build() {
+ Column({ space: 12 }) {
+ Stack() {
+ Circle()
+ .width(56)
+ .height(56)
+ .fill('#552586')
+ .scale({ x: this.iconScale, y: this.iconScale })
+
+ SymbolGlyph(this.achievementIcon)
+ .fontSize(28)
+ .fontColor([Color.White])
+ .scale({ x: this.iconScale, y: this.iconScale })
+ }
+
+ Text($r('app.string.achievement_unlocked'))
+ .fontSize(13)
+ .fontColor('#FFD700')
+ .fontWeight(FontWeight.Bold)
+
+ Text(this.achievementName)
+ .fontSize(11)
+ .fontColor(Color.White)
+ .textAlign(TextAlign.Center)
+
+ Button() {
+ Text($r('app.string.awesome'))
+ .fontColor(Color.White)
+ .fontSize(11)
+ }
+ .width('70%')
+ .height(28)
+ .backgroundColor('#552586')
+ .borderRadius(14)
+ .onClick(() => this.controller.close())
+ }
+ .padding(14)
+ .width('86%')
+ .alignItems(HorizontalAlign.Center)
+ .backgroundColor('#1A1A1A')
+ .borderRadius(18)
+ }
+}
diff --git a/entry/src/main/ets/components/GoalRingView.ets b/entry/src/main/ets/components/GoalRingView.ets
new file mode 100644
index 0000000..88acd8e
--- /dev/null
+++ b/entry/src/main/ets/components/GoalRingView.ets
@@ -0,0 +1,85 @@
+import { DailyGoal } from '../model/GoalModel'
+import { SizeConstants } from '../constants/SizeConstants'
+
+@Component
+export default struct GoalRingView {
+ @ObjectLink goal: DailyGoal
+ @State private animatedProgress: number = 0
+ @State private showCelebration: boolean = false
+
+ aboutToAppear() {
+ setTimeout(() => {
+ this.getUIContext()?.animateTo({ duration: 800, curve: Curve.EaseOut }, () => {
+ this.animatedProgress = this.goal.progress * 100
+ })
+ }, 200)
+ }
+
+ build() {
+ Column({ space: 4 }) {
+ Stack() {
+ Gauge({ value: this.animatedProgress, min: 0, max: 100 })
+ .width(130)
+ .height(130)
+ .colors([[('#552586') as string, 0.3], [('#9969C7') as string, 0.6], [('#B589D6') as string, 1.0]])
+ .strokeWidth(12)
+ .description(this.buildDescription())
+
+ if (this.showCelebration) {
+ Text('Nice!')
+ .fontSize(28)
+ .offset({ y: -50 })
+ .transition(TransitionEffect.scale({ x: 0, y: 0 }).animation({ duration: 500, curve: Curve.EaseOut }))
+ }
+ }
+
+ Row({ space: 8 }) {
+ Column() {
+ Text(`${this.goal.completedMinutes}`)
+ .fontSize(16)
+ .fontColor(Color.White)
+ .fontWeight(FontWeight.Bold)
+ Text($r('app.string.minutes'))
+ .fontSize(9)
+ .fontColor('#888888')
+ }
+
+ Divider().vertical(true).height(24).color('#333333')
+
+ Column() {
+ Text(`${this.goal.targetMinutes}`)
+ .fontSize(16)
+ .fontColor('#888888')
+ .fontWeight(FontWeight.Bold)
+ Text($r('app.string.goal'))
+ .fontSize(9)
+ .fontColor('#888888')
+ }
+ }
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .justifyContent(FlexAlign.Center)
+ .onAppear(() => {
+ if (this.goal.isCompleted) {
+ setTimeout(() => {
+ this.getUIContext()?.animateTo({ duration: 600, curve: Curve.EaseOut }, () => {
+ this.showCelebration = true
+ })
+ }, 1000)
+ }
+ })
+ }
+
+ @Builder
+ buildDescription() {
+ Column({ space: 2 }) {
+ Text(`${Math.round(this.goal.progress * 100)}%`)
+ .fontSize(22)
+ .fontColor(Color.White)
+ .fontWeight(FontWeight.Bold)
+ Text($r('app.string.daily_goal'))
+ .fontSize(9)
+ .fontColor('#AAAAAA')
+ }
+ }
+}
diff --git a/entry/src/main/ets/components/IntensitySelector.ets b/entry/src/main/ets/components/IntensitySelector.ets
new file mode 100644
index 0000000..9db526f
--- /dev/null
+++ b/entry/src/main/ets/components/IntensitySelector.ets
@@ -0,0 +1,63 @@
+import { IntensityLevel, IntensityConfig, INTENSITY_CONFIGS } from '../model/IntensityModel'
+
+@Component
+export default struct IntensitySelector {
+ @Link selectedIntensity: IntensityLevel
+
+ build() {
+ Column({ space: 8 }) {
+ Text($r('app.string.intensity'))
+ .fontSize(12)
+ .fontColor('#CCCCCC')
+
+ Row({ space: 4 }) {
+ ForEach(INTENSITY_CONFIGS, (config: IntensityConfig) => {
+ Column({ space: 4 }) {
+ Stack() {
+ Circle()
+ .width(36)
+ .height(36)
+ .fill(this.selectedIntensity === config.level ? config.color : '#2A2A2A')
+
+ SymbolGlyph(config.icon)
+ .fontSize(16)
+ .fontColor([this.selectedIntensity === config.level ? Color.White : config.color])
+ }
+
+ Text(config.name)
+ .fontSize(8)
+ .fontColor(this.selectedIntensity === config.level ? Color.White : '#888888')
+ }
+ .onClick(() => {
+ this.getUIContext()?.animateTo({ duration: 200, curve: Curve.EaseInOut }, () => {
+ this.selectedIntensity = config.level
+ AppStorage.setOrCreate('selectedIntensity', config.level)
+ })
+ })
+ }, (config: IntensityConfig) => `${config.level}`)
+ }
+ .justifyContent(FlexAlign.Center)
+
+ // Multiplier info
+ DataPanel({ values: [this.getConfig().focusMultiplier * 50, this.getConfig().breakMultiplier * 50], max: 100 })
+ .width(120)
+ .height(8)
+ .valueColors([this.getConfig().color, '#4CAF50'])
+
+ Row({ space: 16 }) {
+ Text(`Focus x${this.getConfig().focusMultiplier}`)
+ .fontSize(8)
+ .fontColor('#AAAAAA')
+ Text(`Break x${this.getConfig().breakMultiplier}`)
+ .fontSize(8)
+ .fontColor('#AAAAAA')
+ }
+ }
+ .width('100%')
+ .alignItems(HorizontalAlign.Center)
+ }
+
+ private getConfig(): IntensityConfig {
+ return INTENSITY_CONFIGS.find((c: IntensityConfig) => c.level === this.selectedIntensity) ?? INTENSITY_CONFIGS[1]
+ }
+}
diff --git a/entry/src/main/ets/components/ProgressTimes.ets b/entry/src/main/ets/components/ProgressTimes.ets
index d0eb027..09e10e0 100644
--- a/entry/src/main/ets/components/ProgressTimes.ets
+++ b/entry/src/main/ets/components/ProgressTimes.ets
@@ -7,15 +7,18 @@ import { SizeConstants } from '../constants/SizeConstants';
@Component
export default struct ProgressTimes {
@ObjectLink program: Program;
+ onTimesChanged: () => void = () => {}
private focuses = this.program.getFocusList()
build() {
Column() {
- List() {
+ List({ space: 6 }) {
ForEach(this.focuses, (focus: Focus) => {
ListItem() {
- Column() {
+ Column({ space: 4 }) {
Text(focusName(focus.type, true))
+ .fontSize(11)
+ .fontColor('#CCCCCC')
CounterComponent({
options: {
type: CounterType.COMPACT,
@@ -27,19 +30,30 @@ export default struct ProgressTimes {
focusable: false,
onChange: (val: number) => {
this.program.changeDefaultTime(focus.type, val)
+ focus.value = val
+ this.onTimesChanged()
}
}
}
})
}
.width(SizeConstants.FULL_WIDTH_PERCENT)
+ .padding({ top: 4, bottom: 4 })
}
}, (item: Focus, index: number) => `${JSON.stringify(item)}`)
}
.width(SizeConstants.FULL_WIDTH_PERCENT)
- .height(SizeConstants.FULL_HEIGHT_PERCENT)
+ .layoutWeight(1)
+ .padding({
+ top: SizeConstants.PAGE_HEADER_TOP_PADDING,
+ bottom: SizeConstants.PAGE_CONTENT_BOTTOM_PADDING,
+ left: SizeConstants.PAGE_SIDE_PADDING,
+ right: SizeConstants.PAGE_SIDE_PADDING
+ })
+ .scrollBar(BarState.Auto)
+ .edgeEffect(EdgeEffect.Spring)
}
.width(SizeConstants.FULL_WIDTH_PERCENT)
.height(SizeConstants.FULL_HEIGHT_PERCENT)
}
-}
\ No newline at end of file
+}
diff --git a/entry/src/main/ets/components/QuotesProvider.ets b/entry/src/main/ets/components/QuotesProvider.ets
new file mode 100644
index 0000000..bef88be
--- /dev/null
+++ b/entry/src/main/ets/components/QuotesProvider.ets
@@ -0,0 +1,58 @@
+import { SizeConstants } from '../constants/SizeConstants'
+
+@Component
+export default struct QuotesProvider {
+ @State private currentQuote: string = ''
+ @State private currentAuthor: string = ''
+ private quotes: string[][] = [
+ ['The secret of getting ahead is getting started.', 'Mark Twain'],
+ ['Focus on being productive instead of busy.', 'Tim Ferriss'],
+ ['It is not enough to be busy. The question is: what are we busy about?', 'Thoreau'],
+ ['Concentrate all your thoughts upon the work at hand.', 'Alexander Graham Bell'],
+ ['Do what you can, with what you have, where you are.', 'Theodore Roosevelt'],
+ ['The way to get started is to quit talking and begin doing.', 'Walt Disney'],
+ ['You don\'t have to be great to start, but you have to start to be great.', 'Zig Ziglar'],
+ ['Action is the foundational key to all success.', 'Pablo Picasso'],
+ ['Small daily improvements are the key to staggering long-term results.', ''],
+ ['Your mind is for having ideas, not holding them.', 'David Allen'],
+ ['Simplicity is the ultimate sophistication.', 'Leonardo da Vinci'],
+ ['Amateurs sit and wait for inspiration. The rest of us just get up and go to work.', 'Stephen King'],
+ ['The only way to do great work is to love what you do.', 'Steve Jobs'],
+ ['Deep work is the ability to focus without distraction on a cognitively demanding task.', 'Cal Newport'],
+ ['Time is what we want most, but what we use worst.', 'William Penn'],
+ ['Productivity is never an accident. It is the result of commitment to excellence.', 'Paul J. Meyer'],
+ ]
+
+ aboutToAppear() {
+ this.pickRandom()
+ }
+
+ build() {
+ Column({ space: 4 }) {
+ Marquee({
+ start: true,
+ step: 3,
+ loop: -1,
+ fromStart: true,
+ src: this.currentQuote
+ })
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .fontSize(11)
+ .fontColor('#CCCCCC')
+
+ if (this.currentAuthor !== '') {
+ Text(`— ${this.currentAuthor}`)
+ .fontSize(10)
+ .fontColor('#888888')
+ .fontStyle(FontStyle.Italic)
+ }
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ }
+
+ private pickRandom() {
+ const index = Math.floor(Math.random() * this.quotes.length)
+ this.currentQuote = this.quotes[index][0]
+ this.currentAuthor = this.quotes[index][1]
+ }
+}
diff --git a/entry/src/main/ets/components/StatsChart.ets b/entry/src/main/ets/components/StatsChart.ets
new file mode 100644
index 0000000..3c6ae6f
--- /dev/null
+++ b/entry/src/main/ets/components/StatsChart.ets
@@ -0,0 +1,161 @@
+import { SizeConstants } from '../constants/SizeConstants'
+
+interface ChartSegment {
+ tag: string
+ value: number
+}
+
+@Component
+export default struct StatsChart {
+ @Prop weeklyData: number[] = [0, 0, 0, 0, 0, 0, 0]
+ @Prop tagData: string[] = [] // "tag:minutes" format
+ private canvasController: CanvasRenderingContext2D = new CanvasRenderingContext2D(new RenderingContextSettings(true))
+ private pieController: CanvasRenderingContext2D = new CanvasRenderingContext2D(new RenderingContextSettings(true))
+ private dayLabels: string[] = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
+ private chartColors: string[] = ['#552586', '#2196F3', '#4CAF50', '#FF9800', '#E91E63', '#00BCD4', '#795548', '#607D8B']
+
+ build() {
+ Column({ space: 10 }) {
+ Canvas(this.canvasController)
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .height(120)
+ .onReady(() => this.drawBarChart())
+
+ Divider().color('#262626').margin({ top: 2, bottom: 2 })
+
+ // Tag distribution pie chart
+ Text($r('app.string.tag_distribution'))
+ .fontSize(10)
+ .fontColor('#888888')
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .padding({ left: 4 })
+
+ Canvas(this.pieController)
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .height(120)
+ .onReady(() => this.drawPieChart())
+ }
+ .padding({ left: 10, right: 10, top: 12, bottom: 12 })
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ }
+
+ private drawBarChart() {
+ const ctx = this.canvasController
+ const w = 180
+ const h = 110
+ const maxVal = Math.max(...this.weeklyData, 1)
+ const barWidth = (w - 40) / 7
+ const barGap = 2
+
+ ctx.clearRect(0, 0, w, h)
+
+ // Y-axis
+ ctx.strokeStyle = '#555555'
+ ctx.lineWidth = 0.5
+ ctx.beginPath()
+ ctx.moveTo(30, 5)
+ ctx.lineTo(30, h - 20)
+ ctx.lineTo(w - 5, h - 20)
+ ctx.stroke()
+
+ // Y-axis labels
+ ctx.fillStyle = '#888888'
+ ctx.font = '8vp sans-serif'
+ ctx.textAlign = 'right'
+ const steps = 3
+ for (let i = 0; i <= steps; i++) {
+ const val = Math.round((maxVal / steps) * i)
+ const y = (h - 20) - ((h - 30) / steps) * i
+ ctx.fillText(`${val}`, 28, y + 3)
+ if (i > 0) {
+ ctx.strokeStyle = '#333333'
+ ctx.beginPath()
+ ctx.moveTo(30, y)
+ ctx.lineTo(w - 5, y)
+ ctx.stroke()
+ }
+ }
+
+ // Bars
+ for (let i = 0; i < 7; i++) {
+ const barHeight = (this.weeklyData[i] / maxVal) * (h - 30)
+ const x = 32 + i * barWidth + barGap
+ const y = (h - 20) - barHeight
+
+ // Gradient bar
+ const gradient = ctx.createLinearGradient(x, y, x, h - 20)
+ gradient.addColorStop(0, '#9969C7')
+ gradient.addColorStop(1, '#552586')
+ ctx.fillStyle = gradient
+ ctx.fillRect(x, y, barWidth - barGap * 2, barHeight)
+
+ // Day label
+ ctx.fillStyle = '#AAAAAA'
+ ctx.font = '7vp sans-serif'
+ ctx.textAlign = 'center'
+ ctx.fillText(this.dayLabels[i], x + (barWidth - barGap * 2) / 2, h - 8)
+ }
+ }
+
+ private drawPieChart() {
+ const ctx = this.pieController
+ const centerX = 60
+ const centerY = 55
+ const radius = 40
+
+ ctx.clearRect(0, 0, 200, 120)
+
+ if (this.tagData.length === 0) {
+ ctx.fillStyle = '#555555'
+ ctx.font = '10vp sans-serif'
+ ctx.textAlign = 'center'
+ ctx.fillText('No data', centerX, centerY)
+ return
+ }
+
+ // Parse tag data
+ let total = 0
+ const segments: ChartSegment[] = []
+ this.tagData.forEach((entry: string) => {
+ const parts = entry.split(':')
+ if (parts.length === 2) {
+ const val = parseInt(parts[1])
+ segments.push({ tag: parts[0], value: val })
+ total += val
+ }
+ })
+
+ // Draw pie slices
+ let currentAngle = -Math.PI / 2
+ segments.forEach((seg: ChartSegment, i: number) => {
+ const sliceAngle = (seg.value / total) * 2 * Math.PI
+ ctx.beginPath()
+ ctx.moveTo(centerX, centerY)
+ ctx.arc(centerX, centerY, radius, currentAngle, currentAngle + sliceAngle)
+ ctx.closePath()
+ ctx.fillStyle = this.chartColors[i % this.chartColors.length]
+ ctx.fill()
+
+ currentAngle += sliceAngle
+ })
+
+ // Inner circle for donut effect
+ ctx.beginPath()
+ ctx.arc(centerX, centerY, radius * 0.5, 0, Math.PI * 2)
+ ctx.fillStyle = '#1A1A1A'
+ ctx.fill()
+
+ // Legend
+ const legendX = 115
+ segments.forEach((seg: ChartSegment, i: number) => {
+ const y = 15 + i * 16
+ ctx.fillStyle = this.chartColors[i % this.chartColors.length]
+ ctx.fillRect(legendX, y - 6, 8, 8)
+ ctx.fillStyle = '#CCCCCC'
+ ctx.font = '8vp sans-serif'
+ ctx.textAlign = 'left'
+ const pct = Math.round((seg.value / total) * 100)
+ ctx.fillText(`${seg.tag} ${pct}%`, legendX + 12, y + 1)
+ })
+ }
+}
diff --git a/entry/src/main/ets/components/TagSelector.ets b/entry/src/main/ets/components/TagSelector.ets
new file mode 100644
index 0000000..51df7b3
--- /dev/null
+++ b/entry/src/main/ets/components/TagSelector.ets
@@ -0,0 +1,36 @@
+import { DEFAULT_TAGS, Tag } from '../model/TagModel'
+import { SizeConstants } from '../constants/SizeConstants'
+
+@Component
+export default struct TagSelector {
+ @Link selectedTagId: string
+ private tags: Tag[] = DEFAULT_TAGS
+
+ build() {
+ Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center, alignItems: ItemAlign.Center }) {
+ ForEach(this.tags, (tag: Tag) => {
+ Row({ space: 3 }) {
+ SymbolGlyph(tag.icon)
+ .fontSize(11)
+ .fontColor([this.selectedTagId === tag.id ? Color.White : tag.color])
+
+ Text(tag.name)
+ .fontSize(10)
+ .fontColor(this.selectedTagId === tag.id ? Color.White : '#CCCCCC')
+ }
+ .padding({ left: 7, right: 7, top: 3, bottom: 3 })
+ .borderRadius(14)
+ .backgroundColor(this.selectedTagId === tag.id ? tag.color : '#2A2A2A')
+ .border({
+ width: 1,
+ color: this.selectedTagId === tag.id ? tag.color : '#444444'
+ })
+ .margin({ right: 4, bottom: 4 })
+ .onClick(() => {
+ this.selectedTagId = this.selectedTagId === tag.id ? '' : tag.id
+ })
+ }, (tag: Tag) => tag.id)
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ }
+}
diff --git a/entry/src/main/ets/components/ThemePickerView.ets b/entry/src/main/ets/components/ThemePickerView.ets
new file mode 100644
index 0000000..0e334ea
--- /dev/null
+++ b/entry/src/main/ets/components/ThemePickerView.ets
@@ -0,0 +1,65 @@
+import { AppTheme, THEMES } from '../model/ThemeModel'
+import { SizeConstants } from '../constants/SizeConstants'
+
+@Component
+export default struct ThemePickerView {
+ @Link selectedThemeId: string
+
+ build() {
+ Column({ space: 8 }) {
+ Text($r('app.string.select_theme'))
+ .fontSize(12)
+ .fontColor('#CCCCCC')
+
+ Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) {
+ ForEach(THEMES, (theme: AppTheme) => {
+ Column({ space: 4 }) {
+ Stack() {
+ Circle()
+ .width(36)
+ .height(36)
+ .fill(theme.colors.gradient5)
+
+ Circle()
+ .width(24)
+ .height(24)
+ .fill(theme.colors.gradient3)
+
+ Circle()
+ .width(12)
+ .height(12)
+ .fill(theme.colors.gradient1)
+
+ if (this.selectedThemeId === theme.id) {
+ Circle()
+ .width(40)
+ .height(40)
+ .fill(Color.Transparent)
+ .stroke(Color.White)
+ .strokeWidth(2)
+ }
+ }
+
+ Text(theme.name)
+ .fontSize(8)
+ .fontColor(this.selectedThemeId === theme.id ? Color.White : '#888888')
+ .textAlign(TextAlign.Center)
+ }
+ .margin({ right: 8, bottom: 8 })
+ .onClick(() => {
+ this.selectedThemeId = theme.id
+ AppStorage.setOrCreate('selectedTheme', theme.id)
+ // Track themes used for achievement
+ const usedThemes: string = AppStorage.get('usedThemesList') ?? ''
+ if (!usedThemes.includes(theme.id)) {
+ const updated = usedThemes ? `${usedThemes},${theme.id}` : theme.id
+ AppStorage.setOrCreate('usedThemesList', updated)
+ AppStorage.setOrCreate('themesUsedCount', updated.split(',').length)
+ }
+ })
+ }, (theme: AppTheme) => theme.id)
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ }
+ }
+}
diff --git a/entry/src/main/ets/constants/SizeConstants.ets b/entry/src/main/ets/constants/SizeConstants.ets
index 4ebb619..1d83adf 100644
--- a/entry/src/main/ets/constants/SizeConstants.ets
+++ b/entry/src/main/ets/constants/SizeConstants.ets
@@ -1,14 +1,23 @@
export class SizeConstants {
- /**
- * The width percentage setting.
- */
static readonly FULL_WIDTH_PERCENT: string = '100%';
- /**
- * The height percentage setting.
- */
static readonly FULL_HEIGHT_PERCENT: string = '100%';
- /**
- * 70% percent value.
- */
static readonly PERCENT_70 = '70%';
-}
\ No newline at end of file
+ static readonly PERCENT_80 = '80%';
+ static readonly PERCENT_90 = '90%';
+ static readonly PERCENT_50 = '50%';
+
+ // Circular display safe-area. Huawei Watch 5 (466x466) inscribed square ≈ 70%
+ // of diameter, so top/bottom arcs ~34vp each are clipped. Use generous insets.
+ static readonly PAGE_SIDE_PADDING: number = 16;
+ static readonly PAGE_HEADER_TOP_PADDING: number = 30;
+ static readonly PAGE_CONTENT_BOTTOM_PADDING: number = 56;
+ static readonly FLOATING_BUTTON_TOP_OFFSET: number = 24;
+
+ // Tap targets — HarmonyOS wearable minimum is 36vp.
+ static readonly TAP_TARGET_MIN: number = 36;
+ static readonly HEADER_BUTTON_SIZE: number = 32;
+ static readonly HEADER_BUTTON_RADIUS: number = 16;
+
+ // Dialog inset width on circular display.
+ static readonly DIALOG_WIDTH_PERCENT: string = '86%';
+}
diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets
index d9fc2ca..c176938 100644
--- a/entry/src/main/ets/entryability/EntryAbility.ets
+++ b/entry/src/main/ets/entryability/EntryAbility.ets
@@ -2,6 +2,9 @@ import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
+import { DbService } from '../service/DbService';
+import { AchievementService } from '../service/AchievementService';
+import { StreakTracker } from '../service/StreakTracker';
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
@@ -13,33 +16,58 @@ export default class EntryAbility extends UIAbility {
}
onWindowStageCreate(windowStage: window.WindowStage): void {
- // Main window is created, set main page for this ability
hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageCreate');
- windowStage.loadContent('pages/Index', (err: BusinessError) => {
+ // Initialize persistent storage
+ PersistentStorage.persistProp('defaultTimes', null)
+ PersistentStorage.persistProp('programIndex', null)
+ PersistentStorage.persistProp('onboardingComplete', false)
+ PersistentStorage.persistProp('selectedTheme', 'purple')
+ PersistentStorage.persistProp('selectedIntensity', 1)
+ PersistentStorage.persistProp('dailyGoalTarget', 120)
+ PersistentStorage.persistProp('dailyGoalCompleted', 0)
+ PersistentStorage.persistProp('dailyGoalDate', '')
+ PersistentStorage.persistProp('autoSkip', false)
+ PersistentStorage.persistProp('showQuotes', true)
+ PersistentStorage.persistProp('currentStreak', 0)
+ PersistentStorage.persistProp('maxStreak', 0)
+ PersistentStorage.persistProp('achievementProgress', '')
+ PersistentStorage.persistProp('selectedProgram', 'pomodoro')
+ PersistentStorage.persistProp('themesUsedCount', 1)
+ PersistentStorage.persistProp('programsUsedCount', 1)
+ PersistentStorage.persistProp('earlyBirdCount', 0)
+ PersistentStorage.persistProp('usedThemesList', 'purple')
+ PersistentStorage.persistProp('usedProgramsList', 'pomodoro')
+ PersistentStorage.persistProp('lastGoalDate', '')
+
+ // Initialize database
+ const dbService = DbService.getInstance()
+ dbService.initialize(this.context).then(() => {
+ hilog.info(0x0000, 'testTag', 'Database initialized')
+ })
+
+ // Initialize services
+ AchievementService.getInstance().initialize()
+ StreakTracker.getInstance().initialize()
+
+ windowStage.loadContent('pages/NavigationContainer', (err: BusinessError) => {
if (err.code) {
hilog.error(0x0000, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? '');
return;
}
hilog.info(0x0000, 'testTag', 'Succeeded in loading the content.');
-
- PersistentStorage.persistProp('defaultTimes', null)
- PersistentStorage.persistProp('programIndex', null)
});
}
onWindowStageDestroy(): void {
- // Main window is destroyed, release UI related resources
hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageDestroy');
}
onForeground(): void {
- // Ability has brought to foreground
hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onForeground');
}
onBackground(): void {
- // Ability has back to background
hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onBackground');
}
}
diff --git a/entry/src/main/ets/model/Achievement.ets b/entry/src/main/ets/model/Achievement.ets
new file mode 100644
index 0000000..ef4829d
--- /dev/null
+++ b/entry/src/main/ets/model/Achievement.ets
@@ -0,0 +1,89 @@
+export interface Achievement {
+ id: string
+ name: Resource
+ description: Resource
+ icon: Resource
+ targetValue: number
+ category: AchievementCategory
+}
+
+export enum AchievementCategory {
+ sessions,
+ minutes,
+ streak,
+ exploration
+}
+
+export interface AchievementProgress {
+ achievementId: string
+ currentValue: number
+ unlocked: boolean
+ unlockedDate?: number
+}
+
+export const ACHIEVEMENTS: Achievement[] = [
+ // Session milestones
+ {
+ id: 'first_session', name: $r('app.string.ach_first_session'), description: $r('app.string.ach_first_session_desc'),
+ icon: $r('sys.symbol.star_fill'), targetValue: 1, category: AchievementCategory.sessions
+ },
+ {
+ id: 'ten_sessions', name: $r('app.string.ach_ten_sessions'), description: $r('app.string.ach_ten_sessions_desc'),
+ icon: $r('sys.symbol.flame_fill'), targetValue: 10, category: AchievementCategory.sessions
+ },
+ {
+ id: 'fifty_sessions', name: $r('app.string.ach_fifty_sessions'), description: $r('app.string.ach_fifty_sessions_desc'),
+ icon: $r('sys.symbol.bolt_fill'), targetValue: 50, category: AchievementCategory.sessions
+ },
+ {
+ id: 'hundred_sessions', name: $r('app.string.ach_hundred_sessions'), description: $r('app.string.ach_hundred_sessions_desc'),
+ icon: $r('sys.symbol.checkmark_circle_fill'), targetValue: 100, category: AchievementCategory.sessions
+ },
+ {
+ id: 'five_hundred_sessions', name: $r('app.string.ach_five_hundred_sessions'), description: $r('app.string.ach_five_hundred_sessions_desc'),
+ icon: $r('sys.symbol.star_fill'), targetValue: 500, category: AchievementCategory.sessions
+ },
+ // Minute milestones
+ {
+ id: 'hour_focus', name: $r('app.string.ach_hour_focus'), description: $r('app.string.ach_hour_focus_desc'),
+ icon: $r('sys.symbol.clock_fill'), targetValue: 60, category: AchievementCategory.minutes
+ },
+ {
+ id: 'five_hour_focus', name: $r('app.string.ach_five_hour_focus'), description: $r('app.string.ach_five_hour_focus_desc'),
+ icon: $r('sys.symbol.clock_fill'), targetValue: 300, category: AchievementCategory.minutes
+ },
+ {
+ id: 'day_focus', name: $r('app.string.ach_day_focus'), description: $r('app.string.ach_day_focus_desc'),
+ icon: $r('sys.symbol.sun_max_fill'), targetValue: 1440, category: AchievementCategory.minutes
+ },
+ // Streak milestones
+ {
+ id: 'three_day_streak', name: $r('app.string.ach_three_day_streak'), description: $r('app.string.ach_three_day_streak_desc'),
+ icon: $r('sys.symbol.flame'), targetValue: 3, category: AchievementCategory.streak
+ },
+ {
+ id: 'seven_day_streak', name: $r('app.string.ach_seven_day_streak'), description: $r('app.string.ach_seven_day_streak_desc'),
+ icon: $r('sys.symbol.flame_fill'), targetValue: 7, category: AchievementCategory.streak
+ },
+ {
+ id: 'thirty_day_streak', name: $r('app.string.ach_thirty_day_streak'), description: $r('app.string.ach_thirty_day_streak_desc'),
+ icon: $r('sys.symbol.heart_fill'), targetValue: 30, category: AchievementCategory.streak
+ },
+ // Exploration
+ {
+ id: 'all_tags', name: $r('app.string.ach_all_tags'), description: $r('app.string.ach_all_tags_desc'),
+ icon: $r('sys.symbol.list_bullet'), targetValue: 6, category: AchievementCategory.exploration
+ },
+ {
+ id: 'theme_changer', name: $r('app.string.ach_theme_changer'), description: $r('app.string.ach_theme_changer_desc'),
+ icon: $r('sys.symbol.paintpalette_fill'), targetValue: 3, category: AchievementCategory.exploration
+ },
+ {
+ id: 'all_programs', name: $r('app.string.ach_all_programs'), description: $r('app.string.ach_all_programs_desc'),
+ icon: $r('sys.symbol.list_bullet'), targetValue: 4, category: AchievementCategory.exploration
+ },
+ {
+ id: 'early_bird', name: $r('app.string.ach_early_bird'), description: $r('app.string.ach_early_bird_desc'),
+ icon: $r('sys.symbol.sunrise_fill'), targetValue: 5, category: AchievementCategory.exploration
+ }
+]
diff --git a/entry/src/main/ets/model/GoalModel.ets b/entry/src/main/ets/model/GoalModel.ets
new file mode 100644
index 0000000..b1223d5
--- /dev/null
+++ b/entry/src/main/ets/model/GoalModel.ets
@@ -0,0 +1,42 @@
+@Observed
+export class DailyGoal {
+ targetMinutes: number
+ completedMinutes: number
+ date: string
+
+ constructor(targetMinutes: number = 120, completedMinutes: number = 0) {
+ this.targetMinutes = targetMinutes
+ this.completedMinutes = completedMinutes
+ this.date = DailyGoal.todayString()
+ }
+
+ get progress(): number {
+ if (this.targetMinutes <= 0) return 0
+ return Math.min(this.completedMinutes / this.targetMinutes, 1.0)
+ }
+
+ get isCompleted(): boolean {
+ return this.completedMinutes >= this.targetMinutes
+ }
+
+ get remainingMinutes(): number {
+ return Math.max(this.targetMinutes - this.completedMinutes, 0)
+ }
+
+ addMinutes(minutes: number) {
+ this.completedMinutes += minutes
+ }
+
+ static todayString(): string {
+ const now = new Date()
+ return `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}`
+ }
+
+ resetIfNewDay() {
+ const today = DailyGoal.todayString()
+ if (this.date !== today) {
+ this.completedMinutes = 0
+ this.date = today
+ }
+ }
+}
diff --git a/entry/src/main/ets/model/IntensityModel.ets b/entry/src/main/ets/model/IntensityModel.ets
new file mode 100644
index 0000000..8566bc4
--- /dev/null
+++ b/entry/src/main/ets/model/IntensityModel.ets
@@ -0,0 +1,45 @@
+export enum IntensityLevel {
+ light,
+ normal,
+ intense
+}
+
+export interface IntensityConfig {
+ level: IntensityLevel
+ name: Resource
+ icon: Resource
+ color: string
+ focusMultiplier: number
+ breakMultiplier: number
+}
+
+export const INTENSITY_CONFIGS: IntensityConfig[] = [
+ {
+ level: IntensityLevel.light,
+ name: $r('app.string.intensity_light'),
+ icon: $r('sys.symbol.leaf_fill'),
+ color: '#4CAF50',
+ focusMultiplier: 0.8,
+ breakMultiplier: 1.2
+ },
+ {
+ level: IntensityLevel.normal,
+ name: $r('app.string.intensity_normal'),
+ icon: $r('sys.symbol.circle_fill'),
+ color: '#2196F3',
+ focusMultiplier: 1.0,
+ breakMultiplier: 1.0
+ },
+ {
+ level: IntensityLevel.intense,
+ name: $r('app.string.intensity_intense'),
+ icon: $r('sys.symbol.flame_fill'),
+ color: '#F44336',
+ focusMultiplier: 1.2,
+ breakMultiplier: 0.8
+ }
+]
+
+export function getIntensityConfig(level: IntensityLevel): IntensityConfig {
+ return INTENSITY_CONFIGS.find((c: IntensityConfig) => c.level === level) ?? INTENSITY_CONFIGS[1]
+}
diff --git a/entry/src/main/ets/model/Program.ets b/entry/src/main/ets/model/Program.ets
index 482554c..6bd1166 100644
--- a/entry/src/main/ets/model/Program.ets
+++ b/entry/src/main/ets/model/Program.ets
@@ -15,6 +15,10 @@ export default class Program {
private programIndex: number = AppStorage.get('programIndex') ?? 0
currentFocus = this.getCurrentFocus()
+ getProgramIndex(): number {
+ return this.programIndex
+ }
+
getCurrentFocus() {
const currentType = this.program[this.programIndex]
const currentFocus: Focus = {
@@ -39,8 +43,15 @@ export default class Program {
}
changeDefaultTime(type: FocusType, val: number) {
- this.focusTimes.set(type, val)
- AppStorage.setOrCreate('defaultTimes', this.focusTimes)
+ // Replace the map reference so @Observed notices the change.
+ const updated = new Map(this.focusTimes)
+ updated.set(type, val)
+ this.focusTimes = updated
+ AppStorage.setOrCreate('defaultTimes', updated)
+ // Keep currentFocus in sync if the edited type is the one on screen.
+ if (this.program[this.programIndex] === type) {
+ this.currentFocus = this.getCurrentFocus()
+ }
}
setProgramIndex(index: number) {
diff --git a/entry/src/main/ets/model/ProgramTemplate.ets b/entry/src/main/ets/model/ProgramTemplate.ets
new file mode 100644
index 0000000..7671149
--- /dev/null
+++ b/entry/src/main/ets/model/ProgramTemplate.ets
@@ -0,0 +1,70 @@
+import { FocusType } from './FocusType'
+
+export interface ProgramTemplate {
+ id: string
+ name: Resource
+ description: Resource
+ icon: Resource
+ focusMinutes: number
+ shortBreakMinutes: number
+ longBreakMinutes: number
+ cyclePattern: FocusType[]
+}
+
+export const PROGRAM_TEMPLATES: ProgramTemplate[] = [
+ {
+ id: 'pomodoro',
+ name: $r('app.string.prog_pomodoro'),
+ description: $r('app.string.prog_pomodoro_desc'),
+ icon: $r('sys.symbol.timer'),
+ focusMinutes: 25,
+ shortBreakMinutes: 5,
+ longBreakMinutes: 20,
+ cyclePattern: [
+ FocusType.focus, FocusType.shortBreak,
+ FocusType.focus, FocusType.shortBreak,
+ FocusType.focus, FocusType.longBreak
+ ]
+ },
+ {
+ id: 'deep_work',
+ name: $r('app.string.prog_deep_work'),
+ description: $r('app.string.prog_deep_work_desc'),
+ icon: $r('sys.symbol.flame_fill'),
+ focusMinutes: 90,
+ shortBreakMinutes: 20,
+ longBreakMinutes: 30,
+ cyclePattern: [
+ FocusType.focus, FocusType.shortBreak,
+ FocusType.focus, FocusType.longBreak
+ ]
+ },
+ {
+ id: 'sprint',
+ name: $r('app.string.prog_sprint'),
+ description: $r('app.string.prog_sprint_desc'),
+ icon: $r('sys.symbol.forward_end_fill'),
+ focusMinutes: 15,
+ shortBreakMinutes: 3,
+ longBreakMinutes: 10,
+ cyclePattern: [
+ FocusType.focus, FocusType.shortBreak,
+ FocusType.focus, FocusType.shortBreak,
+ FocusType.focus, FocusType.shortBreak,
+ FocusType.focus, FocusType.longBreak
+ ]
+ },
+ {
+ id: 'study',
+ name: $r('app.string.prog_study'),
+ description: $r('app.string.prog_study_desc'),
+ icon: $r('sys.symbol.list_bullet'),
+ focusMinutes: 50,
+ shortBreakMinutes: 10,
+ longBreakMinutes: 25,
+ cyclePattern: [
+ FocusType.focus, FocusType.shortBreak,
+ FocusType.focus, FocusType.longBreak
+ ]
+ }
+]
diff --git a/entry/src/main/ets/model/SessionRecord.ets b/entry/src/main/ets/model/SessionRecord.ets
new file mode 100644
index 0000000..141775e
--- /dev/null
+++ b/entry/src/main/ets/model/SessionRecord.ets
@@ -0,0 +1,11 @@
+import { FocusType } from './FocusType'
+
+export default interface SessionRecord {
+ id?: number
+ startTime: number
+ endTime: number
+ duration: number
+ focusType: FocusType
+ tag: string
+ completed: boolean
+}
diff --git a/entry/src/main/ets/model/TagModel.ets b/entry/src/main/ets/model/TagModel.ets
new file mode 100644
index 0000000..e9633c4
--- /dev/null
+++ b/entry/src/main/ets/model/TagModel.ets
@@ -0,0 +1,20 @@
+export interface Tag {
+ id: string
+ name: Resource
+ icon: Resource
+ color: string
+}
+
+export const TAG_COLORS: string[] = [
+ '#552586', '#2196F3', '#4CAF50', '#FF9800', '#E91E63',
+ '#00BCD4', '#795548', '#607D8B'
+]
+
+export const DEFAULT_TAGS: Tag[] = [
+ { id: 'work', name: $r('app.string.tag_work'), icon: $r('sys.symbol.briefcase'), color: '#552586' },
+ { id: 'study', name: $r('app.string.tag_study'), icon: $r('sys.symbol.list_bullet'), color: '#2196F3' },
+ { id: 'coding', name: $r('app.string.tag_coding'), icon: $r('sys.symbol.bolt_fill'), color: '#4CAF50' },
+ { id: 'exercise', name: $r('app.string.tag_exercise'), icon: $r('sys.symbol.figure_walk'), color: '#FF9800' },
+ { id: 'reading', name: $r('app.string.tag_reading'), icon: $r('sys.symbol.clock_fill'), color: '#E91E63' },
+ { id: 'meditation', name: $r('app.string.tag_meditation'), icon: $r('sys.symbol.moon_fill'), color: '#00BCD4' }
+]
diff --git a/entry/src/main/ets/model/ThemeModel.ets b/entry/src/main/ets/model/ThemeModel.ets
new file mode 100644
index 0000000..57d0c05
--- /dev/null
+++ b/entry/src/main/ets/model/ThemeModel.ets
@@ -0,0 +1,50 @@
+export interface ThemeColors {
+ gradient1: string
+ gradient2: string
+ gradient3: string
+ gradient4: string
+ gradient5: string
+}
+
+export interface AppTheme {
+ id: string
+ name: Resource
+ colors: ThemeColors
+}
+
+export const THEMES: AppTheme[] = [
+ {
+ id: 'purple',
+ name: $r('app.string.theme_purple'),
+ colors: { gradient1: '#B589D6', gradient2: '#9969C7', gradient3: '#804FB3', gradient4: '#6A359C', gradient5: '#552586' }
+ },
+ {
+ id: 'blue',
+ name: $r('app.string.theme_blue'),
+ colors: { gradient1: '#64B5F6', gradient2: '#42A5F5', gradient3: '#2196F3', gradient4: '#1E88E5', gradient5: '#1565C0' }
+ },
+ {
+ id: 'green',
+ name: $r('app.string.theme_green'),
+ colors: { gradient1: '#81C784', gradient2: '#66BB6A', gradient3: '#4CAF50', gradient4: '#43A047', gradient5: '#2E7D32' }
+ },
+ {
+ id: 'red',
+ name: $r('app.string.theme_red'),
+ colors: { gradient1: '#E57373', gradient2: '#EF5350', gradient3: '#F44336', gradient4: '#E53935', gradient5: '#C62828' }
+ },
+ {
+ id: 'orange',
+ name: $r('app.string.theme_orange'),
+ colors: { gradient1: '#FFB74D', gradient2: '#FFA726', gradient3: '#FF9800', gradient4: '#FB8C00', gradient5: '#E65100' }
+ },
+ {
+ id: 'cyan',
+ name: $r('app.string.theme_cyan'),
+ colors: { gradient1: '#4DD0E1', gradient2: '#26C6DA', gradient3: '#00BCD4', gradient4: '#00ACC1', gradient5: '#00838F' }
+ }
+]
+
+export function getThemeById(id: string): AppTheme {
+ return THEMES.find((t: AppTheme) => t.id === id) ?? THEMES[0]
+}
diff --git a/entry/src/main/ets/pages/AchievementsPage.ets b/entry/src/main/ets/pages/AchievementsPage.ets
new file mode 100644
index 0000000..4171da5
--- /dev/null
+++ b/entry/src/main/ets/pages/AchievementsPage.ets
@@ -0,0 +1,118 @@
+import { ACHIEVEMENTS, Achievement, AchievementProgress } from '../model/Achievement'
+import { AchievementService } from '../service/AchievementService'
+import BadgeCard from '../components/BadgeCard'
+import { SizeConstants } from '../constants/SizeConstants'
+
+@Component
+export struct AchievementsPage {
+ @Consume('pageInfos') pageInfos: NavPathStack
+ @State progressList: AchievementProgress[] = []
+ @State unlockedCount: number = 0
+
+ aboutToAppear() {
+ const service = AchievementService.getInstance()
+ this.progressList = service.getAllProgress()
+ this.unlockedCount = service.getUnlockedCount()
+ }
+
+ build() {
+ NavDestination() {
+ Column() {
+ // Header
+ Row() {
+ Button() {
+ SymbolGlyph($r('sys.symbol.chevron_left'))
+ .fontColor([Color.White])
+ .fontSize(16)
+ }
+ .width(SizeConstants.HEADER_BUTTON_SIZE)
+ .height(SizeConstants.HEADER_BUTTON_SIZE)
+ .borderRadius(SizeConstants.HEADER_BUTTON_RADIUS)
+ .backgroundColor('#333333')
+ .onClick(() => this.pageInfos.pop())
+
+ Text($r('app.string.achievements'))
+ .fontSize(15)
+ .fontColor(Color.White)
+ .fontWeight(FontWeight.Bold)
+ .layoutWeight(1)
+ .textAlign(TextAlign.Center)
+
+ Blank().width(SizeConstants.HEADER_BUTTON_SIZE)
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .padding({
+ left: SizeConstants.PAGE_SIDE_PADDING,
+ right: SizeConstants.PAGE_SIDE_PADDING,
+ top: SizeConstants.PAGE_HEADER_TOP_PADDING,
+ bottom: 6
+ })
+
+ Scroll() {
+ Column() {
+ // Progress summary
+ Row({ space: 8 }) {
+ Text(`${this.unlockedCount}`)
+ .fontSize(22)
+ .fontColor('#FFD700')
+ .fontWeight(FontWeight.Bold)
+ Text('/')
+ .fontSize(16)
+ .fontColor('#666666')
+ Text(`${ACHIEVEMENTS.length}`)
+ .fontSize(16)
+ .fontColor('#888888')
+ }
+ .margin({ top: 4, bottom: 4 })
+ .justifyContent(FlexAlign.Center)
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+
+ Row() {
+ Progress({ value: this.unlockedCount, total: ACHIEVEMENTS.length })
+ .width('80%')
+ .height(4)
+ .color('#FFD700')
+ .backgroundColor('#333333')
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .justifyContent(FlexAlign.Center)
+ .margin({ bottom: 12 })
+
+ Grid() {
+ ForEach(ACHIEVEMENTS, (achievement: Achievement, index: number) => {
+ GridItem() {
+ BadgeCard({
+ achievement: achievement,
+ progress: this.progressList[index] ?? {
+ achievementId: achievement.id,
+ currentValue: 0,
+ unlocked: false
+ }
+ })
+ }
+ }, (achievement: Achievement) => achievement.id)
+ }
+ .columnsTemplate('1fr 1fr 1fr')
+ .rowsGap(8)
+ .columnsGap(4)
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+
+ Blank().height(SizeConstants.PAGE_CONTENT_BOTTOM_PADDING)
+ }
+ .padding({
+ left: SizeConstants.PAGE_SIDE_PADDING,
+ right: SizeConstants.PAGE_SIDE_PADDING
+ })
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .layoutWeight(1)
+ .scrollBar(BarState.Auto)
+ .edgeEffect(EdgeEffect.Spring)
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .height(SizeConstants.FULL_HEIGHT_PERCENT)
+ .backgroundColor('#0D0D0D')
+ }
+ .hideTitleBar(true)
+ }
+}
diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets
index c2da792..05d303b 100644
--- a/entry/src/main/ets/pages/Index.ets
+++ b/entry/src/main/ets/pages/Index.ets
@@ -1,63 +1,261 @@
-import {
- ArcDirection,
- ArcDotIndicator,
- ArcSwiper,
- ArcSwiperAttribute
-} from '@kit.ArkUI'
-
import ProgressController from '../components/ProgressController'
import ProgressRing from '../components/ProgressRing'
import ProgressTimes from '../components/ProgressTimes'
+import BreathingExercise from '../components/BreathingExercise'
+import BreakSuggestions from '../components/BreakSuggestions'
+import CycleVisualizer from '../components/CycleVisualizer'
+import QuotesProvider from '../components/QuotesProvider'
+import TagSelector from '../components/TagSelector'
import Program from '../model/Program'
import Timer from '../model/Timer'
import { FocusType } from '../model/FocusType'
+import { DbService } from '../service/DbService'
+import { AchievementService } from '../service/AchievementService'
+import { DailyGoal } from '../model/GoalModel'
+import SessionRecord from '../model/SessionRecord'
+import { SessionCompleteDialog, AchievementUnlockDialog } from '../components/FocusDialog'
+import { ACHIEVEMENTS } from '../model/Achievement'
+import { SizeConstants } from '../constants/SizeConstants'
-@Entry
@Component
-struct Index {
- private arcDotIndicator: ArcDotIndicator = new ArcDotIndicator();
-
+export struct Index {
+ @Consume('pageInfos') pageInfos: NavPathStack
@State @Watch('updateFocus') program: Program = new Program()
- @State currentFocusType: FocusType = this.program.currentFocus.type;
+ @State currentFocusType: FocusType = this.program.currentFocus.type
@State timer: Timer = new Timer(this.program.currentFocus.value * 60, () => {
- this.program.skip()
+ this.onSessionComplete()
+ })
+ @State selectedTag: string = ''
+ @State showQuotes: boolean = (AppStorage.get('showQuotes') as boolean) ?? true
+ private sessionStartTime: number = 0
+
+ // Session complete dialog
+ private sessionCompleteDialogController: CustomDialogController = new CustomDialogController({
+ builder: SessionCompleteDialog({
+ focusType: this.currentFocusType,
+ duration: 0,
+ onDismiss: () => {}
+ }),
+ autoCancel: true,
+ customStyle: true,
+ alignment: DialogAlignment.Center
+ })
+
+ // Achievement unlock dialog
+ private achievementDialogController: CustomDialogController = new CustomDialogController({
+ builder: AchievementUnlockDialog({
+ achievementName: '',
+ achievementIcon: $r('sys.symbol.star_fill')
+ }),
+ autoCancel: true,
+ customStyle: true,
+ alignment: DialogAlignment.Center
})
updateFocus() {
const currentFocus = this.program.currentFocus;
- if (currentFocus.type !== this.currentFocusType) {
- // Current focus changed
+ const newGoal = currentFocus.value * 60
+ const typeChanged = currentFocus.type !== this.currentFocusType
+ const valueChanged = this.timer.goal !== newGoal
+ if (typeChanged || (valueChanged && !this.timer.running)) {
this.currentFocusType = currentFocus.type
- this.timer.refresh(currentFocus.value * 60)
+ this.timer.refresh(newGoal)
}
}
build() {
- ArcSwiper() {
- ProgressController({
- timer: this.timer,
- progressType: this.currentFocusType,
- program: this.program
- })
-
- ProgressRing({
- timer: this.timer,
- progressType: this.currentFocusType
- })
-
- ProgressTimes({
- program: this.program
- })
+ NavDestination() {
+ Stack() {
+ Swiper() {
+ // Page 0: Ring with quotes
+ Column() {
+ ProgressRing({
+ timer: this.timer,
+ progressType: this.currentFocusType,
+ })
+ .layoutWeight(1)
+
+ Column() {
+ if (this.showQuotes && !this.timer.running) {
+ QuotesProvider()
+ }
+ }
+ .width(SizeConstants.PERCENT_70)
+ .height(44)
+ .margin({ bottom: 12 })
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .height(SizeConstants.FULL_HEIGHT_PERCENT)
+ .alignItems(HorizontalAlign.Center)
+
+ // Page 1: Controller with tag selector
+ Column() {
+ ProgressController({
+ timer: this.timer,
+ progressType: this.currentFocusType,
+ program: this.program
+ })
+ .layoutWeight(1)
+
+ Column() {
+ if (this.currentFocusType === FocusType.focus) {
+ TagSelector({ selectedTagId: this.selectedTag })
+ }
+ }
+ .width(SizeConstants.PERCENT_80)
+ .height(72)
+ .margin({ bottom: 10 })
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .height(SizeConstants.FULL_HEIGHT_PERCENT)
+ .alignItems(HorizontalAlign.Center)
+
+ // Page 2: Time settings
+ ProgressTimes({
+ program: this.program,
+ onTimesChanged: () => {
+ if (!this.timer.running) {
+ this.timer.refresh(this.program.currentFocus.value * 60)
+ }
+ }
+ })
+
+ // Page 3: Cycle Visualizer
+ CycleVisualizer({
+ programCycle: this.program.program.map((t: FocusType) => t as number),
+ currentIndex: this.program.getProgramIndex(),
+ progress: this.timer.goal > 0 ? this.timer.progress / this.timer.goal : 0
+ })
+
+ // Page 4: Breathing Exercise (shown during breaks)
+ BreathingExercise()
+
+ // Page 5: Break Suggestions
+ BreakSuggestions()
+ }
+ .indicator(
+ new DotIndicator()
+ .color('#444444')
+ .selectedColor('#FFFFFF')
+ .itemWidth(4)
+ .itemHeight(4)
+ .selectedItemWidth(8)
+ .selectedItemHeight(4)
+ )
+
+ // Settings button (top-right)
+ Button() {
+ SymbolGlyph($r('sys.symbol.gearshape'))
+ .fontColor([Color.White])
+ .fontSize(16)
+ }
+ .width(SizeConstants.HEADER_BUTTON_SIZE)
+ .height(SizeConstants.HEADER_BUTTON_SIZE)
+ .borderRadius(SizeConstants.HEADER_BUTTON_RADIUS)
+ .backgroundColor('#33333380')
+ .position({ x: '62%', y: SizeConstants.FLOATING_BUTTON_TOP_OFFSET })
+ .onClick(() => {
+ this.pageInfos.pushPathByName('Settings', null)
+ })
+
+ // Stats button (top-left)
+ Button() {
+ SymbolGlyph($r('sys.symbol.list_bullet'))
+ .fontColor([Color.White])
+ .fontSize(16)
+ }
+ .width(SizeConstants.HEADER_BUTTON_SIZE)
+ .height(SizeConstants.HEADER_BUTTON_SIZE)
+ .borderRadius(SizeConstants.HEADER_BUTTON_RADIUS)
+ .backgroundColor('#33333380')
+ .position({ x: '24%', y: SizeConstants.FLOATING_BUTTON_TOP_OFFSET })
+ .onClick(() => {
+ this.pageInfos.pushPathByName('Stats', null)
+ })
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .height(SizeConstants.FULL_HEIGHT_PERCENT)
+ }
+ .hideTitleBar(true)
+ }
+
+ private async onSessionComplete() {
+ const endTime = Date.now()
+ const duration = this.timer.goal
+
+ // Record session in database
+ if (this.currentFocusType === FocusType.focus) {
+ const record: SessionRecord = {
+ startTime: this.sessionStartTime > 0 ? this.sessionStartTime : endTime - duration * 1000,
+ endTime: endTime,
+ duration: duration,
+ focusType: this.currentFocusType,
+ tag: this.selectedTag,
+ completed: true
+ }
+
+ const db = DbService.getInstance()
+ await db.insertSession(record)
+
+ // Update daily goal
+ const minutes = Math.floor(duration / 60)
+ const goalTarget: number = AppStorage.get('dailyGoalTarget') ?? 120
+ const goalCompleted: number = AppStorage.get('dailyGoalCompleted') ?? 0
+ const goalDate: string = AppStorage.get('dailyGoalDate') ?? ''
+ const today = DailyGoal.todayString()
+ let newCompleted = minutes
+ if (goalDate === today) {
+ newCompleted = goalCompleted + minutes
+ }
+ AppStorage.setOrCreate('dailyGoalCompleted', newCompleted)
+ AppStorage.setOrCreate('dailyGoalDate', today)
+
+ // Check if goal met
+ if (newCompleted >= goalTarget) {
+ await db.markGoalMet(today)
+ }
+
+ // Check early bird (before 7 AM)
+ const hour = new Date().getHours()
+ if (hour < 7) {
+ const earlyCount: number = AppStorage.get('earlyBirdCount') ?? 0
+ AppStorage.setOrCreate('earlyBirdCount', earlyCount + 1)
+ }
+
+ // Check achievements
+ const achievementService = AchievementService.getInstance()
+ const newlyUnlocked = await achievementService.checkAndUpdate()
+ if (newlyUnlocked.length > 0) {
+ const achId = newlyUnlocked[0]
+ const ach = ACHIEVEMENTS.find((a) => a.id === achId)
+ if (ach) {
+ this.achievementDialogController = new CustomDialogController({
+ builder: AchievementUnlockDialog({
+ achievementName: ach.name,
+ achievementIcon: ach.icon
+ }),
+ autoCancel: true,
+ customStyle: true,
+ alignment: DialogAlignment.Center
+ })
+ this.achievementDialogController.open()
+ }
+ }
}
- .indicator(
- this.arcDotIndicator
- .arcDirection(ArcDirection.SIX_CLOCK_DIRECTION)
- .maskColor(new LinearGradient([{
- color: Color.Transparent,
- offset: $r('app.float.offset_zero')
- }]))
- )
- .effectMode(EdgeEffect.None)
- .index(1)
+
+ // Show session complete dialog
+ this.sessionCompleteDialogController = new CustomDialogController({
+ builder: SessionCompleteDialog({
+ focusType: this.currentFocusType,
+ duration: duration,
+ onDismiss: () => {
+ this.program.skip()
+ }
+ }),
+ autoCancel: true,
+ customStyle: true,
+ alignment: DialogAlignment.Center
+ })
+ this.sessionCompleteDialogController.open()
}
-}
\ No newline at end of file
+}
diff --git a/entry/src/main/ets/pages/NavigationContainer.ets b/entry/src/main/ets/pages/NavigationContainer.ets
new file mode 100644
index 0000000..124cdd0
--- /dev/null
+++ b/entry/src/main/ets/pages/NavigationContainer.ets
@@ -0,0 +1,59 @@
+import { Index } from './Index'
+import { OnboardingPage } from './OnboardingPage'
+import { StatsPage } from './StatsPage'
+import { SettingsPage } from './SettingsPage'
+import { AchievementsPage } from './AchievementsPage'
+import { ProgramListPage } from './ProgramListPage'
+
+@Entry
+@Component
+struct NavigationContainer {
+ @Provide('pageInfos') pageInfos: NavPathStack = new NavPathStack()
+ private initialized: boolean = false
+
+ @Builder
+ routerMap(builderName: string, _param: object) {
+ if (builderName === 'Index') {
+ Index()
+ } else if (builderName === 'Onboarding') {
+ OnboardingPage()
+ } else if (builderName === 'Stats') {
+ StatsPage()
+ } else if (builderName === 'Settings') {
+ SettingsPage()
+ } else if (builderName === 'Achievements') {
+ AchievementsPage()
+ } else if (builderName === 'ProgramList') {
+ ProgramListPage()
+ }
+ }
+
+ aboutToAppear() {
+
+ this.pageInfos.pushPathByName('Index', null)
+
+ return;
+
+ if (this.initialized) {
+ return
+ }
+
+ this.initialized = true
+ const onboardingComplete = (AppStorage.get('onboardingComplete') as boolean) ?? false
+ const startPage = onboardingComplete ? 'Index' : 'Onboarding'
+
+ setTimeout(() => {
+ this.pageInfos.clear()
+ this.pageInfos.pushPathByName(startPage, null)
+ }, 0)
+ }
+
+ build() {
+ Navigation(this.pageInfos) {
+ Column() {}
+ }
+ .mode(NavigationMode.Stack)
+ .navDestination(this.routerMap)
+ .hideTitleBar(true)
+ }
+}
diff --git a/entry/src/main/ets/pages/OnboardingPage.ets b/entry/src/main/ets/pages/OnboardingPage.ets
new file mode 100644
index 0000000..cfcff71
--- /dev/null
+++ b/entry/src/main/ets/pages/OnboardingPage.ets
@@ -0,0 +1,232 @@
+import { SizeConstants } from '../constants/SizeConstants'
+import ThemePickerView from '../components/ThemePickerView'
+
+@Component
+export struct OnboardingPage {
+ @Consume('pageInfos') pageInfos: NavPathStack
+ @State private currentStep: number = 0
+ @State private selectedTheme: string = 'purple'
+ @State private dailyGoal: number = 120
+ @State private fadeIn: number = 0
+
+ aboutToAppear() {
+ this.getUIContext()?.animateTo({ duration: 600, curve: Curve.EaseOut }, () => {
+ this.fadeIn = 1
+ })
+ }
+
+ build() {
+ NavDestination() {
+ Column({ space: 12 }) {
+ Row({ space: 6 }) {
+ ForEach([0, 1, 2, 3], (step: number) => {
+ Circle()
+ .width(this.currentStep === step ? 10 : 6)
+ .height(6)
+ .fill(this.currentStep === step ? '#552586' : '#333333')
+ }, (step: number) => `${step}`)
+ }
+ .justifyContent(FlexAlign.Center)
+ .margin({ top: SizeConstants.PAGE_HEADER_TOP_PADDING })
+
+ Scroll() {
+ Column() {
+ if (this.currentStep === 0) {
+ this.welcomeStep()
+ } else if (this.currentStep === 1) {
+ this.howItWorksStep()
+ } else if (this.currentStep === 2) {
+ this.goalStep()
+ } else {
+ this.themeStep()
+ }
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ }
+ .layoutWeight(1)
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .scrollBar(BarState.Auto)
+ .edgeEffect(EdgeEffect.Spring)
+
+ Row({ space: 8 }) {
+ if (this.currentStep > 0) {
+ Button() {
+ Text('Back')
+ .fontColor('#CCCCCC')
+ .fontSize(11)
+ }
+ .layoutWeight(1)
+ .height(36)
+ .backgroundColor('#222222')
+ .borderRadius(18)
+ .onClick(() => {
+ this.changeStep(this.currentStep - 1)
+ })
+ }
+
+ Button() {
+ Text(this.currentStep === 3 ? $r('app.string.get_started') : $r('app.string.next'))
+ .fontColor(Color.White)
+ .fontSize(11)
+ }
+ .layoutWeight(1)
+ .height(36)
+ .backgroundColor('#552586')
+ .borderRadius(18)
+ .onClick(() => {
+ if (this.currentStep === 3) {
+ this.completeOnboarding()
+ } else {
+ this.changeStep(this.currentStep + 1)
+ }
+ })
+ }
+ .padding({
+ left: SizeConstants.PAGE_SIDE_PADDING + 8,
+ right: SizeConstants.PAGE_SIDE_PADDING + 8,
+ bottom: SizeConstants.PAGE_CONTENT_BOTTOM_PADDING - 16
+ })
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .height(SizeConstants.FULL_HEIGHT_PERCENT)
+ .backgroundColor('#0D0D0D')
+ }
+ .hideTitleBar(true)
+ }
+
+ @Builder
+ welcomeStep() {
+ Column({ space: 12 }) {
+ SymbolGlyph($r('sys.symbol.timer'))
+ .fontSize(40)
+ .fontColor(['#552586'])
+
+ Text($r('app.string.onboarding_welcome'))
+ .fontSize(16)
+ .fontColor(Color.White)
+ .fontWeight(FontWeight.Bold)
+ .textAlign(TextAlign.Center)
+
+ Text($r('app.string.onboarding_welcome_desc'))
+ .fontSize(11)
+ .fontColor('#AAAAAA')
+ .textAlign(TextAlign.Center)
+ .padding({ left: 16, right: 16 })
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .justifyContent(FlexAlign.Center)
+ .opacity(this.fadeIn)
+ .padding({ left: 16, right: 16, top: 24, bottom: 24 })
+ }
+
+ @Builder
+ howItWorksStep() {
+ Column({ space: 8 }) {
+ Text($r('app.string.onboarding_how_title'))
+ .fontSize(14)
+ .fontColor(Color.White)
+ .fontWeight(FontWeight.Bold)
+
+ this.stepItem('1', $r('app.string.onboarding_step1'), '#552586')
+ this.stepItem('2', $r('app.string.onboarding_step2'), '#4CAF50')
+ this.stepItem('3', $r('app.string.onboarding_step3'), '#2196F3')
+ this.stepItem('4', $r('app.string.onboarding_step4'), '#FF9800')
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .padding(16)
+ }
+
+ @Builder
+ goalStep() {
+ Column({ space: 12 }) {
+ SymbolGlyph($r('sys.symbol.clock_fill'))
+ .fontSize(32)
+ .fontColor(['#FF9800'])
+
+ Text($r('app.string.onboarding_goal_title'))
+ .fontSize(14)
+ .fontColor(Color.White)
+ .fontWeight(FontWeight.Bold)
+
+ Text(`${this.dailyGoal} min`)
+ .fontSize(28)
+ .fontColor('#FF9800')
+ .fontWeight(FontWeight.Bold)
+
+ Slider({
+ value: this.dailyGoal,
+ min: 30,
+ max: 300,
+ step: 15,
+ style: SliderStyle.InSet
+ })
+ .width('80%')
+ .trackColor('#333333')
+ .selectedColor('#552586')
+ .blockColor('#FFFFFF')
+ .onChange((value: number) => {
+ this.dailyGoal = value
+ })
+
+ Row({ space: 20 }) {
+ Text('30m').fontSize(9).fontColor('#888888')
+ Text('5h').fontSize(9).fontColor('#888888')
+ }
+ .width('80%')
+ .justifyContent(FlexAlign.SpaceBetween)
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .justifyContent(FlexAlign.Center)
+ .padding({ left: 16, right: 16, top: 24, bottom: 24 })
+ }
+
+ @Builder
+ themeStep() {
+ Column({ space: 12 }) {
+ Text($r('app.string.onboarding_theme_title'))
+ .fontSize(14)
+ .fontColor(Color.White)
+ .fontWeight(FontWeight.Bold)
+
+ ThemePickerView({ selectedThemeId: this.selectedTheme })
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .justifyContent(FlexAlign.Center)
+ .padding({ left: 16, right: 16, top: 24, bottom: 24 })
+ }
+
+ @Builder
+ stepItem(num: string, text: Resource, color: string) {
+ Row({ space: 8 }) {
+ Stack() {
+ Circle().width(24).height(24).fill(color)
+ Text(num)
+ .fontSize(12)
+ .fontColor(Color.White)
+ .fontWeight(FontWeight.Bold)
+ }
+
+ Text(text)
+ .fontSize(10)
+ .fontColor('#CCCCCC')
+ .layoutWeight(1)
+ }
+ .width('100%')
+ .padding(4)
+ }
+
+ private changeStep(index: number) {
+ this.getUIContext()?.animateTo({ duration: 300, curve: Curve.EaseInOut }, () => {
+ this.currentStep = index
+ })
+ }
+
+ private completeOnboarding() {
+ AppStorage.setOrCreate('onboardingComplete', true)
+ AppStorage.setOrCreate('dailyGoalTarget', this.dailyGoal)
+ AppStorage.setOrCreate('selectedTheme', this.selectedTheme)
+ this.pageInfos.clear()
+ this.pageInfos.pushPathByName('Index', null)
+ }
+}
diff --git a/entry/src/main/ets/pages/ProgramListPage.ets b/entry/src/main/ets/pages/ProgramListPage.ets
new file mode 100644
index 0000000..fae0b84
--- /dev/null
+++ b/entry/src/main/ets/pages/ProgramListPage.ets
@@ -0,0 +1,156 @@
+import { PROGRAM_TEMPLATES, ProgramTemplate } from '../model/ProgramTemplate'
+import { FocusType } from '../model/FocusType'
+import { SizeConstants } from '../constants/SizeConstants'
+
+@Component
+export struct ProgramListPage {
+ @Consume('pageInfos') pageInfos: NavPathStack
+ @State selectedProgramId: string = (AppStorage.get('selectedProgram') as string) ?? 'pomodoro'
+ @State programs: ProgramTemplate[] = PROGRAM_TEMPLATES
+
+ build() {
+ NavDestination() {
+ Column() {
+ // Header
+ Row() {
+ Button() {
+ SymbolGlyph($r('sys.symbol.chevron_left'))
+ .fontColor([Color.White])
+ .fontSize(16)
+ }
+ .width(SizeConstants.HEADER_BUTTON_SIZE)
+ .height(SizeConstants.HEADER_BUTTON_SIZE)
+ .borderRadius(SizeConstants.HEADER_BUTTON_RADIUS)
+ .backgroundColor('#333333')
+ .onClick(() => this.pageInfos.pop())
+
+ Text($r('app.string.programs'))
+ .fontSize(15)
+ .fontColor(Color.White)
+ .fontWeight(FontWeight.Bold)
+ .layoutWeight(1)
+ .textAlign(TextAlign.Center)
+
+ Blank().width(SizeConstants.HEADER_BUTTON_SIZE)
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .padding({
+ left: SizeConstants.PAGE_SIDE_PADDING,
+ right: SizeConstants.PAGE_SIDE_PADDING,
+ top: SizeConstants.PAGE_HEADER_TOP_PADDING,
+ bottom: 6
+ })
+
+ // Program list
+ List({ space: 8 }) {
+ ForEach(this.programs, (prog: ProgramTemplate) => {
+ ListItem() {
+ Row({ space: 10 }) {
+ Stack() {
+ Circle()
+ .width(40)
+ .height(40)
+ .fill(this.selectedProgramId === prog.id ? '#552586' : '#2A2A2A')
+
+ SymbolGlyph(prog.icon)
+ .fontSize(18)
+ .fontColor([this.selectedProgramId === prog.id ? Color.White : '#AAAAAA'])
+ }
+
+ Column({ space: 3 }) {
+ Text(prog.name)
+ .fontSize(12)
+ .fontColor(Color.White)
+ .fontWeight(FontWeight.Medium)
+
+ Text(prog.description)
+ .fontSize(9)
+ .fontColor('#888888')
+ .maxLines(1)
+ .textOverflow({ overflow: TextOverflow.Ellipsis })
+
+ // Cycle preview dots
+ Row({ space: 3 }) {
+ ForEach(prog.cyclePattern, (type: FocusType, index: number) => {
+ Circle()
+ .width(6)
+ .height(6)
+ .fill(this.getTypeColor(type))
+ }, (_type: FocusType, index: number) => `${prog.id}_${index}`)
+ }
+
+ // Duration info
+ Text(`${prog.focusMinutes}m / ${prog.shortBreakMinutes}m / ${prog.longBreakMinutes}m`)
+ .fontSize(8)
+ .fontColor('#666666')
+ }
+ .layoutWeight(1)
+ .alignItems(HorizontalAlign.Start)
+
+ if (this.selectedProgramId === prog.id) {
+ SymbolGlyph($r('sys.symbol.checkmark_circle_fill'))
+ .fontSize(18)
+ .fontColor(['#4CAF50'])
+ }
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .padding(10)
+ .backgroundColor(this.selectedProgramId === prog.id ? '#1E1040' : '#1A1A1A')
+ .borderRadius(12)
+ .border({
+ width: this.selectedProgramId === prog.id ? 1 : 0,
+ color: '#552586'
+ })
+ .onClick(() => {
+ this.getUIContext()?.animateTo({ duration: 200, curve: Curve.EaseInOut }, () => {
+ this.selectedProgramId = prog.id
+ this.applyProgram(prog)
+ })
+ })
+ }
+ }, (prog: ProgramTemplate) => prog.id)
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .layoutWeight(1)
+ .padding({
+ left: SizeConstants.PAGE_SIDE_PADDING,
+ right: SizeConstants.PAGE_SIDE_PADDING,
+ bottom: SizeConstants.PAGE_CONTENT_BOTTOM_PADDING
+ })
+ .scrollBar(BarState.Auto)
+ .edgeEffect(EdgeEffect.Spring)
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .height(SizeConstants.FULL_HEIGHT_PERCENT)
+ .backgroundColor('#0D0D0D')
+ }
+ .hideTitleBar(true)
+ }
+
+ private getTypeColor(type: FocusType): string {
+ switch (type) {
+ case FocusType.focus: return '#552586'
+ case FocusType.shortBreak: return '#4CAF50'
+ case FocusType.longBreak: return '#2196F3'
+ default: return '#552586'
+ }
+ }
+
+ private applyProgram(prog: ProgramTemplate) {
+ AppStorage.setOrCreate('selectedProgram', prog.id)
+ const times = new Map([
+ [FocusType.focus, prog.focusMinutes],
+ [FocusType.shortBreak, prog.shortBreakMinutes],
+ [FocusType.longBreak, prog.longBreakMinutes]
+ ])
+ AppStorage.setOrCreate('defaultTimes', times)
+ AppStorage.setOrCreate('programIndex', 0)
+ // Track programs used for achievement
+ const usedPrograms: string = AppStorage.get('usedProgramsList') ?? ''
+ if (!usedPrograms.includes(prog.id)) {
+ const updated = usedPrograms ? `${usedPrograms},${prog.id}` : prog.id
+ AppStorage.setOrCreate('usedProgramsList', updated)
+ AppStorage.setOrCreate('programsUsedCount', updated.split(',').length)
+ }
+ }
+}
diff --git a/entry/src/main/ets/pages/SettingsPage.ets b/entry/src/main/ets/pages/SettingsPage.ets
new file mode 100644
index 0000000..f635504
--- /dev/null
+++ b/entry/src/main/ets/pages/SettingsPage.ets
@@ -0,0 +1,225 @@
+import ThemePickerView from '../components/ThemePickerView'
+import IntensitySelector from '../components/IntensitySelector'
+import { IntensityLevel } from '../model/IntensityModel'
+import { SizeConstants } from '../constants/SizeConstants'
+
+@Component
+export struct SettingsPage {
+ @Consume('pageInfos') pageInfos: NavPathStack
+ @State selectedTheme: string = (AppStorage.get('selectedTheme') as string) ?? 'purple'
+ @State selectedIntensity: IntensityLevel = (AppStorage.get('selectedIntensity') as IntensityLevel) ?? IntensityLevel.normal
+ @State dailyGoalTarget: number = (AppStorage.get('dailyGoalTarget') as number) ?? 120
+ @State autoSkip: boolean = (AppStorage.get('autoSkip') as boolean) ?? false
+ @State showQuotes: boolean = (AppStorage.get('showQuotes') as boolean) ?? true
+
+ build() {
+ NavDestination() {
+ Column() {
+ // Header
+ Row() {
+ Button() {
+ SymbolGlyph($r('sys.symbol.chevron_left'))
+ .fontColor([Color.White])
+ .fontSize(16)
+ }
+ .width(SizeConstants.HEADER_BUTTON_SIZE)
+ .height(SizeConstants.HEADER_BUTTON_SIZE)
+ .borderRadius(SizeConstants.HEADER_BUTTON_RADIUS)
+ .backgroundColor('#333333')
+ .onClick(() => this.pageInfos.pop())
+
+ Text($r('app.string.settings'))
+ .fontSize(15)
+ .fontColor(Color.White)
+ .fontWeight(FontWeight.Bold)
+ .layoutWeight(1)
+ .textAlign(TextAlign.Center)
+
+ Blank().width(SizeConstants.HEADER_BUTTON_SIZE)
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .padding({
+ left: SizeConstants.PAGE_SIDE_PADDING,
+ right: SizeConstants.PAGE_SIDE_PADDING,
+ top: SizeConstants.PAGE_HEADER_TOP_PADDING,
+ bottom: 6
+ })
+
+ Scroll() {
+ Column({ space: 4 }) {
+ // Timer Settings Group
+ this.sectionHeader($r('app.string.settings_timer'))
+
+ Column({ space: 0 }) {
+ Row() {
+ Text($r('app.string.daily_goal'))
+ .fontSize(11)
+ .fontColor(Color.White)
+ .layoutWeight(1)
+ Text(`${this.dailyGoalTarget} min`)
+ .fontSize(11)
+ .fontColor('#AAAAAA')
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .padding(10)
+
+ Divider().color('#333333')
+
+ Column({ space: 8 }) {
+ Slider({
+ value: this.dailyGoalTarget,
+ min: 30,
+ max: 300,
+ step: 15,
+ style: SliderStyle.InSet
+ })
+ .trackColor('#333333')
+ .selectedColor('#552586')
+ .blockColor('#FFFFFF')
+ .onChange((value: number) => {
+ this.dailyGoalTarget = value
+ AppStorage.setOrCreate('dailyGoalTarget', value)
+ })
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .padding({ left: 10, right: 10, bottom: 8 })
+
+ Divider().color('#333333')
+
+ Row() {
+ Text($r('app.string.auto_skip'))
+ .fontSize(11)
+ .fontColor(Color.White)
+ .layoutWeight(1)
+ Toggle({ type: ToggleType.Switch, isOn: this.autoSkip })
+ .selectedColor('#552586')
+ .onChange((isOn: boolean) => {
+ this.autoSkip = isOn
+ AppStorage.setOrCreate('autoSkip', isOn)
+ })
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .padding(10)
+
+ Divider().color('#333333')
+
+ Row() {
+ Text($r('app.string.show_quotes'))
+ .fontSize(11)
+ .fontColor(Color.White)
+ .layoutWeight(1)
+ Toggle({ type: ToggleType.Switch, isOn: this.showQuotes })
+ .selectedColor('#552586')
+ .onChange((isOn: boolean) => {
+ this.showQuotes = isOn
+ AppStorage.setOrCreate('showQuotes', isOn)
+ })
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .padding(10)
+ }
+ .borderRadius(12)
+ .backgroundColor('#1A1A1A')
+
+ // Intensity
+ this.sectionHeader($r('app.string.intensity'))
+ Column() {
+ IntensitySelector({ selectedIntensity: this.selectedIntensity })
+ }
+ .backgroundColor('#1A1A1A')
+ .borderRadius(12)
+ .padding(10)
+
+ // Appearance Group
+ this.sectionHeader($r('app.string.settings_appearance'))
+ Column() {
+ ThemePickerView({ selectedThemeId: this.selectedTheme })
+ }
+ .backgroundColor('#1A1A1A')
+ .borderRadius(12)
+ .padding(10)
+
+ // Navigation links
+ this.sectionHeader($r('app.string.settings_more'))
+
+ Column() {
+ this.navItem($r('sys.symbol.list_bullet'), $r('app.string.statistics'), '#2196F3', () => {
+ this.pageInfos.pushPathByName('Stats', null)
+ })
+ Divider().color('#333333')
+ this.navItem($r('sys.symbol.star_fill'), $r('app.string.achievements'), '#FFD700', () => {
+ this.pageInfos.pushPathByName('Achievements', null)
+ })
+ Divider().color('#333333')
+ this.navItem($r('sys.symbol.list_bullet'), $r('app.string.programs'), '#4CAF50', () => {
+ this.pageInfos.pushPathByName('ProgramList', null)
+ })
+ }
+ .backgroundColor('#1A1A1A')
+ .borderRadius(12)
+
+ // About
+ this.sectionHeader($r('app.string.about'))
+ Column() {
+ Row() {
+ Text($r('app.string.version'))
+ .fontSize(11)
+ .fontColor(Color.White)
+ .layoutWeight(1)
+ Text('1.0.0')
+ .fontSize(11)
+ .fontColor('#888888')
+ }
+ .padding(10)
+ }
+ .backgroundColor('#1A1A1A')
+ .borderRadius(12)
+ }
+ .padding({
+ left: SizeConstants.PAGE_SIDE_PADDING,
+ right: SizeConstants.PAGE_SIDE_PADDING,
+ bottom: SizeConstants.PAGE_CONTENT_BOTTOM_PADDING
+ })
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .layoutWeight(1)
+ .scrollBar(BarState.Auto)
+ .edgeEffect(EdgeEffect.Spring)
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .height(SizeConstants.FULL_HEIGHT_PERCENT)
+ .backgroundColor('#0D0D0D')
+ }
+ .hideTitleBar(true)
+ }
+
+ @Builder
+ sectionHeader(title: Resource) {
+ Text(title)
+ .fontSize(10)
+ .fontColor('#888888')
+ .fontWeight(FontWeight.Medium)
+ .padding({ left: 4, top: 8, bottom: 4 })
+ }
+
+ @Builder
+ navItem(icon: Resource, label: Resource, color: string, action: () => void) {
+ Row({ space: 8 }) {
+ SymbolGlyph(icon)
+ .fontSize(16)
+ .fontColor([color])
+
+ Text(label)
+ .fontSize(11)
+ .fontColor(Color.White)
+ .layoutWeight(1)
+
+ SymbolGlyph($r('sys.symbol.chevron_right'))
+ .fontSize(12)
+ .fontColor(['#666666'])
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .padding(10)
+ .onClick(action)
+ }
+}
diff --git a/entry/src/main/ets/pages/StatsPage.ets b/entry/src/main/ets/pages/StatsPage.ets
new file mode 100644
index 0000000..24615ca
--- /dev/null
+++ b/entry/src/main/ets/pages/StatsPage.ets
@@ -0,0 +1,199 @@
+import StatsChart from '../components/StatsChart'
+import CalendarHeatmap from '../components/CalendarHeatmap'
+import GoalRingView from '../components/GoalRingView'
+import { DailyGoal } from '../model/GoalModel'
+import { DbService } from '../service/DbService'
+import { StreakTracker } from '../service/StreakTracker'
+import { SizeConstants } from '../constants/SizeConstants'
+
+@Component
+export struct StatsPage {
+ @Consume('pageInfos') pageInfos: NavPathStack
+ @State weeklyData: number[] = [0, 0, 0, 0, 0, 0, 0]
+ @State tagData: string[] = []
+ @State heatmapData: string[] = []
+ @State totalSessions: number = 0
+ @State totalMinutes: number = 0
+ @State currentStreak: number = 0
+ @State maxStreak: number = 0
+ @State goal: DailyGoal = new DailyGoal()
+
+ async aboutToAppear() {
+ await this.loadStats()
+ }
+
+ build() {
+ NavDestination() {
+ Column() {
+ // Header
+ Row() {
+ Button() {
+ SymbolGlyph($r('sys.symbol.chevron_left'))
+ .fontColor([Color.White])
+ .fontSize(16)
+ }
+ .width(SizeConstants.HEADER_BUTTON_SIZE)
+ .height(SizeConstants.HEADER_BUTTON_SIZE)
+ .borderRadius(SizeConstants.HEADER_BUTTON_RADIUS)
+ .backgroundColor('#333333')
+ .onClick(() => this.pageInfos.pop())
+
+ Text($r('app.string.statistics'))
+ .fontSize(15)
+ .fontColor(Color.White)
+ .fontWeight(FontWeight.Bold)
+ .layoutWeight(1)
+ .textAlign(TextAlign.Center)
+
+ Blank().width(SizeConstants.HEADER_BUTTON_SIZE)
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .padding({
+ left: SizeConstants.PAGE_SIDE_PADDING,
+ right: SizeConstants.PAGE_SIDE_PADDING,
+ top: SizeConstants.PAGE_HEADER_TOP_PADDING,
+ bottom: 6
+ })
+
+ Scroll() {
+ Column({ space: 14 }) {
+ // Goal ring card
+ Column() {
+ GoalRingView({ goal: this.goal })
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .padding({ top: 8, bottom: 12 })
+ .backgroundColor('#141414')
+ .borderRadius(16)
+
+ // Summary stats
+ Row({ space: 8 }) {
+ this.statCard($r('sys.symbol.play_circle_fill'), $r('app.string.sessions'), `${this.totalSessions}`, '#9969C7')
+ this.statCard($r('sys.symbol.clock_fill'), $r('app.string.minutes_label'), `${this.totalMinutes}`, '#64B5F6')
+ }
+
+ Row({ space: 8 }) {
+ this.statCard($r('sys.symbol.flame_fill'), $r('app.string.streak'), `${this.currentStreak}d`, '#FFB74D')
+ this.statCard($r('sys.symbol.star_trophy_fill'), $r('app.string.best_streak'), `${this.maxStreak}d`, '#81C784')
+ }
+
+ // Weekly chart
+ this.sectionHeader($r('app.string.weekly_chart'))
+ Column() {
+ StatsChart({ weeklyData: this.weeklyData, tagData: this.tagData })
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .backgroundColor('#141414')
+ .borderRadius(16)
+
+ // Calendar heatmap
+ this.sectionHeader($r('app.string.calendar_heatmap'))
+ Column() {
+ CalendarHeatmap({ dailyData: this.heatmapData })
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .backgroundColor('#141414')
+ .borderRadius(16)
+ }
+ .padding({
+ left: SizeConstants.PAGE_SIDE_PADDING,
+ right: SizeConstants.PAGE_SIDE_PADDING,
+ bottom: SizeConstants.PAGE_CONTENT_BOTTOM_PADDING
+ })
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .layoutWeight(1)
+ .scrollBar(BarState.Auto)
+ .edgeEffect(EdgeEffect.Spring)
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .height(SizeConstants.FULL_HEIGHT_PERCENT)
+ .backgroundColor('#0D0D0D')
+ }
+ .hideTitleBar(true)
+ }
+
+ @Builder
+ sectionHeader(title: Resource) {
+ Row() {
+ Text(title)
+ .fontSize(11)
+ .fontColor('#888888')
+ .fontWeight(FontWeight.Medium)
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .padding({ left: 6, top: 4 })
+ }
+
+ @Builder
+ statCard(icon: Resource, label: Resource, value: string, color: string) {
+ Column({ space: 4 }) {
+ Row({ space: 6 }) {
+ SymbolGlyph(icon)
+ .fontSize(14)
+ .fontColor([color])
+ Text(label)
+ .fontSize(10)
+ .fontColor('#888888')
+ .maxLines(1)
+ .textOverflow({ overflow: TextOverflow.Ellipsis })
+ .layoutWeight(1)
+ }
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+
+ Text(value)
+ .fontSize(20)
+ .fontColor(Color.White)
+ .fontWeight(FontWeight.Bold)
+ .width(SizeConstants.FULL_WIDTH_PERCENT)
+ .textAlign(TextAlign.Start)
+ }
+ .layoutWeight(1)
+ .padding({ left: 10, right: 10, top: 10, bottom: 10 })
+ .backgroundColor('#141414')
+ .borderRadius(14)
+ .border({ width: 1, color: '#1F1F1F' })
+ }
+
+ private async loadStats() {
+ const db = DbService.getInstance()
+ const streak = StreakTracker.getInstance()
+
+ this.totalSessions = await db.getTotalSessionCount()
+ this.totalMinutes = await db.getTotalFocusMinutes()
+ this.weeklyData = await db.getWeeklyStats()
+
+ await streak.refresh()
+ this.currentStreak = streak.currentStreak
+ this.maxStreak = streak.maxStreak
+
+ // Load tag distribution
+ const tagDist = await db.getTagDistribution()
+ const tagArr: string[] = []
+ tagDist.forEach((minutes: number, tag: string) => {
+ tagArr.push(`${tag}:${minutes}`)
+ })
+ this.tagData = tagArr
+
+ // Load heatmap data (last 3 months)
+ const endDate = DailyGoal.todayString()
+ const start = new Date()
+ start.setDate(start.getDate() - 98)
+ const startDate = `${start.getFullYear()}-${(start.getMonth() + 1).toString().padStart(2, '0')}-${start.getDate().toString().padStart(2, '0')}`
+ const dailyStats = await db.getDailyStatsForRange(startDate, endDate)
+ const heatArr: string[] = []
+ dailyStats.forEach((minutes: number, date: string) => {
+ heatArr.push(`${date}:${minutes}`)
+ })
+ this.heatmapData = heatArr
+
+ // Load goal
+ const savedTarget: number = AppStorage.get('dailyGoalTarget') ?? 120
+ const savedCompleted: number = AppStorage.get('dailyGoalCompleted') ?? 0
+ const savedDate: string = AppStorage.get('dailyGoalDate') ?? ''
+ this.goal.targetMinutes = savedTarget
+ if (savedDate === DailyGoal.todayString()) {
+ this.goal.completedMinutes = savedCompleted
+ }
+ }
+}
diff --git a/entry/src/main/ets/service/AchievementService.ets b/entry/src/main/ets/service/AchievementService.ets
new file mode 100644
index 0000000..d6548f1
--- /dev/null
+++ b/entry/src/main/ets/service/AchievementService.ets
@@ -0,0 +1,111 @@
+import { ACHIEVEMENTS, Achievement, AchievementProgress, AchievementCategory } from '../model/Achievement'
+import { DbService } from './DbService'
+
+export class AchievementService {
+ private static instance: AchievementService | null = null
+ private progressMap: Map = new Map()
+
+ static getInstance(): AchievementService {
+ if (!AchievementService.instance) {
+ AchievementService.instance = new AchievementService()
+ }
+ return AchievementService.instance
+ }
+
+ initialize() {
+ const saved: string | undefined = AppStorage.get('achievementProgress')
+ if (saved) {
+ try {
+ const entries: AchievementProgress[] = JSON.parse(saved) as AchievementProgress[]
+ entries.forEach((p: AchievementProgress) => {
+ this.progressMap.set(p.achievementId, p)
+ })
+ } catch (_e) {
+ // ignore parse errors
+ }
+ }
+ }
+
+ private save() {
+ const entries: AchievementProgress[] = []
+ this.progressMap.forEach((v: AchievementProgress) => entries.push(v))
+ AppStorage.setOrCreate('achievementProgress', JSON.stringify(entries))
+ }
+
+ getProgress(achievementId: string): AchievementProgress {
+ return this.progressMap.get(achievementId) ?? {
+ achievementId: achievementId,
+ currentValue: 0,
+ unlocked: false
+ }
+ }
+
+ getAllProgress(): AchievementProgress[] {
+ const result: AchievementProgress[] = []
+ ACHIEVEMENTS.forEach((a: Achievement) => {
+ result.push(this.getProgress(a.id))
+ })
+ return result
+ }
+
+ getUnlockedCount(): number {
+ let count = 0
+ this.progressMap.forEach((p: AchievementProgress) => {
+ if (p.unlocked) count++
+ })
+ return count
+ }
+
+ async checkAndUpdate(): Promise {
+ const db = DbService.getInstance()
+ const newlyUnlocked: string[] = []
+
+ const totalSessions = await db.getTotalSessionCount()
+ const totalMinutes = await db.getTotalFocusMinutes()
+ const streak = await db.getStreakDays()
+ const tagCount = await db.getDistinctTagCount()
+ const themesUsed: number = AppStorage.get('themesUsedCount') ?? 1
+ const programsUsed: number = AppStorage.get('programsUsedCount') ?? 1
+ const earlyBirdCount: number = AppStorage.get('earlyBirdCount') ?? 0
+
+ for (const achievement of ACHIEVEMENTS) {
+ let currentValue = 0
+ switch (achievement.category) {
+ case AchievementCategory.sessions:
+ currentValue = totalSessions
+ break
+ case AchievementCategory.minutes:
+ currentValue = totalMinutes
+ break
+ case AchievementCategory.streak:
+ currentValue = streak
+ break
+ case AchievementCategory.exploration:
+ if (achievement.id === 'all_tags') currentValue = tagCount
+ else if (achievement.id === 'theme_changer') currentValue = themesUsed
+ else if (achievement.id === 'all_programs') currentValue = programsUsed
+ else if (achievement.id === 'early_bird') currentValue = earlyBirdCount
+ break
+ }
+
+ const existing = this.getProgress(achievement.id)
+ const wasUnlocked = existing.unlocked
+ const isNowUnlocked = currentValue >= achievement.targetValue
+
+ const updated: AchievementProgress = {
+ achievementId: achievement.id,
+ currentValue: currentValue,
+ unlocked: isNowUnlocked,
+ unlockedDate: isNowUnlocked && !wasUnlocked ? Date.now() : existing.unlockedDate
+ }
+ this.progressMap.set(achievement.id, updated)
+
+ if (isNowUnlocked && !wasUnlocked) {
+ newlyUnlocked.push(achievement.id)
+ }
+ }
+
+ this.save()
+ return newlyUnlocked
+ }
+}
diff --git a/entry/src/main/ets/service/DbService.ets b/entry/src/main/ets/service/DbService.ets
new file mode 100644
index 0000000..7626692
--- /dev/null
+++ b/entry/src/main/ets/service/DbService.ets
@@ -0,0 +1,349 @@
+import { relationalStore, ValuesBucket } from '@kit.ArkData'
+import { hilog } from '@kit.PerformanceAnalysisKit'
+import { BusinessError } from '@kit.BasicServicesKit'
+import SessionRecord from '../model/SessionRecord'
+import { FocusType } from '../model/FocusType'
+
+const TAG = 'DbService'
+const DB_NAME = 'FocusTimer.db'
+
+const CREATE_SESSIONS_TABLE = `CREATE TABLE IF NOT EXISTS sessions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ start_time INTEGER NOT NULL,
+ end_time INTEGER NOT NULL,
+ duration INTEGER NOT NULL,
+ focus_type INTEGER NOT NULL,
+ tag TEXT NOT NULL DEFAULT '',
+ completed INTEGER NOT NULL DEFAULT 1
+)`
+
+const CREATE_DAILY_STATS_TABLE = `CREATE TABLE IF NOT EXISTS daily_stats (
+ date TEXT PRIMARY KEY,
+ total_minutes INTEGER NOT NULL DEFAULT 0,
+ session_count INTEGER NOT NULL DEFAULT 0,
+ goal_met INTEGER NOT NULL DEFAULT 0
+)`
+
+export class DbService {
+ private rdbStore: relationalStore.RdbStore | null = null
+ private static instance: DbService | null = null
+
+ static getInstance(): DbService {
+ if (!DbService.instance) {
+ DbService.instance = new DbService()
+ }
+ return DbService.instance
+ }
+
+ async initialize(context: Context): Promise {
+ const config: relationalStore.StoreConfig = {
+ name: DB_NAME,
+ securityLevel: relationalStore.SecurityLevel.S1
+ }
+ try {
+ this.rdbStore = await relationalStore.getRdbStore(context, config)
+ await this.rdbStore.executeSql(CREATE_SESSIONS_TABLE)
+ await this.rdbStore.executeSql(CREATE_DAILY_STATS_TABLE)
+ hilog.info(0x0000, TAG, 'Database initialized successfully')
+ } catch (err) {
+ this.logError('Failed to initialize database', err)
+ }
+ }
+
+ async insertSession(record: SessionRecord): Promise {
+ if (!this.rdbStore) return -1
+
+ const bucket: ValuesBucket = {
+ 'start_time': record.startTime,
+ 'end_time': record.endTime,
+ 'duration': record.duration,
+ 'focus_type': record.focusType as number,
+ 'tag': record.tag,
+ 'completed': record.completed ? 1 : 0
+ }
+
+ try {
+ const rowId = await this.rdbStore.insert('sessions', bucket)
+ hilog.info(0x0000, TAG, 'Session inserted with id: %{public}d', rowId)
+ await this.updateDailyStats(record)
+ return rowId
+ } catch (err) {
+ this.logError('Failed to insert session', err)
+ return -1
+ }
+ }
+
+ private async updateDailyStats(record: SessionRecord): Promise {
+ if (!this.rdbStore) return
+
+ const date = this.getDateString(record.startTime)
+ const minutes = Math.floor(record.duration / 60)
+
+ try {
+ const predicates = new relationalStore.RdbPredicates('daily_stats')
+ predicates.equalTo('date', date)
+
+ const result = await this.rdbStore.query(predicates, ['date', 'total_minutes', 'session_count'])
+ if (result.goToFirstRow()) {
+ const currentMinutes = result.getLong(result.getColumnIndex('total_minutes'))
+ const currentCount = result.getLong(result.getColumnIndex('session_count'))
+ const bucket: ValuesBucket = {
+ 'total_minutes': currentMinutes + minutes,
+ 'session_count': currentCount + 1
+ }
+ result.close()
+ await this.rdbStore.update(bucket, predicates)
+ } else {
+ result.close()
+ const bucket: ValuesBucket = {
+ 'date': date,
+ 'total_minutes': minutes,
+ 'session_count': 1,
+ 'goal_met': 0
+ }
+ await this.rdbStore.insert('daily_stats', bucket)
+ }
+ } catch (err) {
+ this.logError('Failed to update daily stats', err)
+ }
+ }
+
+ async markGoalMet(date: string): Promise {
+ if (!this.rdbStore) return
+
+ try {
+ const predicates = new relationalStore.RdbPredicates('daily_stats')
+ predicates.equalTo('date', date)
+ const bucket: ValuesBucket = { 'goal_met': 1 }
+ await this.rdbStore.update(bucket, predicates)
+ } catch (err) {
+ this.logError('Failed to mark goal as met', err)
+ }
+ }
+
+ async getSessionsForDate(date: string): Promise {
+ if (!this.rdbStore) return []
+
+ try {
+ const dayStart = new Date(date).getTime()
+ const dayEnd = dayStart + 86400000
+ const predicates = new relationalStore.RdbPredicates('sessions')
+ predicates.greaterThanOrEqualTo('start_time', dayStart)
+ predicates.lessThan('start_time', dayEnd)
+ predicates.orderByDesc('start_time')
+ return await this.querySessionsWithPredicates(predicates)
+ } catch (err) {
+ this.logError('Failed to get sessions for date', err)
+ return []
+ }
+ }
+
+ async getRecentSessions(limit: number = 50): Promise {
+ if (!this.rdbStore) return []
+
+ try {
+ const predicates = new relationalStore.RdbPredicates('sessions')
+ predicates.orderByDesc('start_time')
+ predicates.limitAs(limit)
+ return await this.querySessionsWithPredicates(predicates)
+ } catch (err) {
+ this.logError('Failed to get recent sessions', err)
+ return []
+ }
+ }
+
+ async getTotalSessionCount(): Promise {
+ if (!this.rdbStore) return 0
+
+ try {
+ const predicates = new relationalStore.RdbPredicates('sessions')
+ predicates.equalTo('completed', 1)
+ const result = await this.rdbStore.query(predicates, ['id'])
+ const count = result.rowCount
+ result.close()
+ return count
+ } catch (err) {
+ this.logError('Failed to get total session count', err)
+ return 0
+ }
+ }
+
+ async getTotalFocusMinutes(): Promise {
+ if (!this.rdbStore) return 0
+
+ try {
+ const predicates = new relationalStore.RdbPredicates('sessions')
+ predicates.equalTo('completed', 1)
+ predicates.equalTo('focus_type', FocusType.focus as number)
+ const result = await this.rdbStore.query(predicates, ['duration'])
+ let total = 0
+ while (result.goToNextRow()) {
+ total += result.getLong(result.getColumnIndex('duration'))
+ }
+ result.close()
+ return Math.floor(total / 60)
+ } catch (err) {
+ this.logError('Failed to get total focus minutes', err)
+ return 0
+ }
+ }
+
+ async getDailyStatsForRange(startDate: string, endDate: string): Promise