From 579ae3510bfafcc28684d5413d514368ff119cbf Mon Sep 17 00:00:00 2001 From: yagizdo Date: Tue, 2 Jun 2026 19:25:53 +0300 Subject: [PATCH 1/9] fix: handle system sleep/wake and replace TabView with custom tab bar Cancel break timers and dismiss overlay on system sleep to prevent rapid-fire auto-completions when Mac wakes. Replace native TabView with a custom tab bar to eliminate icon size jitter caused by frequent @EnvironmentObject re-renders. --- StandLock/AppState/AppCoordinator.swift | 17 +++++ StandLock/StandLockApp.swift | 2 +- StandLock/Views/Settings/SettingsView.swift | 72 ++++++++++++++----- .../Coordination/BreakCoordinator.swift | 14 ++++ 4 files changed, 86 insertions(+), 19 deletions(-) diff --git a/StandLock/AppState/AppCoordinator.swift b/StandLock/AppState/AppCoordinator.swift index 0707929..9c86c47 100644 --- a/StandLock/AppState/AppCoordinator.swift +++ b/StandLock/AppState/AppCoordinator.swift @@ -59,6 +59,7 @@ final class AppCoordinator: ObservableObject { loadData() syncPreferencesWithPermissions() startProgressTimer() + observeSystemSleep() permissionSyncCancellable = permissionChecker.objectWillChange .receive(on: RunLoop.main) .sink { [weak self] _ in @@ -305,6 +306,22 @@ final class AppCoordinator: ObservableObject { coordinator?.changeDisciplineLevel(level) } + // MARK: - System Sleep + + private func observeSystemSleep() { + let center = NSWorkspace.shared.notificationCenter + center.addObserver(forName: NSWorkspace.willSleepNotification, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { + self?.coordinator?.handleSystemSleep() + } + } + center.addObserver(forName: NSWorkspace.didWakeNotification, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { + self?.coordinator?.handleSystemWake() + } + } + } + // MARK: - Break Progress private func startProgressTimer() { diff --git a/StandLock/StandLockApp.swift b/StandLock/StandLockApp.swift index 53853ec..7e80694 100644 --- a/StandLock/StandLockApp.swift +++ b/StandLock/StandLockApp.swift @@ -22,7 +22,7 @@ struct StandLockApp: App { .menuBarExtraStyle(.window) Settings { - SettingsView() + SettingsView(selectedTab: $appCoordinator.selectedSettingsTab) .environmentObject(appCoordinator) .environmentObject(appCoordinator.permissionChecker) } diff --git a/StandLock/Views/Settings/SettingsView.swift b/StandLock/Views/Settings/SettingsView.swift index f15640a..d276f0a 100644 --- a/StandLock/Views/Settings/SettingsView.swift +++ b/StandLock/Views/Settings/SettingsView.swift @@ -1,30 +1,66 @@ import SwiftUI struct SettingsView: View { - @EnvironmentObject private var coordinator: AppCoordinator + @Binding var selectedTab: AppCoordinator.SettingsTab var body: some View { - TabView(selection: $coordinator.selectedSettingsTab) { - GeneralSettingsView() - .tabItem { Label("General", systemImage: "gearshape") } - .tag(AppCoordinator.SettingsTab.general) + VStack(spacing: 0) { + SettingsTabBar(selectedTab: $selectedTab) + Divider() + tabContent + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(width: 520, height: 480) + } - ScheduleEditorView() - .tabItem { Label("Schedules", systemImage: "calendar.badge.clock") } - .tag(AppCoordinator.SettingsTab.schedules) + @ViewBuilder + private var tabContent: some View { + switch selectedTab { + case .general: GeneralSettingsView() + case .schedules: ScheduleEditorView() + case .detection: DetectionSettingsView() + case .permissions: PermissionsView() + case .about: AboutView() + } + } +} - DetectionSettingsView() - .tabItem { Label("Detection", systemImage: "eye") } - .tag(AppCoordinator.SettingsTab.detection) +private struct SettingsTabBar: View { + @Binding var selectedTab: AppCoordinator.SettingsTab - PermissionsView() - .tabItem { Label("Permissions", systemImage: "lock.shield") } - .tag(AppCoordinator.SettingsTab.permissions) + private static let tabs: [(AppCoordinator.SettingsTab, String, String)] = [ + (.general, "General", "gearshape"), + (.schedules, "Schedules", "calendar.badge.clock"), + (.detection, "Detection", "eye"), + (.permissions, "Permissions", "lock.shield"), + (.about, "About", "info.circle"), + ] - AboutView() - .tabItem { Label("About", systemImage: "info.circle") } - .tag(AppCoordinator.SettingsTab.about) + var body: some View { + HStack(spacing: 2) { + ForEach(Self.tabs, id: \.0) { tab, title, icon in + Button { + selectedTab = tab + } label: { + VStack(spacing: 2) { + Image(systemName: icon) + .font(.system(size: 16)) + .frame(width: 24, height: 20) + Text(title) + .font(.system(size: 10)) + } + .padding(.vertical, 6) + .padding(.horizontal, 10) + .foregroundStyle(selectedTab == tab ? Color.accentColor : .secondary) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(selectedTab == tab ? Color.accentColor.opacity(0.12) : .clear) + ) + } + .buttonStyle(.plain) + } } - .frame(width: 520, height: 480) + .padding(.horizontal, 16) + .padding(.vertical, 8) } } diff --git a/StandLockKit/Sources/Coordination/BreakCoordinator.swift b/StandLockKit/Sources/Coordination/BreakCoordinator.swift index 0a060da..9d14e97 100644 --- a/StandLockKit/Sources/Coordination/BreakCoordinator.swift +++ b/StandLockKit/Sources/Coordination/BreakCoordinator.swift @@ -76,6 +76,20 @@ public final class BreakCoordinator: BreakCoordinating { scheduleNextBreak() } + public func handleSystemSleep() { + breakTimer?.cancel() + breakTimer = nil + if locker.isShowing { + locker.dismissOverlay() + currentBreak = nil + currentSchedule = nil + } + } + + public func handleSystemWake() { + scheduleNextBreak() + } + public func skipNextBreak() { breakTimer?.cancel() breakTimer = nil From 72389817b8be1bb6cd5a69a7b14f99c3729e879f Mon Sep 17 00:00:00 2001 From: yagizdo Date: Tue, 2 Jun 2026 22:04:01 +0300 Subject: [PATCH 2/9] fix: pause break timer during screen lock Listen for com.apple.screenIsLocked/screenIsUnlocked via DistributedNotificationCenter. Cancel the break timer and dismiss any active overlay when the screen locks; reschedule the next break through the scheduling engine on unlock so time-window and daily-cap checks are preserved. --- StandLock/AppState/AppCoordinator.swift | 12 ++++++++++++ .../Sources/Coordination/BreakCoordinator.swift | 14 ++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/StandLock/AppState/AppCoordinator.swift b/StandLock/AppState/AppCoordinator.swift index 9c86c47..cd37473 100644 --- a/StandLock/AppState/AppCoordinator.swift +++ b/StandLock/AppState/AppCoordinator.swift @@ -320,6 +320,18 @@ final class AppCoordinator: ObservableObject { self?.coordinator?.handleSystemWake() } } + + let distributed = DistributedNotificationCenter.default() + distributed.addObserver(forName: .init("com.apple.screenIsLocked"), object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { + self?.coordinator?.handleScreenLock() + } + } + distributed.addObserver(forName: .init("com.apple.screenIsUnlocked"), object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { + self?.coordinator?.handleScreenUnlock() + } + } } // MARK: - Break Progress diff --git a/StandLockKit/Sources/Coordination/BreakCoordinator.swift b/StandLockKit/Sources/Coordination/BreakCoordinator.swift index 9d14e97..11a0584 100644 --- a/StandLockKit/Sources/Coordination/BreakCoordinator.swift +++ b/StandLockKit/Sources/Coordination/BreakCoordinator.swift @@ -90,6 +90,20 @@ public final class BreakCoordinator: BreakCoordinating { scheduleNextBreak() } + public func handleScreenLock() { + breakTimer?.cancel() + breakTimer = nil + if locker.isShowing { + locker.dismissOverlay() + currentBreak = nil + currentSchedule = nil + } + } + + public func handleScreenUnlock() { + scheduleNextBreak() + } + public func skipNextBreak() { breakTimer?.cancel() breakTimer = nil From cdd9e342efc07671703e460ddc24a21e3c24ba00 Mon Sep 17 00:00:00 2001 From: yagizdo Date: Tue, 2 Jun 2026 22:15:49 +0300 Subject: [PATCH 3/9] fix: expand tab bar hit area with contentShape .buttonStyle(.plain) on macOS limits the clickable region to the natural content size, ignoring padding. Adding contentShape makes the entire padded frame tappable. --- StandLock/Views/Settings/SettingsView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/StandLock/Views/Settings/SettingsView.swift b/StandLock/Views/Settings/SettingsView.swift index d276f0a..8cdfb20 100644 --- a/StandLock/Views/Settings/SettingsView.swift +++ b/StandLock/Views/Settings/SettingsView.swift @@ -51,6 +51,7 @@ private struct SettingsTabBar: View { } .padding(.vertical, 6) .padding(.horizontal, 10) + .contentShape(Rectangle()) .foregroundStyle(selectedTab == tab ? Color.accentColor : .secondary) .background( RoundedRectangle(cornerRadius: 6) From c6658457bde4a0adeac38fa0e43d7379f379adff Mon Sep 17 00:00:00 2001 From: yagizdo Date: Tue, 2 Jun 2026 22:44:00 +0300 Subject: [PATCH 4/9] fix: replace NSApplicationDelegateAdaptor with parameter injection in AboutView --- StandLock/AppDelegate.swift | 2 +- StandLock/StandLockApp.swift | 2 +- StandLock/Views/Settings/AboutView.swift | 6 +++--- StandLock/Views/Settings/SettingsView.swift | 4 +++- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/StandLock/AppDelegate.swift b/StandLock/AppDelegate.swift index f0a5543..c1ae492 100644 --- a/StandLock/AppDelegate.swift +++ b/StandLock/AppDelegate.swift @@ -23,4 +23,4 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let hasActiveOverlay = sender.windows.contains { $0 is BreakOverlayWindow && $0.isVisible } return hasActiveOverlay ? .terminateCancel : .terminateNow } -} \ No newline at end of file +} diff --git a/StandLock/StandLockApp.swift b/StandLock/StandLockApp.swift index 7e80694..8e27aaf 100644 --- a/StandLock/StandLockApp.swift +++ b/StandLock/StandLockApp.swift @@ -22,7 +22,7 @@ struct StandLockApp: App { .menuBarExtraStyle(.window) Settings { - SettingsView(selectedTab: $appCoordinator.selectedSettingsTab) + SettingsView(selectedTab: $appCoordinator.selectedSettingsTab, updater: appDelegate.updaterController.updater) .environmentObject(appCoordinator) .environmentObject(appCoordinator.permissionChecker) } diff --git a/StandLock/Views/Settings/AboutView.swift b/StandLock/Views/Settings/AboutView.swift index 49cb183..060047b 100644 --- a/StandLock/Views/Settings/AboutView.swift +++ b/StandLock/Views/Settings/AboutView.swift @@ -2,7 +2,7 @@ import SwiftUI @preconcurrency import Sparkle struct AboutView: View { - @NSApplicationDelegateAdaptor private var appDelegate: AppDelegate + let updater: SPUUpdater private var version: String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" @@ -35,8 +35,8 @@ struct AboutView: View { .foregroundStyle(.secondary) VStack(alignment: .leading, spacing: 8) { - UpdaterSettingsView(updater: appDelegate.updaterController.updater) - CheckForUpdatesView(updater: appDelegate.updaterController.updater) + UpdaterSettingsView(updater: updater) + CheckForUpdatesView(updater: updater) } .padding(.horizontal, 40) .padding(12) diff --git a/StandLock/Views/Settings/SettingsView.swift b/StandLock/Views/Settings/SettingsView.swift index 8cdfb20..b9436da 100644 --- a/StandLock/Views/Settings/SettingsView.swift +++ b/StandLock/Views/Settings/SettingsView.swift @@ -1,7 +1,9 @@ import SwiftUI +@preconcurrency import Sparkle struct SettingsView: View { @Binding var selectedTab: AppCoordinator.SettingsTab + let updater: SPUUpdater var body: some View { VStack(spacing: 0) { @@ -20,7 +22,7 @@ struct SettingsView: View { case .schedules: ScheduleEditorView() case .detection: DetectionSettingsView() case .permissions: PermissionsView() - case .about: AboutView() + case .about: AboutView(updater: updater) } } } From 2d3dd5ae580311457ab5363289c22823138f199c Mon Sep 17 00:00:00 2001 From: yagizdo Date: Tue, 2 Jun 2026 23:17:39 +0300 Subject: [PATCH 5/9] fix: reduce timer numeral tracking to prevent glyph clipping Negative tracking at -0.04 caused edge glyphs to be clipped at large serif font sizes. Reducing to -0.02 gives enough breathing room. --- StandLock/Views/BreakScreen/TimerNumerals.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/StandLock/Views/BreakScreen/TimerNumerals.swift b/StandLock/Views/BreakScreen/TimerNumerals.swift index e5f4f93..caaeeaf 100644 --- a/StandLock/Views/BreakScreen/TimerNumerals.swift +++ b/StandLock/Views/BreakScreen/TimerNumerals.swift @@ -31,7 +31,7 @@ struct TimerNumerals: View { Text(timeString) .font(BreakTypography.timerNumerals(size: fontSize)) .monospacedDigit() - .tracking(fontSize * -0.04) + .tracking(fontSize * -0.02) .foregroundStyle(palette.ink) .contentTransition(.identity) .transaction { $0.animation = nil } From 0040e9701f17f353cfacec2babe1f8608f38d5bf Mon Sep 17 00:00:00 2001 From: yagizdo Date: Tue, 2 Jun 2026 23:17:47 +0300 Subject: [PATCH 6/9] fix: handle midnight-crossing time windows in schedule evaluation TimeWindow.contains() returned false for windows spanning midnight (e.g. 23:00-00:00) because end < start made the AND check impossible. Use OR logic when the window wraps past midnight. --- .../StandLockCore/Models/Schedule.swift | 5 ++++- .../SchedulingTests/SchedulingTests.swift | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/StandLockKit/Sources/StandLockCore/Models/Schedule.swift b/StandLockKit/Sources/StandLockCore/Models/Schedule.swift index f1a7121..39d9ae6 100644 --- a/StandLockKit/Sources/StandLockCore/Models/Schedule.swift +++ b/StandLockKit/Sources/StandLockCore/Models/Schedule.swift @@ -103,7 +103,10 @@ public struct TimeWindow: Codable, Sendable, Equatable { let time = hour * 60 + minute let start = startHour * 60 + startMinute let end = endHour * 60 + endMinute - return time >= start && time < end + if start <= end { + return time >= start && time < end + } + return time >= start || time < end } } diff --git a/StandLockKit/Tests/SchedulingTests/SchedulingTests.swift b/StandLockKit/Tests/SchedulingTests/SchedulingTests.swift index 1ce3569..5883b23 100644 --- a/StandLockKit/Tests/SchedulingTests/SchedulingTests.swift +++ b/StandLockKit/Tests/SchedulingTests/SchedulingTests.swift @@ -115,6 +115,28 @@ struct ScheduleEvaluatorTests { #expect(abs(next!.timeIntervalSince(expected)) < 1) } + @Test func isWithinActiveWindow_midnightCrossing() { + let schedule = makeSchedule(windows: [ + TimeWindow(startHour: 23, startMinute: 0, endHour: 0, endMinute: 0) + ]) + let monday23_10 = makeDate(day: 11, hour: 23, minute: 10) + #expect(evaluator.isWithinActiveWindow(schedule, at: monday23_10)) + let monday22_59 = makeDate(day: 11, hour: 22, minute: 59) + #expect(!evaluator.isWithinActiveWindow(schedule, at: monday22_59)) + } + + @Test func isWithinActiveWindow_overnightWindow() { + let schedule = makeSchedule(windows: [ + TimeWindow(startHour: 22, startMinute: 0, endHour: 6, endMinute: 0) + ]) + let monday23 = makeDate(day: 11, hour: 23) + let monday1 = makeDate(day: 12, hour: 1) + let monday10 = makeDate(day: 12, hour: 10) + #expect(evaluator.isWithinActiveWindow(schedule, at: monday23)) + #expect(evaluator.isWithinActiveWindow(schedule, at: monday1)) + #expect(!evaluator.isWithinActiveWindow(schedule, at: monday10)) + } + @Test func nextBreakTime_noActiveSchedule() { let schedule = makeSchedule(days: .custom([])) let result = evaluator.nextBreakTime(for: schedule, after: Date()) From d9ac47bd00483fb044e1da9637274b1c430f8056 Mon Sep 17 00:00:00 2001 From: yagizdo Date: Tue, 2 Jun 2026 23:36:20 +0300 Subject: [PATCH 7/9] fix: emit break event on screen lock/sleep and restart countdown timer Screen lock and system sleep during an active break dismissed the overlay but never emitted a CoordinatorEvent, leaving isBreakActive stuck at true. Now emits .breakSkipped so AppCoordinator resets state correctly. Also restarts the progress timer when a scheduled break enters the countdown threshold, preventing a ~10s stale display on first appearance. --- StandLock/AppState/AppCoordinator.swift | 4 ++++ .../Sources/Coordination/BreakCoordinator.swift | 14 ++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/StandLock/AppState/AppCoordinator.swift b/StandLock/AppState/AppCoordinator.swift index cd37473..c8921d7 100644 --- a/StandLock/AppState/AppCoordinator.swift +++ b/StandLock/AppState/AppCoordinator.swift @@ -224,6 +224,10 @@ final class AppCoordinator: ObservableObject { breakScheduledAt = Date() recalculateProgress() updateMenuBarTimer() + if menuBarTimerText != nil { + progressTimer?.cancel() + startProgressTimer() + } case .breakStarted(let e): deferralReason = nil diff --git a/StandLockKit/Sources/Coordination/BreakCoordinator.swift b/StandLockKit/Sources/Coordination/BreakCoordinator.swift index 11a0584..3a31a5c 100644 --- a/StandLockKit/Sources/Coordination/BreakCoordinator.swift +++ b/StandLockKit/Sources/Coordination/BreakCoordinator.swift @@ -81,6 +81,13 @@ public final class BreakCoordinator: BreakCoordinating { breakTimer = nil if locker.isShowing { locker.dismissOverlay() + if var event = currentBreak { + event.outcome = .skipped + statistics.breaksSkipped += 1 + statistics.currentStreak = 0 + eventContinuation.yield(.breakSkipped(event)) + eventContinuation.yield(.statisticsUpdated(statistics)) + } currentBreak = nil currentSchedule = nil } @@ -95,6 +102,13 @@ public final class BreakCoordinator: BreakCoordinating { breakTimer = nil if locker.isShowing { locker.dismissOverlay() + if var event = currentBreak { + event.outcome = .skipped + statistics.breaksSkipped += 1 + statistics.currentStreak = 0 + eventContinuation.yield(.breakSkipped(event)) + eventContinuation.yield(.statisticsUpdated(statistics)) + } currentBreak = nil currentSchedule = nil } From 32bc0d330060072ebf35a401f6639c5e2bcf68d5 Mon Sep 17 00:00:00 2001 From: yagizdo Date: Tue, 2 Jun 2026 23:58:03 +0300 Subject: [PATCH 8/9] feat: add social links and copyright to About view --- StandLock/Views/Settings/AboutView.swift | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/StandLock/Views/Settings/AboutView.swift b/StandLock/Views/Settings/AboutView.swift index 060047b..3dde0f4 100644 --- a/StandLock/Views/Settings/AboutView.swift +++ b/StandLock/Views/Settings/AboutView.swift @@ -42,14 +42,24 @@ struct AboutView: View { .padding(12) .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8)) - VStack(spacing: 4) { - Text("Made by Yağız") - .font(.footnote) - Link("GitHub", destination: URL(string: "https://github.com/yagizdo/StandLock")!) - .font(.footnote) + HStack(spacing: 16) { + aboutLink("GitHub", icon: "curlybraces", url: "https://github.com/yagizdo/StandLock") + aboutLink("Website", icon: "globe", url: "https://standlock.app") + aboutLink("Twitter", icon: "at", url: "https://x.com/yagizdo") } + + Text("© 2026 Yılmaz Yağız Dokumacı. MIT License.") + .font(.footnote) + .foregroundStyle(.tertiary) } .padding(.top, 12) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } + + private func aboutLink(_ title: String, icon: String, url: String) -> some View { + Link(destination: URL(string: url)!) { + Label(title, systemImage: icon) + .font(.footnote) + } + } } From 958c6a8d47c19af4e108ccfc5818af4be5a82838 Mon Sep 17 00:00:00 2001 From: yagizdo Date: Wed, 3 Jun 2026 00:36:14 +0300 Subject: [PATCH 9/9] fix: resume schedule correctly after sleep/wake during active pause System sleep or screen lock during a user-initiated pause destroyed the pause-resume timer and left isPaused=true permanently, blocking all future break scheduling. Wake/unlock handlers now check isPaused and call resume() instead of scheduleNextBreak(). Also extract duplicate handler logic into cancelAndDismissIfShowing() and add tests for all four system event handlers including the pause+sleep interaction. --- .../Coordination/BreakCoordinator.swift | 37 ++- .../CoordinationTests/CoordinationTests.swift | 258 ++++++++++++++++++ 2 files changed, 276 insertions(+), 19 deletions(-) diff --git a/StandLockKit/Sources/Coordination/BreakCoordinator.swift b/StandLockKit/Sources/Coordination/BreakCoordinator.swift index 3a31a5c..0a26b1a 100644 --- a/StandLockKit/Sources/Coordination/BreakCoordinator.swift +++ b/StandLockKit/Sources/Coordination/BreakCoordinator.swift @@ -77,27 +77,30 @@ public final class BreakCoordinator: BreakCoordinating { } public func handleSystemSleep() { - breakTimer?.cancel() - breakTimer = nil - if locker.isShowing { - locker.dismissOverlay() - if var event = currentBreak { - event.outcome = .skipped - statistics.breaksSkipped += 1 - statistics.currentStreak = 0 - eventContinuation.yield(.breakSkipped(event)) - eventContinuation.yield(.statisticsUpdated(statistics)) - } - currentBreak = nil - currentSchedule = nil - } + cancelAndDismissIfShowing() } public func handleSystemWake() { - scheduleNextBreak() + if isPaused { + resume() + } else { + scheduleNextBreak() + } } public func handleScreenLock() { + cancelAndDismissIfShowing() + } + + public func handleScreenUnlock() { + if isPaused { + resume() + } else { + scheduleNextBreak() + } + } + + private func cancelAndDismissIfShowing() { breakTimer?.cancel() breakTimer = nil if locker.isShowing { @@ -114,10 +117,6 @@ public final class BreakCoordinator: BreakCoordinating { } } - public func handleScreenUnlock() { - scheduleNextBreak() - } - public func skipNextBreak() { breakTimer?.cancel() breakTimer = nil diff --git a/StandLockKit/Tests/CoordinationTests/CoordinationTests.swift b/StandLockKit/Tests/CoordinationTests/CoordinationTests.swift index cecfa42..f12f1bc 100644 --- a/StandLockKit/Tests/CoordinationTests/CoordinationTests.swift +++ b/StandLockKit/Tests/CoordinationTests/CoordinationTests.swift @@ -799,4 +799,262 @@ struct BreakCoordinatorTests { coordinator.stop() listener.cancel() } + + // MARK: - System Sleep/Wake & Screen Lock/Unlock Tests + + @Test @MainActor + func systemSleepDismissesOverlayAndSkips() async { + let scheduler = MockScheduler() + scheduler.nextBreakTimeToReturn = Date().addingTimeInterval(0.05) + let detector = MockDetector() + let locker = MockLocker() + + let coordinator = BreakCoordinator(scheduler: scheduler, detector: detector, locker: locker) + let schedule = makeSchedule(breakDuration: 5) + + var skippedEvents: [BreakEvent] = [] + var lastStats: BreakStatistics? + let listener = Task { + for await event in coordinator.events { + if case .breakSkipped(let e) = event { skippedEvents.append(e) } + if case .statisticsUpdated(let s) = event { lastStats = s } + } + } + + coordinator.start(with: [schedule], preferences: AppPreferences()) + try? await Task.sleep(for: .milliseconds(300)) + #expect(locker.isShowing) + + coordinator.handleSystemSleep() + try? await Task.sleep(for: .milliseconds(100)) + + #expect(locker.dismissOverlayCalled) + #expect(!locker.isShowing) + #expect(skippedEvents.count == 1) + if case .skipped = skippedEvents.first?.outcome {} else { + Issue.record("Expected outcome .skipped") + } + #expect(lastStats?.breaksSkipped == 1) + #expect(lastStats?.currentStreak == 0) + + coordinator.stop() + listener.cancel() + } + + @Test @MainActor + func systemSleepCancelsTimerWhenNoOverlay() async { + let scheduler = MockScheduler() + scheduler.nextBreakTimeToReturn = Date().addingTimeInterval(60) + let detector = MockDetector() + let locker = MockLocker() + + let coordinator = BreakCoordinator(scheduler: scheduler, detector: detector, locker: locker) + let schedule = makeSchedule() + + coordinator.start(with: [schedule], preferences: AppPreferences()) + try? await Task.sleep(for: .milliseconds(50)) + #expect(!locker.isShowing) + + coordinator.handleSystemSleep() + + #expect(!locker.dismissOverlayCalled) + + coordinator.stop() + } + + @Test @MainActor + func systemWakeReschedulesBreak() async { + let scheduler = MockScheduler() + scheduler.nextBreakTimeToReturn = Date().addingTimeInterval(60) + let detector = MockDetector() + let locker = MockLocker() + + let coordinator = BreakCoordinator(scheduler: scheduler, detector: detector, locker: locker) + let schedule = makeSchedule() + + var scheduledEvents: [CoordinatorEvent] = [] + let listener = Task { + for await event in coordinator.events { + if case .nextBreakScheduled = event { scheduledEvents.append(event) } + } + } + + coordinator.start(with: [schedule], preferences: AppPreferences()) + try? await Task.sleep(for: .milliseconds(50)) + let countAfterStart = scheduledEvents.count + + coordinator.handleSystemSleep() + coordinator.handleSystemWake() + try? await Task.sleep(for: .milliseconds(100)) + + #expect(scheduledEvents.count > countAfterStart) + + coordinator.stop() + listener.cancel() + } + + @Test @MainActor + func screenLockDismissesOverlayAndSkips() async { + let scheduler = MockScheduler() + scheduler.nextBreakTimeToReturn = Date().addingTimeInterval(0.05) + let detector = MockDetector() + let locker = MockLocker() + + let coordinator = BreakCoordinator(scheduler: scheduler, detector: detector, locker: locker) + let schedule = makeSchedule(breakDuration: 5) + + var skippedEvents: [BreakEvent] = [] + var lastStats: BreakStatistics? + let listener = Task { + for await event in coordinator.events { + if case .breakSkipped(let e) = event { skippedEvents.append(e) } + if case .statisticsUpdated(let s) = event { lastStats = s } + } + } + + coordinator.start(with: [schedule], preferences: AppPreferences()) + try? await Task.sleep(for: .milliseconds(300)) + #expect(locker.isShowing) + + coordinator.handleScreenLock() + try? await Task.sleep(for: .milliseconds(100)) + + #expect(locker.dismissOverlayCalled) + #expect(!locker.isShowing) + #expect(skippedEvents.count == 1) + if case .skipped = skippedEvents.first?.outcome {} else { + Issue.record("Expected outcome .skipped") + } + #expect(lastStats?.breaksSkipped == 1) + #expect(lastStats?.currentStreak == 0) + + coordinator.stop() + listener.cancel() + } + + @Test @MainActor + func screenLockCancelsTimerWhenNoOverlay() async { + let scheduler = MockScheduler() + scheduler.nextBreakTimeToReturn = Date().addingTimeInterval(60) + let detector = MockDetector() + let locker = MockLocker() + + let coordinator = BreakCoordinator(scheduler: scheduler, detector: detector, locker: locker) + let schedule = makeSchedule() + + coordinator.start(with: [schedule], preferences: AppPreferences()) + try? await Task.sleep(for: .milliseconds(50)) + #expect(!locker.isShowing) + + coordinator.handleScreenLock() + + #expect(!locker.dismissOverlayCalled) + + coordinator.stop() + } + + @Test @MainActor + func screenUnlockReschedulesBreak() async { + let scheduler = MockScheduler() + scheduler.nextBreakTimeToReturn = Date().addingTimeInterval(60) + let detector = MockDetector() + let locker = MockLocker() + + let coordinator = BreakCoordinator(scheduler: scheduler, detector: detector, locker: locker) + let schedule = makeSchedule() + + var scheduledEvents: [CoordinatorEvent] = [] + let listener = Task { + for await event in coordinator.events { + if case .nextBreakScheduled = event { scheduledEvents.append(event) } + } + } + + coordinator.start(with: [schedule], preferences: AppPreferences()) + try? await Task.sleep(for: .milliseconds(50)) + let countAfterStart = scheduledEvents.count + + coordinator.handleScreenLock() + coordinator.handleScreenUnlock() + try? await Task.sleep(for: .milliseconds(100)) + + #expect(scheduledEvents.count > countAfterStart) + + coordinator.stop() + listener.cancel() + } + + @Test @MainActor + func systemSleepDuringPauseResumesOnWake() async { + let scheduler = MockScheduler() + scheduler.nextBreakTimeToReturn = Date().addingTimeInterval(60) + let detector = MockDetector() + let locker = MockLocker() + + let coordinator = BreakCoordinator(scheduler: scheduler, detector: detector, locker: locker) + let schedule = makeSchedule() + + var resumeEvent: CoordinatorEvent? + var scheduledEvents: [CoordinatorEvent] = [] + let listener = Task { + for await event in coordinator.events { + if case .scheduleResumed = event { resumeEvent = event } + if case .nextBreakScheduled = event { scheduledEvents.append(event) } + } + } + + coordinator.start(with: [schedule], preferences: AppPreferences()) + try? await Task.sleep(for: .milliseconds(50)) + + coordinator.pause(for: 300) + try? await Task.sleep(for: .milliseconds(50)) + + coordinator.handleSystemSleep() + let countBeforeWake = scheduledEvents.count + coordinator.handleSystemWake() + try? await Task.sleep(for: .milliseconds(100)) + + #expect(resumeEvent != nil) + #expect(scheduledEvents.count > countBeforeWake) + + coordinator.stop() + listener.cancel() + } + + @Test @MainActor + func screenLockDuringPauseResumesOnUnlock() async { + let scheduler = MockScheduler() + scheduler.nextBreakTimeToReturn = Date().addingTimeInterval(60) + let detector = MockDetector() + let locker = MockLocker() + + let coordinator = BreakCoordinator(scheduler: scheduler, detector: detector, locker: locker) + let schedule = makeSchedule() + + var resumeEvent: CoordinatorEvent? + var scheduledEvents: [CoordinatorEvent] = [] + let listener = Task { + for await event in coordinator.events { + if case .scheduleResumed = event { resumeEvent = event } + if case .nextBreakScheduled = event { scheduledEvents.append(event) } + } + } + + coordinator.start(with: [schedule], preferences: AppPreferences()) + try? await Task.sleep(for: .milliseconds(50)) + + coordinator.pause(for: 300) + try? await Task.sleep(for: .milliseconds(50)) + + coordinator.handleScreenLock() + let countBeforeUnlock = scheduledEvents.count + coordinator.handleScreenUnlock() + try? await Task.sleep(for: .milliseconds(100)) + + #expect(resumeEvent != nil) + #expect(scheduledEvents.count > countBeforeUnlock) + + coordinator.stop() + listener.cancel() + } }