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/AppState/AppCoordinator.swift b/StandLock/AppState/AppCoordinator.swift index 0707929..c8921d7 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 @@ -223,6 +224,10 @@ final class AppCoordinator: ObservableObject { breakScheduledAt = Date() recalculateProgress() updateMenuBarTimer() + if menuBarTimerText != nil { + progressTimer?.cancel() + startProgressTimer() + } case .breakStarted(let e): deferralReason = nil @@ -305,6 +310,34 @@ 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() + } + } + + 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 private func startProgressTimer() { diff --git a/StandLock/StandLockApp.swift b/StandLock/StandLockApp.swift index 53853ec..8e27aaf 100644 --- a/StandLock/StandLockApp.swift +++ b/StandLock/StandLockApp.swift @@ -22,7 +22,7 @@ struct StandLockApp: App { .menuBarExtraStyle(.window) Settings { - SettingsView() + SettingsView(selectedTab: $appCoordinator.selectedSettingsTab, updater: appDelegate.updaterController.updater) .environmentObject(appCoordinator) .environmentObject(appCoordinator.permissionChecker) } 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 } diff --git a/StandLock/Views/Settings/AboutView.swift b/StandLock/Views/Settings/AboutView.swift index 49cb183..3dde0f4 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,21 +35,31 @@ 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) .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) + } + } } diff --git a/StandLock/Views/Settings/SettingsView.swift b/StandLock/Views/Settings/SettingsView.swift index f15640a..b9436da 100644 --- a/StandLock/Views/Settings/SettingsView.swift +++ b/StandLock/Views/Settings/SettingsView.swift @@ -1,30 +1,69 @@ import SwiftUI +@preconcurrency import Sparkle struct SettingsView: View { - @EnvironmentObject private var coordinator: AppCoordinator + @Binding var selectedTab: AppCoordinator.SettingsTab + let updater: SPUUpdater 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(updater: updater) + } + } +} - 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) + .contentShape(Rectangle()) + .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..0a26b1a 100644 --- a/StandLockKit/Sources/Coordination/BreakCoordinator.swift +++ b/StandLockKit/Sources/Coordination/BreakCoordinator.swift @@ -76,6 +76,47 @@ public final class BreakCoordinator: BreakCoordinating { scheduleNextBreak() } + public func handleSystemSleep() { + cancelAndDismissIfShowing() + } + + public func handleSystemWake() { + 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 { + 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 + } + } + public func skipNextBreak() { breakTimer?.cancel() breakTimer = nil 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/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() + } } 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())