added features.
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
263b2fffcc
commit
8f79836481
3
PRD.md
3
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
|
||||
|
||||
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
func updateAlarm(_ alarm: Alarm) async {
|
||||
return .success
|
||||
}
|
||||
|
||||
@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)"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,8 +126,35 @@ struct AddAlarmView: View {
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
Button(isSaving ? "Saving..." : "Save") {
|
||||
Task {
|
||||
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,
|
||||
@ -130,20 +166,14 @@ struct AddAlarmView: View {
|
||||
isLightFlashEnabled: isLightFlashEnabled,
|
||||
volume: volume
|
||||
)
|
||||
await viewModel.addAlarm(newAlarm)
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.fontWeight(.semibold)
|
||||
.accessibilityIdentifier("alarms.add.saveButton")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
private func getSoundDisplayName(_ fileName: String) -> String {
|
||||
return AlarmSoundService.shared.getSoundDisplayName(fileName)
|
||||
let result = await viewModel.addAlarm(newAlarm, presentErrorAlert: false)
|
||||
switch result {
|
||||
case .success:
|
||||
isPresented = false
|
||||
case .failure(let message):
|
||||
saveErrorMessage = message
|
||||
isShowingSaveErrorAlert = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,12 +159,39 @@ struct EditAlarmView: View {
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
Button(isSaving ? "Saving..." : "Save") {
|
||||
Task {
|
||||
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, // Keep the same ID
|
||||
id: alarm.id,
|
||||
time: alarmTime,
|
||||
isEnabled: alarm.isEnabled, // Keep the same enabled state
|
||||
isEnabled: alarm.isEnabled,
|
||||
repeatWeekdays: repeatWeekdays,
|
||||
soundName: selectedSoundName,
|
||||
label: alarmLabel,
|
||||
@ -164,21 +201,15 @@ struct EditAlarmView: View {
|
||||
isLightFlashEnabled: isLightFlashEnabled,
|
||||
volume: volume
|
||||
)
|
||||
await viewModel.updateAlarm(updatedAlarm)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.fontWeight(.semibold)
|
||||
.accessibilityIdentifier("alarms.edit.saveButton")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
private func getSoundDisplayName(_ fileName: String) -> String {
|
||||
return AlarmSoundService.shared.getSoundDisplayName(fileName)
|
||||
let result = await viewModel.updateAlarm(updatedAlarm, presentErrorAlert: false)
|
||||
switch result {
|
||||
case .success:
|
||||
dismiss()
|
||||
case .failure(let message):
|
||||
saveErrorMessage = message
|
||||
isShowingSaveErrorAlert = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user