Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions Clockwise/Views/Settings/NotificationPermissionSettingView.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
55 changes: 55 additions & 0 deletions Clockwise/Views/Settings/OpenOnLoginSettingView.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
34 changes: 34 additions & 0 deletions Clockwise/Views/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//
// 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()
Form {
VStack(alignment: .leading, spacing: 16) {
OpenOnLoginSettingView()
NotificationPermissionSettingView()
TimerIntervalSettingView()
}
}
Spacer()
}
.padding()
.frame(width: 480)
}
}

#Preview {
SettingsView()
.environment(NotificationsContext())
}
48 changes: 48 additions & 0 deletions Clockwise/Views/Settings/TimerIntervalSettingView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//
// TimerIntervalSettingView.swift
// Clockwise
//
// Created by Brendan Chen on 2026.01.22.
//

import SwiftUI

struct TimerIntervalSettingView: View {
@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("Minutes for focus", value: $focusIntervalMinutes, formatter: formatter)
.frame(maxWidth: 240)
.accessibilityLabel("Minutes for focus")
TextField("Minutes for break", value: $breakIntervalMinutes, formatter: formatter)
.frame(maxWidth: 240)
.accessibilityLabel("Minutes for break")
}
.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)
}
}
104 changes: 0 additions & 104 deletions Clockwise/Views/SettingsView.swift

This file was deleted.

10 changes: 10 additions & 0 deletions ClockwiseUITests/ClockwiseBaseUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions ClockwiseUITests/ContentViewTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import XCTest

final class ContentViewTests: ClockwiseBaseUITests {
func testChangingModeChangesTimer() throws {
appendDefaultTimerLaunchArguments()
launchAndOpenMenuBarWindow()

app.radioButtons["Break"].firstMatch.click()
Expand Down
41 changes: 41 additions & 0 deletions ClockwiseUITests/SettingsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
5 changes: 3 additions & 2 deletions ClockwiseUITests/TimerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -20,6 +20,7 @@ final class TimerTests: ClockwiseBaseUITests {
}

func testTimerAutomaticallyStops() throws {
appendDefaultTimerLaunchArguments()
app.launchArguments += ["-focusIntervalSeconds", "2"]
app.launch()

Expand Down