Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-02-08 11:04:08 -06:00
parent fbd0377348
commit c3dad57700
20 changed files with 246 additions and 58 deletions

3
PRD.md
View File

@ -770,6 +770,9 @@ Use **iPhone 17 Pro Max (iOS 26.2)** as the primary simulator for build and test
- Alarm-change notifications are now separated from clock-style notifications using `Notification.Name.alarmsDidUpdate`.
- Date overlay formatting now uses a per-thread `DateFormatter` cache to reduce formatter churn.
- Shared `TheNoiseClock` scheme now includes both unit and UI test targets for consistent `xcodebuild test` execution.
- Onboarding Keep Awake setup now routes through `ClockViewModel` to keep clock-style state management centralized.
- Alarm add/update/toggle now rollback on AlarmKit scheduling failures and expose user-facing error alerts.
- Key views and controls now include accessibility identifiers to stabilize UI automation and improve assistive technology support.
## Development Notes

View File

@ -138,6 +138,9 @@ Swift access is provided via:
- Alarm UI refresh uses a dedicated `alarmsDidUpdate` notification instead of reusing clock-style notifications.
- Date overlay formatting now uses cached formatters to reduce repeated allocation overhead.
- Shared scheme configuration includes both unit and UI tests under `xcodebuild ... -scheme TheNoiseClock test`.
- Onboarding now updates Keep Awake through `ClockViewModel` (single source of truth) instead of direct `UserDefaults` writes.
- Alarm scheduling failures now surface user-visible errors and rollback failed add/update/toggle operations.
- Added accessibility identifiers for critical controls and updated UI tests to use stable identifiers instead of coordinate heuristics.
---

View File

@ -0,0 +1,110 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2630"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EA384AFA2E6E6B6000CA7D50"
BuildableName = "The Noise Clock.app"
BlueprintName = "TheNoiseClock"
ReferencedContainer = "container:TheNoiseClock.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EA384AFA2E6E6B6000CA7D50"
BuildableName = "The Noise Clock.app"
BlueprintName = "TheNoiseClock"
ReferencedContainer = "container:TheNoiseClock.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EA384B072E6E6B6100CA7D50"
BuildableName = "TheNoiseClockTests.xctest"
BlueprintName = "TheNoiseClockTests"
ReferencedContainer = "container:TheNoiseClock.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EA384B112E6E6B6100CA7D50"
BuildableName = "TheNoiseClockUITests.xctest"
BlueprintName = "TheNoiseClockUITests"
ReferencedContainer = "container:TheNoiseClock.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EA384AFA2E6E6B6000CA7D50"
BuildableName = "The Noise Clock.app"
BlueprintName = "TheNoiseClock"
ReferencedContainer = "container:TheNoiseClock.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EA384AFA2E6E6B6000CA7D50"
BuildableName = "The Noise Clock.app"
BlueprintName = "TheNoiseClock"
ReferencedContainer = "container:TheNoiseClock.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -50,9 +50,20 @@ struct ContentView: View {
// Show ONLY the onboarding no heavy app views behind it.
// This prevents ClockView, NoiseView, etc. from initializing
// and competing for the main thread during page transitions.
OnboardingView {
OnboardingView(
onComplete: {
onboardingState.completeWelcome()
},
requestAlarmPermission: {
await alarmViewModel.requestAlarmKitAuthorization()
},
isKeepAwakeEnabled: {
clockViewModel.style.keepAwake
},
onEnableKeepAwake: {
clockViewModel.setKeepAwakeEnabled(true)
}
)
.transition(.asymmetric(
insertion: .opacity,
removal: .opacity.combined(with: .move(edge: .bottom)).combined(with: .scale(scale: 0.9))

View File

@ -14,6 +14,7 @@ import Observation
/// AlarmKit provides alarms that cut through Focus modes and silent mode,
/// with built-in Live Activity countdown and system alarm UI.
@Observable
@MainActor
final class AlarmViewModel {
// MARK: - Properties
@ -33,6 +34,9 @@ final class AlarmViewModel {
AppConstants.SystemSounds.availableSounds
}
var isShowingErrorAlert = false
var errorAlertMessage = ""
// MARK: - Initialization
init(alarmService: AlarmService = AlarmService.shared) {
self.alarmService = alarmService
@ -57,11 +61,15 @@ final class AlarmViewModel {
try await alarmKitService.scheduleAlarm(alarm)
} catch {
Design.debugLog("[alarms] AlarmKit scheduling failed: \(error)")
// Roll back add so enabled alarms always represent scheduled alarms.
alarmService.deleteAlarm(id: alarm.id)
presentAlarmOperationError(action: "add", error: error)
}
}
}
func updateAlarm(_ alarm: Alarm) async {
let previousAlarm = alarmService.getAlarm(id: alarm.id)
alarmService.updateAlarm(alarm)
// Cancel existing and reschedule if enabled
@ -73,6 +81,16 @@ final class AlarmViewModel {
try await alarmKitService.scheduleAlarm(alarm)
} catch {
Design.debugLog("[alarms] AlarmKit rescheduling failed: \(error)")
if let previousAlarm {
// Restore prior state when the new schedule cannot be committed.
alarmService.updateAlarm(previousAlarm)
if previousAlarm.isEnabled {
try? await alarmKitService.scheduleAlarm(previousAlarm)
} else {
alarmKitService.cancelAlarm(id: previousAlarm.id)
}
}
presentAlarmOperationError(action: "update", error: error)
}
}
}
@ -87,6 +105,7 @@ final class AlarmViewModel {
func toggleAlarm(id: UUID) async {
guard var alarm = alarmService.getAlarm(id: id) else { return }
let previousAlarm = alarm
alarm.isEnabled.toggle()
alarmService.updateAlarm(alarm)
@ -98,6 +117,9 @@ final class AlarmViewModel {
try await alarmKitService.scheduleAlarm(alarm)
} catch {
Design.debugLog("[alarms] AlarmKit scheduling failed: \(error)")
// Restore previous enabled state if scheduling fails.
alarmService.updateAlarm(previousAlarm)
presentAlarmOperationError(action: "toggle", error: error)
}
} else {
alarmKitService.cancelAlarm(id: id)
@ -154,4 +176,20 @@ final class AlarmViewModel {
Design.debugLog("[alarmkit] ========== RESCHEDULING COMPLETE ==========")
alarmKitService.logCurrentAlarms()
}
func dismissErrorAlert() {
isShowingErrorAlert = false
errorAlertMessage = ""
}
private func presentAlarmOperationError(action: String, error: Error) {
let detail = if let localized = error as? LocalizedError,
let description = localized.errorDescription {
description
} else {
error.localizedDescription
}
errorAlertMessage = "Unable to \(action) alarm. \(detail)"
isShowingErrorAlert = true
}
}

View File

@ -98,6 +98,7 @@ struct AddAlarmView: View {
isPresented = false
}
.foregroundStyle(AppAccent.primary)
.accessibilityIdentifier("alarms.add.cancelButton")
}
ToolbarItem(placement: .navigationBarTrailing) {
@ -119,6 +120,7 @@ struct AddAlarmView: View {
}
.foregroundStyle(AppAccent.primary)
.fontWeight(.semibold)
.accessibilityIdentifier("alarms.add.saveButton")
}
}
}

