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

This commit is contained in:
Matt Bruce 2025-09-08 09:16:25 -05:00
parent 27e949b752
commit 2c986a8071
20 changed files with 508 additions and 73 deletions

View File

@ -13,13 +13,33 @@ struct Alarm: Identifiable, Codable, Equatable {
var time: Date var time: Date
var isEnabled: Bool var isEnabled: Bool
var soundName: String var soundName: String
var label: String
var snoozeDuration: Int // in minutes
var isVibrationEnabled: Bool
var isLightFlashEnabled: Bool
var volume: Float
// MARK: - Initialization // MARK: - Initialization
init(id: UUID = UUID(), time: Date, isEnabled: Bool = true, soundName: String = AppConstants.SystemSounds.defaultSound) { init(
id: UUID = UUID(),
time: Date,
isEnabled: Bool = true,
soundName: String = AppConstants.SystemSounds.defaultSound,
label: String = "Alarm",
snoozeDuration: Int = 9,
isVibrationEnabled: Bool = true,
isLightFlashEnabled: Bool = false,
volume: Float = 1.0
) {
self.id = id self.id = id
self.time = time self.time = time
self.isEnabled = isEnabled self.isEnabled = isEnabled
self.soundName = soundName self.soundName = soundName
self.label = label
self.snoozeDuration = snoozeDuration
self.isVibrationEnabled = isVibrationEnabled
self.isLightFlashEnabled = isLightFlashEnabled
self.volume = volume
} }
// MARK: - Equatable // MARK: - Equatable

View File

@ -115,6 +115,11 @@ class SoundConfigurationService {
.map { $0.toSound() } .map { $0.toSound() }
} }
/// Get alarm sounds specifically
func getAlarmSounds() -> [Sound] {
return getSoundsByCategory("alarm")
}
/// Get available categories /// Get available categories
func getAvailableCategories() -> [SoundCategory] { func getAvailableCategories() -> [SoundCategory] {
return getConfiguration()?.categories ?? [] return getConfiguration()?.categories ?? []
@ -128,9 +133,18 @@ class SoundConfigurationService {
/// Fallback sounds if JSON loading fails /// Fallback sounds if JSON loading fails
private func getFallbackSounds() -> [Sound] { private func getFallbackSounds() -> [Sound] {
return [ return [
// White noise sounds
Sound(name: "White Noise", fileName: "white-noise.mp3", bundleName: "Ambient"), Sound(name: "White Noise", fileName: "white-noise.mp3", bundleName: "Ambient"),
Sound(name: "Heavy Rain White Noise", fileName: "heavy-rain-white-noise.mp3", bundleName: "Nature"), Sound(name: "Heavy Rain White Noise", fileName: "heavy-rain-white-noise.mp3", bundleName: "Nature"),
Sound(name: "Fan White Noise", fileName: "fan-white-noise-heater.mp3", bundleName: "Mechanical") Sound(name: "Fan White Noise", fileName: "fan-white-noise-heater.mp3", bundleName: "Mechanical"),
// Alarm sounds
Sound(name: "Digital Alarm", fileName: "digital-alarm.mp3", bundleName: "AlarmSounds"),
Sound(name: "iPhone Alarm", fileName: "iphone-alarm.mp3", bundleName: "AlarmSounds"),
Sound(name: "Classic Alarm", fileName: "classic-alarm.mp3", bundleName: "AlarmSounds"),
Sound(name: "Beep Alarm", fileName: "beep-alarm.mp3", bundleName: "AlarmSounds"),
Sound(name: "Siren Alarm", fileName: "siren-alarm.mp3", bundleName: "AlarmSounds"),
Sound(name: "Voice Wake Up", fileName: "voice-wakeup.mp3", bundleName: "AlarmSounds")
] ]
} }
} }

View File

@ -23,6 +23,46 @@
"category": "mechanical", "category": "mechanical",
"description": "Fan and heater sounds for consistent background noise", "description": "Fan and heater sounds for consistent background noise",
"bundleName": "Mechanical" "bundleName": "Mechanical"
},
{
"id": "digital-alarm",
"name": "Digital Alarm",
"fileName": "digital-alarm.mp3",
"category": "alarm",
"description": "Classic digital alarm sound",
"bundleName": "AlarmSounds"
},
{
"id": "iphone-alarm",
"name": "Buzzing Alarm",
"fileName": "buzzing-alarm.mp3",
"category": "alarm",
"description": "Buzzing sound",
"bundleName": "AlarmSounds"
},
{
"id": "classic-alarm",
"name": "Classic Alarm",
"fileName": "classic-alarm.mp3",
"category": "alarm",
"description": "Traditional alarm sound",
"bundleName": "AlarmSounds"
},
{
"id": "beep-alarm",
"name": "Beep Alarm",
"fileName": "beep-alarm.mp3",
"category": "alarm",
"description": "Short beep alarm sound",
"bundleName": "AlarmSounds"
},
{
"id": "siren-alarm",
"name": "Siren Alarm",
"fileName": "siren-alarm.mp3",
"category": "alarm",
"description": "Emergency siren alarm for heavy sleepers",
"bundleName": "AlarmSounds"
} }
], ],
"categories": [ "categories": [
@ -43,6 +83,12 @@
"name": "Mechanical", "name": "Mechanical",
"description": "Mechanical and electronic sounds", "description": "Mechanical and electronic sounds",
"bundleName": "Mechanical" "bundleName": "Mechanical"
},
{
"id": "alarm",
"name": "Alarm Sounds",
"description": "Wake-up and notification alarm sounds",
"bundleName": "AlarmSounds"
} }
], ],
"settings": { "settings": {

View File

@ -12,12 +12,15 @@ import Observation
@Observable @Observable
class NoisePlayer { class NoisePlayer {
// MARK: - Singleton
static let shared = NoisePlayer()
// MARK: - Properties // MARK: - Properties
private var players: [String: AVAudioPlayer] = [:] private var players: [String: AVAudioPlayer] = [:]
private var currentPlayer: AVAudioPlayer? private var currentPlayer: AVAudioPlayer?
// MARK: - Initialization // MARK: - Initialization
init() { private init() {
setupAudioSession() setupAudioSession()
preloadSounds() preloadSounds()
} }

View File

@ -44,6 +44,8 @@ class NotificationService {
} }
} }
/// Schedule a single alarm notification
@discardableResult
func scheduleAlarmNotification( func scheduleAlarmNotification(
id: String, id: String,
title: String, title: String,
@ -70,11 +72,15 @@ class NotificationService {
) )
} }
/// Cancel a single notification
func cancelNotification(id: String) { func cancelNotification(id: String) {
NotificationUtils.removeNotification(identifier: id) NotificationUtils.removeNotification(identifier: id)
} }
/// Cancel all notifications
func cancelAllNotifications() { func cancelAllNotifications() {
NotificationUtils.removeAllNotifications() NotificationUtils.removeAllNotifications()
} }
} }

