Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
fbd0377348
commit
c3dad57700
3
PRD.md
3
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`.
|
- 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
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
@ -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))
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -70,6 +70,5 @@ struct EmptyAlarmsView: View {
|
|||||||
// MARK: - Preview
|
// MARK: - Preview
|
||||||
#Preview {
|
#Preview {
|
||||||
EmptyAlarmsView {
|
EmptyAlarmsView {
|
||||||
print("Add alarm tapped")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -125,6 +125,7 @@ struct ClockSettingsView: View {
|
|||||||
.onDisappear {
|
.onDisappear {
|
||||||
onCommit(style)
|
onCommit(style)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier("settings.screen")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -123,6 +123,7 @@ struct ClockView: View {
|
|||||||
resetIdleTimer()
|
resetIdleTimer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier("clock.screen")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Idle Timer
|
// MARK: - Idle Timer
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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.")
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user