View File

@ -82,6 +82,7 @@ struct AlarmView: View {
.font(.title2)
.symbolEffect(.bounce, value: showAddAlarm)
}
.accessibilityIdentifier("alarms.addButton")
}
}
.onAppear {
@ -105,6 +106,14 @@ struct AlarmView: View {
.presentationCornerRadius(Design.CornerRadius.xxLarge)
}
.sensoryFeedback(.impact(flexibility: .soft), trigger: showAddAlarm)
.accessibilityIdentifier("alarms.screen")
.alert("Alarm Error", isPresented: $viewModel.isShowingErrorAlert) {
Button("OK", role: .cancel) {
viewModel.dismissErrorAlert()
}
} message: {
Text(viewModel.errorAlertMessage)
}
}
// MARK: - Private Methods

View File

@ -61,11 +61,16 @@ struct AlarmRowView: View {
accentColor: AppAccent.primary
)
.labelsHidden()
.accessibilityIdentifier("alarms.toggle.\(alarm.id.uuidString)")
}
.contentShape(Rectangle())
.onTapGesture {
onEdit()
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("alarms.row.\(alarm.id.uuidString)")
.accessibilityLabel("\(alarm.label), \(alarm.formattedTime())")
.accessibilityValue(alarm.isEnabled ? "Enabled" : "Disabled")
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {

View File

@ -70,6 +70,5 @@ struct EmptyAlarmsView: View {
// MARK: - Preview
#Preview {
EmptyAlarmsView {
print("Add alarm tapped")
}
}

View File

@ -127,6 +127,7 @@ struct EditAlarmView: View {
dismiss()
}
.foregroundStyle(AppAccent.primary)
.accessibilityIdentifier("alarms.edit.cancelButton")
}
ToolbarItem(placement: .navigationBarTrailing) {
@ -150,6 +151,7 @@ struct EditAlarmView: View {
}
.foregroundStyle(AppAccent.primary)
.fontWeight(.semibold)
.accessibilityIdentifier("alarms.edit.saveButton")
}
}
}

View File