View File

@ -32,32 +32,89 @@ class AlarmViewModel {
} }
// MARK: - Public Interface // MARK: - Public Interface
func addAlarm(_ alarm: Alarm) { func addAlarm(_ alarm: Alarm) async {
alarmService.addAlarm(alarm) alarmService.addAlarm(alarm)
// Schedule notification if alarm is enabled
if alarm.isEnabled {
await notificationService.scheduleAlarmNotification(
id: alarm.id.uuidString,
title: alarm.label,
body: "Time to wake up!",
soundName: alarm.soundName,
date: alarm.time
)
}
} }
func updateAlarm(_ alarm: Alarm) { func updateAlarm(_ alarm: Alarm) async {
alarmService.updateAlarm(alarm) alarmService.updateAlarm(alarm)
// Reschedule notification
if alarm.isEnabled {
await notificationService.scheduleAlarmNotification(
id: alarm.id.uuidString,
title: alarm.label,
body: "Time to wake up!",
soundName: alarm.soundName,
date: alarm.time
)
} else {
notificationService.cancelNotification(id: alarm.id.uuidString)
}
} }
func deleteAlarm(id: UUID) { func deleteAlarm(id: UUID) async {
// Cancel notification first
notificationService.cancelNotification(id: id.uuidString)
// Then delete from storage
alarmService.deleteAlarm(id: id) alarmService.deleteAlarm(id: id)
} }
func toggleAlarm(id: UUID) { func toggleAlarm(id: UUID) async {
alarmService.toggleAlarm(id: id) guard var alarm = alarmService.getAlarm(id: id) else { return }
alarm.isEnabled.toggle()
alarmService.updateAlarm(alarm)
// Schedule or cancel notification based on new state
if alarm.isEnabled {
await notificationService.scheduleAlarmNotification(
id: alarm.id.uuidString,
title: alarm.label,
body: "Time to wake up!",
soundName: alarm.soundName,
date: alarm.time
)
} else {
notificationService.cancelNotification(id: id.uuidString)
}
} }
func getAlarm(id: UUID) -> Alarm? { func getAlarm(id: UUID) -> Alarm? {
return alarmService.getAlarm(id: id) return alarmService.getAlarm(id: id)
} }
func createNewAlarm(time: Date, soundName: String = AppConstants.SystemSounds.defaultSound) -> Alarm { func createNewAlarm(
time: Date,
soundName: String = AppConstants.SystemSounds.defaultSound,
label: String = "Alarm",
snoozeDuration: Int = 9,
isVibrationEnabled: Bool = true,
isLightFlashEnabled: Bool = false,
volume: Float = 1.0
) -> Alarm {
return Alarm( return Alarm(
id: UUID(), id: UUID(),
time: time, time: time,
isEnabled: true, isEnabled: true,
soundName: soundName soundName: soundName,
label: label,
snoozeDuration: snoozeDuration,
isVibrationEnabled: isVibrationEnabled,
isLightFlashEnabled: isLightFlashEnabled,
volume: volume
) )
} }

