From bc960c4a8c4b50b8c337ccb5f3d3545fbf74283a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 21:45:27 +0000 Subject: [PATCH 1/2] Add end-of-timer reminder overlay and caffeine mode When a focus session ends, a full-screen glass overlay shows a reminder (stand up, drink water, rest your eyes, stretch, or custom text) with Dismiss and Start Break buttons; when a break ends it offers Start Focus. Caffeine mode holds an IOKit power assertion so the Mac and display stay awake while the timer runs, released when it pauses, stops or finishes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SxiPF75k29ZLVHYLDSZuFz --- Matinee/App/MatineeApp.swift | 23 +++++- Matinee/Features/Caffeine/CaffeineMode.swift | 51 ++++++++++++ .../Features/Caffeine/CaffeineToggle.swift | 34 ++++++++ .../Features/Caffeine/PowerAssertion.swift | 36 +++++++++ Matinee/Features/Reminders/Reminder.swift | 81 +++++++++++++++++++ .../Features/Reminders/ReminderOverlay.swift | 65 +++++++++++++++ .../Reminders/ReminderOverlayView.swift | 50 ++++++++++++ .../Features/Reminders/ReminderSettings.swift | 41 ++++++++++ .../Reminders/ReminderSettingsView.swift | 30 +++++++ Matinee/Features/Timer/FocusTimer.swift | 6 +- Matinee/Features/Timer/TimerPopover.swift | 3 + MatineeTests/ReminderTests.swift | 65 +++++++++++++++ 12 files changed, 483 insertions(+), 2 deletions(-) create mode 100644 Matinee/Features/Caffeine/CaffeineMode.swift create mode 100644 Matinee/Features/Caffeine/CaffeineToggle.swift create mode 100644 Matinee/Features/Caffeine/PowerAssertion.swift create mode 100644 Matinee/Features/Reminders/Reminder.swift create mode 100644 Matinee/Features/Reminders/ReminderOverlay.swift create mode 100644 Matinee/Features/Reminders/ReminderOverlayView.swift create mode 100644 Matinee/Features/Reminders/ReminderSettings.swift create mode 100644 Matinee/Features/Reminders/ReminderSettingsView.swift create mode 100644 MatineeTests/ReminderTests.swift diff --git a/Matinee/App/MatineeApp.swift b/Matinee/App/MatineeApp.swift index 8aa72d3..0630b81 100644 --- a/Matinee/App/MatineeApp.swift +++ b/Matinee/App/MatineeApp.swift @@ -2,14 +2,35 @@ 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 + + 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) + } var body: some Scene { MenuBarExtra { TimerPopover() .environment(timer) .environment(updates) + .environment(caffeine) + .environment(reminders) } 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..f3aeb83 --- /dev/null +++ b/Matinee/Features/Reminders/ReminderOverlay.swift @@ -0,0 +1,65 @@ +import AppKit +import SwiftUI + +/// Covers every screen with the reminder until the user dismisses it or starts the next phase. +@MainActor +final class ReminderOverlay { + private var panels: [NSPanel] = [] + + var isShowing: Bool { !panels.isEmpty } + + func show(_ reminder: Reminder, onAction: @escaping @MainActor () -> Void) { + dismiss() + + 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() + if screen == NSScreen.main { + panel.makeKey() + } + panels.append(panel) + } + + NSApp.activate() + } + + 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] + 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..cb37b7c --- /dev/null +++ b/Matinee/Features/Reminders/ReminderOverlayView.swift @@ -0,0 +1,50 @@ +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 { + Rectangle() + .fill(.clear) + .glassEffect(.regular, in: Rectangle()) + .ignoresSafeArea() + } + .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..86108fe --- /dev/null +++ b/Matinee/Features/Reminders/ReminderSettingsView.swift @@ -0,0 +1,30 @@ +import SwiftUI + +struct ReminderSettingsView: View { + @Environment(ReminderSettings.self) private var reminders + + 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 { + Picker("Reminder", selection: $reminders.choice) { + ForEach(ReminderChoice.allCases) { choice in + Text(choice.menuTitle).tag(choice) + } + } + .pickerStyle(.menu) + + 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)) + } +} From 6bb6f15a1a4c8540366e1c77ff18f7fc524f8b07 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 22:13:01 +0000 Subject: [PATCH 2/2] Keep the reminder overlay up until dismissed, add a preview, release 0.1.3 The overlay panels no longer hide when Matinee loses focus, the app is activated before a panel is made key so Esc and Return reach it, and the backdrop is dimmed and drawn dark so the text stays readable on any wallpaper. A Preview button shows the chosen reminder right away. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SxiPF75k29ZLVHYLDSZuFz --- Matinee/App/MatineeApp.swift | 3 +++ .../Features/Reminders/ReminderOverlay.swift | 15 ++++++++++----- .../Reminders/ReminderOverlayView.swift | 4 +++- .../Reminders/ReminderSettingsView.swift | 17 +++++++++++++---- project.yml | 2 +- 5 files changed, 30 insertions(+), 11 deletions(-) diff --git a/Matinee/App/MatineeApp.swift b/Matinee/App/MatineeApp.swift index 0630b81..b0f5413 100644 --- a/Matinee/App/MatineeApp.swift +++ b/Matinee/App/MatineeApp.swift @@ -6,6 +6,7 @@ struct MatineeApp: App { @State private var updates = UpdateController() @State private var caffeine: CaffeineMode @State private var reminders: ReminderSettings + @State private var overlay: ReminderOverlay init() { let timer = FocusTimer() @@ -22,6 +23,7 @@ struct MatineeApp: App { _timer = State(initialValue: timer) _caffeine = State(initialValue: caffeine) _reminders = State(initialValue: reminders) + _overlay = State(initialValue: overlay) } var body: some Scene { @@ -31,6 +33,7 @@ struct MatineeApp: App { .environment(updates) .environment(caffeine) .environment(reminders) + .environment(overlay) } label: { MenuBarLabel(timer: timer, updates: updates) } diff --git a/Matinee/Features/Reminders/ReminderOverlay.swift b/Matinee/Features/Reminders/ReminderOverlay.swift index f3aeb83..30777d2 100644 --- a/Matinee/Features/Reminders/ReminderOverlay.swift +++ b/Matinee/Features/Reminders/ReminderOverlay.swift @@ -1,16 +1,21 @@ 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 { - private var panels: [NSPanel] = [] + @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, @@ -22,13 +27,11 @@ final class ReminderOverlay { ) let panel = makePanel(covering: screen, content: view) panel.orderFrontRegardless() - if screen == NSScreen.main { - panel.makeKey() - } panels.append(panel) } - NSApp.activate() + let keyPanel = panels.first { $0.screen == NSScreen.main } ?? panels.first + keyPanel?.makeKeyAndOrderFront(nil) } func dismiss() { @@ -45,6 +48,8 @@ final class ReminderOverlay { ) 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 diff --git a/Matinee/Features/Reminders/ReminderOverlayView.swift b/Matinee/Features/Reminders/ReminderOverlayView.swift index cb37b7c..e38788d 100644 --- a/Matinee/Features/Reminders/ReminderOverlayView.swift +++ b/Matinee/Features/Reminders/ReminderOverlayView.swift @@ -39,11 +39,13 @@ struct ReminderOverlayView: View { .frame(maxWidth: 720) .frame(maxWidth: .infinity, maxHeight: .infinity) .background { + // The dim keeps the reminder readable over any wallpaper; the glass blurs it. Rectangle() - .fill(.clear) + .fill(.black.opacity(0.35)) .glassEffect(.regular, in: Rectangle()) .ignoresSafeArea() } + .environment(\.colorScheme, .dark) .accessibilityElement(children: .contain) .accessibilityAddTraits(.isModal) } diff --git a/Matinee/Features/Reminders/ReminderSettingsView.swift b/Matinee/Features/Reminders/ReminderSettingsView.swift index 86108fe..ad162a0 100644 --- a/Matinee/Features/Reminders/ReminderSettingsView.swift +++ b/Matinee/Features/Reminders/ReminderSettingsView.swift @@ -2,6 +2,7 @@ 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 @@ -13,12 +14,20 @@ struct ReminderSettingsView: View { .toggleStyle(.switch) if reminders.isEnabled { - Picker("Reminder", selection: $reminders.choice) { - ForEach(ReminderChoice.allCases) { choice in - Text(choice.menuTitle).tag(choice) + 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") } - .pickerStyle(.menu) if reminders.choice == .custom { TextField("Reminder text", text: $reminders.customText, prompt: Text("Take a walk")) 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: "-"