feat(macos): add macOS menu bar app — popover timeline, quick timer, keyboard shortcut, settings
This commit is contained in:
parent
a2e8f985d2
commit
2fc277b663
39
ios/ChronoMindMac/ChronoMindMacApp.swift
Normal file
39
ios/ChronoMindMac/ChronoMindMacApp.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
180
ios/ChronoMindMac/MacTimerStore.swift
Normal file
180
ios/ChronoMindMac/MacTimerStore.swift
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
29
ios/ChronoMindMac/MenuBarState.swift
Normal file
29
ios/ChronoMindMac/MenuBarState.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
100
ios/ChronoMindMac/Views/MacSettingsView.swift
Normal file
100
ios/ChronoMindMac/Views/MacSettingsView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
264
ios/ChronoMindMac/Views/MenuBarPopover.swift
Normal file
264
ios/ChronoMindMac/Views/MenuBarPopover.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user