added features.

Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
Matt Bruce 2026-02-08 12:10:38 -06:00
parent 263b2fffcc
commit 8f79836481
9 changed files with 221 additions and 45 deletions

3
PRD.md
View File

@ -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 - **Sound preview**: Play/stop functionality for testing alarm sounds before selection
- **Custom labels**: User-defined alarm names and descriptions - **Custom labels**: User-defined alarm names and descriptions
- **Repeat schedules**: Set alarms to repeat on specific weekdays or daily - **Repeat schedules**: Set alarms to repeat on specific weekdays or daily
- **Locale-aware repeat picker**: Weekday ordering follows the users locale-first weekday
- **Sound selection**: Choose from extensive alarm sounds with live preview - **Sound selection**: Choose from extensive alarm sounds with live preview
- **Volume control**: Adjustable alarm volume (0-100%) - **Volume control**: Adjustable alarm volume (0-100%)
- **Vibration settings**: Enable/disable vibration for each alarm - **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 - **AlarmKit authorization**: Requires user permission via NSAlarmKitUsageDescription
- **Persistent storage**: Alarms saved to UserDefaults with backward compatibility - **Persistent storage**: Alarms saved to UserDefaults with backward compatibility
- **Alarm management**: Add, edit, delete, and duplicate alarms - **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 - **Next trigger preview**: Shows when the next alarm will fire
- **Responsive time picker**: Font sizes adapt to available space and orientation - **Responsive time picker**: Font sizes adapt to available space and orientation
- **AlarmKitService**: Centralized service for AlarmKit integration - **AlarmKitService**: Centralized service for AlarmKit integration

View File

@ -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. - 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. - 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. - 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.
--- ---

View File

@ -143,7 +143,9 @@ struct Alarm: Identifiable, Codable, Equatable {
} }
let symbols = Calendar.current.shortWeekdaySymbols 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 } guard (1...7).contains(weekday) else { return nil }
return symbols[weekday - 1] return symbols[weekday - 1]
} }
@ -153,4 +155,9 @@ struct Alarm: Identifiable, Codable, Equatable {
static func sanitizedWeekdays(_ weekdays: [Int]) -> [Int] { static func sanitizedWeekdays(_ weekdays: [Int]) -> [Int] {
Array(Set(weekdays.filter { (1...7).contains($0) })).sorted() 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..<first)
}
} }

View File

