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> { + if (!this.rdbStore) return new Map() + + try { + const predicates = new relationalStore.RdbPredicates('daily_stats') + predicates.greaterThanOrEqualTo('date', startDate) + predicates.lessThanOrEqualTo('date', endDate) + predicates.orderByAsc('date') + const result = await this.rdbStore.query(predicates, ['date', 'total_minutes']) + const stats = new Map() + while (result.goToNextRow()) { + const date = result.getString(result.getColumnIndex('date')) + const minutes = result.getLong(result.getColumnIndex('total_minutes')) + stats.set(date, minutes) + } + result.close() + return stats + } catch (err) { + this.logError('Failed to get daily stats for range', err) + return new Map() + } + } + + async getTagDistribution(): Promise> { + if (!this.rdbStore) return new Map() + + try { + const predicates = new relationalStore.RdbPredicates('sessions') + predicates.equalTo('completed', 1) + predicates.notEqualTo('tag', '') + const result = await this.rdbStore.query(predicates, ['tag', 'duration']) + const distribution = new Map() + while (result.goToNextRow()) { + const tag = result.getString(result.getColumnIndex('tag')) + const duration = result.getLong(result.getColumnIndex('duration')) + const current = distribution.get(tag) ?? 0 + distribution.set(tag, current + Math.floor(duration / 60)) + } + result.close() + return distribution + } catch (err) { + this.logError('Failed to get tag distribution', err) + return new Map() + } + } + + async getStreakDays(): Promise { + if (!this.rdbStore) return 0 + + try { + const predicates = new relationalStore.RdbPredicates('daily_stats') + predicates.greaterThan('session_count', 0) + predicates.orderByDesc('date') + const result = await this.rdbStore.query(predicates, ['date']) + let streak = 0 + let expectedDate = new Date() + while (result.goToNextRow()) { + const dateStr = result.getString(result.getColumnIndex('date')) + const expected = this.getDateString(expectedDate.getTime()) + if (dateStr === expected) { + streak++ + expectedDate.setDate(expectedDate.getDate() - 1) + } else { + break + } + } + result.close() + return streak + } catch (err) { + this.logError('Failed to get streak days', err) + return 0 + } + } + + async getDistinctTagCount(): Promise { + if (!this.rdbStore) return 0 + + try { + const predicates = new relationalStore.RdbPredicates('sessions') + predicates.notEqualTo('tag', '') + predicates.distinct() + const result = await this.rdbStore.query(predicates, ['tag']) + const tags = new Set() + while (result.goToNextRow()) { + tags.add(result.getString(result.getColumnIndex('tag'))) + } + result.close() + return tags.size + } catch (err) { + this.logError('Failed to get distinct tag count', err) + return 0 + } + } + + async getWeeklyStats(): Promise { + if (!this.rdbStore) return [] + + const stats: number[] = [] + + try { + const today = new Date() + for (let i = 6; i >= 0; i--) { + const d = new Date(today) + d.setDate(d.getDate() - i) + const dateStr = this.getDateString(d.getTime()) + const predicates = new relationalStore.RdbPredicates('daily_stats') + predicates.equalTo('date', dateStr) + + const result = await this.rdbStore.query(predicates, ['total_minutes']) + if (result.goToFirstRow()) { + stats.push(result.getLong(result.getColumnIndex('total_minutes'))) + } else { + stats.push(0) + } + result.close() + } + return stats + } catch (err) { + this.logError('Failed to get weekly stats', err) + return stats + } + } + + private async querySessionsWithPredicates(predicates: relationalStore.RdbPredicates): Promise { + if (!this.rdbStore) return [] + + try { + const result = await this.rdbStore.query(predicates, + ['id', 'start_time', 'end_time', 'duration', 'focus_type', 'tag', 'completed']) + const sessions: SessionRecord[] = [] + while (result.goToNextRow()) { + sessions.push({ + id: result.getLong(result.getColumnIndex('id')), + startTime: result.getLong(result.getColumnIndex('start_time')), + endTime: result.getLong(result.getColumnIndex('end_time')), + duration: result.getLong(result.getColumnIndex('duration')), + focusType: result.getLong(result.getColumnIndex('focus_type')) as FocusType, + tag: result.getString(result.getColumnIndex('tag')), + completed: result.getLong(result.getColumnIndex('completed')) === 1 + }) + } + result.close() + return sessions + } catch (err) { + this.logError('Failed to query sessions', err) + return [] + } + } + + private getDateString(timestamp: number): string { + const d = new Date(timestamp) + return `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}` + } + + private logError(message: string, err: BusinessError): void { + const error = err + hilog.error(0x0000, TAG, `${message}: %{public}s`, error.message) + } +} diff --git a/entry/src/main/ets/service/StreakTracker.ets b/entry/src/main/ets/service/StreakTracker.ets new file mode 100644 index 0000000..5755a16 --- /dev/null +++ b/entry/src/main/ets/service/StreakTracker.ets @@ -0,0 +1,47 @@ +import { DailyGoal } from '../model/GoalModel' +import { DbService } from './DbService' + +export class StreakTracker { + private static instance: StreakTracker | null = null + currentStreak: number = 0 + maxStreak: number = 0 + + static getInstance(): StreakTracker { + if (!StreakTracker.instance) { + StreakTracker.instance = new StreakTracker() + } + return StreakTracker.instance + } + + initialize() { + this.currentStreak = AppStorage.get('currentStreak') ?? 0 + this.maxStreak = AppStorage.get('maxStreak') ?? 0 + } + + private save() { + AppStorage.setOrCreate('currentStreak', this.currentStreak) + AppStorage.setOrCreate('maxStreak', this.maxStreak) + } + + async refresh(): Promise { + const db = DbService.getInstance() + this.currentStreak = await db.getStreakDays() + if (this.currentStreak > this.maxStreak) { + this.maxStreak = this.currentStreak + } + this.save() + } + + onGoalCompleted() { + const today = DailyGoal.todayString() + const lastGoalDate: string = AppStorage.get('lastGoalDate') ?? '' + if (lastGoalDate !== today) { + AppStorage.setOrCreate('lastGoalDate', today) + this.currentStreak++ + if (this.currentStreak > this.maxStreak) { + this.maxStreak = this.currentStreak + } + this.save() + } + } +} diff --git a/entry/src/main/resources/base/element/color.json b/entry/src/main/resources/base/element/color.json index d10ebf0..1fed3d1 100644 --- a/entry/src/main/resources/base/element/color.json +++ b/entry/src/main/resources/base/element/color.json @@ -1,28 +1,46 @@ { "color": [ - { - "name": "start_window_background", - "value": "#FFFFFF" - }, - { - "name": "purple_gradient_1", - "value": "#B589D6" - }, - { - "name": "purple_gradient_2", - "value": "#9969C7" - }, - { - "name": "purple_gradient_3", - "value": "#804FB3" - }, - { - "name": "purple_gradient_4", - "value": "#6A359C" - }, - { - "name": "purple_gradient_5", - "value": "#552586" - } + { "name": "start_window_background", "value": "#0D0D0D" }, + { "name": "purple_gradient_1", "value": "#B589D6" }, + { "name": "purple_gradient_2", "value": "#9969C7" }, + { "name": "purple_gradient_3", "value": "#804FB3" }, + { "name": "purple_gradient_4", "value": "#6A359C" }, + { "name": "purple_gradient_5", "value": "#552586" }, + { "name": "blue_gradient_1", "value": "#64B5F6" }, + { "name": "blue_gradient_2", "value": "#42A5F5" }, + { "name": "blue_gradient_3", "value": "#2196F3" }, + { "name": "blue_gradient_4", "value": "#1E88E5" }, + { "name": "blue_gradient_5", "value": "#1565C0" }, + { "name": "green_gradient_1", "value": "#81C784" }, + { "name": "green_gradient_2", "value": "#66BB6A" }, + { "name": "green_gradient_3", "value": "#4CAF50" }, + { "name": "green_gradient_4", "value": "#43A047" }, + { "name": "green_gradient_5", "value": "#2E7D32" }, + { "name": "red_gradient_1", "value": "#E57373" }, + { "name": "red_gradient_2", "value": "#EF5350" }, + { "name": "red_gradient_3", "value": "#F44336" }, + { "name": "red_gradient_4", "value": "#E53935" }, + { "name": "red_gradient_5", "value": "#C62828" }, + { "name": "orange_gradient_1", "value": "#FFB74D" }, + { "name": "orange_gradient_2", "value": "#FFA726" }, + { "name": "orange_gradient_3", "value": "#FF9800" }, + { "name": "orange_gradient_4", "value": "#FB8C00" }, + { "name": "orange_gradient_5", "value": "#E65100" }, + { "name": "cyan_gradient_1", "value": "#4DD0E1" }, + { "name": "cyan_gradient_2", "value": "#26C6DA" }, + { "name": "cyan_gradient_3", "value": "#00BCD4" }, + { "name": "cyan_gradient_4", "value": "#00ACC1" }, + { "name": "cyan_gradient_5", "value": "#00838F" }, + { "name": "bg_primary", "value": "#0D0D0D" }, + { "name": "bg_card", "value": "#1A1A1A" }, + { "name": "bg_elevated", "value": "#2A2A2A" }, + { "name": "text_primary", "value": "#FFFFFF" }, + { "name": "text_secondary", "value": "#CCCCCC" }, + { "name": "text_tertiary", "value": "#888888" }, + { "name": "divider", "value": "#333333" }, + { "name": "success", "value": "#4CAF50" }, + { "name": "warning", "value": "#FF9800" }, + { "name": "error", "value": "#F44336" }, + { "name": "gold", "value": "#FFD700" } ] -} \ No newline at end of file +} diff --git a/entry/src/main/resources/base/element/float.json b/entry/src/main/resources/base/element/float.json index 510bef4..33d6c5d 100644 --- a/entry/src/main/resources/base/element/float.json +++ b/entry/src/main/resources/base/element/float.json @@ -1,44 +1,28 @@ { "float": [ - { - "name": "offset_zero", - "value": "0vp" - }, - { - "name": "offset_0_2", - "value": "0.2vp" - }, - { - "name": "offset_0_4", - "value": "0.4vp" - }, - { - "name": "offset_0_6", - "value": "0.6vp" - }, - { - "name": "offset_0_8", - "value": "0.8vp" - }, - { - "name": "offset_one", - "value": "1vp" - }, - { - "name": "margin_4", - "value": "4vp" - }, - { - "name": "margin_8", - "value": "8vp" - }, - { - "name": "margin_16", - "value": "16vp" - }, - { - "name": "progress_font_size", - "value": "48fp" - } + { "name": "offset_zero", "value": "0vp" }, + { "name": "offset_0_2", "value": "0.2vp" }, + { "name": "offset_0_4", "value": "0.4vp" }, + { "name": "offset_0_6", "value": "0.6vp" }, + { "name": "offset_0_8", "value": "0.8vp" }, + { "name": "offset_one", "value": "1vp" }, + { "name": "margin_2", "value": "2vp" }, + { "name": "margin_4", "value": "4vp" }, + { "name": "margin_6", "value": "6vp" }, + { "name": "margin_8", "value": "8vp" }, + { "name": "margin_10", "value": "10vp" }, + { "name": "margin_12", "value": "12vp" }, + { "name": "margin_16", "value": "16vp" }, + { "name": "margin_20", "value": "20vp" }, + { "name": "margin_24", "value": "24vp" }, + { "name": "progress_font_size", "value": "48fp" }, + { "name": "title_font_size", "value": "16fp" }, + { "name": "body_font_size", "value": "12fp" }, + { "name": "caption_font_size", "value": "9fp" }, + { "name": "icon_size_small", "value": "14vp" }, + { "name": "icon_size_medium", "value": "18vp" }, + { "name": "icon_size_large", "value": "28vp" }, + { "name": "button_radius", "value": "14vp" }, + { "name": "card_radius", "value": "12vp" } ] } diff --git a/entry/src/main/resources/base/element/string.json b/entry/src/main/resources/base/element/string.json index 139faab..6ead842 100644 --- a/entry/src/main/resources/base/element/string.json +++ b/entry/src/main/resources/base/element/string.json @@ -1,52 +1,136 @@ { "string": [ - { - "name": "module_desc", - "value": "module description" - }, - { - "name": "EntryAbility_desc", - "value": "description" - }, - { - "name": "EntryAbility_label", - "value": "Focus Timer" - }, - { - "name": "reset", - "value": "Reset" - }, - { - "name": "stop", - "value": "Stop" - }, - { - "name": "start", - "value": "Start" - }, - { - "name": "skip", - "value": "Skip" - }, - { - "name": "focus", - "value": "Focus" - }, - { - "name": "break", - "value": "Break" - }, - { - "name": "short_break", - "value": "Short Break" - }, - { - "name": "long_break", - "value": "Long Break" - }, - { - "name": "plus_five", - "value": "+5 Mins" - } + { "name": "module_desc", "value": "Focus Timer - Pomodoro Technique for Wearables" }, + { "name": "EntryAbility_desc", "value": "Focus Timer Application" }, + { "name": "EntryAbility_label", "value": "Focus Timer" }, + { "name": "reset", "value": "Reset" }, + { "name": "stop", "value": "Stop" }, + { "name": "start", "value": "Start" }, + { "name": "skip", "value": "Skip" }, + { "name": "focus", "value": "Focus" }, + { "name": "break", "value": "Break" }, + { "name": "short_break", "value": "Short Break" }, + { "name": "long_break", "value": "Long Break" }, + { "name": "plus_five", "value": "+5 Mins" }, + + { "name": "statistics", "value": "Statistics" }, + { "name": "settings", "value": "Settings" }, + { "name": "achievements", "value": "Achievements" }, + { "name": "programs", "value": "Programs" }, + + { "name": "sessions", "value": "Sessions" }, + { "name": "minutes_label", "value": "Minutes" }, + { "name": "minutes", "value": "min" }, + { "name": "goal", "value": "Goal" }, + { "name": "daily_goal", "value": "Daily Goal" }, + { "name": "streak", "value": "Streak" }, + { "name": "best_streak", "value": "Best" }, + { "name": "weekly_chart", "value": "Weekly Overview" }, + { "name": "tag_distribution", "value": "Categories" }, + { "name": "calendar_heatmap", "value": "Activity Map" }, + { "name": "less", "value": "Less" }, + { "name": "more", "value": "More" }, + + { "name": "select_tag", "value": "Category" }, + { "name": "tag_work", "value": "Work" }, + { "name": "tag_study", "value": "Study" }, + { "name": "tag_coding", "value": "Coding" }, + { "name": "tag_exercise", "value": "Exercise" }, + { "name": "tag_reading", "value": "Reading" }, + { "name": "tag_meditation", "value": "Meditation" }, + + { "name": "select_theme", "value": "Theme" }, + { "name": "theme_purple", "value": "Purple" }, + { "name": "theme_blue", "value": "Blue" }, + { "name": "theme_green", "value": "Green" }, + { "name": "theme_red", "value": "Red" }, + { "name": "theme_orange", "value": "Orange" }, + { "name": "theme_cyan", "value": "Cyan" }, + + { "name": "intensity", "value": "Intensity" }, + { "name": "intensity_light", "value": "Light" }, + { "name": "intensity_normal", "value": "Normal" }, + { "name": "intensity_intense", "value": "Intense" }, + + { "name": "cycles", "value": "Cycles" }, + { "name": "session_complete", "value": "Session Complete!" }, + { "name": "continue", "value": "Continue" }, + { "name": "cancel", "value": "Cancel" }, + { "name": "confirm", "value": "Confirm" }, + { "name": "achievement_unlocked", "value": "Achievement Unlocked!" }, + { "name": "awesome", "value": "Awesome!" }, + + { "name": "settings_timer", "value": "TIMER" }, + { "name": "settings_appearance", "value": "APPEARANCE" }, + { "name": "settings_more", "value": "MORE" }, + { "name": "about", "value": "ABOUT" }, + { "name": "version", "value": "Version" }, + { "name": "auto_skip", "value": "Auto Skip" }, + { "name": "show_quotes", "value": "Show Quotes" }, + + { "name": "onboarding_welcome", "value": "Welcome to Focus Timer" }, + { "name": "onboarding_welcome_desc", "value": "Stay focused and boost your productivity with the Pomodoro Technique" }, + { "name": "onboarding_how_title", "value": "How It Works" }, + { "name": "onboarding_step1", "value": "Focus for 25 minutes" }, + { "name": "onboarding_step2", "value": "Take a 5-minute break" }, + { "name": "onboarding_step3", "value": "Repeat the cycle" }, + { "name": "onboarding_step4", "value": "Long break after 3 cycles" }, + { "name": "onboarding_goal_title", "value": "Set Your Daily Goal" }, + { "name": "onboarding_theme_title", "value": "Choose Your Theme" }, + { "name": "next", "value": "Next" }, + { "name": "get_started", "value": "Get Started" }, + + { "name": "prog_pomodoro", "value": "Pomodoro" }, + { "name": "prog_pomodoro_desc", "value": "Classic 25/5/20 technique" }, + { "name": "prog_deep_work", "value": "Deep Work" }, + { "name": "prog_deep_work_desc", "value": "Long 90-minute focus sessions" }, + { "name": "prog_sprint", "value": "Sprint" }, + { "name": "prog_sprint_desc", "value": "Quick 15-minute bursts" }, + { "name": "prog_study", "value": "Study" }, + { "name": "prog_study_desc", "value": "50/10/25 for study sessions" }, + + { "name": "break_walk", "value": "Walk" }, + { "name": "break_walk_desc", "value": "Take a short walk around" }, + { "name": "break_water", "value": "Hydrate" }, + { "name": "break_water_desc", "value": "Drink a glass of water" }, + { "name": "break_eyes", "value": "Eye Rest" }, + { "name": "break_eyes_desc", "value": "Look at something 20ft away" }, + { "name": "break_stretch", "value": "Stretch" }, + { "name": "break_stretch_desc", "value": "Stretch your body" }, + { "name": "break_relax", "value": "Relax" }, + { "name": "break_relax_desc", "value": "Close your eyes and relax" }, + { "name": "break_smile", "value": "Smile" }, + { "name": "break_smile_desc", "value": "Smile and think positive" }, + + { "name": "ach_first_session", "value": "First Step" }, + { "name": "ach_first_session_desc", "value": "Complete your first session" }, + { "name": "ach_ten_sessions", "value": "Getting Going" }, + { "name": "ach_ten_sessions_desc", "value": "Complete 10 sessions" }, + { "name": "ach_fifty_sessions", "value": "Dedicated" }, + { "name": "ach_fifty_sessions_desc", "value": "Complete 50 sessions" }, + { "name": "ach_hundred_sessions", "value": "Centurion" }, + { "name": "ach_hundred_sessions_desc", "value": "Complete 100 sessions" }, + { "name": "ach_five_hundred_sessions", "value": "Legendary" }, + { "name": "ach_five_hundred_sessions_desc", "value": "Complete 500 sessions" }, + { "name": "ach_hour_focus", "value": "Hour Power" }, + { "name": "ach_hour_focus_desc", "value": "Focus for 60 total minutes" }, + { "name": "ach_five_hour_focus", "value": "Marathon" }, + { "name": "ach_five_hour_focus_desc", "value": "Focus for 5 total hours" }, + { "name": "ach_day_focus", "value": "Full Day" }, + { "name": "ach_day_focus_desc", "value": "Focus for 24 total hours" }, + { "name": "ach_three_day_streak", "value": "Warming Up" }, + { "name": "ach_three_day_streak_desc", "value": "3-day streak" }, + { "name": "ach_seven_day_streak", "value": "On Fire" }, + { "name": "ach_seven_day_streak_desc", "value": "7-day streak" }, + { "name": "ach_thirty_day_streak", "value": "Unstoppable" }, + { "name": "ach_thirty_day_streak_desc", "value": "30-day streak" }, + { "name": "ach_all_tags", "value": "Explorer" }, + { "name": "ach_all_tags_desc", "value": "Use all 6 categories" }, + { "name": "ach_theme_changer", "value": "Stylist" }, + { "name": "ach_theme_changer_desc", "value": "Try 3 different themes" }, + { "name": "ach_all_programs", "value": "Versatile" }, + { "name": "ach_all_programs_desc", "value": "Try all 4 programs" }, + { "name": "ach_early_bird", "value": "Early Bird" }, + { "name": "ach_early_bird_desc", "value": "5 sessions before 7 AM" } ] -} \ No newline at end of file +} diff --git a/entry/src/main/resources/base/profile/main_pages.json b/entry/src/main/resources/base/profile/main_pages.json index 55c3f00..2d5c34d 100644 --- a/entry/src/main/resources/base/profile/main_pages.json +++ b/entry/src/main/resources/base/profile/main_pages.json @@ -1,5 +1,5 @@ { "src": [ - "pages/Index" + "pages/NavigationContainer" ] -} \ No newline at end of file +} diff --git a/entry/src/main/resources/dark/element/color.json b/entry/src/main/resources/dark/element/color.json index 79b11c2..253edd1 100644 --- a/entry/src/main/resources/dark/element/color.json +++ b/entry/src/main/resources/dark/element/color.json @@ -1,8 +1,12 @@ { "color": [ - { - "name": "start_window_background", - "value": "#000000" - } + { "name": "start_window_background", "value": "#000000" }, + { "name": "bg_primary", "value": "#000000" }, + { "name": "bg_card", "value": "#111111" }, + { "name": "bg_elevated", "value": "#222222" }, + { "name": "text_primary", "value": "#FFFFFF" }, + { "name": "text_secondary", "value": "#CCCCCC" }, + { "name": "text_tertiary", "value": "#888888" }, + { "name": "divider", "value": "#2A2A2A" } ] -} \ No newline at end of file +} diff --git a/screenshots/1.PNG b/screenshots/1.PNG deleted file mode 100644 index cf79f1a..0000000 Binary files a/screenshots/1.PNG and /dev/null differ diff --git a/screenshots/2.PNG b/screenshots/2.PNG deleted file mode 100644 index cac6d94..0000000 Binary files a/screenshots/2.PNG and /dev/null differ diff --git a/screenshots/3.PNG b/screenshots/3.PNG deleted file mode 100644 index b565c75..0000000 Binary files a/screenshots/3.PNG and /dev/null differ diff --git a/screenshots/4.PNG b/screenshots/4.PNG deleted file mode 100644 index e1b94dd..0000000 Binary files a/screenshots/4.PNG and /dev/null differ diff --git a/screenshots/ss1.png b/screenshots/ss1.png new file mode 100644 index 0000000..0240b71 Binary files /dev/null and b/screenshots/ss1.png differ diff --git a/screenshots/ss2.png b/screenshots/ss2.png new file mode 100644 index 0000000..88d907c Binary files /dev/null and b/screenshots/ss2.png differ diff --git a/screenshots/ss3.png b/screenshots/ss3.png new file mode 100644 index 0000000..9590241 Binary files /dev/null and b/screenshots/ss3.png differ diff --git a/screenshots/ss4.png b/screenshots/ss4.png new file mode 100644 index 0000000..ab01421 Binary files /dev/null and b/screenshots/ss4.png differ