feat(macos): add macOS menu bar app — popover timeline, quick timer, keyboard shortcut, settings

This commit is contained in:
saravanakumardb1 2026-02-27 22:35:26 -08:00
parent a2e8f985d2
commit 2fc277b663
6 changed files with 655 additions and 0 deletions

View File

@ -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()
}
}
}
}

View File

@ -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 }
}
}

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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
}
}
}

View File

@ -146,6 +146,36 @@ targets:
com.apple.security.application-groups: com.apple.security.application-groups:
- group.com.chronomind.shared - 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: schemes:
ChronoMind: ChronoMind:
build: build:
@ -179,3 +209,16 @@ schemes:
config: Debug config: Debug
archive: archive:
config: Release config: Release
ChronoMindMac:
build:
targets:
ChronoMindMac: all
run:
config: Debug
profile:
config: Release
analyze:
config: Debug
archive:
config: Release