@ -16,6 +16,10 @@ import Observation
@Observable @Observable
@MainActor @MainActor
final class AlarmViewModel { final class AlarmViewModel {
enum AlarmOperationResult {
case success
case failure(String)
}
// MARK: - Properties // MARK: - Properties
private let alarmService: AlarmService private let alarmService: AlarmService
@ -51,7 +55,8 @@ final class AlarmViewModel {
// MARK: - Alarm CRUD Operations // MARK: - Alarm CRUD Operations
func addAlarm(_ alarm: Alarm) async { @discardableResult
func addAlarm(_ alarm: Alarm, presentErrorAlert: Bool = true) async -> AlarmOperationResult {
alarmService.addAlarm(alarm) alarmService.addAlarm(alarm)
// Schedule with AlarmKit if alarm is enabled // Schedule with AlarmKit if alarm is enabled
@ -63,12 +68,19 @@ final class AlarmViewModel {
Design.debugLog("[alarms] AlarmKit scheduling failed: \(error)") Design.debugLog("[alarms] AlarmKit scheduling failed: \(error)")
// Roll back add so enabled alarms always represent scheduled alarms. // Roll back add so enabled alarms always represent scheduled alarms.
alarmService.deleteAlarm(id: alarm.id) 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) let previousAlarm = alarmService.getAlarm(id: alarm.id)
alarmService.updateAlarm(alarm) alarmService.updateAlarm(alarm)
@ -90,9 +102,15 @@ final class AlarmViewModel {
alarmKitService.cancelAlarm(id: previousAlarm.id) 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 { func deleteAlarm(id: UUID) async {
@ -185,13 +203,22 @@ final class AlarmViewModel {
} }
private func presentAlarmOperationError(action: String, error: Error) { 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 detail = if let localized = error as? LocalizedError,
let description = localized.errorDescription { let description = localized.errorDescription {
description description
} else { } else {
error.localizedDescription error.localizedDescription
} }
errorAlertMessage = "Unable to \(action) alarm. \(detail)" return "Unable to \(action) alarm. \(detail)"
isShowingErrorAlert = true
} }
} }

View File

@ -25,6 +25,9 @@ struct AddAlarmView: View {
@State private var isVibrationEnabled = true @State private var isVibrationEnabled = true
@State private var isLightFlashEnabled = false @State private var isLightFlashEnabled = false
@State private var volume: Float = 1.0 @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 { var body: some View {
NavigationStack { NavigationStack {
@ -101,6 +104,12 @@ struct AddAlarmView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
AlertOptionsSection(
isVibrationEnabled: $isVibrationEnabled,
isLightFlashEnabled: $isLightFlashEnabled,
volume: $volume
)
} }
.listStyle(.insetGrouped) .listStyle(.insetGrouped)
} }
@ -117,33 +126,54 @@ struct AddAlarmView: View {
} }
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") { Button(isSaving ? "Saving..." : "Save") {
Task { Task {
let newAlarm = viewModel.createNewAlarm( await saveAlarm()
time: newAlarmTime,
repeatWeekdays: repeatWeekdays,
soundName: selectedSoundName,
label: alarmLabel,
notificationMessage: notificationMessage,
snoozeDuration: snoozeDuration,
isVibrationEnabled: isVibrationEnabled,
isLightFlashEnabled: isLightFlashEnabled,
volume: volume
)
await viewModel.addAlarm(newAlarm)
isPresented = false
} }
} }
.disabled(isSaving)
.foregroundStyle(AppAccent.primary) .foregroundStyle(AppAccent.primary)
.fontWeight(.semibold) .fontWeight(.semibold)
.accessibilityIdentifier("alarms.add.saveButton") .accessibilityIdentifier("alarms.add.saveButton")
} }
} }
} }
.alert("Alarm Error", isPresented: $isShowingSaveErrorAlert) {
Button("OK", role: .cancel) { }
} message: {
Text(saveErrorMessage)
}
} }
// MARK: - Helper Methods // MARK: - Helper Methods
private func getSoundDisplayName(_ fileName: String) -> String { private func getSoundDisplayName(_ fileName: String) -> String {
return AlarmSoundService.shared.getSoundDisplayName(fileName) 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
}
}
} }

View File

