Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-02-08 11:53:56 -06:00
parent c3dad57700
commit 263b2fffcc
11 changed files with 298 additions and 22 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -45,4 +45,30 @@ struct TheNoiseClockTests {
#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")
}
}