View File

@ -24,7 +24,7 @@ class NoiseViewModel {
} }
// MARK: - Initialization // MARK: - Initialization
init(noisePlayer: NoisePlayer = NoisePlayer()) { init(noisePlayer: NoisePlayer = NoisePlayer.shared) {
self.noisePlayer = noisePlayer self.noisePlayer = noisePlayer
} }

View File

@ -7,82 +7,109 @@
import SwiftUI import SwiftUI
/// View for creating new alarms /// View for creating new alarms with iOS-native style interface
struct AddAlarmView: View { struct AddAlarmView: View {
// MARK: - Properties // MARK: - Properties
let viewModel: AlarmViewModel let viewModel: AlarmViewModel
@Binding var isPresented: Bool @Binding var isPresented: Bool
@State private var newAlarmTime = Date() @State private var newAlarmTime = Calendar.current.date(bySettingHour: 6, minute: 0, second: 0, of: Date()) ?? Date()
@State private var selectedSoundName = AppConstants.SystemSounds.defaultSound @State private var selectedSoundName = "digital-alarm.mp3"
@State private var alarmLabel = "Alarm"
@State private var snoozeDuration = 9 // minutes
@State private var isVibrationEnabled = true
@State private var isLightFlashEnabled = false
@State private var volume: Float = 1.0
// MARK: - Body
var body: some View { var body: some View {
NavigationView { NavigationView {
VStack(spacing: UIConstants.Spacing.extraLarge) { VStack(spacing: 0) {
TimePickerView( TimePickerSection(selectedTime: $newAlarmTime)
selectedTime: $newAlarmTime TimeUntilAlarmSection(alarmTime: newAlarmTime)
)
Picker("Sound", selection: $selectedSoundName) { List {
ForEach(viewModel.systemSounds, id: \.self) { sound in // Label Section
Text(sound.capitalized).tag(sound) NavigationLink(destination: LabelEditView(label: $alarmLabel)) {
HStack {
Image(systemName: "textformat")
.foregroundColor(.orange)
.frame(width: 24)
Text("Label")
Spacer()
Text(alarmLabel)
.foregroundColor(.secondary)
}
}
// Sound Section
NavigationLink(destination: SoundSelectionView(selectedSound: $selectedSoundName)) {
HStack {
Image(systemName: "music.note")
.foregroundColor(.orange)
.frame(width: 24)
Text("Sound")
Spacer()
Text(getSoundDisplayName(selectedSoundName))
.foregroundColor(.secondary)
}
}
// Snooze Section
NavigationLink(destination: SnoozeSelectionView(snoozeDuration: $snoozeDuration)) {
HStack {
Image(systemName: "clock.arrow.circlepath")
.foregroundColor(.orange)
.frame(width: 24)
Text("Snooze")
Spacer()
Text("for \(snoozeDuration) min")
.foregroundColor(.secondary)
}
} }
} }
.pickerStyle(.menu) .listStyle(.insetGrouped)
}
HStack(spacing: UIConstants.Spacing.large) { .navigationTitle("Alarm")
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { Button("Cancel") {
isPresented = false isPresented = false
} }
.buttonStyle(isEnabled: true, color: UIConstants.Colors.secondaryText) .foregroundColor(.orange)
}
Spacer()
ToolbarItem(placement: .navigationBarTrailing) {
Button("Add Alarm") { Button("Save") {
let newAlarm = viewModel.createNewAlarm( Task {
time: newAlarmTime, let newAlarm = viewModel.createNewAlarm(
soundName: selectedSoundName time: newAlarmTime,
) soundName: selectedSoundName,
viewModel.addAlarm(newAlarm) label: alarmLabel,
isPresented = false snoozeDuration: snoozeDuration,
isVibrationEnabled: isVibrationEnabled,
isLightFlashEnabled: isLightFlashEnabled,
volume: volume
)
await viewModel.addAlarm(newAlarm)
isPresented = false
}
} }
.buttonStyle(isEnabled: true, color: UIConstants.Colors.accentColor) .foregroundColor(.orange)
.fontWeight(.semibold)
} }
} }
.padding(UIConstants.Spacing.large)
.navigationTitle("New Alarm")
.navigationBarTitleDisplayMode(.inline)
} }
} }
}
// MARK: - Supporting Views
private struct TimePickerView: View {
@Binding var selectedTime: Date
var body: some View { // MARK: - Helper Methods
VStack(alignment: .leading, spacing: UIConstants.Spacing.small) { private func getSoundDisplayName(_ fileName: String) -> String {
Text("Time") let alarmSounds = SoundConfigurationService.shared.getAlarmSounds()
.font(.headline) if let sound = alarmSounds.first(where: { $0.fileName == fileName }) {
.foregroundColor(UIConstants.Colors.primaryText) return sound.name
DatePicker(
"Time",
selection: $selectedTime,
displayedComponents: .hourAndMinute
)
.datePickerStyle(.wheel)
.labelsHidden()
} }
return fileName.replacingOccurrences(of: ".mp3", with: "").capitalized
} }
} }
// MARK: - Preview
#Preview {
AddAlarmView(
viewModel: AlarmViewModel(),
isPresented: .constant(true)
)
}