@ -125,6 +125,7 @@ struct ClockSettingsView: View {
.onDisappear {
onCommit(style)
}
.accessibilityIdentifier("settings.screen")
}
}

View File

@ -123,6 +123,7 @@ struct ClockView: View {
resetIdleTimer()
}
}
.accessibilityIdentifier("clock.screen")
}
// MARK: - Idle Timer

View File

@ -43,8 +43,25 @@ struct ClockDisplayContainer: View {
.frame(width: geometry.size.width, height: geometry.size.height)
.transition(.opacity)
.animation(.smooth(duration: Design.Animation.standard), value: isFullScreenMode)
.accessibilityElement(children: .ignore)
.accessibilityIdentifier("clock.timeDisplay")
.accessibilityLabel("Current time")
.accessibilityValue(accessibilityTimeValue)
}
}
private var accessibilityTimeValue: String {
let format: String
if style.use24Hour {
format = style.showSeconds ? "HH:mm:ss" : "HH:mm"
} else if style.showAmPm {
format = style.showSeconds ? "h:mm:ss a" : "h:mm a"
} else {
format = style.showSeconds ? "h:mm:ss" : "h:mm"
}
return currentTime.formattedForOverlay(format: format)
}
}
// MARK: - Preview

View File

@ -27,6 +27,7 @@ struct AdvancedDisplaySection: View {
isOn: $style.keepAwake,
accentColor: AppAccent.primary
)
.accessibilityIdentifier("settings.keepAwake.toggle")
if style.autoBrightness {
Rectangle()
@ -68,6 +69,7 @@ struct AdvancedDisplaySection: View {
isOn: $style.respectFocusModes,
accentColor: AppAccent.primary
)
.accessibilityIdentifier("settings.respectFocus.toggle")
}
Text("Control how the app behaves when Focus modes are active.")

View File

@ -78,6 +78,8 @@ struct SoundControlView: View {
.opacity(selectedSound == nil ? 0.6 : 1.0)
.animation(.easeInOut(duration: 0.2), value: isPlaying)
.animation(.easeInOut(duration: 0.2), value: selectedSound)
.accessibilityIdentifier("noise.playStopButton")
.accessibilityLabel(isPlaying ? "Stop Sound" : "Play Sound")
}
.frame(maxWidth: 400) // Reasonable max width for iPad
.padding(Design.Spacing.medium)

View File

