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
- **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 users 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

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

View File

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

View File

@ -16,6 +16,10 @@ import Observation
@Observable
@MainActor
final class AlarmViewModel {
enum AlarmOperationResult {
case success
case failure(String)
}
// MARK: - Properties
private let alarmService: AlarmService
@ -51,7 +55,8 @@ final class AlarmViewModel {
// MARK: - Alarm CRUD Operations
func addAlarm(_ alarm: Alarm) async {
@discardableResult
func addAlarm(_ alarm: Alarm, presentErrorAlert: Bool = true) async -> 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)"
}
}

View File

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

View File

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

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 {
@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..<first)
}
@ViewBuilder
private func quickPickRow(title: String, weekdays: [Int]) -> 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))
}
}

View File

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