View File

@ -20,7 +20,11 @@ struct AlarmView: View {
ForEach(viewModel.alarms) { alarm in ForEach(viewModel.alarms) { alarm in
AlarmRowView( AlarmRowView(
alarm: alarm, alarm: alarm,
onToggle: { viewModel.toggleAlarm(id: alarm.id) }, onToggle: {
Task {
await viewModel.toggleAlarm(id: alarm.id)
}
},
onEdit: { /* TODO: Implement edit functionality */ } onEdit: { /* TODO: Implement edit functionality */ }
) )
} }
@ -52,9 +56,11 @@ struct AlarmView: View {
// MARK: - Private Methods // MARK: - Private Methods
private func deleteAlarm(at offsets: IndexSet) { private func deleteAlarm(at offsets: IndexSet) {
for index in offsets { Task {
let alarm = viewModel.alarms[index] for index in offsets {
viewModel.deleteAlarm(id: alarm.id) let alarm = viewModel.alarms[index]
await viewModel.deleteAlarm(id: alarm.id)
}
} }
} }
} }

View File

@ -23,11 +23,11 @@ struct AlarmRowView: View {
.font(.headline) .font(.headline)
.foregroundColor(UIConstants.Colors.primaryText) .foregroundColor(UIConstants.Colors.primaryText)
Text("Enabled: \(alarm.isEnabled ? "Yes" : "No")") Text(alarm.label)
.font(.subheadline) .font(.subheadline)
.foregroundColor(UIConstants.Colors.secondaryText) .foregroundColor(UIConstants.Colors.secondaryText)
Text("Sound: \(alarm.soundName)") Text(" \(alarm.soundName)")
.font(.caption) .font(.caption)
.foregroundColor(UIConstants.Colors.secondaryText) .foregroundColor(UIConstants.Colors.secondaryText)
} }
@ -45,6 +45,7 @@ struct AlarmRowView: View {
onEdit() onEdit()
} }
} }
} }
// MARK: - Preview // MARK: - Preview

View File

@ -0,0 +1,26 @@
//
// LabelEditView.swift
// TheNoiseClock
//
// Created by Matt Bruce on 9/8/25.
//
import SwiftUI
/// View for editing alarm label
struct LabelEditView: View {
@Binding var label: String
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 20) {
TextField("Alarm Label", text: $label)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()
Spacer()
}
.navigationTitle("Label")
.navigationBarTitleDisplayMode(.inline)
}
}

View File