@ -47,6 +47,7 @@ struct NoiseView: View {
TextField("Search sounds", text: $searchText)
.textFieldStyle(.plain)
.foregroundStyle(AppTextColors.primary)
.accessibilityIdentifier("noise.searchField")
if !searchText.isEmpty {
Button(action: { searchText = "" }) {
@ -81,6 +82,7 @@ struct NoiseView: View {
.navigationTitle("Noise")
.navigationBarTitleDisplayMode(.inline)
.animation(.easeInOut(duration: 0.3), value: selectedSound)
.accessibilityIdentifier("noise.screen")
}
// MARK: - Computed Properties

View File

@ -43,6 +43,7 @@ struct OnboardingBottomControls: View {
.frame(maxWidth: .infinity)
.padding(Design.Spacing.medium)
}
.accessibilityIdentifier("onboarding.secondaryButton")
Button {
if currentPage < totalPages - 1 {
@ -59,6 +60,7 @@ struct OnboardingBottomControls: View {
.background(AppAccent.primary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
.accessibilityIdentifier("onboarding.primaryButton")
}
}
}

View File

@ -13,6 +13,9 @@ struct OnboardingPermissionsPage: View {
@Binding var alarmKitPermissionGranted: Bool
@Binding var keepAwakeEnabled: Bool
let requestAlarmPermission: () async -> Bool
let isKeepAwakeEnabled: () -> Bool
let onEnableKeepAwake: () -> Void
let onAdvanceToFinal: () -> Void
var body: some View {
@ -95,6 +98,7 @@ struct OnboardingPermissionsPage: View {
.background(AppAccent.primary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
.accessibilityIdentifier("onboarding.enableAlarmsButton")
}
}
@ -122,6 +126,7 @@ struct OnboardingPermissionsPage: View {
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
.disabled(keepAwakeEnabled)
.accessibilityIdentifier("onboarding.enableKeepAwakeButton")
}
}
@ -129,7 +134,7 @@ struct OnboardingPermissionsPage: View {
private func requestAlarmKitPermission() {
Task {
let granted = await AlarmKitService.shared.requestAuthorization()
let granted = await requestAlarmPermission()
withAnimation(.spring(duration: 0.3)) {
alarmKitPermissionGranted = granted
}
@ -141,32 +146,11 @@ struct OnboardingPermissionsPage: View {
}
private func enableKeepAwake() {
let style = loadClockStyle()
style.keepAwake = true
saveClockStyle(style)
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
onEnableKeepAwake()
withAnimation(.spring(duration: 0.3)) {
keepAwakeEnabled = true
}
}
private func isKeepAwakeEnabled() -> Bool {
loadClockStyle().keepAwake
}
private func loadClockStyle() -> ClockStyle {
guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey),
let decoded = try? JSONDecoder().decode(ClockStyle.self, from: data) else {
return ClockStyle()
}
return decoded
}
private func saveClockStyle(_ style: ClockStyle) {
if let data = try? JSONEncoder().encode(style) {
UserDefaults.standard.set(data, forKey: ClockStyle.appStorageKey)
}
}
}
// MARK: - Preview
@ -175,6 +159,9 @@ struct OnboardingPermissionsPage: View {
OnboardingPermissionsPage(
alarmKitPermissionGranted: .constant(false),
keepAwakeEnabled: .constant(false),
requestAlarmPermission: { true },
isKeepAwakeEnabled: { false },
onEnableKeepAwake: {},
onAdvanceToFinal: {}
)
.preferredColorScheme(.dark)

View File

@ -20,6 +20,9 @@ struct OnboardingView: View {
// MARK: - Properties
let onComplete: () -> Void
let requestAlarmPermission: () async -> Bool
let isKeepAwakeEnabled: () -> Bool
let onEnableKeepAwake: () -> Void
@State private var currentPage = 0
@State private var alarmKitPermissionGranted = false
@ -50,6 +53,9 @@ struct OnboardingView: View {
OnboardingPermissionsPage(
alarmKitPermissionGranted: $alarmKitPermissionGranted,
keepAwakeEnabled: $keepAwakeEnabled,
requestAlarmPermission: requestAlarmPermission,
isKeepAwakeEnabled: isKeepAwakeEnabled,
onEnableKeepAwake: onEnableKeepAwake,
onAdvanceToFinal: {
withAnimation { currentPage = 3 }
}
@ -84,7 +90,11 @@ struct OnboardingView: View {
#Preview {
OnboardingView {
print("Onboarding complete")
} requestAlarmPermission: {
true
} isKeepAwakeEnabled: {
false
} onEnableKeepAwake: {
}
.preferredColorScheme(.dark)
}

View File

@ -135,6 +135,12 @@ final class TheNoiseClockUITests: XCTestCase {
@MainActor
private func dismissOnboardingIfNeeded(_ app: XCUIApplication) {
let secondaryButton = app.buttons["onboarding.secondaryButton"]
if secondaryButton.waitForExistence(timeout: 3), secondaryButton.label == "Skip" {
secondaryButton.tap()
waitForMainTabs(app)
return
}
if app.buttons["Skip"].waitForExistence(timeout: 3) {
app.buttons["Skip"].tap()
waitForMainTabs(app)
@ -163,46 +169,22 @@ final class TheNoiseClockUITests: XCTestCase {
private func ensureKeepAwakeEnabled(_ app: XCUIApplication) {
openTab(named: "Settings", in: app)
let keepAwakeLabel = app.staticTexts["Keep Awake"]
let keepAwakeSwitch = app.switches["settings.keepAwake.toggle"]
for _ in 0..<8 {
if keepAwakeLabel.exists { break }
if keepAwakeSwitch.exists { break }
app.swipeUp()
usleep(200_000)
}
guard keepAwakeLabel.waitForExistence(timeout: 3) else {
XCTFail("Could not find Keep Awake toggle in Settings.")
guard keepAwakeSwitch.waitForExistence(timeout: 3) else {
XCTFail("Could not find Keep Awake toggle by accessibility identifier.")
return
}
let labelY = keepAwakeLabel.frame.midY
let switchCandidates = app.switches.allElementsBoundByIndex.filter { element in
abs(element.frame.midY - labelY) < 90
}
if let keepAwakeSwitch = switchCandidates.first {
if !isSwitchOn(keepAwakeSwitch) {
keepAwakeSwitch.tap()
sleep(1)
}
return
}
// Fallback: tap the right side of the Keep Awake row where the switch is rendered.
let appFrame = app.frame
let switchTap = app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
.withOffset(CGVector(dx: appFrame.maxX - 24, dy: labelY))
switchTap.tap()
sleep(1)
// Verify we can now observe an "on" switch near this row.
let postTapCandidates = app.switches.allElementsBoundByIndex.filter { element in
abs(element.frame.midY - labelY) < 90
}
if let keepAwakeSwitch = postTapCandidates.first, !isSwitchOn(keepAwakeSwitch) {
keepAwakeSwitch.tap()
sleep(1)
}
}
@MainActor