From 8f798364811174d22f4dc45cc2869d26d778203e Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Sun, 8 Feb 2026 12:10:38 -0600 Subject: [PATCH] added features. Signed-off-by: Matt Bruce --- PRD.md | 3 + README.md | 4 ++ .../Features/Alarms/Models/Alarm.swift | 9 ++- .../Alarms/State/AlarmViewModel.swift | 39 ++++++++++-- .../Features/Alarms/Views/AddAlarmView.swift | 58 ++++++++++++----- .../Features/Alarms/Views/AlarmView.swift | 22 ++++++- .../Components/AlertOptionsSection.swift | 48 ++++++++++++++ .../Components/RepeatSelectionView.swift | 20 +++--- .../Features/Alarms/Views/EditAlarmView.swift | 63 ++++++++++++++----- 9 files changed, 221 insertions(+), 45 deletions(-) create mode 100644 TheNoiseClock/Features/Alarms/Views/Components/AlertOptionsSection.swift diff --git a/PRD.md b/PRD.md index 2c0a6a7..e4d7109 100644 --- a/PRD.md +++ b/PRD.md @@ -99,6 +99,7 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di - **Sound preview**: Play/stop functionality for testing alarm sounds before selection - **Custom labels**: User-defined alarm names and descriptions - **Repeat schedules**: Set alarms to repeat on specific weekdays or daily +- **Locale-aware repeat picker**: Weekday ordering follows the user’s locale-first weekday - **Sound selection**: Choose from extensive alarm sounds with live preview - **Volume control**: Adjustable alarm volume (0-100%) - **Vibration settings**: Enable/disable vibration for each alarm @@ -110,6 +111,8 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di - **AlarmKit authorization**: Requires user permission via NSAlarmKitUsageDescription - **Persistent storage**: Alarms saved to UserDefaults with backward compatibility - **Alarm management**: Add, edit, delete, and duplicate alarms +- **Sorted alarm list**: Enabled alarms appear first and are ordered by nearest upcoming trigger +- **Resilient save flow**: Add/Edit sheets stay open on scheduling failures and preserve current edits - **Next trigger preview**: Shows when the next alarm will fire - **Responsive time picker**: Font sizes adapt to available space and orientation - **AlarmKitService**: Centralized service for AlarmKit integration diff --git a/README.md b/README.md index 8b0eb5a..43ddc5c 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,10 @@ Swift access is provided via: - 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. +- Alarms now support weekday repeat schedules end-to-end, including editor UI, next-trigger calculation, and AlarmKit weekly relative schedules. +- Add/Edit alarm sheets now stay open if scheduling fails, preserving user inputs and showing an in-sheet error alert. +- Alarm list ordering now prioritizes enabled alarms and sorts by the next trigger time for faster at-a-glance relevance. +- Repeat-day controls now follow locale weekday order and include accessibility identifiers for repeat/alert options controls. --- diff --git a/TheNoiseClock/Features/Alarms/Models/Alarm.swift b/TheNoiseClock/Features/Alarms/Models/Alarm.swift index b3c177b..562ae8b 100644 --- a/TheNoiseClock/Features/Alarms/Models/Alarm.swift +++ b/TheNoiseClock/Features/Alarms/Models/Alarm.swift @@ -143,7 +143,9 @@ struct Alarm: Identifiable, Codable, Equatable { } let symbols = Calendar.current.shortWeekdaySymbols - let labels = normalized.compactMap { weekday -> String? in + let orderedWeekdays = displayOrderedWeekdays(for: Calendar.current) + let labels = orderedWeekdays.compactMap { weekday -> String? in + guard weekdaySet.contains(weekday) else { return nil } guard (1...7).contains(weekday) else { return nil } return symbols[weekday - 1] } @@ -153,4 +155,9 @@ struct Alarm: Identifiable, Codable, Equatable { static func sanitizedWeekdays(_ weekdays: [Int]) -> [Int] { Array(Set(weekdays.filter { (1...7).contains($0) })).sorted() } + + private static func displayOrderedWeekdays(for calendar: Calendar) -> [Int] { + let first = max(1, min(calendar.firstWeekday, 7)) + return Array(first...7) + Array(1.. AlarmOperationResult { alarmService.addAlarm(alarm) // Schedule with AlarmKit if alarm is enabled @@ -63,12 +68,19 @@ final class AlarmViewModel { 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) + let message = alarmOperationErrorMessage(action: "add", error: error) + if presentErrorAlert { + presentAlarmOperationError(message: message) + } + return .failure(message) } } + + return .success } - func updateAlarm(_ alarm: Alarm) async { + @discardableResult + func updateAlarm(_ alarm: Alarm, presentErrorAlert: Bool = true) async -> AlarmOperationResult { let previousAlarm = alarmService.getAlarm(id: alarm.id) alarmService.updateAlarm(alarm) @@ -90,9 +102,15 @@ final class AlarmViewModel { alarmKitService.cancelAlarm(id: previousAlarm.id) } } - presentAlarmOperationError(action: "update", error: error) + let message = alarmOperationErrorMessage(action: "update", error: error) + if presentErrorAlert { + presentAlarmOperationError(message: message) + } + return .failure(message) } } + + return .success } func deleteAlarm(id: UUID) async { @@ -185,13 +203,22 @@ final class AlarmViewModel { } private func presentAlarmOperationError(action: String, error: Error) { + let message = alarmOperationErrorMessage(action: action, error: error) + presentAlarmOperationError(message: message) + } + + private func presentAlarmOperationError(message: String) { + errorAlertMessage = message + isShowingErrorAlert = true + } + + private func alarmOperationErrorMessage(action: String, error: Error) -> String { let detail = if let localized = error as? LocalizedError, let description = localized.errorDescription { description } else { error.localizedDescription } - errorAlertMessage = "Unable to \(action) alarm. \(detail)" - isShowingErrorAlert = true + return "Unable to \(action) alarm. \(detail)" } } diff --git a/TheNoiseClock/Features/Alarms/Views/AddAlarmView.swift b/TheNoiseClock/Features/Alarms/Views/AddAlarmView.swift index e79227d..b26a234 100644 --- a/TheNoiseClock/Features/Alarms/Views/AddAlarmView.swift +++ b/TheNoiseClock/Features/Alarms/Views/AddAlarmView.swift @@ -25,6 +25,9 @@ struct AddAlarmView: View { @State private var isVibrationEnabled = true @State private var isLightFlashEnabled = false @State private var volume: Float = 1.0 + @State private var isSaving = false + @State private var isShowingSaveErrorAlert = false + @State private var saveErrorMessage = "" var body: some View { NavigationStack { @@ -101,6 +104,12 @@ struct AddAlarmView: View { .foregroundStyle(.secondary) } } + + AlertOptionsSection( + isVibrationEnabled: $isVibrationEnabled, + isLightFlashEnabled: $isLightFlashEnabled, + volume: $volume + ) } .listStyle(.insetGrouped) } @@ -117,33 +126,54 @@ struct AddAlarmView: View { } ToolbarItem(placement: .navigationBarTrailing) { - Button("Save") { + Button(isSaving ? "Saving..." : "Save") { Task { - let newAlarm = viewModel.createNewAlarm( - time: newAlarmTime, - repeatWeekdays: repeatWeekdays, - soundName: selectedSoundName, - label: alarmLabel, - notificationMessage: notificationMessage, - snoozeDuration: snoozeDuration, - isVibrationEnabled: isVibrationEnabled, - isLightFlashEnabled: isLightFlashEnabled, - volume: volume - ) - await viewModel.addAlarm(newAlarm) - isPresented = false + await saveAlarm() } } + .disabled(isSaving) .foregroundStyle(AppAccent.primary) .fontWeight(.semibold) .accessibilityIdentifier("alarms.add.saveButton") } } } + .alert("Alarm Error", isPresented: $isShowingSaveErrorAlert) { + Button("OK", role: .cancel) { } + } message: { + Text(saveErrorMessage) + } } // MARK: - Helper Methods private func getSoundDisplayName(_ fileName: String) -> String { return AlarmSoundService.shared.getSoundDisplayName(fileName) } + + private func saveAlarm() async { + guard !isSaving else { return } + isSaving = true + defer { isSaving = false } + + let newAlarm = viewModel.createNewAlarm( + time: newAlarmTime, + repeatWeekdays: repeatWeekdays, + soundName: selectedSoundName, + label: alarmLabel, + notificationMessage: notificationMessage, + snoozeDuration: snoozeDuration, + isVibrationEnabled: isVibrationEnabled, + isLightFlashEnabled: isLightFlashEnabled, + volume: volume + ) + + let result = await viewModel.addAlarm(newAlarm, presentErrorAlert: false) + switch result { + case .success: + isPresented = false + case .failure(let message): + saveErrorMessage = message + isShowingSaveErrorAlert = true + } + } } diff --git a/TheNoiseClock/Features/Alarms/Views/AlarmView.swift b/TheNoiseClock/Features/Alarms/Views/AlarmView.swift index a964c00..1731cf5 100644 --- a/TheNoiseClock/Features/Alarms/Views/AlarmView.swift +++ b/TheNoiseClock/Features/Alarms/Views/AlarmView.swift @@ -40,7 +40,7 @@ struct AlarmView: View { } } else { List { - ForEach(viewModel.alarms) { alarm in + ForEach(sortedAlarms) { alarm in AlarmRowView( alarm: alarm, onToggle: { @@ -117,6 +117,26 @@ struct AlarmView: View { } // MARK: - Private Methods + private var sortedAlarms: [Alarm] { + viewModel.alarms.sorted { lhs, rhs in + if lhs.isEnabled != rhs.isEnabled { + return lhs.isEnabled && !rhs.isEnabled + } + + let lhsTrigger = lhs.nextTriggerTime() + let rhsTrigger = rhs.nextTriggerTime() + if lhsTrigger != rhsTrigger { + return lhsTrigger < rhsTrigger + } + + if lhs.time != rhs.time { + return lhs.time < rhs.time + } + + return lhs.label.localizedStandardCompare(rhs.label) == .orderedAscending + } + } + private func deleteAlarm(at offsets: IndexSet) { Task { for index in offsets { diff --git a/TheNoiseClock/Features/Alarms/Views/Components/AlertOptionsSection.swift b/TheNoiseClock/Features/Alarms/Views/Components/AlertOptionsSection.swift new file mode 100644 index 0000000..cf442f7 --- /dev/null +++ b/TheNoiseClock/Features/Alarms/Views/Components/AlertOptionsSection.swift @@ -0,0 +1,48 @@ +// +// AlertOptionsSection.swift +// TheNoiseClock +// +// Created by Matt Bruce on 2/8/26. +// + +import SwiftUI +import Bedrock + +/// Reusable alarm alert options section for vibration, flash, and volume. +struct AlertOptionsSection: View { + @Binding var isVibrationEnabled: Bool + @Binding var isLightFlashEnabled: Bool + @Binding var volume: Float + + var body: some View { + Section("Alert Options") { + Toggle("Vibration", isOn: $isVibrationEnabled) + .tint(AppAccent.primary) + .accessibilityIdentifier("alarms.alertOptions.vibrationToggle") + + Toggle("Flash Screen", isOn: $isLightFlashEnabled) + .tint(AppAccent.primary) + .accessibilityIdentifier("alarms.alertOptions.flashToggle") + + VStack(alignment: .leading, spacing: Design.Spacing.small) { + HStack { + Text("Volume") + Spacer() + Text("\(Int((volume * 100).rounded()))%") + .foregroundStyle(.secondary) + } + + Slider( + value: Binding( + get: { Double(volume) }, + set: { volume = Float(min(max($0, 0), 1)) } + ), + in: 0...1 + ) + .tint(AppAccent.primary) + .accessibilityIdentifier("alarms.alertOptions.volumeSlider") + } + .padding(.vertical, Design.Spacing.xxSmall) + } + } +} diff --git a/TheNoiseClock/Features/Alarms/Views/Components/RepeatSelectionView.swift b/TheNoiseClock/Features/Alarms/Views/Components/RepeatSelectionView.swift index bc9e248..56995d2 100644 --- a/TheNoiseClock/Features/Alarms/Views/Components/RepeatSelectionView.swift +++ b/TheNoiseClock/Features/Alarms/Views/Components/RepeatSelectionView.swift @@ -12,15 +12,15 @@ import Bedrock struct RepeatSelectionView: View { @Binding var repeatWeekdays: [Int] - private let orderedWeekdays = [1, 2, 3, 4, 5, 6, 7] + private let allWeekdays = [1, 2, 3, 4, 5, 6, 7] var body: some View { List { Section("Quick Picks") { - quickPickRow(title: "Once", weekdays: []) - quickPickRow(title: "Every Day", weekdays: orderedWeekdays) - quickPickRow(title: "Weekdays", weekdays: [2, 3, 4, 5, 6]) - quickPickRow(title: "Weekends", weekdays: [1, 7]) + quickPickRow(id: "once", title: "Once", weekdays: []) + quickPickRow(id: "everyday", title: "Every Day", weekdays: allWeekdays) + quickPickRow(id: "weekdays", title: "Weekdays", weekdays: [2, 3, 4, 5, 6]) + quickPickRow(id: "weekends", title: "Weekends", weekdays: [1, 7]) } Section("Repeat On") { @@ -39,6 +39,7 @@ struct RepeatSelectionView: View { .onTapGesture { toggleDay(weekday) } + .accessibilityIdentifier("alarms.repeat.day.\(weekday)") } } } @@ -53,8 +54,13 @@ struct RepeatSelectionView: View { Alarm.sanitizedWeekdays(repeatWeekdays) } + private var orderedWeekdays: [Int] { + let first = max(1, min(Calendar.current.firstWeekday, 7)) + return Array(first...7) + Array(1.. some View { + private func quickPickRow(id: String, title: String, weekdays: [Int]) -> some View { HStack { Text(title) .foregroundStyle(AppTextColors.primary) @@ -69,6 +75,7 @@ struct RepeatSelectionView: View { .onTapGesture { repeatWeekdays = Alarm.sanitizedWeekdays(weekdays) } + .accessibilityIdentifier("alarms.repeat.quick.\(id)") } private func dayName(for weekday: Int) -> String { @@ -87,4 +94,3 @@ struct RepeatSelectionView: View { repeatWeekdays = Alarm.sanitizedWeekdays(Array(updated)) } } - diff --git a/TheNoiseClock/Features/Alarms/Views/EditAlarmView.swift b/TheNoiseClock/Features/Alarms/Views/EditAlarmView.swift index 8f5c0ed..95abd76 100644 --- a/TheNoiseClock/Features/Alarms/Views/EditAlarmView.swift +++ b/TheNoiseClock/Features/Alarms/Views/EditAlarmView.swift @@ -27,6 +27,9 @@ struct EditAlarmView: View { @State private var isVibrationEnabled: Bool @State private var isLightFlashEnabled: Bool @State private var volume: Float + @State private var isSaving = false + @State private var isShowingSaveErrorAlert = false + @State private var saveErrorMessage = "" // MARK: - Initialization init(viewModel: AlarmViewModel, alarm: Alarm) { @@ -131,6 +134,13 @@ struct EditAlarmView: View { } } .listRowBackground(AppSurface.card) + + AlertOptionsSection( + isVibrationEnabled: $isVibrationEnabled, + isLightFlashEnabled: $isLightFlashEnabled, + volume: $volume + ) + .listRowBackground(AppSurface.card) } .listStyle(.insetGrouped) .scrollContentBackground(.hidden) @@ -149,37 +159,58 @@ struct EditAlarmView: View { } ToolbarItem(placement: .navigationBarTrailing) { - Button("Save") { + Button(isSaving ? "Saving..." : "Save") { Task { - let updatedAlarm = Alarm( - id: alarm.id, // Keep the same ID - time: alarmTime, - isEnabled: alarm.isEnabled, // Keep the same enabled state - repeatWeekdays: repeatWeekdays, - soundName: selectedSoundName, - label: alarmLabel, - notificationMessage: notificationMessage, - snoozeDuration: snoozeDuration, - isVibrationEnabled: isVibrationEnabled, - isLightFlashEnabled: isLightFlashEnabled, - volume: volume - ) - await viewModel.updateAlarm(updatedAlarm) - dismiss() + await saveAlarm() } } + .disabled(isSaving) .foregroundStyle(AppAccent.primary) .fontWeight(.semibold) .accessibilityIdentifier("alarms.edit.saveButton") } } } + .alert("Alarm Error", isPresented: $isShowingSaveErrorAlert) { + Button("OK", role: .cancel) { } + } message: { + Text(saveErrorMessage) + } } // MARK: - Helper Methods private func getSoundDisplayName(_ fileName: String) -> String { return AlarmSoundService.shared.getSoundDisplayName(fileName) } + + private func saveAlarm() async { + guard !isSaving else { return } + isSaving = true + defer { isSaving = false } + + let updatedAlarm = Alarm( + id: alarm.id, + time: alarmTime, + isEnabled: alarm.isEnabled, + repeatWeekdays: repeatWeekdays, + soundName: selectedSoundName, + label: alarmLabel, + notificationMessage: notificationMessage, + snoozeDuration: snoozeDuration, + isVibrationEnabled: isVibrationEnabled, + isLightFlashEnabled: isLightFlashEnabled, + volume: volume + ) + + let result = await viewModel.updateAlarm(updatedAlarm, presentErrorAlert: false) + switch result { + case .success: + dismiss() + case .failure(let message): + saveErrorMessage = message + isShowingSaveErrorAlert = true + } + } } // MARK: - Preview