Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
c3dad57700
commit
263b2fffcc
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -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")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user