@ -40,7 +40,7 @@ struct AlarmView: View {
} }
} else { } else {
List { List {
ForEach(viewModel.alarms) { alarm in ForEach(sortedAlarms) { alarm in
AlarmRowView( AlarmRowView(
alarm: alarm, alarm: alarm,
onToggle: { onToggle: {
@ -117,6 +117,26 @@ struct AlarmView: View {
} }
// MARK: - Private Methods // 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) { private func deleteAlarm(at offsets: IndexSet) {
Task { Task {
for index in offsets { for index in offsets {

View File

@ -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)
}
}
}

View File

@ -12,15 +12,15 @@ import Bedrock
struct RepeatSelectionView: View { struct RepeatSelectionView: View {
@Binding var repeatWeekdays: [Int] @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 { var body: some View {
List { List {
Section("Quick Picks") { Section("Quick Picks") {
quickPickRow(title: "Once", weekdays: []) quickPickRow(id: "once", title: "Once", weekdays: [])
quickPickRow(title: "Every Day", weekdays: orderedWeekdays) quickPickRow(id: "everyday", title: "Every Day", weekdays: allWeekdays)
quickPickRow(title: "Weekdays", weekdays: [2, 3, 4, 5, 6]) quickPickRow(id: "weekdays", title: "Weekdays", weekdays: [2, 3, 4, 5, 6])
quickPickRow(title: "Weekends", weekdays: [1, 7]) quickPickRow(id: "weekends", title: "Weekends", weekdays: [1, 7])
} }
Section("Repeat On") { Section("Repeat On") {
@ -39,6 +39,7 @@ struct RepeatSelectionView: View {
.onTapGesture { .onTapGesture {
toggleDay(weekday) toggleDay(weekday)
} }
.accessibilityIdentifier("alarms.repeat.day.\(weekday)")
} }
} }
} }
@ -53,8 +54,13 @@ struct RepeatSelectionView: View {
Alarm.sanitizedWeekdays(repeatWeekdays) Alarm.sanitizedWeekdays(repeatWeekdays)
} }
private var orderedWeekdays: [Int] {
let first = max(1, min(Calendar.current.firstWeekday, 7))
return Array(first...7) + Array(1..<first)
}
@ViewBuilder @ViewBuilder
private func quickPickRow(title: String, weekdays: [Int]) -> some View { private func quickPickRow(id: String, title: String, weekdays: [Int]) -> some View {
HStack { HStack {
Text(title) Text(title)
.foregroundStyle(AppTextColors.primary) .foregroundStyle(AppTextColors.primary)
@ -69,6 +75,7 @@ struct RepeatSelectionView: View {
.onTapGesture { .onTapGesture {
repeatWeekdays = Alarm.sanitizedWeekdays(weekdays) repeatWeekdays = Alarm.sanitizedWeekdays(weekdays)
} }
.accessibilityIdentifier("alarms.repeat.quick.\(id)")
} }
private func dayName(for weekday: Int) -> String { private func dayName(for weekday: Int) -> String {
@ -87,4 +94,3 @@ struct RepeatSelectionView: View {
repeatWeekdays = Alarm.sanitizedWeekdays(Array(updated)) repeatWeekdays = Alarm.sanitizedWeekdays(Array(updated))
} }
} }

View File

@ -27,6 +27,9 @@ struct EditAlarmView: View {
@State private var isVibrationEnabled: Bool @State private var isVibrationEnabled: Bool
@State private var isLightFlashEnabled: Bool @State private var isLightFlashEnabled: Bool
@State private var volume: Float @State private var volume: Float
@State private var isSaving = false
@State private var isShowingSaveErrorAlert = false
@State private var saveErrorMessage = ""
// MARK: - Initialization // MARK: - Initialization
init(viewModel: AlarmViewModel, alarm: Alarm) { init(viewModel: AlarmViewModel, alarm: Alarm) {
@ -131,6 +134,13 @@ struct EditAlarmView: View {
} }
} }
.listRowBackground(AppSurface.card) .listRowBackground(AppSurface.card)
AlertOptionsSection(
isVibrationEnabled: $isVibrationEnabled,
isLightFlashEnabled: $isLightFlashEnabled,
volume: $volume
)
.listRowBackground(AppSurface.card)
} }
.listStyle(.insetGrouped) .listStyle(.insetGrouped)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
@ -149,37 +159,58 @@ struct EditAlarmView: View {
} }
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") { Button(isSaving ? "Saving..." : "Save") {
Task { Task {
let updatedAlarm = Alarm( await saveAlarm()
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()
} }
} }
.disabled(isSaving)
.foregroundStyle(AppAccent.primary) .foregroundStyle(AppAccent.primary)
.fontWeight(.semibold) .fontWeight(.semibold)
.accessibilityIdentifier("alarms.edit.saveButton") .accessibilityIdentifier("alarms.edit.saveButton")
} }
} }
} }
.alert("Alarm Error", isPresented: $isShowingSaveErrorAlert) {
Button("OK", role: .cancel) { }
} message: {
Text(saveErrorMessage)
}
} }
// MARK: - Helper Methods // MARK: - Helper Methods
private func getSoundDisplayName(_ fileName: String) -> String { private func getSoundDisplayName(_ fileName: String) -> String {
return AlarmSoundService.shared.getSoundDisplayName(fileName) 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 // MARK: - Preview