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;
|
||||
|
||||
function getBaseUrl(): string {
|
||||
if (typeof window !== 'undefined' && (window as Record<string, unknown>).__PLATFORM_URL__) {
|
||||
return (window as Record<string, unknown>).__PLATFORM_URL__ as string;
|
||||
if (typeof window !== 'undefined' && (window as unknown as Record<string, unknown>).__PLATFORM_URL__) {
|
||||
return (window as unknown as Record<string, unknown>).__PLATFORM_URL__ as string;
|
||||
}
|
||||
return process.env.NEXT_PUBLIC_PLATFORM_SERVICE_URL ?? 'https://api.chronomind.app';
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user