@ -0,0 +1,39 @@
//
// SnoozeSelectionView.swift
// TheNoiseClock
//
// Created by Matt Bruce on 9/8/25.
//
import SwiftUI
/// View for selecting snooze duration
struct SnoozeSelectionView: View {
@Binding var snoozeDuration: Int
@Environment(\.dismiss) private var dismiss
private let snoozeOptions = [5, 9, 10, 15, 20, 30]
var body: some View {
List {
Section("Snooze Duration") {
ForEach(snoozeOptions, id: \.self) { duration in
HStack {
Text("\(duration) minutes")
Spacer()
if snoozeDuration == duration {
Image(systemName: "checkmark")
.foregroundColor(.orange)
}
}
.contentShape(Rectangle())
.onTapGesture {
snoozeDuration = duration
}
}
}
}
.navigationTitle("Snooze")
.navigationBarTitleDisplayMode(.inline)
}
}

View File

@ -0,0 +1,85 @@
//
// SoundSelectionView.swift
// TheNoiseClock
//
// Created by Matt Bruce on 9/8/25.
//
import SwiftUI
/// View for selecting alarm sounds with preview functionality
struct SoundSelectionView: View {
@Binding var selectedSound: String
@Environment(\.dismiss) private var dismiss
// Use shared player instance to avoid audio conflicts
private let noisePlayer = NoisePlayer.shared
private let alarmSounds = SoundConfigurationService.shared.getAlarmSounds().sorted { $0.name < $1.name }
@State private var isPlaying = false
@State private var currentlyPlayingSound: String? = nil
var body: some View {
List {
Section("Alarm Sounds") {
ForEach(alarmSounds, id: \.id) { sound in
HStack {
Text(sound.name)
.font(.body)
Spacer()
if selectedSound == sound.fileName {
Image(systemName: "checkmark")
.foregroundColor(.orange)
}
}
.contentShape(Rectangle())
.onTapGesture {
// Stop any currently playing sound when selecting a new one
if isPlaying {
stopSound()
}
selectedSound = sound.fileName
}
}
}
}
.navigationTitle("Sound")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
if isPlaying && currentlyPlayingSound == selectedSound {
stopSound()
} else {
playSelectedSound()
}
}) {
Image(systemName: isPlaying && currentlyPlayingSound == selectedSound ? "stop.fill" : "play.fill")
.foregroundColor(.orange)
}
}
}
.onDisappear {
// Stop any playing sound when leaving the view
stopSound()
}
}
private func playSelectedSound() {
guard let sound = alarmSounds.first(where: { $0.fileName == selectedSound }) else { return }
// Stop any currently playing sound first
stopSound()
// Start playing the new sound
noisePlayer.playSound(sound)
isPlaying = true
currentlyPlayingSound = selectedSound
}
private func stopSound() {
noisePlayer.stopSound()
isPlaying = false
currentlyPlayingSound = nil
}
}

View File

@ -0,0 +1,27 @@
//
// TimePickerSection.swift
// TheNoiseClock
//
// Created by Matt Bruce on 9/8/25.
//
import SwiftUI
/// Time picker component for alarm creation
struct TimePickerSection: View {
@Binding var selectedTime: Date
var body: some View {
VStack(spacing: 0) {
DatePicker(
"Time",
selection: $selectedTime,
displayedComponents: .hourAndMinute
)
.datePickerStyle(.wheel)
.labelsHidden()
.frame(height: 200)
}
.background(Color(.systemGroupedBackground))
}
}

View File

@ -0,0 +1,78 @@
//
// TimeUntilAlarmSection.swift
// TheNoiseClock
//
// Created by Matt Bruce on 9/8/25.
//
import SwiftUI
/// Component showing time until alarm and day information
struct TimeUntilAlarmSection: View {
let alarmTime: Date
var body: some View {
VStack(spacing: 4) {
HStack {
Image(systemName: "calendar")
.foregroundColor(.orange)
Text(timeUntilAlarm)
.font(.subheadline)
.foregroundColor(.secondary)
}
Text(dayText)
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.vertical, 12)
.background(Color(.systemGroupedBackground))
}
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 {
return "Will turn on in \(hours)h \(minutes)m"
} else if minutes > 0 {
return "Will turn on in \(minutes)m"
} else {
return "Will turn on now"
}
}
return "Will turn on tomorrow"
}
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) {
return "Today"
} else if calendar.isDateInTomorrow(alarmTime) {
return "Tomorrow"
} else {
let formatter = DateFormatter()
formatter.dateFormat = "EEEE"
return formatter.string(from: alarmTime)
}
}
}