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 isEnabled: Bool
var soundName: String
var label: String
var snoozeDuration: Int // in minutes
var isVibrationEnabled: Bool
var isLightFlashEnabled: Bool
var volume: Float
// 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.time = time
self.isEnabled = isEnabled
self.soundName = soundName
self.label = label
self.snoozeDuration = snoozeDuration
self.isVibrationEnabled = isVibrationEnabled
self.isLightFlashEnabled = isLightFlashEnabled
self.volume = volume
}
// MARK: - Equatable

View File

@ -115,6 +115,11 @@ class SoundConfigurationService {
.map { $0.toSound() }
}
/// Get alarm sounds specifically
func getAlarmSounds() -> [Sound] {
return getSoundsByCategory("alarm")
}
/// Get available categories
func getAvailableCategories() -> [SoundCategory] {
return getConfiguration()?.categories ?? []
@ -128,9 +133,18 @@ class SoundConfigurationService {
/// Fallback sounds if JSON loading fails
private func getFallbackSounds() -> [Sound] {
return [
// White noise sounds
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: "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",
"description": "Fan and heater sounds for consistent background noise",
"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": [
@ -43,6 +83,12 @@
"name": "Mechanical",
"description": "Mechanical and electronic sounds",
"bundleName": "Mechanical"
},
{
"id": "alarm",
"name": "Alarm Sounds",
"description": "Wake-up and notification alarm sounds",
"bundleName": "AlarmSounds"
}
],
"settings": {

View File

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

View File

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

View File

@ -32,32 +32,89 @@ class AlarmViewModel {
}
// MARK: - Public Interface
func addAlarm(_ alarm: Alarm) {
func addAlarm(_ alarm: Alarm) async {
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)
// 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)
}
func toggleAlarm(id: UUID) {
alarmService.toggleAlarm(id: id)
func toggleAlarm(id: UUID) async {
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? {
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(
id: UUID(),
time: time,
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
init(noisePlayer: NoisePlayer = NoisePlayer()) {
init(noisePlayer: NoisePlayer = NoisePlayer.shared) {
self.noisePlayer = noisePlayer
}

View File

@ -7,82 +7,109 @@
import SwiftUI
/// View for creating new alarms
/// View for creating new alarms with iOS-native style interface
struct AddAlarmView: View {
// MARK: - Properties
let viewModel: AlarmViewModel
@Binding var isPresented: Bool
@State private var newAlarmTime = Date()
@State private var selectedSoundName = AppConstants.SystemSounds.defaultSound
@State private var newAlarmTime = Calendar.current.date(bySettingHour: 6, minute: 0, second: 0, of: Date()) ?? Date()
@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 {
NavigationView {
VStack(spacing: UIConstants.Spacing.extraLarge) {
TimePickerView(
selectedTime: $newAlarmTime
)
VStack(spacing: 0) {
TimePickerSection(selectedTime: $newAlarmTime)
TimeUntilAlarmSection(alarmTime: newAlarmTime)
Picker("Sound", selection: $selectedSoundName) {
ForEach(viewModel.systemSounds, id: \.self) { sound in
Text(sound.capitalized).tag(sound)
List {
// Label Section
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)
HStack(spacing: UIConstants.Spacing.large) {
.listStyle(.insetGrouped)
}
.navigationTitle("Alarm")
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
isPresented = false
}
.buttonStyle(isEnabled: true, color: UIConstants.Colors.secondaryText)
Spacer()
Button("Add Alarm") {
let newAlarm = viewModel.createNewAlarm(
time: newAlarmTime,
soundName: selectedSoundName
)
viewModel.addAlarm(newAlarm)
isPresented = false
.foregroundColor(.orange)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
Task {
let newAlarm = viewModel.createNewAlarm(
time: newAlarmTime,
soundName: selectedSoundName,
label: alarmLabel,
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 {
VStack(alignment: .leading, spacing: UIConstants.Spacing.small) {
Text("Time")
.font(.headline)
.foregroundColor(UIConstants.Colors.primaryText)
DatePicker(
"Time",
selection: $selectedTime,
displayedComponents: .hourAndMinute
)
.datePickerStyle(.wheel)
.labelsHidden()
// MARK: - Helper Methods
private func getSoundDisplayName(_ fileName: String) -> String {
let alarmSounds = SoundConfigurationService.shared.getAlarmSounds()
if let sound = alarmSounds.first(where: { $0.fileName == fileName }) {
return sound.name
}
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
AlarmRowView(
alarm: alarm,
onToggle: { viewModel.toggleAlarm(id: alarm.id) },
onToggle: {
Task {
await viewModel.toggleAlarm(id: alarm.id)
}
},
onEdit: { /* TODO: Implement edit functionality */ }
)
}
@ -52,9 +56,11 @@ struct AlarmView: View {
// MARK: - Private Methods
private func deleteAlarm(at offsets: IndexSet) {
for index in offsets {
let alarm = viewModel.alarms[index]
viewModel.deleteAlarm(id: alarm.id)
Task {
for index in offsets {
let alarm = viewModel.alarms[index]
await viewModel.deleteAlarm(id: alarm.id)
}
}
}
}

View File

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