feat: fix web build, add repo infra, port iOS engine modules, add routine screens
- fix(web): cast window through unknown in platform-sync.ts (TS2352) - docs: add AGENTS.md, README.md, CLAUDE.md, .windsurfrules, .cursorrules, env.example - feat(ios): port Recurrence.swift from web/src/lib/recurrence.ts - feat(ios): port NLParser.swift from web/src/lib/nl-parser.ts - feat(ios): port ContextMessages.swift from web/src/lib/context-messages.ts - feat(ios): add CMRoutine model + Routines.swift engine with state machine + templates - feat(ios): add RoutineListView, RoutineRunnerView, RoutineEditorView - feat(android): add RoutineScreen.kt with list, runner, templates, step controls Web: 373 tests passing, build succeeds with --webpack flag
This commit is contained in:
parent
af33a2c86d
commit
11e50295ea
17
.cursorrules
Normal file
17
.cursorrules
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# ChronoMind — Cursor Rules
|
||||||
|
# Read AGENTS.md for full context.
|
||||||
|
|
||||||
|
Project: ChronoMind — AI-Powered Contextual Clock & Timer
|
||||||
|
Product ID: chronomind
|
||||||
|
Stack: Next.js 16 (web) + SwiftUI (iOS) + Jetpack Compose (Android) + Fastify 5 (backend)
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Web engine in web/src/lib/ — pure TS, no React
|
||||||
|
- Components in web/src/components/ — React UI only
|
||||||
|
- iOS shared in ios/ChronoMind/Shared/
|
||||||
|
- Android engine in android/.../engine/ — pure Kotlin
|
||||||
|
- Theme: --cm-* CSS props (web), ChronoMindTheme (native)
|
||||||
|
- Commits: type(scope): description
|
||||||
|
- Build web with --webpack flag
|
||||||
|
- Never console.log, never hardcode colors/URLs
|
||||||
|
- Every Cosmos doc: productId: "chronomind"
|
||||||
37
.windsurfrules
Normal file
37
.windsurfrules
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# ChronoMind — Windsurf / Codeium Rules
|
||||||
|
# Read AGENTS.md for full context. These are the critical rules.
|
||||||
|
|
||||||
|
Project: ChronoMind — AI-Powered Contextual Clock & Timer
|
||||||
|
Stack: Next.js 16 (web) + SwiftUI (iOS/Watch/Mac) + Jetpack Compose (Android) + Platform-service (Fastify 5)
|
||||||
|
|
||||||
|
## Architecture Rules
|
||||||
|
- Web engine logic in web/src/lib/ — pure TS, no React imports
|
||||||
|
- Components in web/src/components/ — React UI only
|
||||||
|
- iOS shared logic in ios/ChronoMind/Shared/ — consumed by all Apple targets
|
||||||
|
- Android engine in android/app/.../engine/ — pure Kotlin, no Android framework deps
|
||||||
|
- Backend: platform-service in sibling repo learning_ai_common_plat (port 4003)
|
||||||
|
- Product ID: chronomind — every Cosmos document MUST include productId field
|
||||||
|
|
||||||
|
## Key Paths
|
||||||
|
- Web: web/src/ (Next.js 16, App Router)
|
||||||
|
- iOS: ios/ChronoMind/ (SwiftUI, iOS 17+)
|
||||||
|
- Watch: ios/ChronoMindWatch/ (watchOS 10+)
|
||||||
|
- Mac: ios/ChronoMindMac/ (macOS 14+)
|
||||||
|
- Widgets: ios/ChronoMindWidgets/ (WidgetKit)
|
||||||
|
- Android: android/app/src/main/java/com/chronomind/app/
|
||||||
|
- Wear OS: android/wear/
|
||||||
|
- Docs: docs/ (PRD.md, roadmap.md, INDUSTRY_RESEARCH.md)
|
||||||
|
- Backend modules: ../learning_ai_common_plat/services/platform-service/src/modules/{timers,routines,households,shared-timers}/
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
- Theme tokens: --cm-* CSS custom properties (web), ChronoMindTheme (native)
|
||||||
|
- Commits: feat(scope): description / fix(scope): description
|
||||||
|
- Web build: must use --webpack flag (Serwist incompatible with Turbopack)
|
||||||
|
- Never use console.log — use analytics.ts stub or req.log in Fastify
|
||||||
|
- Never hardcode colors — use theme tokens
|
||||||
|
- Never hardcode API URLs — use env vars
|
||||||
|
|
||||||
|
## Build Verification
|
||||||
|
- Web: cd web && npm test && npm run typecheck && npm run build
|
||||||
|
- iOS: Open ChronoMind.xcodeproj, Cmd+B
|
||||||
|
- Android: cd android && ./gradlew :app:compileDebugKotlin
|
||||||
286
AGENTS.md
Normal file
286
AGENTS.md
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
# AGENTS.md — AI Coding Agent Instructions
|
||||||
|
|
||||||
|
> **For:** Claude Code, OpenAI Codex, Cursor, GitHub Copilot, Windsurf Cascade, and any AI coding agent.
|
||||||
|
> **Repo:** `learning_ai_clock` — ChronoMind AI-powered contextual clock & timer.
|
||||||
|
> **See also:** [`docs/PRD.md`](docs/PRD.md) for full product spec, [`docs/roadmap.md`](docs/roadmap.md) for implementation plan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Project Identity
|
||||||
|
|
||||||
|
| Key | Value |
|
||||||
|
|-----|-------|
|
||||||
|
| **Product** | ChronoMind |
|
||||||
|
| **Product ID** | `chronomind` |
|
||||||
|
| **Bundle ID (iOS)** | `com.saravana.chronomind` |
|
||||||
|
| **Bundle ID (Android)** | `com.chronomind.app` |
|
||||||
|
| **Domain** | chronomind.app (TBD) |
|
||||||
|
| **Repo** | `learning_ai_clock` |
|
||||||
|
| **Ecosystem** | ByteLyst (shares platform-service with LysnrAI, MindLyst, NomGap) |
|
||||||
|
|
||||||
|
## 2. Repo Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
learning_ai_clock/
|
||||||
|
├── web/ # Next.js 16 PWA (App Router)
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── app/ # Pages: dashboard, focus, history, routines, settings, landing, privacy, terms
|
||||||
|
│ │ ├── components/ # React components (12 files)
|
||||||
|
│ │ │ ├── Dashboard.tsx # Main timeline + timer list
|
||||||
|
│ │ │ ├── CreateTimerModal.tsx # Timer creation with NL input
|
||||||
|
│ │ │ ├── AlarmOverlay.tsx # Full-screen critical alarm
|
||||||
|
│ │ │ ├── PomodoroView.tsx # Pomodoro session UI
|
||||||
|
│ │ │ ├── CountdownRing.tsx # Visual countdown ring
|
||||||
|
│ │ │ ├── RoutineEditor.tsx # Create/edit routines
|
||||||
|
│ │ │ ├── RoutineRunner.tsx # Execute routines step-by-step
|
||||||
|
│ │ │ ├── StatsView.tsx # Charts + analytics
|
||||||
|
│ │ │ ├── StreakCard.tsx # Streak display
|
||||||
|
│ │ │ ├── TimerCard.tsx # Individual timer card
|
||||||
|
│ │ │ ├── FocusView.tsx # Focus mode UI
|
||||||
|
│ │ │ └── Toast.tsx # In-app toast notifications
|
||||||
|
│ │ └── lib/ # Pure engine modules (16 files + 16 test files)
|
||||||
|
│ │ ├── timer-engine.ts # Timer state machine (424 lines)
|
||||||
|
│ │ ├── cascade.ts # Pre-warning cascade system
|
||||||
|
│ │ ├── urgency.ts # 5 urgency levels
|
||||||
|
│ │ ├── routines.ts # Routine engine
|
||||||
|
│ │ ├── linked-timers.ts # Timer chaining
|
||||||
|
│ │ ├── nl-parser.ts # Natural language parsing
|
||||||
|
│ │ ├── recurrence.ts # Recurring timer rules
|
||||||
|
│ │ ├── categories.ts # Timer categories/tags
|
||||||
|
│ │ ├── context-messages.ts # Contextual pre-warning messages
|
||||||
|
│ │ ├── prep-time.ts # Prep time intelligence
|
||||||
|
│ │ ├── adaptive-snooze.ts # Snooze pattern learning
|
||||||
|
│ │ ├── calendar-import.ts # .ics import
|
||||||
|
│ │ ├── stats.ts # Statistics + streaks
|
||||||
|
│ │ ├── sounds.ts # Web Audio API alarm tones
|
||||||
|
│ │ ├── notifications.ts # Web Push + Service Worker
|
||||||
|
│ │ ├── platform-sync.ts # Sync client for platform-service
|
||||||
|
│ │ ├── store.ts # Zustand + localStorage
|
||||||
|
│ │ ├── routine-store.ts # Routine Zustand store
|
||||||
|
│ │ ├── format.ts # Time formatting
|
||||||
|
│ │ ├── time-blindness.ts # Familiar time references
|
||||||
|
│ │ ├── export.ts # JSON/CSV export + import
|
||||||
|
│ │ ├── analytics.ts # Analytics stub
|
||||||
|
│ │ ├── use-sync.ts # React sync hook
|
||||||
|
│ │ ├── use-tick.ts # rAF tick hook
|
||||||
|
│ │ ├── use-theme.ts # Theme hook
|
||||||
|
│ │ ├── use-keyboard-shortcuts.ts
|
||||||
|
│ │ └── schemas.ts # Zod schemas
|
||||||
|
│ ├── e2e/ # Playwright E2E tests
|
||||||
|
│ │ └── core-flows.spec.ts # 10 test suites
|
||||||
|
│ ├── public/ # Static assets + manifest
|
||||||
|
│ ├── next.config.ts # Serwist PWA config
|
||||||
|
│ ├── package.json # Next.js 16, React 19, Zustand, Vitest
|
||||||
|
│ └── vitest.config.ts
|
||||||
|
│
|
||||||
|
├── ios/ # Native SwiftUI (iOS + watchOS + macOS)
|
||||||
|
│ ├── ChronoMind/ # iOS app
|
||||||
|
│ │ ├── App/ # Entry point + ContentView
|
||||||
|
│ │ ├── Shared/ # Cross-platform Swift code
|
||||||
|
│ │ │ ├── TimerEngine/ # Cascade, Format, TimerEngine, TimeBlindness, Urgency
|
||||||
|
│ │ │ ├── Store/ # TimerStore (UserDefaults-based)
|
||||||
|
│ │ │ ├── Notifications/ # UNUserNotificationCenter scheduling
|
||||||
|
│ │ │ ├── Haptics/ # UIImpactFeedbackGenerator
|
||||||
|
│ │ │ ├── Calendar/ # EventKit sync
|
||||||
|
│ │ │ ├── Cloud/ # CloudKitSyncManager + PlatformSyncManager
|
||||||
|
│ │ │ ├── Location/ # CoreLocation + MapKit travel time
|
||||||
|
│ │ │ ├── Sleep/ # HealthKit sleep integration
|
||||||
|
│ │ │ ├── Wellness/ # Mood check-in
|
||||||
|
│ │ │ ├── Gamification/ # Streaks, badges, focus score
|
||||||
|
│ │ │ ├── Growth/ # Referral manager
|
||||||
|
│ │ │ ├── Sharing/ # Shareable timer links
|
||||||
|
│ │ │ ├── Data/ # Data export manager
|
||||||
|
│ │ │ ├── Reschedule/ # AI reschedule engine
|
||||||
|
│ │ │ ├── Diagnostics/ # Crash reporter
|
||||||
|
│ │ │ ├── Accessibility/ # VoiceOver + Dynamic Type helpers
|
||||||
|
│ │ │ ├── AppGroup/ # SharedTimerData (iPhone <-> Watch <-> Widget)
|
||||||
|
│ │ │ └── Theme/ # ChronoMindTheme
|
||||||
|
│ │ ├── Views/ # SwiftUI screens + components
|
||||||
|
│ │ │ ├── Components/ # AlarmOverlay, TimerCard, CountdownRing, etc.
|
||||||
|
│ │ │ ├── CreateTimer/ # CreateTimerView, QuickTimerSheet
|
||||||
|
│ │ │ ├── Focus/ # PomodoroView
|
||||||
|
│ │ │ └── Gamification/ # BadgeGrid, ConfettiView, Streak, WeeklySummary, FocusScore
|
||||||
|
│ │ ├── LiveActivity/ # Dynamic Island + Lock Screen
|
||||||
|
│ │ └── SiriShortcuts/ # App Intents
|
||||||
|
│ ├── ChronoMindWatch/ # watchOS app (4 files)
|
||||||
|
│ ├── ChronoMindMac/ # macOS menu bar app (4 files)
|
||||||
|
│ ├── ChronoMindWidgets/ # WidgetKit (5 files)
|
||||||
|
│ ├── ChronoMindTests/ # XCTest (8 test files, 129 tests)
|
||||||
|
│ ├── ChronoMind.xcodeproj/
|
||||||
|
│ └── project.yml # XcodeGen project spec
|
||||||
|
│
|
||||||
|
├── android/ # Jetpack Compose + Kotlin
|
||||||
|
│ ├── app/src/main/java/com/chronomind/app/
|
||||||
|
│ │ ├── engine/ # Models.kt + TimerEngine.kt (Kotlin port)
|
||||||
|
│ │ ├── data/ # Room: TimerDatabase, TimerDao, TimerEntity, TimerMapper
|
||||||
|
│ │ ├── di/ # Hilt AppModule
|
||||||
|
│ │ ├── ui/
|
||||||
|
│ │ │ ├── screens/ # Timeline, Focus, History, Settings (4 screens)
|
||||||
|
│ │ │ ├── navigation/ # NavHost
|
||||||
|
│ │ │ └── theme/ # Material 3 theme
|
||||||
|
│ │ ├── notifications/ # AlarmManager, BroadcastReceiver, NotificationManager
|
||||||
|
│ │ ├── service/ # ForegroundService, QuickSettingsTile
|
||||||
|
│ │ ├── sync/ # PlatformApiClient, SyncRepository
|
||||||
|
│ │ ├── calendar/ # CalendarSyncManager (Google Calendar via CalendarContract)
|
||||||
|
│ │ ├── viewmodel/ # TimerViewModel
|
||||||
|
│ │ └── widget/ # Glance widgets (3 sizes)
|
||||||
|
│ ├── app/src/test/ # JUnit5 tests (30 tests)
|
||||||
|
│ └── wear/ # Wear OS app (1 file, minimal)
|
||||||
|
│
|
||||||
|
├── docs/
|
||||||
|
│ ├── PRD.md # Full product requirements
|
||||||
|
│ ├── roadmap.md # 5-phase implementation roadmap
|
||||||
|
│ ├── roadmap-v2-review.md # Roadmap audit trail
|
||||||
|
│ ├── INDUSTRY_RESEARCH.md # Competitive analysis
|
||||||
|
│ └── raw_idea.md # Original concept
|
||||||
|
│
|
||||||
|
└── .github/workflows/ # GitHub Actions CI
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|-----------|
|
||||||
|
| **Web** | Next.js 16 (App Router), React 19, TailwindCSS v4, Zustand 5, Serwist (PWA), date-fns, Recharts, Zod, Vitest, Playwright |
|
||||||
|
| **iOS** | SwiftUI (iOS 17+), WidgetKit, ActivityKit, EventKit, CoreLocation, MapKit, HealthKit, App Intents |
|
||||||
|
| **watchOS** | SwiftUI (watchOS 10+), WidgetKit complications |
|
||||||
|
| **macOS** | SwiftUI (macOS 14+), AppKit (menu bar) |
|
||||||
|
| **Android** | Jetpack Compose, Material 3, Kotlin, Room, Hilt, Glance widgets, AlarmManager |
|
||||||
|
| **Wear OS** | Compose for Wear OS |
|
||||||
|
| **Backend** | Platform-service (Fastify 5, port 4003) in sibling repo `learning_ai_common_plat` |
|
||||||
|
| **Database** | Azure Cosmos DB via `@bytelyst/cosmos` (`productId: "chronomind"`) |
|
||||||
|
| **Tests** | Vitest (web, 373 tests), XCTest (iOS, 129 tests), JUnit5 (Android, 30 tests) |
|
||||||
|
|
||||||
|
## 4. Coding Conventions
|
||||||
|
|
||||||
|
### MUST follow
|
||||||
|
|
||||||
|
- Every Cosmos document MUST include a `productId: "chronomind"` field
|
||||||
|
- Web engine logic lives in `web/src/lib/` — pure TS, no React imports (testable without DOM)
|
||||||
|
- Components in `web/src/components/` — React UI only
|
||||||
|
- iOS shared logic in `ios/ChronoMind/Shared/` — consumed by iOS, Watch, Mac, Widgets
|
||||||
|
- Android engine in `android/app/.../engine/` — pure Kotlin, no Android framework deps
|
||||||
|
- Theme tokens use `--cm-*` CSS custom properties (web) or `ChronoMindTheme` (native)
|
||||||
|
- Commit messages: `type(scope): description` — types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`
|
||||||
|
|
||||||
|
### MUST NOT do
|
||||||
|
|
||||||
|
- Never use `console.log` in production code
|
||||||
|
- Never hardcode colors — use theme tokens
|
||||||
|
- Never hardcode API URLs — use env vars or config
|
||||||
|
- Never modify tests to make them pass — fix the actual code
|
||||||
|
- Never delete existing comments or documentation unless explicitly asked
|
||||||
|
- Never add emojis to code unless explicitly asked
|
||||||
|
|
||||||
|
## 5. Key Design Decisions
|
||||||
|
|
||||||
|
### Pre-Warning Cascade (Core Feature)
|
||||||
|
- 6 presets: Aggressive, Standard, Light, Minimal, None, Custom
|
||||||
|
- Each timer has cascade intervals (e.g., [120, 60, 30, 15, 5] minutes)
|
||||||
|
- Cascade fires notifications at each interval before target time
|
||||||
|
|
||||||
|
### Urgency Levels
|
||||||
|
- **Critical** — persistent sound, full-screen overlay, no auto-dismiss
|
||||||
|
- **Important** — prominent notification, medium sound
|
||||||
|
- **Standard** — normal notification
|
||||||
|
- **Gentle** — subtle notification, soft sound
|
||||||
|
- **Passive** — badge only, no sound
|
||||||
|
|
||||||
|
### Timer Types
|
||||||
|
- **Alarm** — fires at specific time
|
||||||
|
- **Countdown** — fires after duration
|
||||||
|
- **Pomodoro** — work/break cycles
|
||||||
|
- **Event** — days until future date with milestone warnings
|
||||||
|
|
||||||
|
## 6. Build & Test Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ── Web ────────────────────────────────────────────
|
||||||
|
cd web && npm run dev # Dev server (port 3000)
|
||||||
|
cd web && npm run build # Production build (uses --webpack flag)
|
||||||
|
cd web && npm test # Vitest (373 tests)
|
||||||
|
cd web && npm run typecheck # tsc --noEmit
|
||||||
|
cd web && npm run test:e2e # Playwright E2E
|
||||||
|
|
||||||
|
# ── iOS (requires Xcode) ──────────────────────────
|
||||||
|
# Open ios/ChronoMind.xcodeproj in Xcode
|
||||||
|
# Cmd+B to build, Cmd+U to test (129 XCTests)
|
||||||
|
|
||||||
|
# ── Android (requires Android SDK) ────────────────
|
||||||
|
cd android && ./gradlew :app:compileDebugKotlin
|
||||||
|
cd android && ./gradlew :app:test # 30 JUnit5 tests
|
||||||
|
|
||||||
|
# ── Full Verification ─────────────────────────────
|
||||||
|
cd web && npm test && npm run typecheck && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Backend Integration
|
||||||
|
|
||||||
|
ChronoMind uses the shared **platform-service** (port 4003) from `learning_ai_common_plat`. Four ChronoMind-specific modules exist:
|
||||||
|
|
||||||
|
| Module | Container | Endpoints | Tests |
|
||||||
|
|--------|-----------|-----------|-------|
|
||||||
|
| `timers` | `timers` (pk: `/userId`) | 7 (CRUD + delta sync + batch) | 42 |
|
||||||
|
| `routines` | `routines` (pk: `/userId`) | 7 (CRUD + delta sync + batch) | 32 |
|
||||||
|
| `households` | `households` (pk: `/id`) | 9 (CRUD + invite/join/leave) | 26 |
|
||||||
|
| `shared-timers` | `shared_timers` (pk: `/householdId`) | 6 (CRUD + per-user ack) | 30 |
|
||||||
|
|
||||||
|
All documents include `productId: "chronomind"`.
|
||||||
|
|
||||||
|
### Sync Protocol
|
||||||
|
- `syncVersion` monotonic integer — optimistic concurrency, 409 on stale writes
|
||||||
|
- Delta sync: `GET /timers/sync?since=<ISO>` returns only changed timers
|
||||||
|
- Batch upsert: `POST /timers/batch` for offline queue flush
|
||||||
|
|
||||||
|
### Sync Clients (written, not yet wired into app flow)
|
||||||
|
- Web: `web/src/lib/platform-sync.ts` + `use-sync.ts`
|
||||||
|
- iOS: `ios/ChronoMind/Shared/Cloud/PlatformSyncManager.swift`
|
||||||
|
- Android: `android/.../sync/PlatformApiClient.kt` + `SyncRepository.kt`
|
||||||
|
|
||||||
|
## 8. Color Palette
|
||||||
|
|
||||||
|
| Token | Hex | Use |
|
||||||
|
|-------|-----|-----|
|
||||||
|
| `--cm-bg-canvas` | `#0A0B0F` | Page background |
|
||||||
|
| `--cm-bg-elevated` | `#12131A` | Elevated surfaces |
|
||||||
|
| `--cm-surface-card` | `#1A1B26` | Cards |
|
||||||
|
| `--cm-text-primary` | `#E8ECF4` | Main text |
|
||||||
|
| `--cm-text-secondary` | `#8B92A8` | Descriptions |
|
||||||
|
| `--cm-text-tertiary` | `#565C72` | Timestamps |
|
||||||
|
| `--cm-accent` | `#5B8DEE` | Primary accent |
|
||||||
|
| Critical urgency | `#FF4757` | Red |
|
||||||
|
| Important urgency | `#FF9F43` | Orange |
|
||||||
|
| Standard urgency | `#FECA57` | Yellow |
|
||||||
|
| Gentle urgency | `#2ED573` | Green |
|
||||||
|
| Passive urgency | `#8B92A8` | Gray |
|
||||||
|
|
||||||
|
## 9. Known Gaps (as of Feb 2026)
|
||||||
|
|
||||||
|
### iOS — Missing Swift Ports
|
||||||
|
- `Recurrence.swift` — no recurring timer engine on iOS
|
||||||
|
- `NLParser.swift` — no natural language parser on iOS
|
||||||
|
- `ContextMessages.swift` — no contextual pre-warning messages on iOS
|
||||||
|
- Routine models + UI (`RoutineListView`, `RoutineRunnerView`) not built
|
||||||
|
- Still using UserDefaults — SwiftData migration pending
|
||||||
|
|
||||||
|
### Android
|
||||||
|
- `RoutineScreen.kt` not built
|
||||||
|
- Wear OS is minimal (1 file)
|
||||||
|
|
||||||
|
### Cross-Platform
|
||||||
|
- Sync clients exist but not wired into app flow
|
||||||
|
- No account creation (Apple/Google Sign-In)
|
||||||
|
- No iCloud/CloudKit sync wired
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
- No domain registered
|
||||||
|
- No Vercel deployment
|
||||||
|
- No TestFlight / App Store / Play Store submissions
|
||||||
|
|
||||||
|
## 10. Common Pitfalls
|
||||||
|
|
||||||
|
1. **Next.js 16 + Turbopack** — Must pass `--webpack` flag to `next build` and `next dev` (Serwist not yet compatible with Turbopack)
|
||||||
|
2. **Web engine purity** — `web/src/lib/` files must NOT import from React or Next.js
|
||||||
|
3. **iOS targets** — 4 targets share `Shared/` folder: ChronoMind (iOS), ChronoMindWatch, ChronoMindMac, ChronoMindWidgets
|
||||||
|
4. **Android Hilt** — All new `@Singleton` classes must be provided in `AppModule.kt`
|
||||||
|
5. **Sync version** — All timer mutations must increment `syncVersion` for conflict detection
|
||||||
24
CLAUDE.md
Normal file
24
CLAUDE.md
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# ChronoMind — Claude Code Instructions
|
||||||
|
|
||||||
|
Read AGENTS.md for full context. Key rules:
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
- Product: ChronoMind (productId: "chronomind")
|
||||||
|
- Web: Next.js 16 + React 19 + TailwindCSS v4 + Zustand 5
|
||||||
|
- iOS: SwiftUI (iOS 17+), watchOS 10+, macOS 14+
|
||||||
|
- Android: Jetpack Compose + Material 3 + Room + Hilt
|
||||||
|
- Backend: platform-service (Fastify 5) in ../learning_ai_common_plat
|
||||||
|
- Tests: Vitest (web), XCTest (iOS), JUnit5 (Android)
|
||||||
|
|
||||||
|
## Build Commands
|
||||||
|
```bash
|
||||||
|
cd web && npm test && npm run typecheck && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Critical Rules
|
||||||
|
- Engine logic (web/src/lib/) must be pure TS — no React imports
|
||||||
|
- iOS shared logic in ios/ChronoMind/Shared/ — shared across all Apple targets
|
||||||
|
- Android engine in engine/ package — pure Kotlin
|
||||||
|
- Every Cosmos doc needs productId: "chronomind"
|
||||||
|
- Web build requires --webpack flag (Serwist + Turbopack incompatible)
|
||||||
|
- Commits: type(scope): description
|
||||||
84
README.md
Normal file
84
README.md
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
# ChronoMind
|
||||||
|
|
||||||
|
> AI-powered contextual clock & timer — never be caught off-guard again.
|
||||||
|
|
||||||
|
**ChronoMind** is a cross-platform time awareness layer that understands *why* you set a timer, not just *when*. It provides intelligent pre-warning cascades, urgency-based escalation, routine orchestration, and natural language interaction.
|
||||||
|
|
||||||
|
## Platforms
|
||||||
|
|
||||||
|
| Platform | Stack | Status |
|
||||||
|
|----------|-------|--------|
|
||||||
|
| **Web PWA** | Next.js 16, React 19, TailwindCSS v4, Zustand | ✅ 373 tests passing |
|
||||||
|
| **iOS** | SwiftUI (iOS 17+), WidgetKit, ActivityKit | ✅ 129 XCTests |
|
||||||
|
| **Apple Watch** | SwiftUI (watchOS 10+), Complications | ✅ Built |
|
||||||
|
| **macOS** | SwiftUI menu bar app (macOS 14+) | ✅ Built |
|
||||||
|
| **Android** | Jetpack Compose, Material 3, Room, Hilt | ✅ 30 JUnit5 tests |
|
||||||
|
| **Wear OS** | Compose for Wear OS | 🚧 Minimal |
|
||||||
|
| **Backend** | Platform-service (Fastify 5, port 4003) | ✅ 130 tests (4 modules) |
|
||||||
|
|
||||||
|
## Core Features
|
||||||
|
|
||||||
|
- **Pre-warning cascades** — configurable warnings before every timer (2h → 1h → 30m → 15m → 5m → NOW)
|
||||||
|
- **5 urgency levels** — Critical, Important, Standard, Gentle, Passive — each with distinct notification style
|
||||||
|
- **Routines** — multi-step sequences (Morning, Workout, Cooking, Wind-Down) with transitions
|
||||||
|
- **Natural language** — "meeting in 30 min", "alarm at 3pm", "pomodoro 4 rounds"
|
||||||
|
- **Focus mode** — Pomodoro sessions with notification blocking
|
||||||
|
- **Linked timers** — "When timer A ends, start timer B"
|
||||||
|
- **Visual timeline** — color-coded, urgency-aware dashboard
|
||||||
|
- **Stats & streaks** — daily activity, on-time rate, focus hours, streak tracking
|
||||||
|
- **Calendar import** — .ics file import with conflict detection
|
||||||
|
- **AI reschedule** — "I slept in 30 minutes" → shift all morning timers
|
||||||
|
- **Shareable timers** — share timer links across platforms
|
||||||
|
- **Household shared timers** — family/team coordination (up to 6 members)
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Web (development)
|
||||||
|
cd web && npm install && npm run dev
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
cd web && npm test # 373 Vitest tests
|
||||||
|
cd web && npm run typecheck # TypeScript check
|
||||||
|
cd web && npm run build # Production build
|
||||||
|
|
||||||
|
# iOS — open ios/ChronoMind.xcodeproj in Xcode
|
||||||
|
# Android — open android/ in Android Studio
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
learning_ai_clock/
|
||||||
|
├── web/ # Next.js 16 PWA (12,960 lines, 58 files)
|
||||||
|
├── ios/ # SwiftUI iOS + Watch + Mac + Widgets (12,946 lines, 72 files)
|
||||||
|
├── android/ # Jetpack Compose + Wear OS (3,427 lines, 25 files)
|
||||||
|
├── docs/ # PRD, roadmap, research
|
||||||
|
└── .github/ # CI workflows
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
Uses the shared **platform-service** from [`learning_ai_common_plat`](../learning_ai_common_plat) with 4 ChronoMind-specific modules:
|
||||||
|
|
||||||
|
- `timers` — CRUD + delta sync + batch upsert (42 tests)
|
||||||
|
- `routines` — CRUD + delta sync + batch upsert (32 tests)
|
||||||
|
- `households` — group management + invitations (26 tests)
|
||||||
|
- `shared-timers` — household-scoped timers (30 tests)
|
||||||
|
|
||||||
|
All documents use `productId: "chronomind"`.
|
||||||
|
|
||||||
|
## Ecosystem
|
||||||
|
|
||||||
|
Part of the **ByteLyst** ecosystem alongside [LysnrAI](../learning_voice_ai_agent), [MindLyst](../learning_multimodal_memory_agents), and [NomGap](../learning_ai_fastgap).
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
- [PRD](docs/PRD.md) — Full product requirements
|
||||||
|
- [Roadmap](docs/roadmap.md) — 5-phase implementation plan
|
||||||
|
- [Industry Research](docs/INDUSTRY_RESEARCH.md) — Competitive analysis
|
||||||
|
- [AGENTS.md](AGENTS.md) — AI coding agent instructions
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Closed-source application. Timer engine library open-source (license TBD).
|
||||||
@ -0,0 +1,549 @@
|
|||||||
|
package com.chronomind.app.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.chronomind.app.ui.theme.CMColors
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
// ── Routine Models (pure Kotlin, no Android deps) ─────────────
|
||||||
|
|
||||||
|
enum class TransitionType(val label: String, val minutes: Int) {
|
||||||
|
IMMEDIATE("Immediate", 0),
|
||||||
|
ONE_MIN_BREAK("1 min break", 1),
|
||||||
|
FIVE_MIN_BREAK("5 min break", 5),
|
||||||
|
CUSTOM("Custom", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class RoutineStatus { TEMPLATE, READY, ACTIVE, PAUSED, COMPLETED, CANCELLED }
|
||||||
|
enum class StepStatus { PENDING, ACTIVE, SKIPPED, COMPLETED }
|
||||||
|
|
||||||
|
data class RoutineStep(
|
||||||
|
val id: String = UUID.randomUUID().toString(),
|
||||||
|
val label: String,
|
||||||
|
val durationMinutes: Int,
|
||||||
|
val transition: TransitionType = TransitionType.IMMEDIATE,
|
||||||
|
val notes: String? = null,
|
||||||
|
var status: StepStatus = StepStatus.PENDING,
|
||||||
|
var startedAt: Long? = null,
|
||||||
|
var completedAt: Long? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Routine(
|
||||||
|
val id: String = UUID.randomUUID().toString(),
|
||||||
|
val name: String,
|
||||||
|
val description: String? = null,
|
||||||
|
val steps: List<RoutineStep>,
|
||||||
|
val isTemplate: Boolean = false,
|
||||||
|
var status: RoutineStatus = if (isTemplate) RoutineStatus.TEMPLATE else RoutineStatus.READY,
|
||||||
|
var currentStepIndex: Int = 0,
|
||||||
|
var startedAt: Long? = null,
|
||||||
|
var pausedAt: Long? = null,
|
||||||
|
var completedAt: Long? = null,
|
||||||
|
var elapsedBeforePauseMs: Long = 0
|
||||||
|
) {
|
||||||
|
val totalDurationMinutes: Int
|
||||||
|
get() = steps.mapIndexed { idx, step ->
|
||||||
|
step.durationMinutes + if (idx < steps.size - 1) step.transition.minutes else 0
|
||||||
|
}.sum()
|
||||||
|
|
||||||
|
val progress: Float
|
||||||
|
get() {
|
||||||
|
if (steps.isEmpty()) return 0f
|
||||||
|
val done = steps.count { it.status == StepStatus.COMPLETED || it.status == StepStatus.SKIPPED }
|
||||||
|
return done.toFloat() / steps.size
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentStep: RoutineStep? get() = steps.getOrNull(currentStepIndex)
|
||||||
|
val nextStep: RoutineStep? get() = steps.getOrNull(currentStepIndex + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Built-in Templates ────────────────────────────────────────
|
||||||
|
|
||||||
|
object RoutineTemplates {
|
||||||
|
val morning = Routine(
|
||||||
|
name = "Morning Routine",
|
||||||
|
description = "Start your day with intention",
|
||||||
|
isTemplate = true,
|
||||||
|
steps = listOf(
|
||||||
|
RoutineStep(label = "Wake Up + Hydrate", durationMinutes = 5, notes = "Drink a glass of water"),
|
||||||
|
RoutineStep(label = "Meditation", durationMinutes = 15, transition = TransitionType.ONE_MIN_BREAK, notes = "Mindfulness or breathing"),
|
||||||
|
RoutineStep(label = "Exercise", durationMinutes = 30, transition = TransitionType.ONE_MIN_BREAK, notes = "Workout, yoga, or a walk"),
|
||||||
|
RoutineStep(label = "Shower + Get Ready", durationMinutes = 20),
|
||||||
|
RoutineStep(label = "Breakfast", durationMinutes = 15),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val workout = Routine(
|
||||||
|
name = "Workout",
|
||||||
|
description = "Structured workout with warm-up and cool-down",
|
||||||
|
isTemplate = true,
|
||||||
|
steps = listOf(
|
||||||
|
RoutineStep(label = "Warm Up", durationMinutes = 5, notes = "Light stretching"),
|
||||||
|
RoutineStep(label = "Main Workout", durationMinutes = 30, transition = TransitionType.ONE_MIN_BREAK, notes = "Strength or cardio"),
|
||||||
|
RoutineStep(label = "Cool Down", durationMinutes = 5, notes = "Stretching"),
|
||||||
|
RoutineStep(label = "Hydrate + Recovery", durationMinutes = 5),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val cookingPrep = Routine(
|
||||||
|
name = "Cooking Prep",
|
||||||
|
description = "Organized meal preparation",
|
||||||
|
isTemplate = true,
|
||||||
|
steps = listOf(
|
||||||
|
RoutineStep(label = "Gather Ingredients", durationMinutes = 5, notes = "Get everything out"),
|
||||||
|
RoutineStep(label = "Prep & Chop", durationMinutes = 15, notes = "Wash, peel, and chop"),
|
||||||
|
RoutineStep(label = "Cook", durationMinutes = 25, transition = TransitionType.FIVE_MIN_BREAK, notes = "Follow the recipe"),
|
||||||
|
RoutineStep(label = "Plate & Serve", durationMinutes = 5),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val eveningWindDown = Routine(
|
||||||
|
name = "Evening Wind-Down",
|
||||||
|
description = "Prepare your mind and body for rest",
|
||||||
|
isTemplate = true,
|
||||||
|
steps = listOf(
|
||||||
|
RoutineStep(label = "Screen-Free Time", durationMinutes = 15, notes = "Put devices away"),
|
||||||
|
RoutineStep(label = "Light Stretching", durationMinutes = 10, transition = TransitionType.ONE_MIN_BREAK),
|
||||||
|
RoutineStep(label = "Journal / Reflect", durationMinutes = 10, notes = "Write about your day"),
|
||||||
|
RoutineStep(label = "Read", durationMinutes = 20, notes = "Read a book"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val all = listOf(morning, workout, cookingPrep, eveningWindDown)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Routine Screen ────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RoutineScreen() {
|
||||||
|
var routines by remember { mutableStateOf(RoutineTemplates.all) }
|
||||||
|
var activeRoutine by remember { mutableStateOf<Routine?>(null) }
|
||||||
|
|
||||||
|
if (activeRoutine != null) {
|
||||||
|
RoutineRunnerView(
|
||||||
|
routine = activeRoutine!!,
|
||||||
|
onUpdate = { activeRoutine = it },
|
||||||
|
onComplete = { activeRoutine = null },
|
||||||
|
onCancel = { activeRoutine = null }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
RoutineListView(
|
||||||
|
routines = routines,
|
||||||
|
onStart = { routine ->
|
||||||
|
val instance = if (routine.isTemplate) {
|
||||||
|
routine.copy(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
isTemplate = false,
|
||||||
|
status = RoutineStatus.READY,
|
||||||
|
steps = routine.steps.map { it.copy(id = UUID.randomUUID().toString(), status = StepStatus.PENDING) }
|
||||||
|
)
|
||||||
|
} else routine
|
||||||
|
// Start it
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val started = instance.copy(
|
||||||
|
status = RoutineStatus.ACTIVE,
|
||||||
|
startedAt = now,
|
||||||
|
currentStepIndex = 0,
|
||||||
|
steps = instance.steps.mapIndexed { idx, step ->
|
||||||
|
if (idx == 0) step.copy(status = StepStatus.ACTIVE, startedAt = now)
|
||||||
|
else step.copy(status = StepStatus.PENDING)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
activeRoutine = started
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Routine List ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RoutineListView(
|
||||||
|
routines: List<Routine>,
|
||||||
|
onStart: (Routine) -> Unit
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(CMColors.bg)
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
"Routines",
|
||||||
|
color = CMColors.textPrimary,
|
||||||
|
fontSize = 28.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text("Templates", color = CMColors.textSecondary, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
items(routines) { routine ->
|
||||||
|
RoutineCard(routine = routine, onStart = onStart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RoutineCard(routine: Routine, onStart: (Routine) -> Unit) {
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = CMColors.surface)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.Top
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(routine.name, color = CMColors.textPrimary, fontSize = 18.sp, fontWeight = FontWeight.Bold)
|
||||||
|
routine.description?.let {
|
||||||
|
Text(it, color = CMColors.textSecondary, fontSize = 14.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
|
Text("${routine.totalDurationMinutes} min", color = CMColors.accent, fontSize = 13.sp, fontWeight = FontWeight.Bold)
|
||||||
|
Text("${routine.steps.size} steps", color = CMColors.textTertiary, fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Step chips
|
||||||
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
items(routine.steps.take(4)) { step ->
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(4.dp),
|
||||||
|
color = CMColors.surfaceHover
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
step.label,
|
||||||
|
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
|
color = CMColors.textSecondary,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (routine.steps.size > 4) {
|
||||||
|
item {
|
||||||
|
Text("+${routine.steps.size - 4}", color = CMColors.textTertiary, fontSize = 11.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { onStart(routine) },
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = CMColors.accent),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.PlayArrow, contentDescription = "Start", modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Start", fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Routine Runner ────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RoutineRunnerView(
|
||||||
|
routine: Routine,
|
||||||
|
onUpdate: (Routine) -> Unit,
|
||||||
|
onComplete: () -> Unit,
|
||||||
|
onCancel: () -> Unit
|
||||||
|
) {
|
||||||
|
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (true) {
|
||||||
|
kotlinx.coroutines.delay(500L)
|
||||||
|
now = System.currentTimeMillis()
|
||||||
|
// Auto-advance if step time elapsed
|
||||||
|
if (routine.status == RoutineStatus.ACTIVE) {
|
||||||
|
val step = routine.currentStep ?: continue
|
||||||
|
val start = step.startedAt ?: continue
|
||||||
|
val elapsed = routine.elapsedBeforePauseMs + (now - start)
|
||||||
|
val totalMs = step.durationMinutes * 60_000L
|
||||||
|
if (elapsed >= totalMs) {
|
||||||
|
val completed = completeStep(routine, now)
|
||||||
|
onUpdate(completed)
|
||||||
|
if (completed.status == RoutineStatus.COMPLETED) {
|
||||||
|
// Stay on completed view briefly
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(CMColors.bg)
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
// Header
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(onClick = {
|
||||||
|
onCancel()
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "Cancel", tint = CMColors.textSecondary)
|
||||||
|
}
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(routine.name, color = CMColors.textPrimary, fontWeight = FontWeight.Bold)
|
||||||
|
Text(
|
||||||
|
"Step ${minOf(routine.currentStepIndex + 1, routine.steps.size)} of ${routine.steps.size}",
|
||||||
|
color = CMColors.textSecondary, fontSize = 12.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(48.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress bar
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { routine.progress },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(4.dp),
|
||||||
|
color = CMColors.accent,
|
||||||
|
trackColor = CMColors.surfaceHover
|
||||||
|
)
|
||||||
|
|
||||||
|
if (routine.status == RoutineStatus.COMPLETED) {
|
||||||
|
// Completed view
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.CheckCircle, contentDescription = null, tint = CMColors.success, modifier = Modifier.size(80.dp))
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text("Routine Complete!", color = CMColors.textPrimary, fontSize = 28.sp, fontWeight = FontWeight.Bold)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
val completed = routine.steps.count { it.status == StepStatus.COMPLETED }
|
||||||
|
val skipped = routine.steps.count { it.status == StepStatus.SKIPPED }
|
||||||
|
Text("$completed completed, $skipped skipped", color = CMColors.textSecondary)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Button(
|
||||||
|
onClick = onComplete,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = CMColors.accent),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Text("Done", fontWeight = FontWeight.Bold, modifier = Modifier.padding(8.dp))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Active step display
|
||||||
|
val step = routine.currentStep
|
||||||
|
if (step != null) {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(step.label, color = CMColors.textPrimary, fontSize = 32.sp, fontWeight = FontWeight.Bold)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Remaining time
|
||||||
|
val remainingMs = if (routine.status == RoutineStatus.PAUSED) {
|
||||||
|
step.durationMinutes * 60_000L - routine.elapsedBeforePauseMs
|
||||||
|
} else {
|
||||||
|
val start = step.startedAt ?: now
|
||||||
|
val elapsed = routine.elapsedBeforePauseMs + (now - start)
|
||||||
|
maxOf(0L, step.durationMinutes * 60_000L - elapsed)
|
||||||
|
}
|
||||||
|
val remainingSec = (remainingMs / 1000).toInt()
|
||||||
|
val mm = remainingSec / 60
|
||||||
|
val ss = remainingSec % 60
|
||||||
|
Text(
|
||||||
|
String.format("%d:%02d", mm, ss),
|
||||||
|
color = if (remainingSec <= 30) CMColors.critical else CMColors.accent,
|
||||||
|
fontSize = 56.sp,
|
||||||
|
fontWeight = FontWeight.Thin,
|
||||||
|
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
|
||||||
|
)
|
||||||
|
|
||||||
|
step.notes?.let {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(it, color = CMColors.textSecondary, fontSize = 14.sp)
|
||||||
|
}
|
||||||
|
|
||||||
|
routine.nextStep?.let { next ->
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text("Next: ${next.label}", color = CMColors.textTertiary, fontSize = 13.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step pills
|
||||||
|
LazyRow(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
itemsIndexed(routine.steps) { idx, s ->
|
||||||
|
val isCurrent = idx == routine.currentStepIndex && routine.status == RoutineStatus.ACTIVE
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = if (isCurrent) CMColors.accent.copy(alpha = 0.2f) else CMColors.surfaceHover,
|
||||||
|
border = if (isCurrent) androidx.compose.foundation.BorderStroke(1.dp, CMColors.accent) else null
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
val icon = when (s.status) {
|
||||||
|
StepStatus.COMPLETED -> Icons.Default.CheckCircle
|
||||||
|
StepStatus.SKIPPED -> Icons.Default.SkipNext
|
||||||
|
StepStatus.ACTIVE -> Icons.Default.PlayCircle
|
||||||
|
StepStatus.PENDING -> Icons.Default.RadioButtonUnchecked
|
||||||
|
}
|
||||||
|
val tint = when (s.status) {
|
||||||
|
StepStatus.COMPLETED -> CMColors.success
|
||||||
|
StepStatus.SKIPPED -> CMColors.warning
|
||||||
|
StepStatus.ACTIVE -> CMColors.accent
|
||||||
|
StepStatus.PENDING -> CMColors.textTertiary
|
||||||
|
}
|
||||||
|
Icon(icon, contentDescription = null, modifier = Modifier.size(12.dp), tint = tint)
|
||||||
|
Text(s.label, fontSize = 11.sp, color = if (isCurrent) CMColors.accent else tint, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
if (routine.status == RoutineStatus.ACTIVE) {
|
||||||
|
// Skip
|
||||||
|
IconButton(onClick = {
|
||||||
|
onUpdate(skipStep(routine, now))
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Default.SkipNext, contentDescription = "Skip", tint = CMColors.textSecondary, modifier = Modifier.size(32.dp))
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
// Complete step
|
||||||
|
Button(
|
||||||
|
onClick = { onUpdate(completeStep(routine, now)) },
|
||||||
|
shape = CircleShape,
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = CMColors.accent),
|
||||||
|
contentPadding = PaddingValues(20.dp)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Check, contentDescription = "Complete Step", modifier = Modifier.size(32.dp))
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
// Pause
|
||||||
|
IconButton(onClick = {
|
||||||
|
val paused = routine.copy(
|
||||||
|
status = RoutineStatus.PAUSED,
|
||||||
|
pausedAt = now,
|
||||||
|
elapsedBeforePauseMs = routine.elapsedBeforePauseMs +
|
||||||
|
(now - (routine.currentStep?.startedAt ?: now))
|
||||||
|
)
|
||||||
|
onUpdate(paused)
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Default.Pause, contentDescription = "Pause", tint = CMColors.textSecondary, modifier = Modifier.size(32.dp))
|
||||||
|
}
|
||||||
|
} else if (routine.status == RoutineStatus.PAUSED) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val resumed = routine.copy(
|
||||||
|
status = RoutineStatus.ACTIVE,
|
||||||
|
pausedAt = null,
|
||||||
|
steps = routine.steps.mapIndexed { idx, s ->
|
||||||
|
if (idx == routine.currentStepIndex) s.copy(startedAt = now)
|
||||||
|
else s
|
||||||
|
}
|
||||||
|
)
|
||||||
|
onUpdate(resumed)
|
||||||
|
},
|
||||||
|
shape = CircleShape,
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = CMColors.accent),
|
||||||
|
contentPadding = PaddingValues(20.dp)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.PlayArrow, contentDescription = "Resume", modifier = Modifier.size(32.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── State Helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun completeStep(routine: Routine, now: Long): Routine {
|
||||||
|
val idx = routine.currentStepIndex
|
||||||
|
if (idx >= routine.steps.size) return routine
|
||||||
|
|
||||||
|
val updatedSteps = routine.steps.mapIndexed { i, s ->
|
||||||
|
if (i == idx) s.copy(status = StepStatus.COMPLETED, completedAt = now)
|
||||||
|
else s
|
||||||
|
}
|
||||||
|
|
||||||
|
val nextIdx = idx + 1
|
||||||
|
return if (nextIdx >= routine.steps.size) {
|
||||||
|
routine.copy(steps = updatedSteps, status = RoutineStatus.COMPLETED, completedAt = now, currentStepIndex = nextIdx, elapsedBeforePauseMs = 0)
|
||||||
|
} else {
|
||||||
|
val advanced = updatedSteps.mapIndexed { i, s ->
|
||||||
|
if (i == nextIdx) s.copy(status = StepStatus.ACTIVE, startedAt = now) else s
|
||||||
|
}
|
||||||
|
routine.copy(steps = advanced, currentStepIndex = nextIdx, elapsedBeforePauseMs = 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun skipStep(routine: Routine, now: Long): Routine {
|
||||||
|
val idx = routine.currentStepIndex
|
||||||
|
if (idx >= routine.steps.size) return routine
|
||||||
|
|
||||||
|
val updatedSteps = routine.steps.mapIndexed { i, s ->
|
||||||
|
if (i == idx) s.copy(status = StepStatus.SKIPPED, completedAt = now)
|
||||||
|
else s
|
||||||
|
}
|
||||||
|
|
||||||
|
val nextIdx = idx + 1
|
||||||
|
return if (nextIdx >= routine.steps.size) {
|
||||||
|
routine.copy(steps = updatedSteps, status = RoutineStatus.COMPLETED, completedAt = now, currentStepIndex = nextIdx, elapsedBeforePauseMs = 0)
|
||||||
|
} else {
|
||||||
|
val advanced = updatedSteps.mapIndexed { i, s ->
|
||||||
|
if (i == nextIdx) s.copy(status = StepStatus.ACTIVE, startedAt = now) else s
|
||||||
|
}
|
||||||
|
routine.copy(steps = advanced, currentStepIndex = nextIdx, elapsedBeforePauseMs = 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
176
ios/ChronoMind/Shared/TimerEngine/ContextMessages.swift
Normal file
176
ios/ChronoMind/Shared/TimerEngine/ContextMessages.swift
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
// ── Contextual Pre-Warning Messages ───────────────────────────
|
||||||
|
// Keyword → helpful prep message mapping. Expandable, no LLM needed.
|
||||||
|
// Used in notification body and on timeline to give actionable context.
|
||||||
|
// Ported from web/src/lib/context-messages.ts
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Context Rule
|
||||||
|
|
||||||
|
struct ContextRule {
|
||||||
|
let keywords: [String]
|
||||||
|
let messages: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rules Engine
|
||||||
|
|
||||||
|
private let contextRules: [ContextRule] = [
|
||||||
|
// Meetings & calls
|
||||||
|
ContextRule(
|
||||||
|
keywords: ["meeting", "standup", "sync", "huddle", "scrum", "1:1", "one-on-one"],
|
||||||
|
messages: ["Review your agenda", "Check meeting notes", "Prepare talking points"]
|
||||||
|
),
|
||||||
|
ContextRule(
|
||||||
|
keywords: ["call", "phone", "dial"],
|
||||||
|
messages: ["Have the number ready", "Review call notes", "Find a quiet spot"]
|
||||||
|
),
|
||||||
|
ContextRule(
|
||||||
|
keywords: ["interview", "screening"],
|
||||||
|
messages: ["Review the job description", "Prepare your questions", "Test your camera and mic"]
|
||||||
|
),
|
||||||
|
ContextRule(
|
||||||
|
keywords: ["presentation", "demo", "pitch"],
|
||||||
|
messages: ["Run through your slides", "Check your screen sharing", "Have backup ready"]
|
||||||
|
),
|
||||||
|
|
||||||
|
// Travel & transport
|
||||||
|
ContextRule(
|
||||||
|
keywords: ["flight", "plane", "airport"],
|
||||||
|
messages: ["Check in online", "Verify gate number", "Pack your carry-on"]
|
||||||
|
),
|
||||||
|
ContextRule(
|
||||||
|
keywords: ["train", "bus", "subway", "metro"],
|
||||||
|
messages: ["Check for delays", "Have your ticket ready"]
|
||||||
|
),
|
||||||
|
ContextRule(
|
||||||
|
keywords: ["drive", "commute", "carpool", "uber", "lyft", "taxi", "cab"],
|
||||||
|
messages: ["Check traffic conditions", "Grab your keys"]
|
||||||
|
),
|
||||||
|
ContextRule(
|
||||||
|
keywords: ["pickup", "pick up", "drop off", "dropoff"],
|
||||||
|
messages: ["Confirm the location", "Check for any updates"]
|
||||||
|
),
|
||||||
|
|
||||||
|
// Health & wellness
|
||||||
|
ContextRule(
|
||||||
|
keywords: ["doctor", "dentist", "therapist", "appointment", "clinic", "hospital"],
|
||||||
|
messages: ["Bring your insurance card", "Leave with travel buffer", "Note any symptoms to mention"]
|
||||||
|
),
|
||||||
|
ContextRule(
|
||||||
|
keywords: ["medicine", "medication", "pill", "vitamin"],
|
||||||
|
messages: ["Take with water", "Check dosage"]
|
||||||
|
),
|
||||||
|
ContextRule(
|
||||||
|
keywords: ["workout", "exercise", "gym", "run", "yoga", "stretch"],
|
||||||
|
messages: ["Hydrate beforehand", "Change into workout clothes", "Warm up first"]
|
||||||
|
),
|
||||||
|
|
||||||
|
// Food & cooking
|
||||||
|
ContextRule(
|
||||||
|
keywords: ["cook", "cooking", "bake", "baking", "oven", "recipe"],
|
||||||
|
messages: ["Preheat the oven", "Gather your ingredients", "Check you have everything"]
|
||||||
|
),
|
||||||
|
ContextRule(
|
||||||
|
keywords: ["pasta", "noodle", "rice", "boil"],
|
||||||
|
messages: ["Start boiling water", "Salt the water"]
|
||||||
|
),
|
||||||
|
ContextRule(
|
||||||
|
keywords: ["dinner", "lunch", "breakfast", "meal", "eat"],
|
||||||
|
messages: ["Start prepping ingredients", "Set the table"]
|
||||||
|
),
|
||||||
|
ContextRule(
|
||||||
|
keywords: ["laundry", "washer", "dryer", "clothes"],
|
||||||
|
messages: ["Move clothes to dryer", "Check pockets first"]
|
||||||
|
),
|
||||||
|
|
||||||
|
// Work & productivity
|
||||||
|
ContextRule(
|
||||||
|
keywords: ["deadline", "due", "submit", "submission"],
|
||||||
|
messages: ["Final review before submitting", "Double-check requirements"]
|
||||||
|
),
|
||||||
|
ContextRule(
|
||||||
|
keywords: ["class", "lecture", "lesson", "school", "study"],
|
||||||
|
messages: ["Pack your materials", "Review last session notes"]
|
||||||
|
),
|
||||||
|
ContextRule(
|
||||||
|
keywords: ["focus", "deep work", "concentrate"],
|
||||||
|
messages: ["Close unnecessary tabs", "Put phone on silent", "Grab water"]
|
||||||
|
),
|
||||||
|
|
||||||
|
// Personal
|
||||||
|
ContextRule(
|
||||||
|
keywords: ["birthday", "anniversary", "celebration", "party"],
|
||||||
|
messages: ["Check if the gift is ready", "Confirm the plan"]
|
||||||
|
),
|
||||||
|
ContextRule(
|
||||||
|
keywords: ["walk", "dog", "pet"],
|
||||||
|
messages: ["Grab the leash", "Bring waste bags"]
|
||||||
|
),
|
||||||
|
ContextRule(
|
||||||
|
keywords: ["sleep", "bed", "bedtime", "wind down", "rest"],
|
||||||
|
messages: ["Start winding down", "Put away screens", "Set tomorrow's alarm"]
|
||||||
|
),
|
||||||
|
ContextRule(
|
||||||
|
keywords: ["wake", "morning", "alarm"],
|
||||||
|
messages: ["Time to get up!", "Stretch and hydrate"]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
// MARK: - Lookup
|
||||||
|
|
||||||
|
/// Get a contextual message for a timer label.
|
||||||
|
/// Returns the first matching message, or nil if no match.
|
||||||
|
func getContextMessage(label: String) -> String? {
|
||||||
|
let lower = label.lowercased()
|
||||||
|
for rule in contextRules {
|
||||||
|
if rule.keywords.contains(where: { lower.contains($0) }) {
|
||||||
|
return rule.messages.first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all matching contextual messages for a timer label.
|
||||||
|
/// Returns an array of messages from all matching rules.
|
||||||
|
func getAllContextMessages(label: String) -> [String] {
|
||||||
|
let lower = label.lowercased()
|
||||||
|
var messages: [String] = []
|
||||||
|
for rule in contextRules {
|
||||||
|
if rule.keywords.contains(where: { lower.contains($0) }) {
|
||||||
|
if let first = rule.messages.first {
|
||||||
|
messages.append(first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a contextual message formatted for a pre-warning notification.
|
||||||
|
/// Includes the time remaining context.
|
||||||
|
func getWarningMessage(label: String, minutesBefore: Int) -> String {
|
||||||
|
let contextMsg = getContextMessage(label: label)
|
||||||
|
let timeStr: String
|
||||||
|
if minutesBefore >= 60 {
|
||||||
|
let hours = minutesBefore / 60
|
||||||
|
let mins = minutesBefore % 60
|
||||||
|
timeStr = mins > 0 ? "\(hours)h \(mins)m" : "\(hours)h"
|
||||||
|
} else {
|
||||||
|
timeStr = "\(minutesBefore)m"
|
||||||
|
}
|
||||||
|
|
||||||
|
let base = "\(label) in \(timeStr)"
|
||||||
|
if let msg = contextMsg {
|
||||||
|
return "\(base) — \(msg)"
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a label matches any context rule.
|
||||||
|
func hasContextMatch(label: String) -> Bool {
|
||||||
|
return getContextMessage(label: label) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all registered context rules (for UI display / editing).
|
||||||
|
func getContextRules() -> [ContextRule] {
|
||||||
|
return contextRules
|
||||||
|
}
|
||||||
479
ios/ChronoMind/Shared/TimerEngine/NLParser.swift
Normal file
479
ios/ChronoMind/Shared/TimerEngine/NLParser.swift
Normal file
@ -0,0 +1,479 @@
|
|||||||
|
// ── Natural Language Timer Parser ──────────────────────────────
|
||||||
|
// Regex-based parser for natural time expressions. No LLM needed.
|
||||||
|
// Supports: relative times, absolute times, durations, labels, urgency hints, pomodoro.
|
||||||
|
// Ported from web/src/lib/nl-parser.ts
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Parsed Timer Types
|
||||||
|
|
||||||
|
enum ParsedTimerType: String {
|
||||||
|
case alarm
|
||||||
|
case countdown
|
||||||
|
case pomodoro
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ParsedTimer {
|
||||||
|
let type: ParsedTimerType
|
||||||
|
let label: String
|
||||||
|
let durationSeconds: TimeInterval? // for countdown
|
||||||
|
let targetTime: Date? // for alarm
|
||||||
|
let urgency: UrgencyLevel
|
||||||
|
let cascade: CascadePreset
|
||||||
|
let pomodoroRounds: Int? // for pomodoro
|
||||||
|
let confidence: Double // 0-1
|
||||||
|
let raw: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ParseResult {
|
||||||
|
let success: Bool
|
||||||
|
let timer: ParsedTimer?
|
||||||
|
let error: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Urgency Keywords
|
||||||
|
|
||||||
|
private let urgencyKeywords: [(String, UrgencyLevel)] = [
|
||||||
|
// Critical
|
||||||
|
("critical", .critical),
|
||||||
|
("urgent", .critical),
|
||||||
|
("emergency", .critical),
|
||||||
|
("flight", .critical),
|
||||||
|
("interview", .critical),
|
||||||
|
("exam", .critical),
|
||||||
|
// Important
|
||||||
|
("important", .important),
|
||||||
|
("meeting", .important),
|
||||||
|
("appointment", .important),
|
||||||
|
("doctor", .important),
|
||||||
|
("dentist", .important),
|
||||||
|
("standup", .important),
|
||||||
|
("call", .important),
|
||||||
|
// Gentle
|
||||||
|
("gentle", .gentle),
|
||||||
|
("casual", .gentle),
|
||||||
|
("maybe", .gentle),
|
||||||
|
("check", .gentle),
|
||||||
|
// Passive
|
||||||
|
("passive", .passive),
|
||||||
|
]
|
||||||
|
|
||||||
|
// MARK: - Duration Extraction
|
||||||
|
|
||||||
|
private struct DurationPattern {
|
||||||
|
let pattern: NSRegularExpression
|
||||||
|
let extractSeconds: ([String]) -> TimeInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
private let durationPatterns: [DurationPattern] = {
|
||||||
|
func regex(_ pattern: String) -> NSRegularExpression {
|
||||||
|
try! NSRegularExpression(pattern: pattern, options: .caseInsensitive)
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
// "1h 30m", "1 hour 30 minutes"
|
||||||
|
DurationPattern(
|
||||||
|
pattern: regex(#"(\d+)\s*(?:hours?|hrs?|h)\s*(\d+)\s*(?:minutes?|mins?|m\b)"#),
|
||||||
|
extractSeconds: { groups in
|
||||||
|
(Double(groups[1]) ?? 0) * 3600 + (Double(groups[2]) ?? 0) * 60
|
||||||
|
}
|
||||||
|
),
|
||||||
|
// "30 minutes", "30m", "30 min"
|
||||||
|
DurationPattern(
|
||||||
|
pattern: regex(#"(\d+)\s*(?:minutes?|mins?|m\b)"#),
|
||||||
|
extractSeconds: { groups in (Double(groups[1]) ?? 0) * 60 }
|
||||||
|
),
|
||||||
|
// "2 hours", "2h"
|
||||||
|
DurationPattern(
|
||||||
|
pattern: regex(#"(\d+)\s*(?:hours?|hrs?|h\b)"#),
|
||||||
|
extractSeconds: { groups in (Double(groups[1]) ?? 0) * 3600 }
|
||||||
|
),
|
||||||
|
// "30 seconds", "30s"
|
||||||
|
DurationPattern(
|
||||||
|
pattern: regex(#"(\d+)\s*(?:seconds?|secs?|s\b)"#),
|
||||||
|
extractSeconds: { groups in Double(groups[1]) ?? 0 }
|
||||||
|
),
|
||||||
|
// "half hour"
|
||||||
|
DurationPattern(
|
||||||
|
pattern: regex(#"half\s+(?:an?\s+)?hour"#),
|
||||||
|
extractSeconds: { _ in 30 * 60 }
|
||||||
|
),
|
||||||
|
// "quarter hour"
|
||||||
|
DurationPattern(
|
||||||
|
pattern: regex(#"quarter\s+(?:of\s+)?(?:an?\s+)?hour"#),
|
||||||
|
extractSeconds: { _ in 15 * 60 }
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}()
|
||||||
|
|
||||||
|
// MARK: - Time Extraction (absolute)
|
||||||
|
|
||||||
|
private struct TimePattern {
|
||||||
|
let pattern: NSRegularExpression
|
||||||
|
let extractTime: ([String]) -> (hours: Int, minutes: Int)?
|
||||||
|
}
|
||||||
|
|
||||||
|
private let timePatterns: [TimePattern] = {
|
||||||
|
func regex(_ pattern: String) -> NSRegularExpression {
|
||||||
|
try! NSRegularExpression(pattern: pattern, options: .caseInsensitive)
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
// "3:30pm", "3:30 PM"
|
||||||
|
TimePattern(
|
||||||
|
pattern: regex(#"(\d{1,2}):(\d{2})\s*(am|pm)"#),
|
||||||
|
extractTime: { groups in
|
||||||
|
var hours = Int(groups[1]) ?? 0
|
||||||
|
let minutes = Int(groups[2]) ?? 0
|
||||||
|
let ampm = groups[3].lowercased()
|
||||||
|
if ampm == "pm" && hours != 12 { hours += 12 }
|
||||||
|
if ampm == "am" && hours == 12 { hours = 0 }
|
||||||
|
return (hours, minutes)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
// "3pm", "3 pm"
|
||||||
|
TimePattern(
|
||||||
|
pattern: regex(#"(\d{1,2})\s*(am|pm)"#),
|
||||||
|
extractTime: { groups in
|
||||||
|
var hours = Int(groups[1]) ?? 0
|
||||||
|
let ampm = groups[2].lowercased()
|
||||||
|
if ampm == "pm" && hours != 12 { hours += 12 }
|
||||||
|
if ampm == "am" && hours == 12 { hours = 0 }
|
||||||
|
return (hours, 0)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
// "15:30" (24-hour)
|
||||||
|
TimePattern(
|
||||||
|
pattern: regex(#"(\d{1,2}):(\d{2})(?!\s*(?:am|pm))"#),
|
||||||
|
extractTime: { groups in
|
||||||
|
let hours = Int(groups[1]) ?? 0
|
||||||
|
let minutes = Int(groups[2]) ?? 0
|
||||||
|
if hours > 23 || minutes > 59 { return nil }
|
||||||
|
return (hours, minutes)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}()
|
||||||
|
|
||||||
|
// MARK: - Relative Time Patterns
|
||||||
|
|
||||||
|
private let relativePatterns: [DurationPattern] = {
|
||||||
|
func regex(_ pattern: String) -> NSRegularExpression {
|
||||||
|
try! NSRegularExpression(pattern: pattern, options: .caseInsensitive)
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
DurationPattern(
|
||||||
|
pattern: regex(#"in\s+(\d+)\s*(?:hours?|hrs?|h)\s+(?:and\s+)?(\d+)\s*(?:minutes?|mins?|m\b)"#),
|
||||||
|
extractSeconds: { groups in
|
||||||
|
(Double(groups[1]) ?? 0) * 3600 + (Double(groups[2]) ?? 0) * 60
|
||||||
|
}
|
||||||
|
),
|
||||||
|
DurationPattern(
|
||||||
|
pattern: regex(#"in\s+(\d+)\s*(?:minutes?|mins?|m\b)"#),
|
||||||
|
extractSeconds: { groups in (Double(groups[1]) ?? 0) * 60 }
|
||||||
|
),
|
||||||
|
DurationPattern(
|
||||||
|
pattern: regex(#"in\s+(\d+)\s*(?:hours?|hrs?|h\b)"#),
|
||||||
|
extractSeconds: { groups in (Double(groups[1]) ?? 0) * 3600 }
|
||||||
|
),
|
||||||
|
DurationPattern(
|
||||||
|
pattern: regex(#"in\s+(\d+)\s*(?:seconds?|secs?|s\b)"#),
|
||||||
|
extractSeconds: { groups in Double(groups[1]) ?? 0 }
|
||||||
|
),
|
||||||
|
DurationPattern(
|
||||||
|
pattern: regex(#"in\s+(?:a\s+)?half\s+(?:an?\s+)?hour"#),
|
||||||
|
extractSeconds: { _ in 30 * 60 }
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}()
|
||||||
|
|
||||||
|
// MARK: - Pomodoro Patterns
|
||||||
|
|
||||||
|
private struct PomodoroPattern {
|
||||||
|
let pattern: NSRegularExpression
|
||||||
|
let extractRounds: ([String]) -> Int
|
||||||
|
}
|
||||||
|
|
||||||
|
private let pomodoroPatterns: [PomodoroPattern] = {
|
||||||
|
func regex(_ pattern: String) -> NSRegularExpression {
|
||||||
|
try! NSRegularExpression(pattern: pattern, options: .caseInsensitive)
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
PomodoroPattern(
|
||||||
|
pattern: regex(#"pomodoro\s+(\d+)\s*(?:rounds?|x\b|times?)"#),
|
||||||
|
extractRounds: { groups in Int(groups[1]) ?? 4 }
|
||||||
|
),
|
||||||
|
PomodoroPattern(
|
||||||
|
pattern: regex(#"(\d+)\s*(?:pomodoros?|poms?)"#),
|
||||||
|
extractRounds: { groups in Int(groups[1]) ?? 4 }
|
||||||
|
),
|
||||||
|
PomodoroPattern(
|
||||||
|
pattern: regex(#"\bpomodoro\b"#),
|
||||||
|
extractRounds: { _ in 4 }
|
||||||
|
),
|
||||||
|
PomodoroPattern(
|
||||||
|
pattern: regex(#"\bfocus\s+session\b"#),
|
||||||
|
extractRounds: { _ in 4 }
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}()
|
||||||
|
|
||||||
|
// MARK: - Regex Helpers
|
||||||
|
|
||||||
|
private func matchGroups(_ regex: NSRegularExpression, in text: String) -> [String]? {
|
||||||
|
let range = NSRange(text.startIndex..., in: text)
|
||||||
|
guard let match = regex.firstMatch(in: text, range: range) else { return nil }
|
||||||
|
var groups: [String] = []
|
||||||
|
for i in 0..<match.numberOfRanges {
|
||||||
|
if let r = Range(match.range(at: i), in: text) {
|
||||||
|
groups.append(String(text[r]))
|
||||||
|
} else {
|
||||||
|
groups.append("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Label Extraction
|
||||||
|
|
||||||
|
private let labelStripPatterns: [NSRegularExpression] = {
|
||||||
|
let patterns = [
|
||||||
|
#"\bin\s+\d+\s*(?:hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)\b"#,
|
||||||
|
#"\bin\s+(?:a\s+)?half\s+(?:an?\s+)?hour\b"#,
|
||||||
|
#"\bat\s+\d{1,2}(?::\d{2})?\s*(?:am|pm)?\b"#,
|
||||||
|
#"\bfor\s+\d+\s*(?:hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)\b"#,
|
||||||
|
#"\bpomodoro\s+\d+\s*(?:rounds?|x|times?)\b"#,
|
||||||
|
#"\b\d+\s*(?:pomodoros?|poms?)\b"#,
|
||||||
|
#"\bpomodoro\b"#,
|
||||||
|
#"\bfocus\s+session\b"#,
|
||||||
|
#"\b(?:timer|alarm|reminder|countdown)\b"#,
|
||||||
|
#"\b(?:set|create|start|make|add)\s+(?:a\s+)?"#,
|
||||||
|
#"\b(?:remind\s+me\s+(?:to\s+)?)"#,
|
||||||
|
#"\b(?:in|at|for)\s*$"#,
|
||||||
|
#"\bhalf\s+(?:an?\s+)?hour\b"#,
|
||||||
|
#"\bquarter\s+(?:of\s+)?(?:an?\s+)?hour\b"#,
|
||||||
|
#"\d{1,2}:\d{2}\s*(?:am|pm)?"#,
|
||||||
|
#"\d{1,2}\s*(?:am|pm)"#,
|
||||||
|
]
|
||||||
|
return patterns.map { try! NSRegularExpression(pattern: $0, options: .caseInsensitive) }
|
||||||
|
}()
|
||||||
|
|
||||||
|
private func extractLabel(from input: String) -> String {
|
||||||
|
var label = input.trimmingCharacters(in: .whitespaces)
|
||||||
|
for regex in labelStripPatterns {
|
||||||
|
let range = NSRange(label.startIndex..., in: label)
|
||||||
|
label = regex.stringByReplacingMatches(in: label, range: range, withTemplate: " ")
|
||||||
|
}
|
||||||
|
// Clean up extra whitespace
|
||||||
|
label = label.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||||
|
label = label.trimmingCharacters(in: CharacterSet.whitespaces.union(CharacterSet(charactersIn: ",-—")))
|
||||||
|
// Capitalize first letter
|
||||||
|
if let first = label.first {
|
||||||
|
label = first.uppercased() + label.dropFirst()
|
||||||
|
}
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Urgency Detection
|
||||||
|
|
||||||
|
private func detectUrgency(from input: String) -> UrgencyLevel {
|
||||||
|
let lower = input.lowercased()
|
||||||
|
for (keyword, level) in urgencyKeywords {
|
||||||
|
if lower.contains(keyword) { return level }
|
||||||
|
}
|
||||||
|
return .standard
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cascade Selection
|
||||||
|
|
||||||
|
private func selectCascade(for urgency: UrgencyLevel) -> CascadePreset {
|
||||||
|
switch urgency {
|
||||||
|
case .critical: return .aggressive
|
||||||
|
case .important: return .standard
|
||||||
|
case .standard: return .standard
|
||||||
|
case .gentle: return .minimal
|
||||||
|
case .passive: return .none
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Main Parser
|
||||||
|
|
||||||
|
func parseNaturalLanguage(_ input: String) -> ParseResult {
|
||||||
|
let trimmed = input.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !trimmed.isEmpty else {
|
||||||
|
return ParseResult(success: false, timer: nil, error: "Empty input")
|
||||||
|
}
|
||||||
|
|
||||||
|
let urgency = detectUrgency(from: trimmed)
|
||||||
|
let cascade = selectCascade(for: urgency)
|
||||||
|
|
||||||
|
// 1. Try pomodoro patterns first
|
||||||
|
for pomo in pomodoroPatterns {
|
||||||
|
if let groups = matchGroups(pomo.pattern, in: trimmed) {
|
||||||
|
let rounds = pomo.extractRounds(groups)
|
||||||
|
let label = extractLabel(from: trimmed).isEmpty ? "Focus Session" : extractLabel(from: trimmed)
|
||||||
|
return ParseResult(
|
||||||
|
success: true,
|
||||||
|
timer: ParsedTimer(
|
||||||
|
type: .pomodoro,
|
||||||
|
label: label,
|
||||||
|
durationSeconds: nil,
|
||||||
|
targetTime: nil,
|
||||||
|
urgency: .standard,
|
||||||
|
cascade: .minimal,
|
||||||
|
pomodoroRounds: min(max(rounds, 1), 12),
|
||||||
|
confidence: 0.9,
|
||||||
|
raw: trimmed
|
||||||
|
),
|
||||||
|
error: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try "at <time>" patterns (alarm)
|
||||||
|
let atRegex = try! NSRegularExpression(pattern: #"\bat\s+"#, options: .caseInsensitive)
|
||||||
|
if matchGroups(atRegex, in: trimmed) != nil {
|
||||||
|
for tp in timePatterns {
|
||||||
|
if let groups = matchGroups(tp.pattern, in: trimmed), let time = tp.extractTime(groups) {
|
||||||
|
let now = Date()
|
||||||
|
var components = Calendar.current.dateComponents([.year, .month, .day], from: now)
|
||||||
|
components.hour = time.hours
|
||||||
|
components.minute = time.minutes
|
||||||
|
components.second = 0
|
||||||
|
guard var target = Calendar.current.date(from: components) else { continue }
|
||||||
|
if target <= now {
|
||||||
|
target = Calendar.current.date(byAdding: .day, value: 1, to: target) ?? target
|
||||||
|
}
|
||||||
|
let label = extractLabel(from: trimmed).isEmpty ? "Alarm" : extractLabel(from: trimmed)
|
||||||
|
return ParseResult(
|
||||||
|
success: true,
|
||||||
|
timer: ParsedTimer(
|
||||||
|
type: .alarm,
|
||||||
|
label: label,
|
||||||
|
durationSeconds: nil,
|
||||||
|
targetTime: target,
|
||||||
|
urgency: urgency,
|
||||||
|
cascade: cascade,
|
||||||
|
pomodoroRounds: nil,
|
||||||
|
confidence: 0.95,
|
||||||
|
raw: trimmed
|
||||||
|
),
|
||||||
|
error: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Try relative time "in X minutes" (countdown)
|
||||||
|
for rp in relativePatterns {
|
||||||
|
if let groups = matchGroups(rp.pattern, in: trimmed) {
|
||||||
|
let seconds = rp.extractSeconds(groups)
|
||||||
|
if seconds > 0 {
|
||||||
|
let label = extractLabel(from: trimmed).isEmpty ? "Timer" : extractLabel(from: trimmed)
|
||||||
|
return ParseResult(
|
||||||
|
success: true,
|
||||||
|
timer: ParsedTimer(
|
||||||
|
type: .countdown,
|
||||||
|
label: label,
|
||||||
|
durationSeconds: seconds,
|
||||||
|
targetTime: nil,
|
||||||
|
urgency: urgency,
|
||||||
|
cascade: cascade,
|
||||||
|
pomodoroRounds: nil,
|
||||||
|
confidence: 0.9,
|
||||||
|
raw: trimmed
|
||||||
|
),
|
||||||
|
error: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Try "for X minutes" duration (countdown)
|
||||||
|
let forRegex = try! NSRegularExpression(pattern: #"\bfor\s+"#, options: .caseInsensitive)
|
||||||
|
if matchGroups(forRegex, in: trimmed) != nil {
|
||||||
|
for dp in durationPatterns {
|
||||||
|
if let groups = matchGroups(dp.pattern, in: trimmed) {
|
||||||
|
let seconds = dp.extractSeconds(groups)
|
||||||
|
if seconds > 0 {
|
||||||
|
let label = extractLabel(from: trimmed).isEmpty ? "Timer" : extractLabel(from: trimmed)
|
||||||
|
return ParseResult(
|
||||||
|
success: true,
|
||||||
|
timer: ParsedTimer(
|
||||||
|
type: .countdown,
|
||||||
|
label: label,
|
||||||
|
durationSeconds: seconds,
|
||||||
|
targetTime: nil,
|
||||||
|
urgency: urgency,
|
||||||
|
cascade: cascade,
|
||||||
|
pomodoroRounds: nil,
|
||||||
|
confidence: 0.85,
|
||||||
|
raw: trimmed
|
||||||
|
),
|
||||||
|
error: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Try bare duration "30 minutes", "1h", etc.
|
||||||
|
for dp in durationPatterns {
|
||||||
|
if let groups = matchGroups(dp.pattern, in: trimmed) {
|
||||||
|
let seconds = dp.extractSeconds(groups)
|
||||||
|
if seconds > 0 {
|
||||||
|
let label = extractLabel(from: trimmed).isEmpty ? "Timer" : extractLabel(from: trimmed)
|
||||||
|
return ParseResult(
|
||||||
|
success: true,
|
||||||
|
timer: ParsedTimer(
|
||||||
|
type: .countdown,
|
||||||
|
label: label,
|
||||||
|
durationSeconds: seconds,
|
||||||
|
targetTime: nil,
|
||||||
|
urgency: urgency,
|
||||||
|
cascade: cascade,
|
||||||
|
pomodoroRounds: nil,
|
||||||
|
confidence: 0.7,
|
||||||
|
raw: trimmed
|
||||||
|
),
|
||||||
|
error: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Try bare absolute time without "at" (lower confidence)
|
||||||
|
for tp in timePatterns {
|
||||||
|
if let groups = matchGroups(tp.pattern, in: trimmed), let time = tp.extractTime(groups) {
|
||||||
|
let now = Date()
|
||||||
|
var components = Calendar.current.dateComponents([.year, .month, .day], from: now)
|
||||||
|
components.hour = time.hours
|
||||||
|
components.minute = time.minutes
|
||||||
|
components.second = 0
|
||||||
|
guard var target = Calendar.current.date(from: components) else { continue }
|
||||||
|
if target <= now {
|
||||||
|
target = Calendar.current.date(byAdding: .day, value: 1, to: target) ?? target
|
||||||
|
}
|
||||||
|
let label = extractLabel(from: trimmed).isEmpty ? "Alarm" : extractLabel(from: trimmed)
|
||||||
|
return ParseResult(
|
||||||
|
success: true,
|
||||||
|
timer: ParsedTimer(
|
||||||
|
type: .alarm,
|
||||||
|
label: label,
|
||||||
|
durationSeconds: nil,
|
||||||
|
targetTime: target,
|
||||||
|
urgency: urgency,
|
||||||
|
cascade: cascade,
|
||||||
|
pomodoroRounds: nil,
|
||||||
|
confidence: 0.6,
|
||||||
|
raw: trimmed
|
||||||
|
),
|
||||||
|
error: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ParseResult(
|
||||||
|
success: false,
|
||||||
|
timer: nil,
|
||||||
|
error: "Could not parse: \"\(trimmed)\". Try \"meeting in 30 minutes\" or \"alarm at 3pm\"."
|
||||||
|
)
|
||||||
|
}
|
||||||
264
ios/ChronoMind/Shared/TimerEngine/Recurrence.swift
Normal file
264
ios/ChronoMind/Shared/TimerEngine/Recurrence.swift
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
// ── Recurring Timer Engine ────────────────────────────────────
|
||||||
|
// Recurrence rules, next-occurrence calculation, skip/pause logic
|
||||||
|
// Ported from web/src/lib/recurrence.ts
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Recurrence Frequency
|
||||||
|
|
||||||
|
enum RecurrenceFrequency: String, Codable, CaseIterable, Identifiable {
|
||||||
|
case daily
|
||||||
|
case weekday
|
||||||
|
case weekend
|
||||||
|
case weekly
|
||||||
|
case biweekly
|
||||||
|
case monthly
|
||||||
|
case custom
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .daily: return "Every day"
|
||||||
|
case .weekday: return "Weekdays (Mon–Fri)"
|
||||||
|
case .weekend: return "Weekends (Sat–Sun)"
|
||||||
|
case .weekly: return "Every week"
|
||||||
|
case .biweekly: return "Every 2 weeks"
|
||||||
|
case .monthly: return "Every month"
|
||||||
|
case .custom: return "Custom days"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Recurrence Rule
|
||||||
|
|
||||||
|
struct RecurrenceRule: Codable, Equatable {
|
||||||
|
let frequency: RecurrenceFrequency
|
||||||
|
var daysOfWeek: [Int]? // 1=Sun, 2=Mon, ..., 7=Sat (Calendar weekday)
|
||||||
|
var interval: Int // Every N periods (default 1)
|
||||||
|
var endDate: Date? // Stop recurring after this
|
||||||
|
var timeOfDay: Int // Minutes since midnight (e.g., 540 = 9:00 AM)
|
||||||
|
|
||||||
|
init(
|
||||||
|
frequency: RecurrenceFrequency,
|
||||||
|
daysOfWeek: [Int]? = nil,
|
||||||
|
interval: Int = 1,
|
||||||
|
endDate: Date? = nil,
|
||||||
|
timeOfDay: Int
|
||||||
|
) {
|
||||||
|
self.frequency = frequency
|
||||||
|
self.daysOfWeek = daysOfWeek
|
||||||
|
self.interval = interval
|
||||||
|
self.endDate = endDate
|
||||||
|
self.timeOfDay = timeOfDay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Recurring Timer
|
||||||
|
|
||||||
|
struct RecurringTimer: Codable, Identifiable, Equatable {
|
||||||
|
let id: String
|
||||||
|
var recurrence: RecurrenceRule
|
||||||
|
var paused: Bool
|
||||||
|
var skipNext: Bool
|
||||||
|
var lastOccurrence: Date?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Next Occurrence Calculation
|
||||||
|
|
||||||
|
private let daySeconds: TimeInterval = 24 * 60 * 60
|
||||||
|
|
||||||
|
/// Calculate the next occurrence of a recurring timer after `afterDate`.
|
||||||
|
/// Returns the date of next occurrence, or nil if no more occurrences.
|
||||||
|
func getNextOccurrence(
|
||||||
|
rule: RecurrenceRule,
|
||||||
|
afterDate: Date,
|
||||||
|
maxLookaheadDays: Int = 366
|
||||||
|
) -> Date? {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let interval = max(rule.interval, 1)
|
||||||
|
|
||||||
|
// Start from the beginning of the afterDate day
|
||||||
|
let startOfDay = calendar.startOfDay(for: afterDate)
|
||||||
|
|
||||||
|
// Helper: set time-of-day on a given date
|
||||||
|
func setTimeOnDate(_ date: Date) -> Date {
|
||||||
|
let hours = rule.timeOfDay / 60
|
||||||
|
let minutes = rule.timeOfDay % 60
|
||||||
|
return calendar.date(bySettingHour: hours, minute: minutes, second: 0, of: date) ?? date
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check same day first
|
||||||
|
let sameDayCandidate = setTimeOnDate(startOfDay)
|
||||||
|
var candidates: [Date] = []
|
||||||
|
if sameDayCandidate > afterDate {
|
||||||
|
candidates.append(sameDayCandidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate candidates going forward
|
||||||
|
for dayOffset in 1...maxLookaheadDays {
|
||||||
|
guard let futureDay = calendar.date(byAdding: .day, value: dayOffset, to: startOfDay) else { continue }
|
||||||
|
candidates.append(setTimeOnDate(futureDay))
|
||||||
|
}
|
||||||
|
|
||||||
|
for candidate in candidates {
|
||||||
|
// Check end date
|
||||||
|
if let endDate = rule.endDate, candidate > endDate {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if matchesFrequency(date: candidate, rule: rule, afterDate: afterDate, interval: interval) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a given date matches the recurrence frequency rule.
|
||||||
|
private func matchesFrequency(
|
||||||
|
date: Date,
|
||||||
|
rule: RecurrenceRule,
|
||||||
|
afterDate: Date,
|
||||||
|
interval: Int
|
||||||
|
) -> Bool {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let weekday = calendar.component(.weekday, from: date) // 1=Sun, 2=Mon, ..., 7=Sat
|
||||||
|
|
||||||
|
switch rule.frequency {
|
||||||
|
case .daily:
|
||||||
|
return true
|
||||||
|
|
||||||
|
case .weekday:
|
||||||
|
return weekday >= 2 && weekday <= 6 // Mon-Fri
|
||||||
|
|
||||||
|
case .weekend:
|
||||||
|
return weekday == 1 || weekday == 7 // Sun or Sat
|
||||||
|
|
||||||
|
case .weekly:
|
||||||
|
let refWeekday = calendar.component(.weekday, from: afterDate)
|
||||||
|
if weekday != refWeekday { return false }
|
||||||
|
if interval <= 1 { return true }
|
||||||
|
let refStart = calendar.startOfDay(for: afterDate)
|
||||||
|
let diffDays = calendar.dateComponents([.day], from: refStart, to: date).day ?? 0
|
||||||
|
let mod = diffDays % (interval * 7)
|
||||||
|
return mod == 0 || mod == 7
|
||||||
|
|
||||||
|
case .biweekly:
|
||||||
|
let refWeekday = calendar.component(.weekday, from: afterDate)
|
||||||
|
if weekday != refWeekday { return false }
|
||||||
|
let refStart = calendar.startOfDay(for: afterDate)
|
||||||
|
let diffDays = calendar.dateComponents([.day], from: refStart, to: date).day ?? 0
|
||||||
|
return diffDays >= 0 && diffDays % 14 < 7
|
||||||
|
|
||||||
|
case .monthly:
|
||||||
|
let refDayOfMonth = calendar.component(.day, from: afterDate)
|
||||||
|
let daysInMonth = calendar.range(of: .day, in: .month, for: date)?.count ?? 28
|
||||||
|
let targetDay = min(refDayOfMonth, daysInMonth)
|
||||||
|
return calendar.component(.day, from: date) == targetDay
|
||||||
|
|
||||||
|
case .custom:
|
||||||
|
guard let days = rule.daysOfWeek else { return false }
|
||||||
|
return days.contains(weekday)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bulk Helpers
|
||||||
|
|
||||||
|
/// Get the next N occurrences of a recurring timer.
|
||||||
|
func getNextNOccurrences(
|
||||||
|
rule: RecurrenceRule,
|
||||||
|
afterDate: Date,
|
||||||
|
count: Int
|
||||||
|
) -> [Date] {
|
||||||
|
var occurrences: [Date] = []
|
||||||
|
var cursor = afterDate
|
||||||
|
|
||||||
|
for _ in 0..<count {
|
||||||
|
guard let next = getNextOccurrence(rule: rule, afterDate: cursor) else { break }
|
||||||
|
occurrences.append(next)
|
||||||
|
cursor = next
|
||||||
|
}
|
||||||
|
|
||||||
|
return occurrences
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply "skip next" — get the occurrence after the next one.
|
||||||
|
func getOccurrenceAfterSkip(
|
||||||
|
rule: RecurrenceRule,
|
||||||
|
afterDate: Date
|
||||||
|
) -> Date? {
|
||||||
|
guard let next = getNextOccurrence(rule: rule, afterDate: afterDate) else { return nil }
|
||||||
|
return getNextOccurrence(rule: rule, afterDate: next)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rule Builders
|
||||||
|
|
||||||
|
func createDailyRule(timeOfDayMinutes: Int, endDate: Date? = nil) -> RecurrenceRule {
|
||||||
|
RecurrenceRule(frequency: .daily, endDate: endDate, timeOfDay: timeOfDayMinutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createWeekdayRule(timeOfDayMinutes: Int, endDate: Date? = nil) -> RecurrenceRule {
|
||||||
|
RecurrenceRule(frequency: .weekday, endDate: endDate, timeOfDay: timeOfDayMinutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createWeekendRule(timeOfDayMinutes: Int, endDate: Date? = nil) -> RecurrenceRule {
|
||||||
|
RecurrenceRule(frequency: .weekend, endDate: endDate, timeOfDay: timeOfDayMinutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createWeeklyRule(timeOfDayMinutes: Int, endDate: Date? = nil) -> RecurrenceRule {
|
||||||
|
RecurrenceRule(frequency: .weekly, interval: 1, endDate: endDate, timeOfDay: timeOfDayMinutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createBiweeklyRule(timeOfDayMinutes: Int, endDate: Date? = nil) -> RecurrenceRule {
|
||||||
|
RecurrenceRule(frequency: .biweekly, endDate: endDate, timeOfDay: timeOfDayMinutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createMonthlyRule(timeOfDayMinutes: Int, endDate: Date? = nil) -> RecurrenceRule {
|
||||||
|
RecurrenceRule(frequency: .monthly, endDate: endDate, timeOfDay: timeOfDayMinutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createCustomRule(daysOfWeek: [Int], timeOfDayMinutes: Int, endDate: Date? = nil) -> RecurrenceRule {
|
||||||
|
RecurrenceRule(frequency: .custom, daysOfWeek: daysOfWeek, endDate: endDate, timeOfDay: timeOfDayMinutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Display Helpers
|
||||||
|
|
||||||
|
/// Format time-of-day minutes as "HH:MM AM/PM"
|
||||||
|
func formatTimeOfDay(minutes: Int) -> String {
|
||||||
|
let h = minutes / 60
|
||||||
|
let m = minutes % 60
|
||||||
|
let period = h >= 12 ? "PM" : "AM"
|
||||||
|
let h12 = h == 0 ? 12 : (h > 12 ? h - 12 : h)
|
||||||
|
return "\(h12):\(String(format: "%02d", m)) \(period)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a human-readable description of a recurrence rule.
|
||||||
|
func describeRecurrence(rule: RecurrenceRule) -> String {
|
||||||
|
let time = formatTimeOfDay(minutes: rule.timeOfDay)
|
||||||
|
let dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
||||||
|
|
||||||
|
switch rule.frequency {
|
||||||
|
case .daily:
|
||||||
|
return "Every day at \(time)"
|
||||||
|
case .weekday:
|
||||||
|
return "Weekdays at \(time)"
|
||||||
|
case .weekend:
|
||||||
|
return "Weekends at \(time)"
|
||||||
|
case .weekly:
|
||||||
|
return "Every week at \(time)"
|
||||||
|
case .biweekly:
|
||||||
|
return "Every 2 weeks at \(time)"
|
||||||
|
case .monthly:
|
||||||
|
return "Monthly at \(time)"
|
||||||
|
case .custom:
|
||||||
|
guard let dows = rule.daysOfWeek, !dows.isEmpty else { return "Custom at \(time)" }
|
||||||
|
// Convert Calendar weekday (1=Sun) to dayNames index (0=Sun)
|
||||||
|
let days = dows.sorted().compactMap { d -> String? in
|
||||||
|
let idx = d - 1
|
||||||
|
guard idx >= 0 && idx < dayNames.count else { return nil }
|
||||||
|
return dayNames[idx]
|
||||||
|
}.joined(separator: ", ")
|
||||||
|
return "\(days) at \(time)"
|
||||||
|
}
|
||||||
|
}
|
||||||
345
ios/ChronoMind/Shared/TimerEngine/Routines.swift
Normal file
345
ios/ChronoMind/Shared/TimerEngine/Routines.swift
Normal file
@ -0,0 +1,345 @@
|
|||||||
|
// ── Routine Engine ────────────────────────────────────────────
|
||||||
|
// Ordered sequences of timed steps with transitions, state machine, and templates
|
||||||
|
// Ported from web/src/lib/routines.ts
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Types
|
||||||
|
|
||||||
|
enum TransitionType: String, Codable, CaseIterable, Identifiable {
|
||||||
|
case immediate
|
||||||
|
case oneMinBreak = "1m_break"
|
||||||
|
case fiveMinBreak = "5m_break"
|
||||||
|
case custom
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .immediate: return "Immediate"
|
||||||
|
case .oneMinBreak: return "1 min break"
|
||||||
|
case .fiveMinBreak: return "5 min break"
|
||||||
|
case .custom: return "Custom break"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var minutes: Int {
|
||||||
|
switch self {
|
||||||
|
case .immediate: return 0
|
||||||
|
case .oneMinBreak: return 1
|
||||||
|
case .fiveMinBreak: return 5
|
||||||
|
case .custom: return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RoutineStatus: String, Codable {
|
||||||
|
case template
|
||||||
|
case ready
|
||||||
|
case active
|
||||||
|
case paused
|
||||||
|
case completed
|
||||||
|
case cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StepStatus: String, Codable {
|
||||||
|
case pending
|
||||||
|
case active
|
||||||
|
case skipped
|
||||||
|
case completed
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Routine Step
|
||||||
|
|
||||||
|
struct CMRoutineStep: Codable, Identifiable, Equatable {
|
||||||
|
let id: String
|
||||||
|
var label: String
|
||||||
|
var durationMinutes: Int
|
||||||
|
var transition: TransitionType
|
||||||
|
var customTransitionMinutes: Int?
|
||||||
|
var notes: String?
|
||||||
|
var status: StepStatus
|
||||||
|
var startedAt: Date?
|
||||||
|
var completedAt: Date?
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: String = UUID().uuidString,
|
||||||
|
label: String,
|
||||||
|
durationMinutes: Int,
|
||||||
|
transition: TransitionType = .immediate,
|
||||||
|
customTransitionMinutes: Int? = nil,
|
||||||
|
notes: String? = nil,
|
||||||
|
status: StepStatus = .pending,
|
||||||
|
startedAt: Date? = nil,
|
||||||
|
completedAt: Date? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.label = label
|
||||||
|
self.durationMinutes = durationMinutes
|
||||||
|
self.transition = transition
|
||||||
|
self.customTransitionMinutes = customTransitionMinutes
|
||||||
|
self.notes = notes
|
||||||
|
self.status = status
|
||||||
|
self.startedAt = startedAt
|
||||||
|
self.completedAt = completedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
var transitionMinutes: Int {
|
||||||
|
switch transition {
|
||||||
|
case .custom: return customTransitionMinutes ?? 0
|
||||||
|
default: return transition.minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Routine
|
||||||
|
|
||||||
|
struct CMRoutine: Codable, Identifiable, Equatable {
|
||||||
|
let id: String
|
||||||
|
var name: String
|
||||||
|
var routineDescription: String?
|
||||||
|
var steps: [CMRoutineStep]
|
||||||
|
var totalDurationMinutes: Int
|
||||||
|
var status: RoutineStatus
|
||||||
|
var currentStepIndex: Int
|
||||||
|
let createdAt: Date
|
||||||
|
var startedAt: Date?
|
||||||
|
var pausedAt: Date?
|
||||||
|
var completedAt: Date?
|
||||||
|
var elapsedBeforePause: TimeInterval
|
||||||
|
var isTemplate: Bool
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: String = UUID().uuidString,
|
||||||
|
name: String,
|
||||||
|
routineDescription: String? = nil,
|
||||||
|
steps: [CMRoutineStep],
|
||||||
|
isTemplate: Bool = false
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.routineDescription = routineDescription
|
||||||
|
self.steps = steps
|
||||||
|
self.totalDurationMinutes = CMRoutine.calculateTotalDuration(steps: steps)
|
||||||
|
self.status = isTemplate ? .template : .ready
|
||||||
|
self.currentStepIndex = 0
|
||||||
|
self.createdAt = Date()
|
||||||
|
self.startedAt = nil
|
||||||
|
self.pausedAt = nil
|
||||||
|
self.completedAt = nil
|
||||||
|
self.elapsedBeforePause = 0
|
||||||
|
self.isTemplate = isTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
static func calculateTotalDuration(steps: [CMRoutineStep]) -> Int {
|
||||||
|
steps.enumerated().reduce(0) { total, pair in
|
||||||
|
let (idx, step) = pair
|
||||||
|
let transition = idx < steps.count - 1 ? step.transitionMinutes : 0
|
||||||
|
return total + step.durationMinutes + transition
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - State Machine
|
||||||
|
|
||||||
|
extension CMRoutine {
|
||||||
|
mutating func start() {
|
||||||
|
guard status == .ready || status == .template else { return }
|
||||||
|
guard !steps.isEmpty else { return }
|
||||||
|
|
||||||
|
let now = Date()
|
||||||
|
for i in steps.indices {
|
||||||
|
steps[i].status = i == 0 ? .active : .pending
|
||||||
|
steps[i].startedAt = i == 0 ? now : nil
|
||||||
|
steps[i].completedAt = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
status = .active
|
||||||
|
currentStepIndex = 0
|
||||||
|
startedAt = now
|
||||||
|
pausedAt = nil
|
||||||
|
completedAt = nil
|
||||||
|
elapsedBeforePause = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func pause() {
|
||||||
|
guard status == .active else { return }
|
||||||
|
let now = Date()
|
||||||
|
if let currentStep = currentStep, let stepStart = currentStep.startedAt {
|
||||||
|
elapsedBeforePause += now.timeIntervalSince(stepStart)
|
||||||
|
}
|
||||||
|
status = .paused
|
||||||
|
pausedAt = now
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func resume() {
|
||||||
|
guard status == .paused else { return }
|
||||||
|
let now = Date()
|
||||||
|
steps[currentStepIndex].startedAt = now
|
||||||
|
status = .active
|
||||||
|
pausedAt = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func completeCurrentStep() {
|
||||||
|
guard status == .active else { return }
|
||||||
|
guard currentStepIndex < steps.count else { return }
|
||||||
|
|
||||||
|
let now = Date()
|
||||||
|
steps[currentStepIndex].status = .completed
|
||||||
|
steps[currentStepIndex].completedAt = now
|
||||||
|
|
||||||
|
let nextIndex = currentStepIndex + 1
|
||||||
|
if nextIndex >= steps.count {
|
||||||
|
status = .completed
|
||||||
|
completedAt = now
|
||||||
|
currentStepIndex = nextIndex
|
||||||
|
elapsedBeforePause = 0
|
||||||
|
} else {
|
||||||
|
steps[nextIndex].status = .active
|
||||||
|
steps[nextIndex].startedAt = now
|
||||||
|
currentStepIndex = nextIndex
|
||||||
|
elapsedBeforePause = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func skipCurrentStep() {
|
||||||
|
guard status == .active else { return }
|
||||||
|
guard currentStepIndex < steps.count else { return }
|
||||||
|
|
||||||
|
let now = Date()
|
||||||
|
steps[currentStepIndex].status = .skipped
|
||||||
|
steps[currentStepIndex].completedAt = now
|
||||||
|
|
||||||
|
let nextIndex = currentStepIndex + 1
|
||||||
|
if nextIndex >= steps.count {
|
||||||
|
status = .completed
|
||||||
|
completedAt = now
|
||||||
|
currentStepIndex = nextIndex
|
||||||
|
elapsedBeforePause = 0
|
||||||
|
} else {
|
||||||
|
steps[nextIndex].status = .active
|
||||||
|
steps[nextIndex].startedAt = now
|
||||||
|
currentStepIndex = nextIndex
|
||||||
|
elapsedBeforePause = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func cancel() {
|
||||||
|
guard status != .completed && status != .cancelled else { return }
|
||||||
|
status = .cancelled
|
||||||
|
completedAt = Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Utility
|
||||||
|
|
||||||
|
extension CMRoutine {
|
||||||
|
var currentStep: CMRoutineStep? {
|
||||||
|
guard currentStepIndex < steps.count else { return nil }
|
||||||
|
return steps[currentStepIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
var nextStep: CMRoutineStep? {
|
||||||
|
let nextIdx = currentStepIndex + 1
|
||||||
|
guard nextIdx < steps.count else { return nil }
|
||||||
|
return steps[nextIdx]
|
||||||
|
}
|
||||||
|
|
||||||
|
var completedStepCount: Int {
|
||||||
|
steps.filter { $0.status == .completed }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var skippedStepCount: Int {
|
||||||
|
steps.filter { $0.status == .skipped }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var progress: Double {
|
||||||
|
guard !steps.isEmpty else { return 0 }
|
||||||
|
let done = steps.filter { $0.status == .completed || $0.status == .skipped }.count
|
||||||
|
return Double(done) / Double(steps.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func remainingStepSeconds(now: Date = Date()) -> TimeInterval {
|
||||||
|
if status == .paused {
|
||||||
|
guard let currentStep = currentStep else { return 0 }
|
||||||
|
return TimeInterval(currentStep.durationMinutes * 60) - elapsedBeforePause
|
||||||
|
}
|
||||||
|
guard status == .active else { return 0 }
|
||||||
|
guard let currentStep = currentStep, let stepStart = currentStep.startedAt else { return 0 }
|
||||||
|
let elapsed = elapsedBeforePause + now.timeIntervalSince(stepStart)
|
||||||
|
return max(0, TimeInterval(currentStep.durationMinutes * 60) - elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldStepComplete(now: Date = Date()) -> Bool {
|
||||||
|
status == .active && remainingStepSeconds(now: now) <= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func instantiate() -> CMRoutine {
|
||||||
|
guard isTemplate || status == .template else { return self }
|
||||||
|
let newSteps = steps.map { step in
|
||||||
|
CMRoutineStep(
|
||||||
|
label: step.label,
|
||||||
|
durationMinutes: step.durationMinutes,
|
||||||
|
transition: step.transition,
|
||||||
|
customTransitionMinutes: step.customTransitionMinutes,
|
||||||
|
notes: step.notes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return CMRoutine(name: name, routineDescription: routineDescription, steps: newSteps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Built-in Templates
|
||||||
|
|
||||||
|
struct RoutineTemplates {
|
||||||
|
static let morning = CMRoutine(
|
||||||
|
name: "Morning Routine",
|
||||||
|
routineDescription: "Start your day with intention",
|
||||||
|
steps: [
|
||||||
|
CMRoutineStep(label: "Wake Up + Hydrate", durationMinutes: 5, notes: "Drink a glass of water"),
|
||||||
|
CMRoutineStep(label: "Meditation", durationMinutes: 15, transition: .oneMinBreak, notes: "Mindfulness or breathing exercise"),
|
||||||
|
CMRoutineStep(label: "Exercise", durationMinutes: 30, transition: .oneMinBreak, notes: "Workout, yoga, or a walk"),
|
||||||
|
CMRoutineStep(label: "Shower + Get Ready", durationMinutes: 20),
|
||||||
|
CMRoutineStep(label: "Breakfast", durationMinutes: 15),
|
||||||
|
],
|
||||||
|
isTemplate: true
|
||||||
|
)
|
||||||
|
|
||||||
|
static let workout = CMRoutine(
|
||||||
|
name: "Workout",
|
||||||
|
routineDescription: "Structured workout with warm-up and cool-down",
|
||||||
|
steps: [
|
||||||
|
CMRoutineStep(label: "Warm Up", durationMinutes: 5, notes: "Light stretching and mobility"),
|
||||||
|
CMRoutineStep(label: "Main Workout", durationMinutes: 30, transition: .oneMinBreak, notes: "Strength or cardio"),
|
||||||
|
CMRoutineStep(label: "Cool Down", durationMinutes: 5, notes: "Stretching and foam rolling"),
|
||||||
|
CMRoutineStep(label: "Hydrate + Recovery", durationMinutes: 5),
|
||||||
|
],
|
||||||
|
isTemplate: true
|
||||||
|
)
|
||||||
|
|
||||||
|
static let cookingPrep = CMRoutine(
|
||||||
|
name: "Cooking Prep",
|
||||||
|
routineDescription: "Organized meal preparation",
|
||||||
|
steps: [
|
||||||
|
CMRoutineStep(label: "Gather Ingredients", durationMinutes: 5, notes: "Get everything out"),
|
||||||
|
CMRoutineStep(label: "Prep & Chop", durationMinutes: 15, notes: "Wash, peel, and chop"),
|
||||||
|
CMRoutineStep(label: "Cook", durationMinutes: 25, transition: .fiveMinBreak, notes: "Follow the recipe"),
|
||||||
|
CMRoutineStep(label: "Plate & Serve", durationMinutes: 5),
|
||||||
|
],
|
||||||
|
isTemplate: true
|
||||||
|
)
|
||||||
|
|
||||||
|
static let eveningWindDown = CMRoutine(
|
||||||
|
name: "Evening Wind-Down",
|
||||||
|
routineDescription: "Prepare your mind and body for rest",
|
||||||
|
steps: [
|
||||||
|
CMRoutineStep(label: "Screen-Free Time", durationMinutes: 15, notes: "Put devices away"),
|
||||||
|
CMRoutineStep(label: "Light Stretching", durationMinutes: 10, transition: .oneMinBreak, notes: "Gentle yoga or stretching"),
|
||||||
|
CMRoutineStep(label: "Journal / Reflect", durationMinutes: 10, notes: "Write about your day"),
|
||||||
|
CMRoutineStep(label: "Read", durationMinutes: 20, notes: "Read a book or magazine"),
|
||||||
|
],
|
||||||
|
isTemplate: true
|
||||||
|
)
|
||||||
|
|
||||||
|
static let all: [CMRoutine] = [morning, workout, cookingPrep, eveningWindDown]
|
||||||
|
}
|
||||||
243
ios/ChronoMind/Views/Routines/RoutineEditorView.swift
Normal file
243
ios/ChronoMind/Views/Routines/RoutineEditorView.swift
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
// ── Routine Editor View ───────────────────────────────────────
|
||||||
|
// Create or edit a routine with steps, durations, and transitions
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct RoutineEditorView: View {
|
||||||
|
let routine: CMRoutine?
|
||||||
|
let onSave: (CMRoutine) -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
|
||||||
|
@State private var name: String = ""
|
||||||
|
@State private var description: String = ""
|
||||||
|
@State private var steps: [CMRoutineStep] = []
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// Name & description
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Name")
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.foregroundColor(CMColors.textSecondary)
|
||||||
|
TextField("Morning Routine", text: $name)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.padding(10)
|
||||||
|
.background(CMColors.surfaceHover)
|
||||||
|
.cornerRadius(8)
|
||||||
|
.foregroundColor(CMColors.text)
|
||||||
|
|
||||||
|
Text("Description")
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.foregroundColor(CMColors.textSecondary)
|
||||||
|
TextField("Start your day with intention", text: $description)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.padding(10)
|
||||||
|
.background(CMColors.surfaceHover)
|
||||||
|
.cornerRadius(8)
|
||||||
|
.foregroundColor(CMColors.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steps
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("Steps")
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.foregroundColor(CMColors.textSecondary)
|
||||||
|
Spacer()
|
||||||
|
Text("\(totalDuration) min total")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(CMColors.accent)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(Array(steps.enumerated()), id: \.element.id) { idx, step in
|
||||||
|
stepRow(step, index: idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
steps.append(CMRoutineStep(
|
||||||
|
label: "",
|
||||||
|
durationMinutes: 10
|
||||||
|
))
|
||||||
|
} label: {
|
||||||
|
Label("Add Step", systemImage: "plus.circle.fill")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(CMColors.accent)
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.background(CMColors.bg.ignoresSafeArea())
|
||||||
|
.navigationTitle(routine == nil ? "New Routine" : "Edit Routine")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { onCancel() }
|
||||||
|
.foregroundColor(CMColors.textSecondary)
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Save") { save() }
|
||||||
|
.foregroundColor(CMColors.accent)
|
||||||
|
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || steps.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if let r = routine {
|
||||||
|
name = r.name
|
||||||
|
description = r.routineDescription ?? ""
|
||||||
|
steps = r.steps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Step Row
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func stepRow(_ step: CMRoutineStep, index: Int) -> some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
// Drag handle / index
|
||||||
|
Text("\(index + 1)")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundColor(CMColors.textMuted)
|
||||||
|
.frame(width: 20)
|
||||||
|
|
||||||
|
// Label
|
||||||
|
TextField("Step name", text: Binding(
|
||||||
|
get: { steps[safe: index]?.label ?? "" },
|
||||||
|
set: { if steps.indices.contains(index) { steps[index].label = $0 } }
|
||||||
|
))
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.padding(8)
|
||||||
|
.background(CMColors.surfaceHover)
|
||||||
|
.cornerRadius(6)
|
||||||
|
.foregroundColor(CMColors.text)
|
||||||
|
.font(.subheadline)
|
||||||
|
|
||||||
|
// Duration stepper
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Button {
|
||||||
|
if steps.indices.contains(index) && steps[index].durationMinutes > 1 {
|
||||||
|
steps[index].durationMinutes -= 1
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "minus")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(CMColors.textSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("\(steps[safe: index]?.durationMinutes ?? 0)m")
|
||||||
|
.font(.caption.monospacedDigit())
|
||||||
|
.foregroundColor(CMColors.accent)
|
||||||
|
.frame(width: 30)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
if steps.indices.contains(index) {
|
||||||
|
steps[index].durationMinutes += 1
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(CMColors.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(CMColors.surface)
|
||||||
|
.cornerRadius(6)
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
Button {
|
||||||
|
steps.remove(at: index)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(CMColors.critical.opacity(0.7))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transition picker (not on last step)
|
||||||
|
if index < steps.count - 1 {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Spacer().frame(width: 20)
|
||||||
|
Image(systemName: "arrow.down")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(CMColors.textMuted)
|
||||||
|
|
||||||
|
Picker("Transition", selection: Binding(
|
||||||
|
get: { steps[safe: index]?.transition ?? .immediate },
|
||||||
|
set: { if steps.indices.contains(index) { steps[index].transition = $0 } }
|
||||||
|
)) {
|
||||||
|
ForEach(TransitionType.allCases) { t in
|
||||||
|
Text(t.label).tag(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
.tint(CMColors.textSecondary)
|
||||||
|
.font(.caption)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
TextField("Notes (optional)", text: Binding(
|
||||||
|
get: { steps[safe: index]?.notes ?? "" },
|
||||||
|
set: { if steps.indices.contains(index) { steps[index].notes = $0.isEmpty ? nil : $0 } }
|
||||||
|
))
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.padding(6)
|
||||||
|
.background(CMColors.surfaceHover.opacity(0.5))
|
||||||
|
.cornerRadius(4)
|
||||||
|
.foregroundColor(CMColors.textMuted)
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.leading, 28)
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(CMColors.surface)
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Computed
|
||||||
|
|
||||||
|
private var totalDuration: Int {
|
||||||
|
CMRoutine.calculateTotalDuration(steps: steps)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
let trimmedName = name.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !trimmedName.isEmpty, !steps.isEmpty else { return }
|
||||||
|
|
||||||
|
let validSteps = steps.filter { !$0.label.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||||
|
guard !validSteps.isEmpty else { return }
|
||||||
|
|
||||||
|
if var existing = routine {
|
||||||
|
existing.name = trimmedName
|
||||||
|
existing.routineDescription = description.isEmpty ? nil : description
|
||||||
|
existing.steps = validSteps
|
||||||
|
existing.totalDurationMinutes = CMRoutine.calculateTotalDuration(steps: validSteps)
|
||||||
|
onSave(existing)
|
||||||
|
} else {
|
||||||
|
let newRoutine = CMRoutine(
|
||||||
|
name: trimmedName,
|
||||||
|
routineDescription: description.isEmpty ? nil : description,
|
||||||
|
steps: validSteps
|
||||||
|
)
|
||||||
|
onSave(newRoutine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Safe Array Subscript
|
||||||
|
|
||||||
|
private extension Array {
|
||||||
|
subscript(safe index: Int) -> Element? {
|
||||||
|
indices.contains(index) ? self[index] : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
210
ios/ChronoMind/Views/Routines/RoutineListView.swift
Normal file
210
ios/ChronoMind/Views/Routines/RoutineListView.swift
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
// ── Routine List View ─────────────────────────────────────────
|
||||||
|
// Displays built-in templates and user routines with start/edit actions
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct RoutineListView: View {
|
||||||
|
@State private var routines: [CMRoutine] = RoutineTemplates.all
|
||||||
|
@State private var activeRoutine: CMRoutine?
|
||||||
|
@State private var showEditor = false
|
||||||
|
@State private var editingRoutine: CMRoutine?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// Active routine banner
|
||||||
|
if let active = activeRoutine {
|
||||||
|
activeRoutineBanner(active)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Templates section
|
||||||
|
sectionHeader("Templates")
|
||||||
|
ForEach(routines.filter { $0.isTemplate }) { routine in
|
||||||
|
routineCard(routine)
|
||||||
|
}
|
||||||
|
|
||||||
|
// User routines section
|
||||||
|
let userRoutines = routines.filter { !$0.isTemplate }
|
||||||
|
if !userRoutines.isEmpty {
|
||||||
|
sectionHeader("My Routines")
|
||||||
|
ForEach(userRoutines) { routine in
|
||||||
|
routineCard(routine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.background(CMColors.bg.ignoresSafeArea())
|
||||||
|
.navigationTitle("Routines")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button {
|
||||||
|
editingRoutine = nil
|
||||||
|
showEditor = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
.foregroundColor(CMColors.accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showEditor) {
|
||||||
|
RoutineEditorView(
|
||||||
|
routine: editingRoutine,
|
||||||
|
onSave: { routine in
|
||||||
|
if let idx = routines.firstIndex(where: { $0.id == routine.id }) {
|
||||||
|
routines[idx] = routine
|
||||||
|
} else {
|
||||||
|
routines.append(routine)
|
||||||
|
}
|
||||||
|
showEditor = false
|
||||||
|
},
|
||||||
|
onCancel: { showEditor = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.fullScreenCover(item: $activeRoutine) { routine in
|
||||||
|
RoutineRunnerView(
|
||||||
|
routine: routine,
|
||||||
|
onComplete: { completed in
|
||||||
|
activeRoutine = nil
|
||||||
|
if let idx = routines.firstIndex(where: { $0.id == completed.id }) {
|
||||||
|
routines[idx] = completed
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCancel: {
|
||||||
|
activeRoutine = nil
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subviews
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func sectionHeader(_ title: String) -> some View {
|
||||||
|
HStack {
|
||||||
|
Text(title)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(CMColors.textSecondary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func routineCard(_ routine: CMRoutine) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(routine.name)
|
||||||
|
.font(.title3.bold())
|
||||||
|
.foregroundColor(CMColors.text)
|
||||||
|
|
||||||
|
if let desc = routine.routineDescription {
|
||||||
|
Text(desc)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(CMColors.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text("\(routine.totalDurationMinutes) min")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundColor(CMColors.accent)
|
||||||
|
|
||||||
|
Text("\(routine.steps.count) steps")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(CMColors.textMuted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step preview
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
ForEach(routine.steps.prefix(5)) { step in
|
||||||
|
Text(step.label)
|
||||||
|
.font(.caption2)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(CMColors.surfaceHover)
|
||||||
|
.cornerRadius(4)
|
||||||
|
.foregroundColor(CMColors.textSecondary)
|
||||||
|
}
|
||||||
|
if routine.steps.count > 5 {
|
||||||
|
Text("+\(routine.steps.count - 5)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(CMColors.textMuted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button {
|
||||||
|
var instance = routine.isTemplate ? routine.instantiate() : routine
|
||||||
|
instance.start()
|
||||||
|
activeRoutine = instance
|
||||||
|
} label: {
|
||||||
|
Label("Start", systemImage: "play.fill")
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(CMColors.accent)
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !routine.isTemplate {
|
||||||
|
Button {
|
||||||
|
editingRoutine = routine
|
||||||
|
showEditor = true
|
||||||
|
} label: {
|
||||||
|
Label("Edit", systemImage: "pencil")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(CMColors.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(CMColors.surface)
|
||||||
|
.cornerRadius(12)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func activeRoutineBanner(_ routine: CMRoutine) -> some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Active Routine")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundColor(CMColors.accent)
|
||||||
|
Text(routine.name)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(CMColors.text)
|
||||||
|
if let step = routine.currentStep {
|
||||||
|
Text("Step: \(step.label)")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(CMColors.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button("Resume") {
|
||||||
|
activeRoutine = routine
|
||||||
|
}
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(CMColors.accent)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(CMColors.surface)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(CMColors.accent.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
348
ios/ChronoMind/Views/Routines/RoutineRunnerView.swift
Normal file
348
ios/ChronoMind/Views/Routines/RoutineRunnerView.swift
Normal file
@ -0,0 +1,348 @@
|
|||||||
|
// ── Routine Runner View ───────────────────────────────────────
|
||||||
|
// Full-screen view for executing a routine step-by-step
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct RoutineRunnerView: View {
|
||||||
|
@State var routine: CMRoutine
|
||||||
|
let onComplete: (CMRoutine) -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
|
||||||
|
@State private var now = Date()
|
||||||
|
@State private var timer: Timer?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
CMColors.bg.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Header
|
||||||
|
header
|
||||||
|
|
||||||
|
// Progress bar
|
||||||
|
progressBar
|
||||||
|
|
||||||
|
// Current step
|
||||||
|
if let step = routine.currentStep {
|
||||||
|
currentStepCard(step)
|
||||||
|
} else if routine.status == .completed {
|
||||||
|
completedView
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Step list
|
||||||
|
stepList
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
controls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear { startTicking() }
|
||||||
|
.onDisappear { stopTicking() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack {
|
||||||
|
Button {
|
||||||
|
routine.cancel()
|
||||||
|
onCancel()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundColor(CMColors.textSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text(routine.name)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(CMColors.text)
|
||||||
|
Text("Step \(min(routine.currentStepIndex + 1, routine.steps.count)) of \(routine.steps.count)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(CMColors.textSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Spacer for symmetry
|
||||||
|
Color.clear.frame(width: 28, height: 28)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Progress Bar
|
||||||
|
|
||||||
|
private var progressBar: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(CMColors.surfaceHover)
|
||||||
|
.frame(height: 4)
|
||||||
|
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(CMColors.accent)
|
||||||
|
.frame(width: geo.size.width * routine.progress, height: 4)
|
||||||
|
.animation(.easeInOut(duration: 0.3), value: routine.progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 4)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Current Step Card
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func currentStepCard(_ step: CMRoutineStep) -> some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Spacer().frame(height: 20)
|
||||||
|
|
||||||
|
// Step label
|
||||||
|
Text(step.label)
|
||||||
|
.font(.largeTitle.bold())
|
||||||
|
.foregroundColor(CMColors.text)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
// Remaining time
|
||||||
|
let remaining = routine.remainingStepSeconds(now: now)
|
||||||
|
Text(formatRemaining(remaining))
|
||||||
|
.font(.system(size: 60, weight: .thin, design: .monospaced))
|
||||||
|
.foregroundColor(remaining <= 30 ? CMColors.critical : CMColors.accent)
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
if let notes = step.notes, !notes.isEmpty {
|
||||||
|
Text(notes)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(CMColors.textSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next step preview
|
||||||
|
if let next = routine.nextStep {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text("Next:")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(CMColors.textMuted)
|
||||||
|
Text(next.label)
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundColor(CMColors.textSecondary)
|
||||||
|
if step.transition != .immediate {
|
||||||
|
Text("(\(step.transition.label))")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(CMColors.textMuted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Completed View
|
||||||
|
|
||||||
|
private var completedView: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 80))
|
||||||
|
.foregroundColor(CMColors.gentle)
|
||||||
|
|
||||||
|
Text("Routine Complete!")
|
||||||
|
.font(.largeTitle.bold())
|
||||||
|
.foregroundColor(CMColors.text)
|
||||||
|
|
||||||
|
HStack(spacing: 24) {
|
||||||
|
VStack {
|
||||||
|
Text("\(routine.completedStepCount)")
|
||||||
|
.font(.title.bold())
|
||||||
|
.foregroundColor(CMColors.accent)
|
||||||
|
Text("Completed")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(CMColors.textSecondary)
|
||||||
|
}
|
||||||
|
if routine.skippedStepCount > 0 {
|
||||||
|
VStack {
|
||||||
|
Text("\(routine.skippedStepCount)")
|
||||||
|
.font(.title.bold())
|
||||||
|
.foregroundColor(CMColors.important)
|
||||||
|
Text("Skipped")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(CMColors.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
onComplete(routine)
|
||||||
|
} label: {
|
||||||
|
Text("Done")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
.background(CMColors.accent)
|
||||||
|
.cornerRadius(12)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
.padding(.bottom, 32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Step List
|
||||||
|
|
||||||
|
private var stepList: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(Array(routine.steps.enumerated()), id: \.element.id) { idx, step in
|
||||||
|
stepPill(step, index: idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func stepPill(_ step: CMRoutineStep, index: Int) -> some View {
|
||||||
|
let isCurrent = index == routine.currentStepIndex && routine.status == .active
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
statusIcon(step.status)
|
||||||
|
.font(.caption2)
|
||||||
|
Text(step.label)
|
||||||
|
.font(.caption2)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(isCurrent ? CMColors.accent.opacity(0.2) : CMColors.surfaceHover)
|
||||||
|
.cornerRadius(16)
|
||||||
|
.foregroundColor(isCurrent ? CMColors.accent : statusColor(step.status))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.stroke(isCurrent ? CMColors.accent : Color.clear, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func statusIcon(_ status: StepStatus) -> some View {
|
||||||
|
switch status {
|
||||||
|
case .completed:
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(CMColors.gentle)
|
||||||
|
case .skipped:
|
||||||
|
Image(systemName: "forward.fill")
|
||||||
|
.foregroundColor(CMColors.important)
|
||||||
|
case .active:
|
||||||
|
Image(systemName: "play.circle.fill")
|
||||||
|
.foregroundColor(CMColors.accent)
|
||||||
|
case .pending:
|
||||||
|
Image(systemName: "circle")
|
||||||
|
.foregroundColor(CMColors.textMuted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusColor(_ status: StepStatus) -> Color {
|
||||||
|
switch status {
|
||||||
|
case .completed: return CMColors.gentle
|
||||||
|
case .skipped: return CMColors.important
|
||||||
|
case .active: return CMColors.accent
|
||||||
|
case .pending: return CMColors.textMuted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Controls
|
||||||
|
|
||||||
|
private var controls: some View {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
if routine.status == .active {
|
||||||
|
// Skip button
|
||||||
|
Button {
|
||||||
|
routine.skipCurrentStep()
|
||||||
|
checkCompletion()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "forward.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundColor(CMColors.textSecondary)
|
||||||
|
.frame(width: 56, height: 56)
|
||||||
|
.background(CMColors.surfaceHover)
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete step button
|
||||||
|
Button {
|
||||||
|
routine.completeCurrentStep()
|
||||||
|
checkCompletion()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.title.bold())
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(width: 72, height: 72)
|
||||||
|
.background(CMColors.accent)
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pause button
|
||||||
|
Button {
|
||||||
|
routine.pause()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "pause.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundColor(CMColors.textSecondary)
|
||||||
|
.frame(width: 56, height: 56)
|
||||||
|
.background(CMColors.surfaceHover)
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
} else if routine.status == .paused {
|
||||||
|
Button {
|
||||||
|
routine.resume()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "play.fill")
|
||||||
|
.font(.title.bold())
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(width: 72, height: 72)
|
||||||
|
.background(CMColors.accent)
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func formatRemaining(_ seconds: TimeInterval) -> String {
|
||||||
|
let total = Int(max(0, seconds))
|
||||||
|
let m = total / 60
|
||||||
|
let s = total % 60
|
||||||
|
return String(format: "%d:%02d", m, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startTicking() {
|
||||||
|
timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
|
||||||
|
now = Date()
|
||||||
|
if routine.shouldStepComplete(now: now) {
|
||||||
|
routine.completeCurrentStep()
|
||||||
|
checkCompletion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopTicking() {
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkCompletion() {
|
||||||
|
if routine.status == .completed {
|
||||||
|
stopTicking()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
web/env.example
Normal file
8
web/env.example
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# ── ChronoMind Web — Environment Variables ────────────────────
|
||||||
|
# Copy this file to .env.local and fill in values.
|
||||||
|
|
||||||
|
# Platform-service URL (for cloud sync — optional for local-only use)
|
||||||
|
NEXT_PUBLIC_PLATFORM_SERVICE_URL=http://localhost:4003
|
||||||
|
|
||||||
|
# Analytics (optional — stub logs to console in dev)
|
||||||
|
# NEXT_PUBLIC_PLAUSIBLE_DOMAIN=chronomind.app
|
||||||
@ -63,8 +63,8 @@ const STORAGE_KEYS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
function getBaseUrl(): string {
|
function getBaseUrl(): string {
|
||||||
if (typeof window !== 'undefined' && (window as Record<string, unknown>).__PLATFORM_URL__) {
|
if (typeof window !== 'undefined' && (window as unknown as Record<string, unknown>).__PLATFORM_URL__) {
|
||||||
return (window as Record<string, unknown>).__PLATFORM_URL__ as string;
|
return (window as unknown as Record<string, unknown>).__PLATFORM_URL__ as string;
|
||||||
}
|
}
|
||||||
return process.env.NEXT_PUBLIC_PLATFORM_SERVICE_URL ?? 'https://api.chronomind.app';
|
return process.env.NEXT_PUBLIC_PLATFORM_SERVICE_URL ?? 'https://api.chronomind.app';
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user