diff --git a/TheNoiseClock/Features/Alarms/Models/Alarm.swift b/TheNoiseClock/Features/Alarms/Models/Alarm.swift index 6958676..b3c177b 100644 --- a/TheNoiseClock/Features/Alarms/Models/Alarm.swift +++ b/TheNoiseClock/Features/Alarms/Models/Alarm.swift @@ -12,6 +12,8 @@ struct Alarm: Identifiable, Codable, Equatable { let id: UUID var time: Date var isEnabled: Bool + /// Calendar weekday values (1=Sunday...7=Saturday). Empty means one-time alarm. + var repeatWeekdays: [Int] var soundName: String var label: String var notificationMessage: String // Custom notification message @@ -20,11 +22,26 @@ struct Alarm: Identifiable, Codable, Equatable { var isLightFlashEnabled: Bool var volume: Float + private enum CodingKeys: String, CodingKey { + case id + case time + case isEnabled + case repeatWeekdays + case soundName + case label + case notificationMessage + case snoozeDuration + case isVibrationEnabled + case isLightFlashEnabled + case volume + } + // MARK: - Initialization init( id: UUID = UUID(), time: Date, isEnabled: Bool = true, + repeatWeekdays: [Int] = [], soundName: String = AppConstants.SystemSounds.defaultSound, label: String = "Alarm", notificationMessage: String = "Your alarm is ringing", @@ -36,6 +53,7 @@ struct Alarm: Identifiable, Codable, Equatable { self.id = id self.time = time self.isEnabled = isEnabled + self.repeatWeekdays = Self.sanitizedWeekdays(repeatWeekdays) self.soundName = soundName self.label = label self.notificationMessage = notificationMessage @@ -45,6 +63,23 @@ struct Alarm: Identifiable, Codable, Equatable { self.volume = volume } + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(UUID.self, forKey: .id) + self.time = try container.decode(Date.self, forKey: .time) + self.isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true + self.repeatWeekdays = Self.sanitizedWeekdays( + try container.decodeIfPresent([Int].self, forKey: .repeatWeekdays) ?? [] + ) + self.soundName = try container.decodeIfPresent(String.self, forKey: .soundName) ?? AppConstants.SystemSounds.defaultSound + self.label = try container.decodeIfPresent(String.self, forKey: .label) ?? "Alarm" + self.notificationMessage = try container.decodeIfPresent(String.self, forKey: .notificationMessage) ?? "Your alarm is ringing" + self.snoozeDuration = try container.decodeIfPresent(Int.self, forKey: .snoozeDuration) ?? 9 + self.isVibrationEnabled = try container.decodeIfPresent(Bool.self, forKey: .isVibrationEnabled) ?? true + self.isLightFlashEnabled = try container.decodeIfPresent(Bool.self, forKey: .isLightFlashEnabled) ?? false + self.volume = try container.decodeIfPresent(Float.self, forKey: .volume) ?? 1.0 + } + // MARK: - Equatable static func ==(lhs: Alarm, rhs: Alarm) -> Bool { lhs.id == rhs.id @@ -52,10 +87,70 @@ struct Alarm: Identifiable, Codable, Equatable { // MARK: - Helper Methods func nextTriggerTime() -> Date { + let calendar = Calendar.current + let now = Date() + let timeComponents = calendar.dateComponents([.hour, .minute], from: time) + + guard let hour = timeComponents.hour, let minute = timeComponents.minute else { + return time.nextOccurrence() + } + + let weekdays = Self.sanitizedWeekdays(repeatWeekdays) + guard !weekdays.isEmpty else { + return time.nextOccurrence() + } + + let today = calendar.startOfDay(for: now) + for offset in 0...7 { + guard let candidateDay = calendar.date(byAdding: .day, value: offset, to: today), + let candidateDate = calendar.date(bySettingHour: hour, minute: minute, second: 0, of: candidateDay) + else { + continue + } + + let weekday = calendar.component(.weekday, from: candidateDay) + if weekdays.contains(weekday), candidateDate > now { + return candidateDate + } + } + return time.nextOccurrence() } func formattedTime() -> String { time.formatted(date: .omitted, time: .shortened) } + + var repeatSummary: String { + Self.repeatSummary(for: repeatWeekdays) + } + + static func repeatSummary(for weekdays: [Int]) -> String { + let normalized = sanitizedWeekdays(weekdays) + guard !normalized.isEmpty else { return "Once" } + + let weekdaySet = Set(normalized) + if weekdaySet.count == 7 { + return "Every day" + } + + if weekdaySet == Set([2, 3, 4, 5, 6]) { + return "Weekdays" + } + + if weekdaySet == Set([1, 7]) { + return "Weekends" + } + + let symbols = Calendar.current.shortWeekdaySymbols + let labels = normalized.compactMap { weekday -> String? in + guard (1...7).contains(weekday) else { return nil } + return symbols[weekday - 1] + } + return labels.joined(separator: ", ") + } + + static func sanitizedWeekdays(_ weekdays: [Int]) -> [Int] { + Array(Set(weekdays.filter { (1...7).contains($0) })).sorted() + } } diff --git a/TheNoiseClock/Features/Alarms/Services/AlarmKitService.swift b/TheNoiseClock/Features/Alarms/Services/AlarmKitService.swift index 976d71b..d443f72 100644 --- a/TheNoiseClock/Features/Alarms/Services/AlarmKitService.swift +++ b/TheNoiseClock/Features/Alarms/Services/AlarmKitService.swift @@ -359,6 +359,24 @@ final class AlarmKitService { let debugFormat = Date.FormatStyle.dateTime.year().month().day().hour().minute().second().timeZone() Design.debugLog("[alarmkit] Raw alarm.time: \(alarm.time.formatted(debugFormat))") + let normalizedWeekdays = Alarm.sanitizedWeekdays(alarm.repeatWeekdays) + if !normalizedWeekdays.isEmpty { + let calendar = Calendar.current + let components = calendar.dateComponents([.hour, .minute], from: alarm.time) + let hour = components.hour ?? 0 + let minute = components.minute ?? 0 + let localeWeekdays = normalizedWeekdays.compactMap(localeWeekday(from:)) + + Design.debugLog("[alarmkit] Repeat weekdays: \(normalizedWeekdays)") + let relative = AlarmKit.Alarm.Schedule.Relative( + time: .init(hour: hour, minute: minute), + repeats: .weekly(localeWeekdays) + ) + let schedule = AlarmKit.Alarm.Schedule.relative(relative) + Design.debugLog("[alarmkit] Schedule created: relative weekly at \(hour):\(String(format: "%02d", minute))") + return schedule + } + // Calculate the next trigger time let triggerDate = alarm.nextTriggerTime() @@ -380,6 +398,19 @@ final class AlarmKitService { Design.debugLog("[alarmkit] Schedule created: fixed at \(triggerDate.formatted(debugFormat))") return schedule } + + private func localeWeekday(from calendarWeekday: Int) -> Locale.Weekday? { + switch calendarWeekday { + case 1: return .sunday + case 2: return .monday + case 3: return .tuesday + case 4: return .wednesday + case 5: return .thursday + case 6: return .friday + case 7: return .saturday + default: return nil + } + } } // MARK: - Errors diff --git a/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift b/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift index ee02410..15fdc85 100644 --- a/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift +++ b/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift @@ -132,6 +132,7 @@ final class AlarmViewModel { func createNewAlarm( time: Date, + repeatWeekdays: [Int] = [], soundName: String = AppConstants.SystemSounds.defaultSound, label: String = "Alarm", notificationMessage: String = "Your alarm is ringing", @@ -144,6 +145,7 @@ final class AlarmViewModel { id: UUID(), time: time, isEnabled: true, + repeatWeekdays: repeatWeekdays, soundName: soundName, label: label, notificationMessage: notificationMessage, diff --git a/TheNoiseClock/Features/Alarms/Views/AddAlarmView.swift b/TheNoiseClock/Features/Alarms/Views/AddAlarmView.swift index 169ade3..e79227d 100644 --- a/TheNoiseClock/Features/Alarms/Views/AddAlarmView.swift +++ b/TheNoiseClock/Features/Alarms/Views/AddAlarmView.swift @@ -17,6 +17,7 @@ struct AddAlarmView: View { @Binding var isPresented: Bool @State private var newAlarmTime = Calendar.current.date(bySettingHour: 6, minute: 0, second: 0, of: Date()) ?? Date() + @State private var repeatWeekdays: [Int] = [] @State private var selectedSoundName = AppConstants.SystemSounds.defaultSound @State private var alarmLabel = "Alarm" @State private var notificationMessage = "Your alarm is ringing" @@ -30,7 +31,7 @@ struct AddAlarmView: View { VStack(spacing: 0) { // Time picker section at top TimePickerSection(selectedTime: $newAlarmTime) - TimeUntilAlarmSection(alarmTime: newAlarmTime) + TimeUntilAlarmSection(alarmTime: newAlarmTime, repeatWeekdays: repeatWeekdays) // List for settings below List { @@ -74,6 +75,20 @@ struct AddAlarmView: View { } } + // Repeat Section + NavigationLink(destination: RepeatSelectionView(repeatWeekdays: $repeatWeekdays)) { + HStack { + Image(systemName: "repeat") + .foregroundStyle(AppAccent.primary) + .frame(width: 24) + Text("Repeat") + Spacer() + Text(Alarm.repeatSummary(for: repeatWeekdays)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + // Snooze Section NavigationLink(destination: SnoozeSelectionView(snoozeDuration: $snoozeDuration)) { HStack { @@ -106,6 +121,7 @@ struct AddAlarmView: View { Task { let newAlarm = viewModel.createNewAlarm( time: newAlarmTime, + repeatWeekdays: repeatWeekdays, soundName: selectedSoundName, label: alarmLabel, notificationMessage: notificationMessage, diff --git a/TheNoiseClock/Features/Alarms/Views/Components/AlarmRowView.swift b/TheNoiseClock/Features/Alarms/Views/Components/AlarmRowView.swift index fbbfc25..f99ee2b 100644 --- a/TheNoiseClock/Features/Alarms/Views/Components/AlarmRowView.swift +++ b/TheNoiseClock/Features/Alarms/Views/Components/AlarmRowView.swift @@ -32,6 +32,10 @@ struct AlarmRowView: View { .font(.subheadline) .foregroundStyle(AppTextColors.secondary) + Text(alarm.repeatSummary) + .font(.caption) + .foregroundStyle(AppTextColors.tertiary) + Text("• \(AlarmSoundService.shared.getSoundDisplayName(alarm.soundName))") .font(.caption) .foregroundStyle(AppTextColors.secondary) diff --git a/TheNoiseClock/Features/Alarms/Views/Components/RepeatSelectionView.swift b/TheNoiseClock/Features/Alarms/Views/Components/RepeatSelectionView.swift new file mode 100644 index 0000000..bc9e248 --- /dev/null +++ b/TheNoiseClock/Features/Alarms/Views/Components/RepeatSelectionView.swift @@ -0,0 +1,90 @@ +// +// RepeatSelectionView.swift +// TheNoiseClock +// +// Created by Matt Bruce on 2/8/26. +// + +import SwiftUI +import Bedrock + +/// View for selecting repeat days for an alarm. +struct RepeatSelectionView: View { + @Binding var repeatWeekdays: [Int] + + private let orderedWeekdays = [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]) + } + + Section("Repeat On") { + ForEach(orderedWeekdays, id: \.self) { weekday in + HStack { + Text(dayName(for: weekday)) + .foregroundStyle(AppTextColors.primary) + Spacer() + if normalizedWeekdays.contains(weekday) { + Image(systemName: "checkmark") + .foregroundStyle(AppAccent.primary) + } + } + .contentShape(Rectangle()) + .listRowBackground(AppSurface.card) + .onTapGesture { + toggleDay(weekday) + } + } + } + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .background(AppSurface.primary.ignoresSafeArea()) + .navigationTitle("Repeat") + .navigationBarTitleDisplayMode(.inline) + } + + private var normalizedWeekdays: [Int] { + Alarm.sanitizedWeekdays(repeatWeekdays) + } + + @ViewBuilder + private func quickPickRow(title: String, weekdays: [Int]) -> some View { + HStack { + Text(title) + .foregroundStyle(AppTextColors.primary) + Spacer() + if Set(normalizedWeekdays) == Set(Alarm.sanitizedWeekdays(weekdays)) { + Image(systemName: "checkmark") + .foregroundStyle(AppAccent.primary) + } + } + .contentShape(Rectangle()) + .listRowBackground(AppSurface.card) + .onTapGesture { + repeatWeekdays = Alarm.sanitizedWeekdays(weekdays) + } + } + + private func dayName(for weekday: Int) -> String { + let symbols = Calendar.current.weekdaySymbols + guard (1...7).contains(weekday) else { return "" } + return symbols[weekday - 1] + } + + private func toggleDay(_ weekday: Int) { + var updated = Set(normalizedWeekdays) + if updated.contains(weekday) { + updated.remove(weekday) + } else { + updated.insert(weekday) + } + repeatWeekdays = Alarm.sanitizedWeekdays(Array(updated)) + } +} + diff --git a/TheNoiseClock/Features/Alarms/Views/Components/TimeUntilAlarmSection.swift b/TheNoiseClock/Features/Alarms/Views/Components/TimeUntilAlarmSection.swift index f0cdf38..3d5f205 100644 --- a/TheNoiseClock/Features/Alarms/Views/Components/TimeUntilAlarmSection.swift +++ b/TheNoiseClock/Features/Alarms/Views/Components/TimeUntilAlarmSection.swift @@ -10,6 +10,12 @@ import SwiftUI /// Component showing time until alarm and day information struct TimeUntilAlarmSection: View { let alarmTime: Date + let repeatWeekdays: [Int] + + init(alarmTime: Date, repeatWeekdays: [Int] = []) { + self.alarmTime = alarmTime + self.repeatWeekdays = repeatWeekdays + } var body: some View { VStack(spacing: 4) { @@ -30,21 +36,13 @@ struct TimeUntilAlarmSection: View { .background(AppSurface.primary) } + private var nextAlarmTime: Date { + Alarm(time: alarmTime, repeatWeekdays: repeatWeekdays).nextTriggerTime() + } + private var timeUntilAlarm: String { let now = Date() let calendar = Calendar.current - - // Calculate the next occurrence of the alarm time - let nextAlarmTime: Date - if calendar.isDateInToday(alarmTime) && alarmTime < now { - // If alarm time has passed today, calculate for tomorrow - nextAlarmTime = calendar.date(byAdding: .day, value: 1, to: alarmTime) ?? alarmTime - } else { - // Use the alarm time as-is (either future today or already tomorrow+) - nextAlarmTime = alarmTime - } - - // Calculate time difference from now to next alarm let components = calendar.dateComponents([.hour, .minute], from: now, to: nextAlarmTime) if let hours = components.hour, let minutes = components.minute { if hours > 0 { @@ -61,17 +59,12 @@ struct TimeUntilAlarmSection: View { private var dayText: String { let calendar = Calendar.current - let now = Date() - - // If alarm time is in the past today, show tomorrow - if calendar.isDateInToday(alarmTime) && alarmTime < now { - return "Tomorrow" - } else if calendar.isDateInToday(alarmTime) { + if calendar.isDateInToday(nextAlarmTime) { return "Today" - } else if calendar.isDateInTomorrow(alarmTime) { + } else if calendar.isDateInTomorrow(nextAlarmTime) { return "Tomorrow" } else { - return alarmTime.formatted(.dateTime.weekday(.wide)) + return nextAlarmTime.formatted(.dateTime.weekday(.wide)) } } } diff --git a/TheNoiseClock/Features/Alarms/Views/EditAlarmView.swift b/TheNoiseClock/Features/Alarms/Views/EditAlarmView.swift index f140096..8f5c0ed 100644 --- a/TheNoiseClock/Features/Alarms/Views/EditAlarmView.swift +++ b/TheNoiseClock/Features/Alarms/Views/EditAlarmView.swift @@ -19,6 +19,7 @@ struct EditAlarmView: View { @Environment(\.dismiss) private var dismiss @State private var alarmTime: Date + @State private var repeatWeekdays: [Int] @State private var selectedSoundName: String @State private var alarmLabel: String @State private var notificationMessage: String @@ -34,6 +35,7 @@ struct EditAlarmView: View { // Initialize state with current alarm values self._alarmTime = State(initialValue: alarm.time) + self._repeatWeekdays = State(initialValue: alarm.repeatWeekdays) self._selectedSoundName = State(initialValue: alarm.soundName) self._alarmLabel = State(initialValue: alarm.label) self._notificationMessage = State(initialValue: alarm.notificationMessage) @@ -49,7 +51,7 @@ struct EditAlarmView: View { VStack(spacing: 0) { // Time picker section at top TimePickerSection(selectedTime: $alarmTime) - TimeUntilAlarmSection(alarmTime: alarmTime) + TimeUntilAlarmSection(alarmTime: alarmTime, repeatWeekdays: repeatWeekdays) // List for settings below List { @@ -99,6 +101,22 @@ struct EditAlarmView: View { } .listRowBackground(AppSurface.card) + // Repeat Section + NavigationLink(destination: RepeatSelectionView(repeatWeekdays: $repeatWeekdays)) { + HStack { + Image(systemName: "repeat") + .foregroundStyle(AppAccent.primary) + .frame(width: 24) + Text("Repeat") + .foregroundStyle(AppTextColors.primary) + Spacer() + Text(Alarm.repeatSummary(for: repeatWeekdays)) + .foregroundStyle(AppTextColors.secondary) + .lineLimit(1) + } + } + .listRowBackground(AppSurface.card) + // Snooze Section NavigationLink(destination: SnoozeSelectionView(snoozeDuration: $snoozeDuration)) { HStack { @@ -137,6 +155,7 @@ struct EditAlarmView: View { 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, diff --git a/TheNoiseClock/Resources/Mechanical.bundle/electric-fan.mp3 b/TheNoiseClock/Resources/Mechanical.bundle/electric-fan.mp3 index ec5e686..e71f1ac 100644 Binary files a/TheNoiseClock/Resources/Mechanical.bundle/electric-fan.mp3 and b/TheNoiseClock/Resources/Mechanical.bundle/electric-fan.mp3 differ diff --git a/TheNoiseClock/Resources/Nature.bundle/crickets-night.mp3 b/TheNoiseClock/Resources/Nature.bundle/crickets-night.mp3 index 3778cb4..04feba1 100644 Binary files a/TheNoiseClock/Resources/Nature.bundle/crickets-night.mp3 and b/TheNoiseClock/Resources/Nature.bundle/crickets-night.mp3 differ diff --git a/TheNoiseClockTests/TheNoiseClockTests.swift b/TheNoiseClockTests/TheNoiseClockTests.swift index 84d62b9..d6e18c6 100644 --- a/TheNoiseClockTests/TheNoiseClockTests.swift +++ b/TheNoiseClockTests/TheNoiseClockTests.swift @@ -44,5 +44,31 @@ struct TheNoiseClockTests { #expect(decoded.fontFamily == .avenir) #expect(decoded.digitAnimationStyle == .glitch) } + + @Test + func repeatingAlarmComputesNextMatchingWeekday() { + let calendar = Calendar.current + let now = Date() + let targetDate = calendar.date(byAdding: .day, value: 2, to: now) ?? now + let targetWeekday = calendar.component(.weekday, from: targetDate) + + let templateTime = calendar.date(bySettingHour: 9, minute: 30, second: 0, of: now) ?? now + let alarm = Alarm(time: templateTime, repeatWeekdays: [targetWeekday]) + let next = alarm.nextTriggerTime() + + let components = calendar.dateComponents([.hour, .minute, .weekday], from: next) + #expect(next > now) + #expect(components.weekday == targetWeekday) + #expect(components.hour == 9) + #expect(components.minute == 30) + } + + @Test + func repeatSummaryRecognizesCommonPatterns() { + #expect(Alarm.repeatSummary(for: []) == "Once") + #expect(Alarm.repeatSummary(for: [1, 2, 3, 4, 5, 6, 7]) == "Every day") + #expect(Alarm.repeatSummary(for: [2, 3, 4, 5, 6]) == "Weekdays") + #expect(Alarm.repeatSummary(for: [1, 7]) == "Weekends") + } }