diff --git a/Matinee/App/MatineeApp.swift b/Matinee/App/MatineeApp.swift index 8aa72d3..b0f5413 100644 --- a/Matinee/App/MatineeApp.swift +++ b/Matinee/App/MatineeApp.swift @@ -2,14 +2,38 @@ import SwiftUI @main struct MatineeApp: App { - @State private var timer = FocusTimer() + @State private var timer: FocusTimer @State private var updates = UpdateController() + @State private var caffeine: CaffeineMode + @State private var reminders: ReminderSettings + @State private var overlay: ReminderOverlay + + init() { + let timer = FocusTimer() + let caffeine = CaffeineMode() + let reminders = ReminderSettings() + let overlay = ReminderOverlay() + + caffeine.follow(timer) + timer.onPhaseFinished = { finished in + guard let reminder = reminders.reminder(after: finished) else { return } + overlay.show(reminder) { timer.start() } + } + + _timer = State(initialValue: timer) + _caffeine = State(initialValue: caffeine) + _reminders = State(initialValue: reminders) + _overlay = State(initialValue: overlay) + } var body: some Scene { MenuBarExtra { TimerPopover() .environment(timer) .environment(updates) + .environment(caffeine) + .environment(reminders) + .environment(overlay) } label: { MenuBarLabel(timer: timer, updates: updates) } diff --git a/Matinee/Features/Caffeine/CaffeineMode.swift b/Matinee/Features/Caffeine/CaffeineMode.swift new file mode 100644 index 0000000..df67930 --- /dev/null +++ b/Matinee/Features/Caffeine/CaffeineMode.swift @@ -0,0 +1,51 @@ +import Foundation +import Observation + +/// When on, keeps the Mac and its display from sleeping while the timer is running, and lets +/// them sleep normally again as soon as the timer pauses, stops or finishes. +@MainActor +@Observable +final class CaffeineMode { + private static let isEnabledKey = "caffeine.isEnabled" + + var isEnabled: Bool { + didSet { + defaults.set(isEnabled, forKey: Self.isEnabledKey) + refresh() + } + } + + private(set) var isKeepingAwake = false + + @ObservationIgnored private let defaults: UserDefaults + @ObservationIgnored private let assertion = PowerAssertion(reason: "Matinee timer is running") + @ObservationIgnored private var timerIsRunning = false + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + isEnabled = defaults.bool(forKey: Self.isEnabledKey) + } + + static func shouldKeepAwake(isEnabled: Bool, timerIsRunning: Bool) -> Bool { + isEnabled && timerIsRunning + } + + /// Follows `timer.isRunning` for the life of the app. + func follow(_ timer: FocusTimer) { + timerIsRunning = withObservationTracking { + timer.isRunning + } onChange: { + Task { @MainActor in self.follow(timer) } + } + refresh() + } + + private func refresh() { + if Self.shouldKeepAwake(isEnabled: isEnabled, timerIsRunning: timerIsRunning) { + assertion.hold() + } else { + assertion.release() + } + isKeepingAwake = assertion.isHeld + } +} diff --git a/Matinee/Features/Caffeine/CaffeineToggle.swift b/Matinee/Features/Caffeine/CaffeineToggle.swift new file mode 100644 index 0000000..1e2f281 --- /dev/null +++ b/Matinee/Features/Caffeine/CaffeineToggle.swift @@ -0,0 +1,34 @@ +import SwiftUI + +struct CaffeineToggle: View { + @Environment(CaffeineMode.self) private var caffeine + + var body: some View { + @Bindable var caffeine = caffeine + + Toggle(isOn: $caffeine.isEnabled) { + Label { + VStack(alignment: .leading, spacing: 2) { + Text("Caffeine") + Text(caption) + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: caffeine.isKeepingAwake ? "cup.and.heat.waves.fill" : "cup.and.heat.waves") + } + } + .toggleStyle(.switch) + .help("Keep the Mac and screen awake until the timer ends") + } + + private var caption: String { + if caffeine.isKeepingAwake { + "Keeping your Mac awake" + } else if caffeine.isEnabled { + "Keeps your Mac awake while the timer runs" + } else { + "Off" + } + } +} diff --git a/Matinee/Features/Caffeine/PowerAssertion.swift b/Matinee/Features/Caffeine/PowerAssertion.swift new file mode 100644 index 0000000..cd4236c --- /dev/null +++ b/Matinee/Features/Caffeine/PowerAssertion.swift @@ -0,0 +1,36 @@ +import Foundation +import IOKit.pwr_mgt + +/// An IOKit power assertion that stops idle display sleep, which also stops idle system sleep. +/// The system drops it automatically if Matinee quits or crashes. +@MainActor +final class PowerAssertion { + private let reason: String + private var assertionID: IOPMAssertionID? + + var isHeld: Bool { assertionID != nil } + + init(reason: String) { + self.reason = reason + } + + func hold() { + guard assertionID == nil else { return } + var newID = IOPMAssertionID(0) + let result = IOPMAssertionCreateWithName( + "PreventUserIdleDisplaySleep" as CFString, + IOPMAssertionLevel(kIOPMAssertionLevelOn), + reason as CFString, + &newID + ) + if result == kIOReturnSuccess { + assertionID = newID + } + } + + func release() { + guard let assertionID else { return } + IOPMAssertionRelease(assertionID) + self.assertionID = nil + } +} diff --git a/Matinee/Features/Reminders/Reminder.swift b/Matinee/Features/Reminders/Reminder.swift new file mode 100644 index 0000000..b4a3f95 --- /dev/null +++ b/Matinee/Features/Reminders/Reminder.swift @@ -0,0 +1,81 @@ +import Foundation + +/// What the full-screen overlay says when a phase ends. +struct Reminder: Equatable, Sendable { + let title: String + let detail: String + let systemImage: String + /// Label of the button that starts the next phase. + let actionTitle: String +} + +enum ReminderChoice: String, CaseIterable, Identifiable, Sendable { + case standUp + case drinkWater + case restEyes + case stretch + case custom + + var id: Self { self } + + var menuTitle: String { + switch self { + case .standUp: "Stand up" + case .drinkWater: "Drink water" + case .restEyes: "Rest your eyes" + case .stretch: "Stretch" + case .custom: "Custom…" + } + } + + /// The overlay for the end of a focus session. A blank custom text falls back to "Stand up". + func reminder(customText: String) -> Reminder { + let breakAction = "Start Break" + switch self { + case .standUp: + return Reminder( + title: "Stand up", + detail: "Get up and move around for a minute.", + systemImage: "figure.stand", + actionTitle: breakAction + ) + case .drinkWater: + return Reminder( + title: "Drink some water", + detail: "Refill your glass before the next session.", + systemImage: "drop.fill", + actionTitle: breakAction + ) + case .restEyes: + return Reminder( + title: "Rest your eyes", + detail: "Look at something far away for 20 seconds.", + systemImage: "eye", + actionTitle: breakAction + ) + case .stretch: + return Reminder( + title: "Stretch", + detail: "Roll your shoulders and stretch your back.", + systemImage: "figure.flexibility", + actionTitle: breakAction + ) + case .custom: + let text = customText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return ReminderChoice.standUp.reminder(customText: "") } + return Reminder( + title: text, + detail: "Your focus session is done.", + systemImage: "bell.fill", + actionTitle: breakAction + ) + } + } + + static let breakOver = Reminder( + title: "Break's over", + detail: "Ready for the next focus session?", + systemImage: "brain.head.profile", + actionTitle: "Start Focus" + ) +} diff --git a/Matinee/Features/Reminders/ReminderOverlay.swift b/Matinee/Features/Reminders/ReminderOverlay.swift new file mode 100644 index 0000000..30777d2 --- /dev/null +++ b/Matinee/Features/Reminders/ReminderOverlay.swift @@ -0,0 +1,70 @@ +import AppKit +import Observation +import SwiftUI + +/// Covers every screen with the reminder until the user dismisses it or starts the next phase. +@MainActor +@Observable +final class ReminderOverlay { + @ObservationIgnored private var panels: [NSPanel] = [] + + var isShowing: Bool { !panels.isEmpty } + + func show(_ reminder: Reminder, onAction: @escaping @MainActor () -> Void) { + dismiss() + + // Activate first so the panel made key below actually receives Esc and Return. + NSApp.activate() + + for screen in NSScreen.screens { + let view = ReminderOverlayView( + reminder: reminder, + onAction: { [weak self] in + self?.dismiss() + onAction() + }, + onDismiss: { [weak self] in self?.dismiss() } + ) + let panel = makePanel(covering: screen, content: view) + panel.orderFrontRegardless() + panels.append(panel) + } + + let keyPanel = panels.first { $0.screen == NSScreen.main } ?? panels.first + keyPanel?.makeKeyAndOrderFront(nil) + } + + func dismiss() { + panels.forEach { $0.orderOut(nil) } + panels.removeAll() + } + + private func makePanel(covering screen: NSScreen, content: ReminderOverlayView) -> NSPanel { + let panel = OverlayPanel( + contentRect: screen.frame, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + panel.level = .screenSaver + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle] + // Panels hide when the app deactivates by default; the reminder must stay until dismissed. + panel.hidesOnDeactivate = false + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = false + panel.isReleasedWhenClosed = false + + let hostingView = NSHostingView(rootView: content) + hostingView.sizingOptions = [] + panel.contentView = hostingView + panel.setFrame(screen.frame, display: true) + return panel + } +} + +/// Borderless panels can't take keyboard focus by default; the overlay needs it for Esc and Return. +private final class OverlayPanel: NSPanel { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } +} diff --git a/Matinee/Features/Reminders/ReminderOverlayView.swift b/Matinee/Features/Reminders/ReminderOverlayView.swift new file mode 100644 index 0000000..e38788d --- /dev/null +++ b/Matinee/Features/Reminders/ReminderOverlayView.swift @@ -0,0 +1,52 @@ +import SwiftUI + +/// Full-screen glass that blurs whatever is underneath, with the reminder in the middle. +struct ReminderOverlayView: View { + let reminder: Reminder + let onAction: () -> Void + let onDismiss: () -> Void + + var body: some View { + VStack(spacing: 20) { + Image(systemName: reminder.systemImage) + .font(.system(size: 72)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.tint) + + Text(reminder.title) + .font(.system(size: 48, weight: .bold, design: .rounded)) + .multilineTextAlignment(.center) + + Text(reminder.detail) + .font(.title2) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + GlassEffectContainer(spacing: 12) { + HStack(spacing: 12) { + Button("Dismiss", action: onDismiss) + .buttonStyle(.glass) + .keyboardShortcut(.cancelAction) + Button(reminder.actionTitle, action: onAction) + .buttonStyle(.glassProminent) + .keyboardShortcut(.defaultAction) + } + .controlSize(.extraLarge) + } + .padding(.top, 12) + } + .padding(48) + .frame(maxWidth: 720) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background { + // The dim keeps the reminder readable over any wallpaper; the glass blurs it. + Rectangle() + .fill(.black.opacity(0.35)) + .glassEffect(.regular, in: Rectangle()) + .ignoresSafeArea() + } + .environment(\.colorScheme, .dark) + .accessibilityElement(children: .contain) + .accessibilityAddTraits(.isModal) + } +} diff --git a/Matinee/Features/Reminders/ReminderSettings.swift b/Matinee/Features/Reminders/ReminderSettings.swift new file mode 100644 index 0000000..3046967 --- /dev/null +++ b/Matinee/Features/Reminders/ReminderSettings.swift @@ -0,0 +1,41 @@ +import Foundation +import Observation + +/// Whether to show the end-of-phase overlay, and which reminder it shows. Persisted in defaults. +@MainActor +@Observable +final class ReminderSettings { + private enum Keys { + static let isEnabled = "reminders.isEnabled" + static let choice = "reminders.choice" + static let customText = "reminders.customText" + } + + var isEnabled: Bool { + didSet { defaults.set(isEnabled, forKey: Keys.isEnabled) } + } + var choice: ReminderChoice { + didSet { defaults.set(choice.rawValue, forKey: Keys.choice) } + } + var customText: String { + didSet { defaults.set(customText, forKey: Keys.customText) } + } + + @ObservationIgnored private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + isEnabled = defaults.object(forKey: Keys.isEnabled) as? Bool ?? true + choice = defaults.string(forKey: Keys.choice).flatMap(ReminderChoice.init(rawValue:)) ?? .standUp + customText = defaults.string(forKey: Keys.customText) ?? "" + } + + /// The overlay to show once `finished` reaches zero, or nil when reminders are off. + func reminder(after finished: TimerPhase) -> Reminder? { + guard isEnabled else { return nil } + switch finished { + case .focus: return choice.reminder(customText: customText) + case .rest: return ReminderChoice.breakOver + } + } +} diff --git a/Matinee/Features/Reminders/ReminderSettingsView.swift b/Matinee/Features/Reminders/ReminderSettingsView.swift new file mode 100644 index 0000000..ad162a0 --- /dev/null +++ b/Matinee/Features/Reminders/ReminderSettingsView.swift @@ -0,0 +1,39 @@ +import SwiftUI + +struct ReminderSettingsView: View { + @Environment(ReminderSettings.self) private var reminders + @Environment(ReminderOverlay.self) private var overlay + + var body: some View { + @Bindable var reminders = reminders + + VStack(alignment: .leading, spacing: 8) { + Toggle(isOn: $reminders.isEnabled) { + Label("Reminder when time is up", systemImage: "bell.badge") + } + .toggleStyle(.switch) + + if reminders.isEnabled { + HStack { + Picker("Reminder", selection: $reminders.choice) { + ForEach(ReminderChoice.allCases) { choice in + Text(choice.menuTitle).tag(choice) + } + } + .pickerStyle(.menu) + + Button("Preview") { + guard let reminder = reminders.reminder(after: .focus) else { return } + overlay.show(reminder) {} + } + .help("Show the reminder now, the way it appears when focus time is up") + } + + if reminders.choice == .custom { + TextField("Reminder text", text: $reminders.customText, prompt: Text("Take a walk")) + .textFieldStyle(.roundedBorder) + } + } + } + } +} diff --git a/Matinee/Features/Timer/FocusTimer.swift b/Matinee/Features/Timer/FocusTimer.swift index e00ba87..fc859c1 100644 --- a/Matinee/Features/Timer/FocusTimer.swift +++ b/Matinee/Features/Timer/FocusTimer.swift @@ -16,6 +16,9 @@ final class FocusTimer { @ObservationIgnored private let defaults: UserDefaults @ObservationIgnored private var tickTask: Task? + /// Called with the phase that just reached zero. + @ObservationIgnored var onPhaseFinished: (@MainActor (TimerPhase) -> Void)? + init(defaults: UserDefaults = .standard) { self.defaults = defaults let focus = defaults.object(forKey: Keys.focusMinutes) as? Int ?? TimerDurations.standard.focusMinutes @@ -93,9 +96,10 @@ final class FocusTimer { private func tick() { now = .now - if session.advanceIfFinished(at: now) != nil { + if let finished = session.advanceIfFinished(at: now) { stopTicking() NSSound(named: "Glass")?.play() + onPhaseFinished?(finished) } } } diff --git a/Matinee/Features/Timer/TimerPopover.swift b/Matinee/Features/Timer/TimerPopover.swift index f23fc77..11ea992 100644 --- a/Matinee/Features/Timer/TimerPopover.swift +++ b/Matinee/Features/Timer/TimerPopover.swift @@ -16,6 +16,9 @@ struct TimerPopover: View { Divider() DurationSettings() Divider() + CaffeineToggle() + ReminderSettingsView() + Divider() footer } .padding(16) diff --git a/MatineeTests/ReminderTests.swift b/MatineeTests/ReminderTests.swift new file mode 100644 index 0000000..06eecf8 --- /dev/null +++ b/MatineeTests/ReminderTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing +@testable import Matinee + +@MainActor +struct ReminderTests { + let defaults: UserDefaults + + init() { + let suite = "ReminderTests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + } + + @Test func defaultsToStandUpAfterFocus() { + let settings = ReminderSettings(defaults: defaults) + #expect(settings.isEnabled) + #expect(settings.reminder(after: .focus)?.title == "Stand up") + #expect(settings.reminder(after: .focus)?.actionTitle == "Start Break") + } + + @Test func breakEndShowsBreakOver() { + let settings = ReminderSettings(defaults: defaults) + #expect(settings.reminder(after: .rest) == ReminderChoice.breakOver) + } + + @Test func disabledShowsNothing() { + let settings = ReminderSettings(defaults: defaults) + settings.isEnabled = false + #expect(settings.reminder(after: .focus) == nil) + #expect(settings.reminder(after: .rest) == nil) + } + + @Test func customTextIsTrimmedAndUsed() { + let settings = ReminderSettings(defaults: defaults) + settings.choice = .custom + settings.customText = " Call mom \n" + #expect(settings.reminder(after: .focus)?.title == "Call mom") + } + + @Test func blankCustomTextFallsBackToStandUp() { + let settings = ReminderSettings(defaults: defaults) + settings.choice = .custom + settings.customText = " " + #expect(settings.reminder(after: .focus)?.title == "Stand up") + } + + @Test func choicesPersist() { + let settings = ReminderSettings(defaults: defaults) + settings.choice = .drinkWater + settings.isEnabled = false + settings.customText = "Walk" + + let reloaded = ReminderSettings(defaults: defaults) + #expect(reloaded.choice == .drinkWater) + #expect(!reloaded.isEnabled) + #expect(reloaded.customText == "Walk") + } + + @Test func caffeineOnlyHoldsWhileEnabledAndRunning() { + #expect(CaffeineMode.shouldKeepAwake(isEnabled: true, timerIsRunning: true)) + #expect(!CaffeineMode.shouldKeepAwake(isEnabled: true, timerIsRunning: false)) + #expect(!CaffeineMode.shouldKeepAwake(isEnabled: false, timerIsRunning: true)) + } +} diff --git a/project.yml b/project.yml index 6051880..02e3a8f 100644 --- a/project.yml +++ b/project.yml @@ -16,7 +16,7 @@ settings: SWIFT_VERSION: "6.0" SWIFT_STRICT_CONCURRENCY: complete MACOSX_DEPLOYMENT_TARGET: "27.0" - MARKETING_VERSION: "0.1.2" + MARKETING_VERSION: "0.1.3" CURRENT_PROJECT_VERSION: "1" CODE_SIGN_STYLE: Automatic CODE_SIGN_IDENTITY: "-"