From 95477b5348ade79109f5b77d6b1f5a5bf5dee3e9 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 22 Jan 2026 12:25:55 -0800 Subject: [PATCH 1/6] Split the settings view into different sub-views --- .../NotificationPermissionSettingView.swift | 55 +++++++++ .../Settings/OpenOnLoginSettingView.swift | 55 +++++++++ Clockwise/Views/Settings/SettingsView.swift | 31 ++++++ Clockwise/Views/SettingsView.swift | 104 ------------------ 4 files changed, 141 insertions(+), 104 deletions(-) create mode 100644 Clockwise/Views/Settings/NotificationPermissionSettingView.swift create mode 100644 Clockwise/Views/Settings/OpenOnLoginSettingView.swift create mode 100644 Clockwise/Views/Settings/SettingsView.swift delete mode 100644 Clockwise/Views/SettingsView.swift diff --git a/Clockwise/Views/Settings/NotificationPermissionSettingView.swift b/Clockwise/Views/Settings/NotificationPermissionSettingView.swift new file mode 100644 index 0000000..6199797 --- /dev/null +++ b/Clockwise/Views/Settings/NotificationPermissionSettingView.swift @@ -0,0 +1,55 @@ +// +// NotificationPermissionSettingView.swift +// Clockwise +// +// Created by Brendan Chen on 2026.01.22. +// + +import SwiftUI +import UserNotifications + +struct NotificationPermissionSettingView: NotificationsContextView { + @Environment(NotificationsContext.self) var notificationsContext + @State private var renderedNotificationPermissionStatus: UNAuthorizationStatus = .notDetermined + + @State private var errorLocalizedDescription: String? = nil + + var body: some View { + VStack(alignment: .leading) { + Text("Notification permission: \(renderedNotificationPermissionStatus.asString())") + + if renderedNotificationPermissionStatus == .notDetermined { + Button("Grant notification permission", action: grantNotificationPermission) + .buttonStyle(.borderedProminent) + } + Button("Open system notification settings", action: openNotificationSystemSettingsPane) + + Text( + "Receive notifications when a timer ends." + ) + .font(.footnote) + .foregroundStyle(.secondary) + + if let errorLocalizedDescription = errorLocalizedDescription { + Text(errorLocalizedDescription) + .foregroundStyle(.red) + } + } + } + + private func initializeNotificationPermissionState() { + Task { + renderedNotificationPermissionStatus = await notificationsContext.getNotificationPermissionStatus() + } + } + + private func openNotificationSystemSettingsPane() { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") { + NSWorkspace.shared.open(url) + } + } + + func handleError(_ error: any Error) { + errorLocalizedDescription = error.localizedDescription + } +} diff --git a/Clockwise/Views/Settings/OpenOnLoginSettingView.swift b/Clockwise/Views/Settings/OpenOnLoginSettingView.swift new file mode 100644 index 0000000..5bddbcc --- /dev/null +++ b/Clockwise/Views/Settings/OpenOnLoginSettingView.swift @@ -0,0 +1,55 @@ +// +// OpenOnLoginSettingView.swift +// Clockwise +// +// Created by Brendan Chen on 2026.01.22. +// + +import SwiftUI +import ServiceManagement + +struct OpenOnLoginSettingView: ErrorHandlingView { + @State private var launchAtLogin = false + private let appService = SMAppService() + + @State private var errorLocalizedDescription: String? = nil + + var body: some View { + VStack { + Toggle("Open on login", isOn: $launchAtLogin) + .onChange(of: launchAtLogin) { + updateAppServiceRegistration() + } + .onAppear { + initializeOpenOnLoginState() + } + + if let errorLocalizedDescription = errorLocalizedDescription { + Text(errorLocalizedDescription) + .foregroundStyle(.red) + } + } + } + + private func updateAppServiceRegistration() { + do { + if launchAtLogin { + try appService.register() + } else { + try appService.unregister() + } + } catch { + handleError(error) + } + } + + private func initializeOpenOnLoginState() { + if appService.status == .enabled { + launchAtLogin = true + } + } + + func handleError(_ error: any Error) { + errorLocalizedDescription = error.localizedDescription + } +} diff --git a/Clockwise/Views/Settings/SettingsView.swift b/Clockwise/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..687b8d9 --- /dev/null +++ b/Clockwise/Views/Settings/SettingsView.swift @@ -0,0 +1,31 @@ +// +// SettingsView.swift +// Clockwise +// +// Created by Brendan Chen on 2026.01.19. +// + +import SwiftUI +import ServiceManagement +import UserNotifications + +@MainActor // for some reason the provided Settings struct throws a warning if this isn't here +struct SettingsView: View { + var body: some View { + HStack { + Spacer() + VStack(alignment: .leading, spacing: 16) { + OpenOnLoginSettingView() + NotificationPermissionSettingView() + } + Spacer() + } + .padding() + .frame(width: 480) + } +} + +#Preview { + SettingsView() + .environment(NotificationsContext()) +} diff --git a/Clockwise/Views/SettingsView.swift b/Clockwise/Views/SettingsView.swift deleted file mode 100644 index a2bd104..0000000 --- a/Clockwise/Views/SettingsView.swift +++ /dev/null @@ -1,104 +0,0 @@ -// -// SettingsView.swift -// Clockwise -// -// Created by Brendan Chen on 2026.01.19. -// - -import SwiftUI -import ServiceManagement -import UserNotifications - -@MainActor // for some reason the provided Settings struct throws a warning if this isn't here -struct SettingsView: NotificationsContextView { - @State private var launchAtLogin = false - private let appService = SMAppService() - - @State private var errorLocalizedDescription: String? = nil - - @Environment(NotificationsContext.self) var notificationsContext - @State private var renderedNotificationPermissionStatus: UNAuthorizationStatus = .notDetermined - - var body: some View { - HStack { - Spacer() - VStack(alignment: .leading, spacing: 16) { - Toggle("Open on login", isOn: $launchAtLogin) - - VStack(alignment: .leading) { - Text("Notification permission: \(renderedNotificationPermissionStatus.asString())") - - if renderedNotificationPermissionStatus == .notDetermined { - Button("Grant notification permission", action: grantNotificationPermission) - .buttonStyle(.borderedProminent) - } - Button("Open system notification settings", action: openNotificationSystemSettingsPane) - - Text( - "Receive notifications when a timer ends." - ) - .font(.footnote) - .foregroundStyle(.secondary) - - } - - if let errorLocalizedDescription = errorLocalizedDescription { - Text(errorLocalizedDescription) - .foregroundStyle(.red) - } - - } - Spacer() - } - .padding() - .frame(width: 480) - .onChange(of: launchAtLogin) { - updateAppServiceRegistration() - } - .onAppear(perform: initializeAllStates) - } - - private func updateAppServiceRegistration() { - do { - if launchAtLogin { - try appService.register() - } else { - try appService.unregister() - } - } catch { - handleError(error) - } - } - - private func initializeAllStates() { - initializeOpenOnLoginState() - initializeNotificationPermissionState() - } - - private func initializeOpenOnLoginState() { - if appService.status == .enabled { - launchAtLogin = true - } - } - - private func initializeNotificationPermissionState() { - Task { - renderedNotificationPermissionStatus = await notificationsContext.getNotificationPermissionStatus() - } - } - - func handleError(_ error: any Error) { - errorLocalizedDescription = error.localizedDescription - } - - private func openNotificationSystemSettingsPane() { - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") { - NSWorkspace.shared.open(url) - } - } -} - -#Preview { - SettingsView() - .environment(NotificationsContext()) -} From a9fe6b7b2ce5cd71c4d5773aefeac0b2c96b79ec Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 22 Jan 2026 12:34:23 -0800 Subject: [PATCH 2/6] Add TimerIntervalSettingView UI --- Clockwise/Views/Settings/SettingsView.swift | 9 +++++--- .../Settings/TimerIntervalSettingView.swift | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 Clockwise/Views/Settings/TimerIntervalSettingView.swift diff --git a/Clockwise/Views/Settings/SettingsView.swift b/Clockwise/Views/Settings/SettingsView.swift index 687b8d9..2be175d 100644 --- a/Clockwise/Views/Settings/SettingsView.swift +++ b/Clockwise/Views/Settings/SettingsView.swift @@ -14,9 +14,12 @@ struct SettingsView: View { var body: some View { HStack { Spacer() - VStack(alignment: .leading, spacing: 16) { - OpenOnLoginSettingView() - NotificationPermissionSettingView() + Form { + VStack(alignment: .leading, spacing: 16) { + OpenOnLoginSettingView() + NotificationPermissionSettingView() + TimerIntervalSettingView() + } } Spacer() } diff --git a/Clockwise/Views/Settings/TimerIntervalSettingView.swift b/Clockwise/Views/Settings/TimerIntervalSettingView.swift new file mode 100644 index 0000000..97ccb59 --- /dev/null +++ b/Clockwise/Views/Settings/TimerIntervalSettingView.swift @@ -0,0 +1,22 @@ +// +// TimerIntervalSettingView.swift +// Clockwise +// +// Created by Brendan Chen on 2026.01.22. +// + +import SwiftUI + +struct TimerIntervalSettingView: View { + @State private var focusIntervalMinutes: String = "" + @State private var breakIntervalMinutes: String = "" + + var body: some View { + VStack { + TextField("Focus number of minutes", text: $focusIntervalMinutes) + .frame(maxWidth: 240) + TextField("Break number of minutes", text: $breakIntervalMinutes) + .frame(maxWidth: 240) + } + } +} From e5c10f8277ea00e3f0bbb055ae666a6f53320b25 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 22 Jan 2026 12:41:01 -0800 Subject: [PATCH 3/6] Update UserDefaults when the values change --- .../Settings/TimerIntervalSettingView.swift | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/Clockwise/Views/Settings/TimerIntervalSettingView.swift b/Clockwise/Views/Settings/TimerIntervalSettingView.swift index 97ccb59..321cfe5 100644 --- a/Clockwise/Views/Settings/TimerIntervalSettingView.swift +++ b/Clockwise/Views/Settings/TimerIntervalSettingView.swift @@ -8,15 +8,39 @@ import SwiftUI struct TimerIntervalSettingView: View { - @State private var focusIntervalMinutes: String = "" - @State private var breakIntervalMinutes: String = "" + @State private var focusIntervalMinutes: Int = 0 + @State private var breakIntervalMinutes: Int = 0 + + let formatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter + }() var body: some View { VStack { - TextField("Focus number of minutes", text: $focusIntervalMinutes) + TextField("Focus number of minutes", value: $focusIntervalMinutes, formatter: formatter) .frame(maxWidth: 240) - TextField("Break number of minutes", text: $breakIntervalMinutes) + TextField("Break number of minutes", value: $breakIntervalMinutes, formatter: formatter) .frame(maxWidth: 240) } + .onAppear { + initializeTextFields() + } + .onChange(of: focusIntervalMinutes) { + updateDefaults(minutes: focusIntervalMinutes, forKey: UserDefaults.focusIntervalSeconds) + } + .onChange(of: breakIntervalMinutes) { + updateDefaults(minutes: breakIntervalMinutes, forKey: UserDefaults.breakIntervalSeconds) + } + } + + private func initializeTextFields() { + focusIntervalMinutes = UserDefaults.standard.integer(forKey: UserDefaults.focusIntervalSeconds) / 60 + breakIntervalMinutes = UserDefaults.standard.integer(forKey: UserDefaults.breakIntervalSeconds) / 60 + } + + private func updateDefaults(minutes: Int, forKey key: String) { + UserDefaults.standard.set(minutes * 60, forKey: key) } } From a2687bbcaff3043f06ac31cde82aff48712197a0 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Fri, 23 Jan 2026 11:21:58 -0800 Subject: [PATCH 4/6] Change labels and add accessibility labels --- Clockwise/Views/Settings/TimerIntervalSettingView.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Clockwise/Views/Settings/TimerIntervalSettingView.swift b/Clockwise/Views/Settings/TimerIntervalSettingView.swift index 321cfe5..aa20e41 100644 --- a/Clockwise/Views/Settings/TimerIntervalSettingView.swift +++ b/Clockwise/Views/Settings/TimerIntervalSettingView.swift @@ -19,10 +19,12 @@ struct TimerIntervalSettingView: View { var body: some View { VStack { - TextField("Focus number of minutes", value: $focusIntervalMinutes, formatter: formatter) + TextField("Minutes for focus", value: $focusIntervalMinutes, formatter: formatter) .frame(maxWidth: 240) - TextField("Break number of minutes", value: $breakIntervalMinutes, formatter: formatter) + .accessibilityLabel("Minutes for focus") + TextField("Minutes for break", value: $breakIntervalMinutes, formatter: formatter) .frame(maxWidth: 240) + .accessibilityLabel("Minutes for break") } .onAppear { initializeTextFields() From a84de1248cd9a505caf71736a73094632a462a9e Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Fri, 23 Jan 2026 11:22:05 -0800 Subject: [PATCH 5/6] Add UI tests, including for invalid input --- ClockwiseUITests/SettingsTests.swift | 41 ++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/ClockwiseUITests/SettingsTests.swift b/ClockwiseUITests/SettingsTests.swift index ab508d2..9eaa827 100644 --- a/ClockwiseUITests/SettingsTests.swift +++ b/ClockwiseUITests/SettingsTests.swift @@ -56,4 +56,45 @@ final class SettingsTests: ClockwiseBaseUITests { systempreferencesApp.activate() XCTAssert(systempreferencesApp.buttons["Clockwise, Off"].waitForExistence(timeout: 5)) } + + func testSetMinutesChangesTimerDisplay() throws { + launchAndOpenSettings() + + let focusTextField = app.textFields["Minutes for focus"] + focusTextField.doubleClick() + focusTextField.typeKey(.delete, modifierFlags: []) + focusTextField.typeText("30") + + let breakTextField = app.textFields["Minutes for break"] + breakTextField.doubleClick() + breakTextField.typeKey(.delete, modifierFlags: []) + breakTextField.typeText("15") + + app.terminate() + launchAndOpenMenuBarWindow() + + XCTAssert(app.staticTexts["30:00"].waitForExistence(timeout: 5)) + app.radioButtons["Break"].tap() + XCTAssert(app.staticTexts["15:00"].waitForExistence(timeout: 5)) + } + + func testInputInvalidMinutesDoesNotSave() throws { + launchAndOpenSettings() + + let focusTextField = app.textFields["Minutes for focus"] + let previousFocusTextFieldValue = focusTextField.value as? String + focusTextField.doubleClick() + focusTextField.typeKey(.delete, modifierFlags: []) + focusTextField.typeText("abcd") + + let breakTextField = app.textFields["Minutes for break"] + let previousBreakTextFieldValue = breakTextField.value as? String + breakTextField.doubleClick() + breakTextField.typeKey(.delete, modifierFlags: []) + breakTextField.typeText("efgh") + breakTextField.typeKey(.enter, modifierFlags: []) + + XCTAssert(focusTextField.value as? String == previousFocusTextFieldValue) + XCTAssert(breakTextField.value as? String == previousBreakTextFieldValue) + } } From bdc915b30258840d890d190853ab66e6d293ec63 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Fri, 23 Jan 2026 11:35:25 -0800 Subject: [PATCH 6/6] Add method appendDefaultTimerLaunchArguments to use the default 25/5 minute interval --- ClockwiseUITests/ClockwiseBaseUITests.swift | 10 ++++++++++ ClockwiseUITests/ContentViewTests.swift | 1 + ClockwiseUITests/TimerTests.swift | 5 +++-- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/ClockwiseUITests/ClockwiseBaseUITests.swift b/ClockwiseUITests/ClockwiseBaseUITests.swift index d42ef02..d5904f6 100644 --- a/ClockwiseUITests/ClockwiseBaseUITests.swift +++ b/ClockwiseUITests/ClockwiseBaseUITests.swift @@ -26,6 +26,14 @@ class ClockwiseBaseUITests: XCTestCase { // Put teardown code here. This method is called after the invocation of each test method in the class. } + // MARK: Launch helpers + + /// For tests which depend on the default focus/break times of 25/5, + /// use this method to initialize the UserDefaults container. + func appendDefaultTimerLaunchArguments() { + app.launchArguments += ["-focusIntervalSeconds", "1500", "-breakIntervalSeconds", "300"] + } + func launchAndOpenMenuBarWindow() { app.launch() app.statusItems.firstMatch.click() @@ -38,6 +46,8 @@ class ClockwiseBaseUITests: XCTestCase { app.menuItems["Settings"].firstMatch.click() } + // MARK: Reusable flows + func resetNotificationPermissions() { let systempreferencesApp = XCUIApplication(bundleIdentifier: "com.apple.systempreferences") func resetNotificationsForListItem(_ item: XCUIElement) { diff --git a/ClockwiseUITests/ContentViewTests.swift b/ClockwiseUITests/ContentViewTests.swift index ecdbb4f..fbe9e95 100644 --- a/ClockwiseUITests/ContentViewTests.swift +++ b/ClockwiseUITests/ContentViewTests.swift @@ -9,6 +9,7 @@ import XCTest final class ContentViewTests: ClockwiseBaseUITests { func testChangingModeChangesTimer() throws { + appendDefaultTimerLaunchArguments() launchAndOpenMenuBarWindow() app.radioButtons["Break"].firstMatch.click() diff --git a/ClockwiseUITests/TimerTests.swift b/ClockwiseUITests/TimerTests.swift index 1b481d9..f84473e 100644 --- a/ClockwiseUITests/TimerTests.swift +++ b/ClockwiseUITests/TimerTests.swift @@ -9,8 +9,8 @@ import XCTest final class TimerTests: ClockwiseBaseUITests { func testStartStopTimer() throws { - app.launch() - app/*@START_MENU_TOKEN@*/.statusItems/*[[".menuBars.statusItems[\"Duration\"]",".statusItems",".statusItems[\"Duration\"]"],[[[-1,2],[-1,1],[-1,0]]],[1]]@END_MENU_TOKEN@*/.firstMatch.click() + appendDefaultTimerLaunchArguments() + launchAndOpenMenuBarWindow() app/*@START_MENU_TOKEN@*/.buttons["Start"]/*[[".groups.buttons[\"Start\"]",".buttons",".buttons[\"Start\"]"],[[[-1,2],[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.click() XCTAssert(app.staticTexts["25:00"].waitForNonExistence(timeout: 5)) @@ -20,6 +20,7 @@ final class TimerTests: ClockwiseBaseUITests { } func testTimerAutomaticallyStops() throws { + appendDefaultTimerLaunchArguments() app.launchArguments += ["-focusIntervalSeconds", "2"] app.launch()