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`. - 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. - 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. - 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 ## 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. - 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. - 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`. - 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. // Show ONLY the onboarding no heavy app views behind it.
// This prevents ClockView, NoiseView, etc. from initializing // This prevents ClockView, NoiseView, etc. from initializing
// and competing for the main thread during page transitions. // and competing for the main thread during page transitions.
OnboardingView { OnboardingView(
onboardingState.completeWelcome() onComplete: {
} onboardingState.completeWelcome()
},
requestAlarmPermission: {
await alarmViewModel.requestAlarmKitAuthorization()
},
isKeepAwakeEnabled: {
clockViewModel.style.keepAwake
},
onEnableKeepAwake: {
clockViewModel.setKeepAwakeEnabled(true)
}
)
.transition(.asymmetric( .transition(.asymmetric(
insertion: .opacity, insertion: .opacity,
removal: .opacity.combined(with: .move(edge: .bottom)).combined(with: .scale(scale: 0.9)) 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, /// AlarmKit provides alarms that cut through Focus modes and silent mode,
/// with built-in Live Activity countdown and system alarm UI. /// with built-in Live Activity countdown and system alarm UI.
@Observable @Observable
@MainActor
final class AlarmViewModel { final class AlarmViewModel {
// MARK: - Properties // MARK: - Properties
@ -32,6 +33,9 @@ final class AlarmViewModel {
var systemSounds: [String] { var systemSounds: [String] {
AppConstants.SystemSounds.availableSounds AppConstants.SystemSounds.availableSounds
} }
var isShowingErrorAlert = false
var errorAlertMessage = ""
// MARK: - Initialization // MARK: - Initialization
init(alarmService: AlarmService = AlarmService.shared) { init(alarmService: AlarmService = AlarmService.shared) {
@ -57,11 +61,15 @@ final class AlarmViewModel {
try await alarmKitService.scheduleAlarm(alarm) try await alarmKitService.scheduleAlarm(alarm)
} catch { } catch {
Design.debugLog("[alarms] AlarmKit scheduling failed: \(error)") 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 { func updateAlarm(_ alarm: Alarm) async {
let previousAlarm = alarmService.getAlarm(id: alarm.id)
alarmService.updateAlarm(alarm) alarmService.updateAlarm(alarm)
// Cancel existing and reschedule if enabled // Cancel existing and reschedule if enabled
@ -73,6 +81,16 @@ final class AlarmViewModel {
try await alarmKitService.scheduleAlarm(alarm) try await alarmKitService.scheduleAlarm(alarm)
} catch { } catch {
Design.debugLog("[alarms] AlarmKit rescheduling failed: \(error)") 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 { func toggleAlarm(id: UUID) async {
guard var alarm = alarmService.getAlarm(id: id) else { return } guard var alarm = alarmService.getAlarm(id: id) else { return }
let previousAlarm = alarm
alarm.isEnabled.toggle() alarm.isEnabled.toggle()
alarmService.updateAlarm(alarm) alarmService.updateAlarm(alarm)
@ -98,6 +117,9 @@ final class AlarmViewModel {
try await alarmKitService.scheduleAlarm(alarm) try await alarmKitService.scheduleAlarm(alarm)
} catch { } catch {
Design.debugLog("[alarms] AlarmKit scheduling failed: \(error)") Design.debugLog("[alarms] AlarmKit scheduling failed: \(error)")
// Restore previous enabled state if scheduling fails.
alarmService.updateAlarm(previousAlarm)
presentAlarmOperationError(action: "toggle", error: error)
} }
} else { } else {
alarmKitService.cancelAlarm(id: id) alarmKitService.cancelAlarm(id: id)
@ -154,4 +176,20 @@ final class AlarmViewModel {
Design.debugLog("[alarmkit] ========== RESCHEDULING COMPLETE ==========") Design.debugLog("[alarmkit] ========== RESCHEDULING COMPLETE ==========")
alarmKitService.logCurrentAlarms() 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 isPresented = false
} }
.foregroundStyle(AppAccent.primary) .foregroundStyle(AppAccent.primary)
.accessibilityIdentifier("alarms.add.cancelButton")
} }
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
@ -119,6 +120,7 @@ struct AddAlarmView: View {
} }
.foregroundStyle(AppAccent.primary) .foregroundStyle(AppAccent.primary)
.fontWeight(.semibold) .fontWeight(.semibold)
.accessibilityIdentifier("alarms.add.saveButton")
} }
} }
} }

View File

@ -82,6 +82,7 @@ struct AlarmView: View {
.font(.title2) .font(.title2)
.symbolEffect(.bounce, value: showAddAlarm) .symbolEffect(.bounce, value: showAddAlarm)
} }
.accessibilityIdentifier("alarms.addButton")
} }
} }
.onAppear { .onAppear {
@ -105,6 +106,14 @@ struct AlarmView: View {
.presentationCornerRadius(Design.CornerRadius.xxLarge) .presentationCornerRadius(Design.CornerRadius.xxLarge)
} }
.sensoryFeedback(.impact(flexibility: .soft), trigger: showAddAlarm) .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 // MARK: - Private Methods

View File

@ -61,11 +61,16 @@ struct AlarmRowView: View {
accentColor: AppAccent.primary accentColor: AppAccent.primary
) )
.labelsHidden() .labelsHidden()
.accessibilityIdentifier("alarms.toggle.\(alarm.id.uuidString)")
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
onEdit() 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) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) { Button(role: .destructive) {

View File

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

View File

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

View File

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

View File

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

View File

@ -43,8 +43,25 @@ struct ClockDisplayContainer: View {
.frame(width: geometry.size.width, height: geometry.size.height) .frame(width: geometry.size.width, height: geometry.size.height)
.transition(.opacity) .transition(.opacity)
.animation(.smooth(duration: Design.Animation.standard), value: isFullScreenMode) .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 // MARK: - Preview

View File

@ -27,6 +27,7 @@ struct AdvancedDisplaySection: View {
isOn: $style.keepAwake, isOn: $style.keepAwake,
accentColor: AppAccent.primary accentColor: AppAccent.primary
) )
.accessibilityIdentifier("settings.keepAwake.toggle")
if style.autoBrightness { if style.autoBrightness {
Rectangle() Rectangle()
@ -68,6 +69,7 @@ struct AdvancedDisplaySection: View {
isOn: $style.respectFocusModes, isOn: $style.respectFocusModes,
accentColor: AppAccent.primary accentColor: AppAccent.primary
) )
.accessibilityIdentifier("settings.respectFocus.toggle")
} }
Text("Control how the app behaves when Focus modes are active.") 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) .opacity(selectedSound == nil ? 0.6 : 1.0)
.animation(.easeInOut(duration: 0.2), value: isPlaying) .animation(.easeInOut(duration: 0.2), value: isPlaying)
.animation(.easeInOut(duration: 0.2), value: selectedSound) .animation(.easeInOut(duration: 0.2), value: selectedSound)
.accessibilityIdentifier("noise.playStopButton")
.accessibilityLabel(isPlaying ? "Stop Sound" : "Play Sound")
} }
.frame(maxWidth: 400) // Reasonable max width for iPad .frame(maxWidth: 400) // Reasonable max width for iPad
.padding(Design.Spacing.medium) .padding(Design.Spacing.medium)

