From c3dad5770002bc839e084cdef5f5ea3c5573b86c Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Sun, 8 Feb 2026 11:04:08 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- PRD.md | 3 + README.md | 3 + .../xcschemes/TheNoiseClock.xcscheme | 110 ++++++++++++++++++ TheNoiseClock/App/ContentView.swift | 17 ++- .../Alarms/State/AlarmViewModel.swift | 38 ++++++ .../Features/Alarms/Views/AddAlarmView.swift | 2 + .../Features/Alarms/Views/AlarmView.swift | 9 ++ .../Views/Components/AlarmRowView.swift | 5 + .../Views/Components/EmptyAlarmsView.swift | 1 - .../Features/Alarms/Views/EditAlarmView.swift | 2 + .../Clock/Views/ClockSettingsView.swift | 1 + .../Features/Clock/Views/ClockView.swift | 1 + .../Components/ClockDisplayContainer.swift | 17 +++ .../Settings/AdvancedDisplaySection.swift | 2 + .../Views/Components/SoundControlView.swift | 2 + .../Features/Noise/Views/NoiseView.swift | 2 + .../Components/OnboardingBottomControls.swift | 2 + .../OnboardingPermissionsPage.swift | 33 ++---- .../Onboarding/Views/OnboardingView.swift | 12 +- .../TheNoiseClockUITests.swift | 42 ++----- 20 files changed, 246 insertions(+), 58 deletions(-) create mode 100644 TheNoiseClock.xcodeproj/xcshareddata/xcschemes/TheNoiseClock.xcscheme diff --git a/PRD.md b/PRD.md index dc2b829..2c0a6a7 100644 --- a/PRD.md +++ b/PRD.md @@ -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 diff --git a/README.md b/README.md index d0ead27..8b0eb5a 100644 --- a/README.md +++ b/README.md @@ -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. --- diff --git a/TheNoiseClock.xcodeproj/xcshareddata/xcschemes/TheNoiseClock.xcscheme b/TheNoiseClock.xcodeproj/xcshareddata/xcschemes/TheNoiseClock.xcscheme new file mode 100644 index 0000000..d157804 --- /dev/null +++ b/TheNoiseClock.xcodeproj/xcshareddata/xcschemes/TheNoiseClock.xcscheme @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TheNoiseClock/App/ContentView.swift b/TheNoiseClock/App/ContentView.swift index 4914879..b3c4686 100644 --- a/TheNoiseClock/App/ContentView.swift +++ b/TheNoiseClock/App/ContentView.swift @@ -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 { - onboardingState.completeWelcome() - } + 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)) diff --git a/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift b/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift index 705aabf..ee02410 100644 --- a/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift +++ b/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift @@ -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 @@ -32,6 +33,9 @@ final class AlarmViewModel { var systemSounds: [String] { AppConstants.SystemSounds.availableSounds } + + var isShowingErrorAlert = false + var errorAlertMessage = "" // MARK: - Initialization init(alarmService: AlarmService = AlarmService.shared) { @@ -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 + } } diff --git a/TheNoiseClock/Features/Alarms/Views/AddAlarmView.swift b/TheNoiseClock/Features/Alarms/Views/AddAlarmView.swift index b40a92d..169ade3 100644 --- a/TheNoiseClock/Features/Alarms/Views/AddAlarmView.swift +++ b/TheNoiseClock/Features/Alarms/Views/AddAlarmView.swift @@ -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") } } } diff --git a/TheNoiseClock/Features/Alarms/Views/AlarmView.swift b/TheNoiseClock/Features/Alarms/Views/AlarmView.swift index 921343e..a964c00 100644 --- a/TheNoiseClock/Features/Alarms/Views/AlarmView.swift +++ b/TheNoiseClock/Features/Alarms/Views/AlarmView.swift @@ -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 diff --git a/TheNoiseClock/Features/Alarms/Views/Components/AlarmRowView.swift b/TheNoiseClock/Features/Alarms/Views/Components/AlarmRowView.swift index e446c8b..fbbfc25 100644 --- a/TheNoiseClock/Features/Alarms/Views/Components/AlarmRowView.swift +++ b/TheNoiseClock/Features/Alarms/Views/Components/AlarmRowView.swift @@ -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) { diff --git a/TheNoiseClock/Features/Alarms/Views/Components/EmptyAlarmsView.swift b/TheNoiseClock/Features/Alarms/Views/Components/EmptyAlarmsView.swift index 2fd21f9..1992d3c 100644 --- a/TheNoiseClock/Features/Alarms/Views/Components/EmptyAlarmsView.swift +++ b/TheNoiseClock/Features/Alarms/Views/Components/EmptyAlarmsView.swift @@ -70,6 +70,5 @@ struct EmptyAlarmsView: View { // MARK: - Preview #Preview { EmptyAlarmsView { - print("Add alarm tapped") } } diff --git a/TheNoiseClock/Features/Alarms/Views/EditAlarmView.swift b/TheNoiseClock/Features/Alarms/Views/EditAlarmView.swift index 28caa6f..f140096 100644 --- a/TheNoiseClock/Features/Alarms/Views/EditAlarmView.swift +++ b/TheNoiseClock/Features/Alarms/Views/EditAlarmView.swift @@ -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") } } } diff --git a/TheNoiseClock/Features/Clock/Views/ClockSettingsView.swift b/TheNoiseClock/Features/Clock/Views/ClockSettingsView.swift index 665c50a..945f479 100644 --- a/TheNoiseClock/Features/Clock/Views/ClockSettingsView.swift +++ b/TheNoiseClock/Features/Clock/Views/ClockSettingsView.swift @@ -125,6 +125,7 @@ struct ClockSettingsView: View { .onDisappear { onCommit(style) } + .accessibilityIdentifier("settings.screen") } } diff --git a/TheNoiseClock/Features/Clock/Views/ClockView.swift b/TheNoiseClock/Features/Clock/Views/ClockView.swift index 1a48e3b..b78471d 100644 --- a/TheNoiseClock/Features/Clock/Views/ClockView.swift +++ b/TheNoiseClock/Features/Clock/Views/ClockView.swift @@ -123,6 +123,7 @@ struct ClockView: View { resetIdleTimer() } } + .accessibilityIdentifier("clock.screen") } // MARK: - Idle Timer diff --git a/TheNoiseClock/Features/Clock/Views/Components/ClockDisplayContainer.swift b/TheNoiseClock/Features/Clock/Views/Components/ClockDisplayContainer.swift index a62da51..b60f1d5 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/ClockDisplayContainer.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/ClockDisplayContainer.swift @@ -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 diff --git a/TheNoiseClock/Features/Clock/Views/Components/Settings/AdvancedDisplaySection.swift b/TheNoiseClock/Features/Clock/Views/Components/Settings/AdvancedDisplaySection.swift index c1bf8c8..36dadd5 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/Settings/AdvancedDisplaySection.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/Settings/AdvancedDisplaySection.swift @@ -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.") diff --git a/TheNoiseClock/Features/Noise/Views/Components/SoundControlView.swift b/TheNoiseClock/Features/Noise/Views/Components/SoundControlView.swift index dc14a0c..fe91715 100644 --- a/TheNoiseClock/Features/Noise/Views/Components/SoundControlView.swift +++ b/TheNoiseClock/Features/Noise/Views/Components/SoundControlView.swift @@ -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) diff --git a/TheNoiseClock/Features/Noise/Views/NoiseView.swift b/TheNoiseClock/Features/Noise/Views/NoiseView.swift index 6b9b875..add9b7d 100644 --- a/TheNoiseClock/Features/Noise/Views/NoiseView.swift +++ b/TheNoiseClock/Features/Noise/Views/NoiseView.swift @@ -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 diff --git a/TheNoiseClock/Features/Onboarding/Views/Components/OnboardingBottomControls.swift b/TheNoiseClock/Features/Onboarding/Views/Components/OnboardingBottomControls.swift index c86ef34..bea87e5 100644 --- a/TheNoiseClock/Features/Onboarding/Views/Components/OnboardingBottomControls.swift +++ b/TheNoiseClock/Features/Onboarding/Views/Components/OnboardingBottomControls.swift @@ -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") } } } diff --git a/TheNoiseClock/Features/Onboarding/Views/Components/OnboardingPermissionsPage.swift b/TheNoiseClock/Features/Onboarding/Views/Components/OnboardingPermissionsPage.swift index 7565179..9f64c0a 100644 --- a/TheNoiseClock/Features/Onboarding/Views/Components/OnboardingPermissionsPage.swift +++ b/TheNoiseClock/Features/Onboarding/Views/Components/OnboardingPermissionsPage.swift @@ -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) diff --git a/TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift b/TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift index 6098cc0..b13eee1 100644 --- a/TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift +++ b/TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift @@ -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) } diff --git a/TheNoiseClockUITests/TheNoiseClockUITests.swift b/TheNoiseClockUITests/TheNoiseClockUITests.swift index c428301..8e55a93 100644 --- a/TheNoiseClockUITests/TheNoiseClockUITests.swift +++ b/TheNoiseClockUITests/TheNoiseClockUITests.swift @@ -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,43 +169,19 @@ 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) { + + if !isSwitchOn(keepAwakeSwitch) { keepAwakeSwitch.tap() sleep(1) }