diff --git a/ios/ChronoMindWatch/ChronoMindWatch.entitlements b/ios/ChronoMindWatch/ChronoMindWatch.entitlements new file mode 100644 index 0000000..cc45b00 --- /dev/null +++ b/ios/ChronoMindWatch/ChronoMindWatch.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.chronomind.shared + + + diff --git a/ios/ChronoMindWatch/ChronoMindWatchApp.swift b/ios/ChronoMindWatch/ChronoMindWatchApp.swift new file mode 100644 index 0000000..a4b0b29 --- /dev/null +++ b/ios/ChronoMindWatch/ChronoMindWatchApp.swift @@ -0,0 +1,15 @@ +// ── ChronoMind Watch App Entry Point ────────────────────────── + +import SwiftUI + +@main +struct ChronoMindWatchApp: App { + @StateObject private var watchStore = WatchTimerStore() + + var body: some Scene { + WindowGroup { + WatchContentView() + .environmentObject(watchStore) + } + } +} diff --git a/ios/ChronoMindWatch/Complications/WatchComplications.swift b/ios/ChronoMindWatch/Complications/WatchComplications.swift new file mode 100644 index 0000000..7954deb --- /dev/null +++ b/ios/ChronoMindWatch/Complications/WatchComplications.swift @@ -0,0 +1,242 @@ +// ── Watch Complications ─────────────────────────────────────── +// WidgetKit complications for Apple Watch faces + +import SwiftUI +import WidgetKit + +// MARK: - Complication Timeline Provider + +struct WatchComplicationProvider: AppIntentTimelineProvider { + typealias Entry = WatchComplicationEntry + typealias Intent = WatchComplicationIntent + + func placeholder(in context: Context) -> WatchComplicationEntry { + WatchComplicationEntry( + date: Date(), + label: "Standup", + targetTime: Date().addingTimeInterval(3600), + urgency: .important, + hasTimer: true + ) + } + + func snapshot(for configuration: WatchComplicationIntent, in context: Context) async -> WatchComplicationEntry { + entryFromSharedData() + } + + func timeline(for configuration: WatchComplicationIntent, in context: Context) async -> Timeline { + let entry = entryFromSharedData() + let now = Date() + + var entries: [WatchComplicationEntry] = [] + for minuteOffset in stride(from: 0, through: 30, by: 5) { + let entryDate = Calendar.current.date(byAdding: .minute, value: minuteOffset, to: now)! + entries.append(WatchComplicationEntry( + date: entryDate, + label: entry.label, + targetTime: entry.targetTime, + urgency: entry.urgency, + hasTimer: entry.hasTimer + )) + } + + let reloadDate: Date + if entry.hasTimer { + reloadDate = min(entry.targetTime, now.addingTimeInterval(15 * 60)) + } else { + reloadDate = now.addingTimeInterval(15 * 60) + } + + return Timeline(entries: entries, policy: .after(reloadDate)) + } + + private func entryFromSharedData() -> WatchComplicationEntry { + if let timer = SharedTimerDataManager.shared.readNextFiringTimer() { + return WatchComplicationEntry( + date: Date(), + label: timer.label, + targetTime: timer.targetTime, + urgency: timer.urgency, + hasTimer: true + ) + } + return WatchComplicationEntry( + date: Date(), + label: "", + targetTime: Date(), + urgency: .standard, + hasTimer: false + ) + } +} + +// MARK: - Intent + +import AppIntents + +struct WatchComplicationIntent: WidgetConfigurationIntent { + static var title: LocalizedStringResource = "Timer Complication" + static var description: IntentDescription = "Shows next timer on watch face" +} + +// MARK: - Entry + +struct WatchComplicationEntry: TimelineEntry { + let date: Date + let label: String + let targetTime: Date + let urgency: UrgencyLevel + let hasTimer: Bool +} + +// MARK: - Circular Complication + +struct CircularComplicationView: View { + let entry: WatchComplicationEntry + + var body: some View { + if entry.hasTimer { + ZStack { + AccessoryWidgetBackground() + VStack(spacing: 0) { + Text(entry.targetTime, style: .timer) + .font(.system(size: 14, weight: .bold, design: .monospaced)) + .minimumScaleFactor(0.5) + .widgetAccentable() + } + } + } else { + ZStack { + AccessoryWidgetBackground() + Image(systemName: "clock") + .font(.system(size: 18)) + } + } + } +} + +// MARK: - Rectangular Complication + +struct RectangularComplicationView: View { + let entry: WatchComplicationEntry + + var body: some View { + if entry.hasTimer { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(entry.label) + .font(.system(size: 13, weight: .semibold)) + .lineLimit(1) + .widgetAccentable() + Text(entry.targetTime, style: .timer) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + Text(formatTime(entry.targetTime)) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + Spacer() + } + } else { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("ChronoMind") + .font(.system(size: 13, weight: .semibold)) + Text("No timers") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + Spacer() + } + } + } +} + +// MARK: - Inline Complication + +struct InlineComplicationView: View { + let entry: WatchComplicationEntry + + var body: some View { + if entry.hasTimer { + HStack(spacing: 4) { + Image(systemName: "clock.fill") + Text("\(entry.label) in") + Text(entry.targetTime, style: .timer) + } + } else { + HStack(spacing: 4) { + Image(systemName: "clock") + Text("No timers") + } + } + } +} + +// MARK: - Corner Complication + +struct CornerComplicationView: View { + let entry: WatchComplicationEntry + + var body: some View { + if entry.hasTimer { + Text(entry.targetTime, style: .timer) + .font(.system(size: 12, weight: .bold, design: .monospaced)) + .widgetAccentable() + .widgetLabel { + Text(entry.label) + } + } else { + Image(systemName: "clock") + .widgetLabel { + Text("ChronoMind") + } + } + } +} + +// MARK: - Family Dispatcher + +struct WatchComplicationEntryView: View { + @Environment(\.widgetFamily) var family + let entry: WatchComplicationEntry + + var body: some View { + switch family { + case .accessoryCircular: + CircularComplicationView(entry: entry) + case .accessoryRectangular: + RectangularComplicationView(entry: entry) + case .accessoryInline: + InlineComplicationView(entry: entry) + case .accessoryCorner: + CornerComplicationView(entry: entry) + default: + CircularComplicationView(entry: entry) + } + } +} + +// MARK: - Widget Definition + +struct WatchTimerComplication: Widget { + let kind: String = "WatchTimerComplication" + + var body: some WidgetConfiguration { + AppIntentConfiguration( + kind: kind, + intent: WatchComplicationIntent.self, + provider: WatchComplicationProvider() + ) { entry in + WatchComplicationEntryView(entry: entry) + .containerBackground(for: .widget) { } + } + .configurationDisplayName("Timer") + .description("Shows next timer countdown on your watch face") + .supportedFamilies([ + .accessoryCircular, + .accessoryRectangular, + .accessoryInline, + .accessoryCorner, + ]) + } +} diff --git a/ios/ChronoMindWatch/Views/WatchContentView.swift b/ios/ChronoMindWatch/Views/WatchContentView.swift new file mode 100644 index 0000000..4b964a2 --- /dev/null +++ b/ios/ChronoMindWatch/Views/WatchContentView.swift @@ -0,0 +1,176 @@ +// ── Watch Content View ──────────────────────────────────────── +// Main navigation for watchOS — timeline + quick timer + +import SwiftUI + +struct WatchContentView: View { + @EnvironmentObject var store: WatchTimerStore + + var body: some View { + NavigationStack { + if store.timers.isEmpty { + emptyState + } else { + timerList + } + } + .onAppear { + store.loadFromSharedData() + } + } + + // MARK: - Timer List + + private var timerList: some View { + List { + // Next up section + if let next = store.nextFiringTimer { + Section { + NavigationLink { + WatchTimerDetailView(timer: next) + } label: { + WatchNextUpCard(timer: next, now: store.now) + } + .listRowBackground(urgencyColor(next.urgency).opacity(0.15)) + } header: { + Text("NEXT UP") + .font(.system(size: 10, weight: .semibold)) + } + } + + // Quick timer + Section { + NavigationLink { + WatchQuickTimerView() + } label: { + Label("Quick Timer", systemImage: "plus.circle.fill") + .foregroundStyle(.blue) + } + } + + // All active timers + if store.activeTimers.count > 1 { + Section { + ForEach(store.activeTimers.filter { $0.id != store.nextFiringTimer?.id }) { timer in + NavigationLink { + WatchTimerDetailView(timer: timer) + } label: { + WatchTimerRow(timer: timer, now: store.now) + } + } + } header: { + Text("UPCOMING") + .font(.system(size: 10, weight: .semibold)) + } + } + } + .navigationTitle("ChronoMind") + } + + // MARK: - Empty State + + private var emptyState: some View { + VStack(spacing: 12) { + Image(systemName: "clock") + .font(.system(size: 36)) + .foregroundStyle(.secondary) + + Text("No Timers") + .font(.system(size: 16, weight: .semibold)) + + NavigationLink { + WatchQuickTimerView() + } label: { + Label("Create Timer", systemImage: "plus") + } + .buttonStyle(.borderedProminent) + .tint(.blue) + } + .navigationTitle("ChronoMind") + } + + private func urgencyColor(_ urgency: UrgencyLevel) -> Color { + switch urgency { + case .critical: return .red + case .important: return .orange + case .standard: return .yellow + case .gentle: return .green + case .passive: return .blue + } + } +} + +// MARK: - Next Up Card + +struct WatchNextUpCard: View { + let timer: TimerSnapshot + let now: Date + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(timer.label) + .font(.system(size: 14, weight: .semibold)) + .lineLimit(1) + + Text(timer.targetTime, style: .timer) + .font(.system(size: 24, weight: .bold, design: .monospaced)) + .foregroundStyle(urgencyColor(timer.urgency)) + + Text(formatTime(timer.targetTime)) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + + private func urgencyColor(_ urgency: UrgencyLevel) -> Color { + switch urgency { + case .critical: return .red + case .important: return .orange + case .standard: return .yellow + case .gentle: return .green + case .passive: return .blue + } + } +} + +// MARK: - Timer Row + +struct WatchTimerRow: View { + let timer: TimerSnapshot + let now: Date + + var body: some View { + HStack { + Circle() + .fill(urgencyColor(timer.urgency)) + .frame(width: 6, height: 6) + + VStack(alignment: .leading, spacing: 2) { + Text(timer.label) + .font(.system(size: 13, weight: .medium)) + .lineLimit(1) + Text(formatTime(timer.targetTime)) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(timer.targetTime, style: .timer) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundStyle(urgencyColor(timer.urgency)) + .multilineTextAlignment(.trailing) + } + } + + private func urgencyColor(_ urgency: UrgencyLevel) -> Color { + switch urgency { + case .critical: return .red + case .important: return .orange + case .standard: return .yellow + case .gentle: return .green + case .passive: return .blue + } + } +} diff --git a/ios/ChronoMindWatch/Views/WatchQuickTimerView.swift b/ios/ChronoMindWatch/Views/WatchQuickTimerView.swift new file mode 100644 index 0000000..1b29948 --- /dev/null +++ b/ios/ChronoMindWatch/Views/WatchQuickTimerView.swift @@ -0,0 +1,55 @@ +// ── Watch Quick Timer View ──────────────────────────────────── +// One-tap preset timers for Apple Watch + +import SwiftUI +import WatchKit + +struct WatchQuickTimerView: View { + @EnvironmentObject var store: WatchTimerStore + @Environment(\.dismiss) private var dismiss + + private let presets: [(minutes: Int, label: String, icon: String)] = [ + (5, "5 min", "5.circle.fill"), + (10, "10 min", "10.circle.fill"), + (15, "15 min", "15.circle.fill"), + (25, "Pomodoro", "target"), + (30, "30 min", "30.circle.fill"), + (45, "45 min", "45.circle.fill"), + (60, "1 hour", "clock.fill"), + (90, "1.5 hours", "clock.badge.checkmark"), + ] + + var body: some View { + ScrollView { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { + ForEach(presets, id: \.minutes) { preset in + Button { + createTimer(preset) + } label: { + VStack(spacing: 4) { + Image(systemName: preset.icon) + .font(.system(size: 18)) + .foregroundStyle(.blue) + Text(preset.label) + .font(.system(size: 12, weight: .medium)) + .lineLimit(1) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(.blue.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 4) + } + .navigationTitle("Quick Timer") + .navigationBarTitleDisplayMode(.inline) + } + + private func createTimer(_ preset: (minutes: Int, label: String, icon: String)) { + store.createQuickTimer(minutes: preset.minutes, label: "\(preset.label) Timer") + dismiss() + } +} diff --git a/ios/ChronoMindWatch/Views/WatchTimerDetailView.swift b/ios/ChronoMindWatch/Views/WatchTimerDetailView.swift new file mode 100644 index 0000000..6edd7df --- /dev/null +++ b/ios/ChronoMindWatch/Views/WatchTimerDetailView.swift @@ -0,0 +1,98 @@ +// ── Watch Timer Detail View ─────────────────────────────────── +// Full detail view for a timer on Apple Watch + +import SwiftUI +import WatchKit + +struct WatchTimerDetailView: View { + let timer: TimerSnapshot + + var body: some View { + ScrollView { + VStack(spacing: 12) { + // Urgency badge + Text(timer.urgency.rawValue.uppercased()) + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(urgencyColor(timer.urgency)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(urgencyColor(timer.urgency).opacity(0.2)) + .clipShape(Capsule()) + + // Timer label + Text(timer.label) + .font(.system(size: 16, weight: .semibold)) + .multilineTextAlignment(.center) + + // Large countdown + Text(timer.targetTime, style: .timer) + .font(.system(size: 32, weight: .bold, design: .monospaced)) + .foregroundStyle(urgencyColor(timer.urgency)) + .contentTransition(.numericText()) + + // Target time + HStack(spacing: 4) { + Image(systemName: "bell.fill") + .font(.system(size: 10)) + Text(formatTime(timer.targetTime)) + .font(.system(size: 13, weight: .medium)) + } + .foregroundStyle(.secondary) + + // Pomodoro info + if let round = timer.pomodoroCurrentRound, + let total = timer.pomodoroTotalRounds { + HStack(spacing: 4) { + Image(systemName: "target") + .font(.system(size: 10)) + Text(timer.pomodoroIsBreak == true ? "Break" : "Round \(round)/\(total)") + .font(.system(size: 12, weight: .medium)) + } + .foregroundStyle(.secondary) + } + + // Cascade progress + if timer.totalWarnings > 0 { + VStack(spacing: 4) { + HStack { + Text("Warnings") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + Spacer() + Text("\(timer.firedWarnings)/\(timer.totalWarnings)") + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + } + ProgressView(value: Double(timer.firedWarnings), total: Double(timer.totalWarnings)) + .tint(urgencyColor(timer.urgency)) + } + .padding(.top, 4) + } + + // Snooze info + if timer.snoozeCount > 0 { + HStack(spacing: 4) { + Image(systemName: "zzz") + .font(.system(size: 10)) + Text("Snoozed \(timer.snoozeCount)x") + .font(.system(size: 11)) + } + .foregroundStyle(.orange) + } + } + .padding() + } + .navigationTitle(timer.type.rawValue.capitalized) + .navigationBarTitleDisplayMode(.inline) + } + + private func urgencyColor(_ urgency: UrgencyLevel) -> Color { + switch urgency { + case .critical: return .red + case .important: return .orange + case .standard: return .yellow + case .gentle: return .green + case .passive: return .blue + } + } +} diff --git a/ios/ChronoMindWatch/WatchTimerStore.swift b/ios/ChronoMindWatch/WatchTimerStore.swift new file mode 100644 index 0000000..802af6e --- /dev/null +++ b/ios/ChronoMindWatch/WatchTimerStore.swift @@ -0,0 +1,124 @@ +// ── Watch Timer Store ───────────────────────────────────────── +// Reads timer data from App Group shared by the iOS app +// Lightweight store for watchOS — read-only from shared data + local quick timers + +import Foundation +import Combine +import WatchKit + +@MainActor +final class WatchTimerStore: ObservableObject { + // MARK: - Published State + + @Published var timers: [TimerSnapshot] = [] + @Published var now: Date = Date() + + // MARK: - Private + + private var tickTimer: Timer? + private let sharedData = SharedTimerDataManager.shared + + // MARK: - Init + + init() { + loadFromSharedData() + startTicking() + } + + deinit { + tickTimer?.invalidate() + } + + // MARK: - Data Loading + + func loadFromSharedData() { + timers = sharedData.readActiveSnapshots() + } + + // MARK: - Queries + + var nextFiringTimer: TimerSnapshot? { + timers + .filter { [.active, .warning].contains($0.state) } + .sorted { $0.targetTime < $1.targetTime } + .first + } + + var activeTimers: [TimerSnapshot] { + timers.filter { [.active, .warning, .snoozed, .firing].contains($0.state) } + } + + // MARK: - Quick Timer Creation (writes to App Group) + + func createQuickTimer(minutes: Int, label: String) { + let now = Date() + let targetTime = now.addingTimeInterval(TimeInterval(minutes * 60)) + let intervals = CascadePreset.minimal.defaultIntervals + + let snapshot = TimerSnapshot( + id: UUID().uuidString, + label: label, + type: .countdown, + urgency: .standard, + state: .active, + targetTime: targetTime, + duration: TimeInterval(minutes * 60), + startedAt: now, + elapsedBeforePause: 0, + snoozeCount: 0, + category: nil, + pomodoroCurrentRound: nil, + pomodoroTotalRounds: nil, + pomodoroIsBreak: nil, + nextWarningTime: intervals.first.map { targetTime.addingTimeInterval(-Double($0) * 60) }, + totalWarnings: intervals.count, + firedWarnings: 0 + ) + + // Add to local list and persist + timers.append(snapshot) + timers.sort { $0.targetTime < $1.targetTime } + + // Write back to shared data so iOS app picks it up + sharedData.writeSnapshots(timers) + + // Haptic confirmation + WKInterfaceDevice.current().play(.success) + } + + // MARK: - Tick + + private func startTicking() { + tickTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.tick() + } + } + } + + private func tick() { + now = Date() + + // Refresh from shared data every 30 seconds + let interval = Int(now.timeIntervalSince1970) % 30 + if interval == 0 { + loadFromSharedData() + } + + // Check for fired timers + var changed = false + for i in timers.indices { + if timers[i].state == .active || timers[i].state == .warning { + if now >= timers[i].targetTime { + // Timer has fired — haptic alert + WKInterfaceDevice.current().play(.notification) + changed = true + } + } + } + + if changed { + loadFromSharedData() // Refresh to get updated states + } + } +}