View File

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

View File

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

View File

@ -13,6 +13,9 @@ struct OnboardingPermissionsPage: View {
@Binding var alarmKitPermissionGranted: Bool @Binding var alarmKitPermissionGranted: Bool
@Binding var keepAwakeEnabled: Bool @Binding var keepAwakeEnabled: Bool
let requestAlarmPermission: () async -> Bool
let isKeepAwakeEnabled: () -> Bool
let onEnableKeepAwake: () -> Void
let onAdvanceToFinal: () -> Void let onAdvanceToFinal: () -> Void
var body: some View { var body: some View {
@ -95,6 +98,7 @@ struct OnboardingPermissionsPage: View {
.background(AppAccent.primary) .background(AppAccent.primary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
} }
.accessibilityIdentifier("onboarding.enableAlarmsButton")
} }
} }
@ -122,6 +126,7 @@ struct OnboardingPermissionsPage: View {
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
} }
.disabled(keepAwakeEnabled) .disabled(keepAwakeEnabled)
.accessibilityIdentifier("onboarding.enableKeepAwakeButton")
} }
} }
@ -129,7 +134,7 @@ struct OnboardingPermissionsPage: View {
private func requestAlarmKitPermission() { private func requestAlarmKitPermission() {
Task { Task {
let granted = await AlarmKitService.shared.requestAuthorization() let granted = await requestAlarmPermission()
withAnimation(.spring(duration: 0.3)) { withAnimation(.spring(duration: 0.3)) {
alarmKitPermissionGranted = granted alarmKitPermissionGranted = granted
} }
@ -141,32 +146,11 @@ struct OnboardingPermissionsPage: View {
} }
private func enableKeepAwake() { private func enableKeepAwake() {
let style = loadClockStyle() onEnableKeepAwake()
style.keepAwake = true
saveClockStyle(style)
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
withAnimation(.spring(duration: 0.3)) { withAnimation(.spring(duration: 0.3)) {
keepAwakeEnabled = true 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 // MARK: - Preview
@ -175,6 +159,9 @@ struct OnboardingPermissionsPage: View {
OnboardingPermissionsPage( OnboardingPermissionsPage(
alarmKitPermissionGranted: .constant(false), alarmKitPermissionGranted: .constant(false),
keepAwakeEnabled: .constant(false), keepAwakeEnabled: .constant(false),
requestAlarmPermission: { true },
isKeepAwakeEnabled: { false },
onEnableKeepAwake: {},
onAdvanceToFinal: {} onAdvanceToFinal: {}
) )
.preferredColorScheme(.dark) .preferredColorScheme(.dark)

View File

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

View File

@ -135,6 +135,12 @@ final class TheNoiseClockUITests: XCTestCase {
@MainActor @MainActor
private func dismissOnboardingIfNeeded(_ app: XCUIApplication) { 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) { if app.buttons["Skip"].waitForExistence(timeout: 3) {
app.buttons["Skip"].tap() app.buttons["Skip"].tap()
waitForMainTabs(app) waitForMainTabs(app)
@ -163,43 +169,19 @@ final class TheNoiseClockUITests: XCTestCase {
private func ensureKeepAwakeEnabled(_ app: XCUIApplication) { private func ensureKeepAwakeEnabled(_ app: XCUIApplication) {
openTab(named: "Settings", in: app) openTab(named: "Settings", in: app)
let keepAwakeLabel = app.staticTexts["Keep Awake"] let keepAwakeSwitch = app.switches["settings.keepAwake.toggle"]
for _ in 0..<8 { for _ in 0..<8 {
if keepAwakeLabel.exists { break } if keepAwakeSwitch.exists { break }
app.swipeUp() app.swipeUp()
usleep(200_000) usleep(200_000)
} }
guard keepAwakeLabel.waitForExistence(timeout: 3) else { guard keepAwakeSwitch.waitForExistence(timeout: 3) else {
XCTFail("Could not find Keep Awake toggle in Settings.") XCTFail("Could not find Keep Awake toggle by accessibility identifier.")
return return
} }
let labelY = keepAwakeLabel.frame.midY if !isSwitchOn(keepAwakeSwitch) {
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() keepAwakeSwitch.tap()
sleep(1) sleep(1)
} }