From 2fc277b663908aa3cb6326eba0916f96ebe86fb1 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 27 Feb 2026 22:35:26 -0800 Subject: [PATCH] =?UTF-8?q?feat(macos):=20add=20macOS=20menu=20bar=20app?= =?UTF-8?q?=20=E2=80=94=20popover=20timeline,=20quick=20timer,=20keyboard?= =?UTF-8?q?=20shortcut,=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ios/ChronoMindMac/ChronoMindMacApp.swift | 39 +++ ios/ChronoMindMac/MacTimerStore.swift | 180 ++++++++++++ ios/ChronoMindMac/MenuBarState.swift | 29 ++ ios/ChronoMindMac/Views/MacSettingsView.swift | 100 +++++++ ios/ChronoMindMac/Views/MenuBarPopover.swift | 264 ++++++++++++++++++ ios/project.yml | 43 +++ 6 files changed, 655 insertions(+) create mode 100644 ios/ChronoMindMac/ChronoMindMacApp.swift create mode 100644 ios/ChronoMindMac/MacTimerStore.swift create mode 100644 ios/ChronoMindMac/MenuBarState.swift create mode 100644 ios/ChronoMindMac/Views/MacSettingsView.swift create mode 100644 ios/ChronoMindMac/Views/MenuBarPopover.swift diff --git a/ios/ChronoMindMac/ChronoMindMacApp.swift b/ios/ChronoMindMac/ChronoMindMacApp.swift new file mode 100644 index 0000000..1121ce4 --- /dev/null +++ b/ios/ChronoMindMac/ChronoMindMacApp.swift @@ -0,0 +1,39 @@ +// ── ChronoMind macOS Menu Bar App ───────────────────────────── +// Native macOS menu bar app sharing 90%+ code with iOS via Shared/ + +import SwiftUI + +@main +struct ChronoMindMacApp: App { + @StateObject private var menuBarState = MenuBarState.shared + @StateObject private var timerStore = MacTimerStore.shared + + var body: some Scene { + // Menu bar extra — always visible + MenuBarExtra { + MenuBarPopover() + .environmentObject(timerStore) + .environmentObject(menuBarState) + } label: { + menuBarLabel + } + .menuBarExtraStyle(.window) + + // Settings window + Settings { + MacSettingsView() + .environmentObject(timerStore) + } + } + + private var menuBarLabel: some View { + HStack(spacing: 4) { + Image(systemName: "clock.fill") + if let next = timerStore.nextFiringTimer { + let remaining = getRemainingSeconds(next, now: timerStore.now) + Text(formatDurationCompact(remaining)) + .monospacedDigit() + } + } + } +} diff --git a/ios/ChronoMindMac/MacTimerStore.swift b/ios/ChronoMindMac/MacTimerStore.swift new file mode 100644 index 0000000..c0244e9 --- /dev/null +++ b/ios/ChronoMindMac/MacTimerStore.swift @@ -0,0 +1,180 @@ +// ── Mac Timer Store ─────────────────────────────────────────── +// macOS-specific timer store that shares engine logic with iOS +// Reads from shared App Group UserDefaults for cross-device sync + +import Foundation +import Combine +import UserNotifications + +@MainActor +final class MacTimerStore: ObservableObject { + static let shared = MacTimerStore() + + @Published var timers: [CMTimer] = [] + @Published var now: Date = Date() + + private var tickTimer: Timer? + private let persistenceKey = "chronomind-timers" + private let sharedDefaults: UserDefaults? + + var activeTimers: [CMTimer] { + timers.filter { isTimerActive($0) } + } + + var nextFiringTimer: CMTimer? { + activeTimers + .filter { $0.state == .active || $0.state == .warning } + .sorted { $0.targetTime < $1.targetTime } + .first + } + + private init() { + sharedDefaults = UserDefaults(suiteName: "group.com.chronomind.shared") + loadTimers() + startTicking() + requestNotificationPermission() + } + + deinit { + tickTimer?.invalidate() + } + + // MARK: - CRUD + + func addCountdown(label: String, durationSeconds: TimeInterval) { + let timer = createCountdown(CreateCountdownParams( + label: label, + durationSeconds: durationSeconds + )) + timers.append(timer) + scheduleNotification(for: timer) + saveTimers() + } + + func addAlarm(label: String, targetTime: Date, urgency: UrgencyLevel = .standard) { + let timer = createAlarm(CreateAlarmParams( + label: label, + targetTime: targetTime, + urgency: urgency + )) + timers.append(timer) + scheduleNotification(for: timer) + saveTimers() + } + + func removeTimer(_ id: String) { + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [id]) + timers.removeAll { $0.id == id } + saveTimers() + } + + func pause(_ id: String) { + updateTimer(id) { pauseTimer($0) } + } + + func resume(_ id: String) { + updateTimer(id) { t in + let resumed = resumeTimer(t) + self.scheduleNotification(for: resumed) + return resumed + } + } + + func snooze(_ id: String, minutes: Int) { + updateTimer(id) { t in + let snoozed = snoozeTimer(t, snoozeMinutes: minutes) + self.scheduleNotification(for: snoozed) + return snoozed + } + } + + func dismiss(_ id: String) { + updateTimer(id) { dismissTimer($0) } + } + + func complete(_ id: String) { + updateTimer(id) { completeTimer($0) } + } + + // 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() + var changed = false + + for i in timers.indices { + if shouldTimerFire(timers[i], now: now) { + timers[i] = fireTimer(timers[i]) + changed = true + } + + let newlyFired = checkWarnings(&timers[i].warnings, now: now) + if !newlyFired.isEmpty { + changed = true + if timers[i].state == .active { + timers[i].state = .warning + } + } + } + + if changed { saveTimers() } + } + + // MARK: - Persistence + + private func loadTimers() { + // Try shared defaults first (synced from iOS), then local + let defaults = sharedDefaults ?? UserDefaults.standard + guard let data = defaults.data(forKey: persistenceKey) else { return } + if let decoded = try? JSONDecoder().decode([CMTimer].self, from: data) { + timers = decoded + } + } + + private func saveTimers() { + guard let data = try? JSONEncoder().encode(timers) else { return } + UserDefaults.standard.set(data, forKey: persistenceKey) + sharedDefaults?.set(data, forKey: persistenceKey) + } + + // MARK: - Notifications + + private func requestNotificationPermission() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in } + } + + private func scheduleNotification(for timer: CMTimer) { + let content = UNMutableNotificationContent() + content.title = timer.label + content.body = "Timer fired!" + content.sound = .default + content.categoryIdentifier = "TIMER_FIRED" + + let interval = timer.targetTime.timeIntervalSinceNow + guard interval > 0 else { return } + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: interval, repeats: false) + let request = UNNotificationRequest(identifier: timer.id, content: content, trigger: trigger) + UNUserNotificationCenter.current().add(request) + } + + // MARK: - Helpers + + private func updateTimer(_ id: String, transform: (CMTimer) -> CMTimer) { + guard let index = timers.firstIndex(where: { $0.id == id }) else { return } + timers[index] = transform(timers[index]) + saveTimers() + } + + func getTimer(_ id: String) -> CMTimer? { + timers.first { $0.id == id } + } +} diff --git a/ios/ChronoMindMac/MenuBarState.swift b/ios/ChronoMindMac/MenuBarState.swift new file mode 100644 index 0000000..75f7390 --- /dev/null +++ b/ios/ChronoMindMac/MenuBarState.swift @@ -0,0 +1,29 @@ +// ── Menu Bar State ──────────────────────────────────────────── +// Observable state for the macOS menu bar popover + +import SwiftUI +import Combine + +@MainActor +final class MenuBarState: ObservableObject { + static let shared = MenuBarState() + + @Published var isExpanded = false + @Published var showQuickTimer = false + @Published var quickTimerLabel = "" + @Published var quickTimerMinutes: Double = 25 + + private init() {} + + func toggleExpanded() { + withAnimation(.easeInOut(duration: 0.2)) { + isExpanded.toggle() + } + } + + func resetQuickTimer() { + quickTimerLabel = "" + quickTimerMinutes = 25 + showQuickTimer = false + } +} diff --git a/ios/ChronoMindMac/Views/MacSettingsView.swift b/ios/ChronoMindMac/Views/MacSettingsView.swift new file mode 100644 index 0000000..702fd8e --- /dev/null +++ b/ios/ChronoMindMac/Views/MacSettingsView.swift @@ -0,0 +1,100 @@ +// ── macOS Settings View ─────────────────────────────────────── + +import SwiftUI + +struct MacSettingsView: View { + @EnvironmentObject var store: MacTimerStore + @AppStorage("cm_defaultUrgency") private var defaultUrgency = "standard" + @AppStorage("cm_defaultCascade") private var defaultCascade = "standard" + @AppStorage("cm_launchAtLogin") private var launchAtLogin = false + + var body: some View { + TabView { + generalTab + .tabItem { Label("General", systemImage: "gearshape") } + + dataTab + .tabItem { Label("Data", systemImage: "externaldrive") } + + aboutTab + .tabItem { Label("About", systemImage: "info.circle") } + } + .frame(width: 400, height: 300) + } + + // MARK: - General + + private var generalTab: some View { + Form { + Toggle("Launch at Login", isOn: $launchAtLogin) + + Picker("Default Urgency", selection: $defaultUrgency) { + ForEach(UrgencyLevel.allCases) { level in + Text(getUrgencyConfig(level).label).tag(level.rawValue) + } + } + + Picker("Default Cascade", selection: $defaultCascade) { + ForEach(CascadePreset.allCases.filter { $0 != .custom }) { preset in + Text(preset.label).tag(preset.rawValue) + } + } + } + .padding() + } + + // MARK: - Data + + private var dataTab: some View { + Form { + HStack { + Text("Total Timers") + Spacer() + Text("\(store.timers.count)") + .foregroundStyle(.secondary) + } + + HStack { + Text("Active") + Spacer() + Text("\(store.activeTimers.count)") + .foregroundStyle(.secondary) + } + + Divider() + + Button("Clear Completed") { + store.timers.removeAll { [.completed, .dismissed].contains($0.state) } + } + + Button("Delete All Timers", role: .destructive) { + store.timers.removeAll() + } + } + .padding() + } + + // MARK: - About + + private var aboutTab: some View { + VStack(spacing: 12) { + Image(systemName: "clock.fill") + .font(.system(size: 48)) + .foregroundStyle(CMColors.accent) + + Text("ChronoMind") + .font(.title2.bold()) + + Text("Version 1.0.0") + .foregroundStyle(.secondary) + + Text("Time management with cascade warnings") + .font(.caption) + .foregroundStyle(.tertiary) + + Spacer() + } + .padding() + .frame(maxWidth: .infinity) + } +} diff --git a/ios/ChronoMindMac/Views/MenuBarPopover.swift b/ios/ChronoMindMac/Views/MenuBarPopover.swift new file mode 100644 index 0000000..87f83fa --- /dev/null +++ b/ios/ChronoMindMac/Views/MenuBarPopover.swift @@ -0,0 +1,264 @@ +// ── Menu Bar Popover ────────────────────────────────────────── +// Main popover view shown when clicking the menu bar icon + +import SwiftUI + +struct MenuBarPopover: View { + @EnvironmentObject var store: MacTimerStore + @EnvironmentObject var menuBar: MenuBarState + + var body: some View { + VStack(spacing: 0) { + // Header + header + + Divider().background(CMColors.border) + + // Quick timer creation + if menuBar.showQuickTimer { + quickTimerForm + Divider().background(CMColors.border) + } + + // Timer list + if store.activeTimers.isEmpty && !menuBar.showQuickTimer { + emptyState + } else { + timerList + } + + Divider().background(CMColors.border) + + // Footer + footer + } + .frame(width: 320) + .background(CMColors.bg) + } + + // MARK: - Header + + private var header: some View { + HStack { + Text("ChronoMind") + .font(CMFonts.display(size: 14)) + .foregroundStyle(CMColors.text) + + Spacer() + + Button { + menuBar.showQuickTimer.toggle() + } label: { + Image(systemName: menuBar.showQuickTimer ? "xmark" : "plus") + .font(.body.weight(.semibold)) + .foregroundStyle(CMColors.accent) + } + .buttonStyle(.plain) + .keyboardShortcut("t", modifiers: [.command, .shift]) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + } + + // MARK: - Quick Timer Form + + private var quickTimerForm: some View { + VStack(spacing: 8) { + TextField("Timer label", text: $menuBar.quickTimerLabel) + .textFieldStyle(.plain) + .font(CMFonts.body(size: 14)) + .foregroundStyle(CMColors.text) + .padding(8) + .background(CMColors.surface) + .clipShape(RoundedRectangle(cornerRadius: 6)) + + HStack { + Text("\(Int(menuBar.quickTimerMinutes))m") + .font(CMFonts.mono(size: 13, weight: .semibold)) + .foregroundStyle(CMColors.accent) + .frame(width: 40) + + Slider(value: $menuBar.quickTimerMinutes, in: 1...120, step: 1) + .tint(CMColors.accent) + } + + HStack(spacing: 8) { + // Preset buttons + ForEach([5, 15, 25, 60], id: \.self) { minutes in + Button("\(minutes)m") { + menuBar.quickTimerMinutes = Double(minutes) + } + .font(CMFonts.body(size: 11, weight: .medium)) + .foregroundStyle(menuBar.quickTimerMinutes == Double(minutes) ? .white : CMColors.textSecondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(menuBar.quickTimerMinutes == Double(minutes) ? CMColors.accent : CMColors.surfaceHover) + .clipShape(Capsule()) + .buttonStyle(.plain) + } + + Spacer() + + Button("Start") { + let label = menuBar.quickTimerLabel.isEmpty ? "Timer" : menuBar.quickTimerLabel + store.addCountdown( + label: label, + durationSeconds: menuBar.quickTimerMinutes * 60 + ) + menuBar.resetQuickTimer() + } + .font(CMFonts.body(size: 13, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 16) + .padding(.vertical, 6) + .background(CMColors.accent) + .clipShape(Capsule()) + .buttonStyle(.plain) + } + } + .padding(12) + } + + // MARK: - Timer List + + private var timerList: some View { + ScrollView { + LazyVStack(spacing: 1) { + ForEach(store.activeTimers.sorted(by: { $0.targetTime < $1.targetTime })) { timer in + MacTimerRow(timer: timer, now: store.now) + } + } + } + .frame(maxHeight: 300) + } + + // MARK: - Empty State + + private var emptyState: some View { + VStack(spacing: 8) { + Image(systemName: "clock") + .font(.system(size: 28)) + .foregroundStyle(CMColors.textMuted) + Text("No active timers") + .font(CMFonts.body(size: 13)) + .foregroundStyle(CMColors.textMuted) + Text("Press ⌘⇧T to create one") + .font(CMFonts.body(size: 11)) + .foregroundStyle(CMColors.textMuted.opacity(0.7)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + } + + // MARK: - Footer + + private var footer: some View { + HStack { + Text("\(store.activeTimers.count) active") + .font(CMFonts.body(size: 11)) + .foregroundStyle(CMColors.textMuted) + + Spacer() + + if #available(macOS 14.0, *) { + SettingsLink { + Text("Settings") + .font(CMFonts.body(size: 11)) + .foregroundStyle(CMColors.textSecondary) + } + .buttonStyle(.plain) + } + + Button("Quit") { + NSApplication.shared.terminate(nil) + } + .font(CMFonts.body(size: 11)) + .foregroundStyle(CMColors.textMuted) + .buttonStyle(.plain) + .keyboardShortcut("q") + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } +} + +// MARK: - Mac Timer Row + +struct MacTimerRow: View { + let timer: CMTimer + let now: Date + + @EnvironmentObject var store: MacTimerStore + + var body: some View { + HStack(spacing: 8) { + // Urgency dot + Circle() + .fill(CMColors.urgencyColor(timer.urgency)) + .frame(width: 6, height: 6) + + // Label + VStack(alignment: .leading, spacing: 2) { + Text(timer.label) + .font(CMFonts.body(size: 13, weight: .medium)) + .foregroundStyle(CMColors.text) + .lineLimit(1) + + Text(formatTime(timer.targetTime)) + .font(CMFonts.body(size: 11)) + .foregroundStyle(CMColors.textMuted) + } + + Spacer() + + // Countdown + let remaining = getRemainingSeconds(timer, now: now) + Text(formatDuration(remaining)) + .font(CMFonts.mono(size: 13, weight: .semibold)) + .foregroundStyle(stateColor) + + // Actions + if timer.state == .firing { + Button { + store.snooze(timer.id, minutes: 5) + } label: { + Image(systemName: "moon.zzz") + .font(.caption) + .foregroundStyle(CMColors.textMuted) + } + .buttonStyle(.plain) + + Button { + store.dismiss(timer.id) + } label: { + Image(systemName: "xmark.circle.fill") + .font(.caption) + .foregroundStyle(CMColors.error) + } + .buttonStyle(.plain) + } else { + Button { + store.removeTimer(timer.id) + } label: { + Image(systemName: "xmark") + .font(.caption2.weight(.bold)) + .foregroundStyle(CMColors.textMuted.opacity(0.5)) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(timer.state == .firing ? CMColors.urgencyBg(timer.urgency) : Color.clear) + .contentShape(Rectangle()) + } + + private var stateColor: Color { + switch timer.state { + case .firing: return CMColors.urgencyColor(timer.urgency) + case .warning: return CMColors.important + case .paused: return CMColors.textMuted + default: return CMColors.accent + } + } +} diff --git a/ios/project.yml b/ios/project.yml index a6b47ed..ed83d8a 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -146,6 +146,36 @@ targets: com.apple.security.application-groups: - group.com.chronomind.shared + ChronoMindMac: + type: application + platform: macOS + deploymentTarget: "14.0" + sources: + - path: ChronoMindMac + excludes: + - "**/.DS_Store" + - path: ChronoMind/Shared/TimerEngine + - path: ChronoMind/Shared/AppGroup + - path: ChronoMind/Shared/Reschedule + - path: ChronoMind/Shared/Gamification + - path: ChronoMind/Shared/Cloud + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.chronomind.mac + INFOPLIST_GENERATION_MODE: GeneratedFile + GENERATE_INFOPLIST_FILE: true + MARKETING_VERSION: "1.0.0" + CURRENT_PROJECT_VERSION: "1" + INFOPLIST_KEY_CFBundleDisplayName: ChronoMind + INFOPLIST_KEY_LSUIElement: true + entitlements: + path: ChronoMindMac/ChronoMindMac.entitlements + properties: + com.apple.security.application-groups: + - group.com.chronomind.shared + com.apple.security.app-sandbox: true + com.apple.security.network.client: true + schemes: ChronoMind: build: @@ -179,3 +209,16 @@ schemes: config: Debug archive: config: Release + + ChronoMindMac: + build: + targets: + ChronoMindMac: all + run: + config: Debug + profile: + config: Release + analyze: + config: Debug + archive: + config: Release