diff --git a/AGENTS.md b/AGENTS.md index 4fad121..3633b39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,7 @@ Use /ios-18-role read the PRD.md read the README.md +read the Bedrock README.md as well Always update the PRD.md and README.md when there are code changes that might cause these files to require those changes documented. diff --git a/AudioPlaybackKit/Sources/AudioPlaybackKit/Services/SoundPlayer.swift b/AudioPlaybackKit/Sources/AudioPlaybackKit/Services/SoundPlayer.swift index 8244d1c..8d398d4 100644 --- a/AudioPlaybackKit/Sources/AudioPlaybackKit/Services/SoundPlayer.swift +++ b/AudioPlaybackKit/Sources/AudioPlaybackKit/Services/SoundPlayer.swift @@ -41,6 +41,14 @@ public class SoundPlayer { } public func playSound(_ sound: Sound) { + playSound(sound, volumeOverride: nil) + } + + public func playSound(_ sound: Sound, volume: Float) { + playSound(sound, volumeOverride: volume) + } + + private func playSound(_ sound: Sound, volumeOverride: Float?) { print("🎵 Attempting to play: \(sound.name)") // Stop current sound if playing @@ -63,7 +71,7 @@ public class SoundPlayer { do { let newPlayer = try AVAudioPlayer(contentsOf: fileUrl) newPlayer.numberOfLoops = AudioConstants.Playback.numberOfLoops - newPlayer.volume = AudioConstants.Volume.default + newPlayer.volume = volumeOverride ?? AudioConstants.Volume.default newPlayer.prepareToPlay() players[sound.fileName] = newPlayer currentPlayer = newPlayer @@ -77,6 +85,9 @@ public class SoundPlayer { } currentPlayer = player + if let volumeOverride { + player.volume = volumeOverride + } let success = player.play() print("🎵 Play result: \(success ? "SUCCESS" : "FAILED")") print("🔊 Player isPlaying: \(player.isPlaying)") diff --git a/PRD.md b/PRD.md index 85bf27e..79bb7d4 100644 --- a/PRD.md +++ b/PRD.md @@ -105,11 +105,18 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di - **Smart notifications**: Automatic scheduling for one-time and repeating alarms - **Enable/disable toggles**: Individual alarm control with instant feedback - **Notification integration**: Uses iOS UserNotifications framework with proper scheduling +- **Background limitations**: Full alarm sound and screen require the app to be foregrounded; background alarms use notification sound +- **Keep Awake prompt**: In-app popup enables Keep Awake without digging into settings +- **Keep Awake guidance**: Banner messaging explains why Keep Awake improves alarm reliability - **Persistent storage**: Alarms saved to UserDefaults with backward compatibility - **Alarm management**: Add, edit, delete, and duplicate alarms - **Next trigger preview**: Shows when the next alarm will fire - **Responsive time picker**: Font sizes adapt to available space and orientation - **AlarmSoundService integration**: Dedicated service for alarm-specific sound management +- **In-app alarm screen**: Full-screen alarm UI with Snooze/Stop when the app is active +- **Foreground alarm sound**: In-app playback of the selected alarm sound and volume +- **Notification tap routing**: Tapping an alarm notification opens the alarm screen +- **Foreground handling**: Alarm notifications surface as in-app UI instead of banners ## Advanced Clock Display Features @@ -351,6 +358,7 @@ These principles are fundamental to the project's long-term success and must be - **Real-time updates**: Changes apply immediately with live preview - **Sheet presentation**: Full-screen settings sheet for uninterrupted editing - **Enum-based architecture**: Type-safe picker selections eliminate string-based errors +- **Always-visible settings**: Advanced sections are always shown for clarity ## File Structure and Organization @@ -401,7 +409,8 @@ TheNoiseClock/ │ │ │ └── SoundCategory.swift # Shared sound category definitions │ │ └── Utilities/ │ │ ├── ColorUtils.swift # Color manipulation utilities -│ │ └── NotificationUtils.swift # Notification helper functions +│ │ ├── NotificationUtils.swift # Notification helper functions +│ │ └── AlarmNotifications.swift # Alarm notification constants and events │ ├── Features/ │ │ ├── Clock/ │ │ │ ├── Models/ @@ -453,6 +462,7 @@ TheNoiseClock/ │ │ │ ├── AlarmView.swift │ │ │ ├── AddAlarmView.swift │ │ │ ├── EditAlarmView.swift +│ │ │ ├── AlarmScreen.swift │ │ │ └── Components/ │ │ │ ├── AlarmRowView.swift │ │ │ ├── EmptyAlarmsView.swift @@ -562,9 +572,10 @@ The following changes **automatically require** PRD updates: 1. **Time format**: Toggle 24-hour, seconds, AM/PM display 2. **Appearance**: Adjust colors, glow, size, opacity 3. **Display**: Control keep awake functionality for display mode -4. **Focus Modes**: Control how app behaves with Focus modes (Do Not Disturb) -5. **Overlays**: Control battery and date display -6. **Background**: Set background color and use presets +4. **Keep Awake prompt**: Auto-prompt when needed (alarms tab, enabling alarms, display mode) +5. **Focus Modes**: Control how app behaves with Focus modes (Do Not Disturb) +6. **Overlays**: Control battery and date display +7. **Background**: Set background color and use presets ### Alarms Tab 1. **View alarms**: List of all created alarms with labels and repeat schedules diff --git a/README.md b/README.md index 3853853..a20829e 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,11 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and - Alarm sound library with preview - Vibration and volume controls per alarm - Focus-mode aware scheduling +- Full-screen in-app alarm screen with Snooze/Stop when active +- In-app alarm sound playback using the selected alarm sound +- Tapping alarm notifications opens the alarm screen +- Background limitations: full alarm sound/screen requires the app to be open in the foreground +- Keep Awake prompt enables staying on-screen for alarms **Display Mode** - Long-press to enter immersive display mode @@ -61,6 +66,7 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and - Full-screen display mode and Dynamic Island awareness - White noise playback with categories and previews - Rich alarm editor with scheduling and snooze controls +- Full-screen in-app alarm screen with Snooze/Stop controls - Bedrock-based theming and branded launch - iPhone and iPad support with adaptive layouts - First-launch onboarding with feature highlights and permission setup diff --git a/TheNoiseClock/App/ContentView.swift b/TheNoiseClock/App/ContentView.swift index 8f4543c..df236c1 100644 --- a/TheNoiseClock/App/ContentView.swift +++ b/TheNoiseClock/App/ContentView.swift @@ -13,16 +13,27 @@ struct ContentView: View { // MARK: - Properties - private enum Tab: Hashable { + private enum Tab: Hashable, CustomStringConvertible { case clock case alarms case noise case settings + + var description: String { + switch self { + case .clock: return "clock" + case .alarms: return "alarms" + case .noise: return "noise" + case .settings: return "settings" + } + } } @State private var selectedTab: Tab = .clock @State private var clockViewModel = ClockViewModel() + @State private var alarmViewModel = AlarmViewModel() @State private var onboardingState = OnboardingState(appIdentifier: "TheNoiseClock") + @State private var keepAwakePromptState = KeepAwakePromptState() // MARK: - Body @@ -39,7 +50,11 @@ struct ContentView: View { .tag(Tab.clock) NavigationStack { - AlarmView() + AlarmView(viewModel: alarmViewModel) + .toolbar(.visible, for: .tabBar) + .onAppear { + Design.debugLog("[AlarmView] onAppear - forcing tabBar visible") + } } .tabItem { Label("Alarms", systemImage: "alarm") @@ -48,6 +63,10 @@ struct ContentView: View { NavigationStack { NoiseView() + .toolbar(.visible, for: .tabBar) + .onAppear { + Design.debugLog("[NoiseView] onAppear - forcing tabBar visible") + } } .tabItem { Label("Noise", systemImage: "waveform") @@ -64,6 +83,10 @@ struct ContentView: View { onboardingState.reset() } ) + .toolbar(.visible, for: .tabBar) + .onAppear { + Design.debugLog("[ClockSettingsView] onAppear - forcing tabBar visible") + } } .tabItem { Label("Settings", systemImage: "gearshape") @@ -71,12 +94,26 @@ struct ContentView: View { .tag(Tab.settings) } .onChange(of: selectedTab) { oldValue, newValue in + Design.debugLog("[ContentView] Tab changed: \(oldValue) -> \(newValue)") if oldValue == .clock && newValue != .clock { + Design.debugLog("[ContentView] Leaving clock tab, setting displayMode to false") clockViewModel.setDisplayMode(false) } } .accentColor(AppAccent.primary) .background(Color.Branding.primary.ignoresSafeArea()) + .fullScreenCover(item: activeAlarmBinding) { alarm in + AlarmScreen( + alarm: alarm, + onSnooze: { + alarmViewModel.snoozeActiveAlarm() + }, + onStop: { + alarmViewModel.stopActiveAlarm() + } + ) + .interactiveDismissDisabled(true) + } // Onboarding overlay for first-time users if !onboardingState.hasCompletedWelcome { @@ -86,8 +123,38 @@ struct ContentView: View { .transition(.opacity) } } + .sheet(isPresented: $keepAwakePromptState.isPresented) { + KeepAwakePrompt( + onEnable: { + clockViewModel.setKeepAwakeEnabled(true) + keepAwakePromptState.dismiss() + }, + onDismiss: { + keepAwakePromptState.dismiss() + } + ) + } + .onReceive(NotificationCenter.default.publisher(for: .alarmDidFire)) { notification in + alarmViewModel.handleAlarmNotification(userInfo: notification.userInfo) + } + .onReceive(NotificationCenter.default.publisher(for: .alarmDidStop)) { _ in + alarmViewModel.stopActiveAlarm() + } + .onReceive(NotificationCenter.default.publisher(for: .alarmDidSnooze)) { _ in + alarmViewModel.stopActiveAlarm() + } + .onReceive(NotificationCenter.default.publisher(for: .keepAwakePromptRequested)) { _ in + keepAwakePromptState.showIfNeeded(isKeepAwakeEnabled: clockViewModel.style.keepAwake) + } .animation(.easeInOut(duration: 0.3), value: onboardingState.hasCompletedWelcome) } + + private var activeAlarmBinding: Binding { + Binding( + get: { alarmViewModel.activeAlarm }, + set: { alarmViewModel.activeAlarm = $0 } + ) + } } // MARK: - Preview diff --git a/TheNoiseClock/Features/Alarms/Services/AlarmService.swift b/TheNoiseClock/Features/Alarms/Services/AlarmService.swift index 4ab8c54..49fb90f 100644 --- a/TheNoiseClock/Features/Alarms/Services/AlarmService.swift +++ b/TheNoiseClock/Features/Alarms/Services/AlarmService.swift @@ -90,7 +90,10 @@ class AlarmService { date: alarm.time, soundName: alarm.soundName, repeats: false, // For now, set to false since Alarm model doesn't have repeatDays - respectFocusModes: respectFocusModes + respectFocusModes: respectFocusModes, + snoozeDuration: alarm.snoozeDuration, + isVibrationEnabled: alarm.isVibrationEnabled, + volume: alarm.volume ) } } diff --git a/TheNoiseClock/Features/Alarms/Services/AlarmSoundService.swift b/TheNoiseClock/Features/Alarms/Services/AlarmSoundService.swift index c73372a..d968052 100644 --- a/TheNoiseClock/Features/Alarms/Services/AlarmSoundService.swift +++ b/TheNoiseClock/Features/Alarms/Services/AlarmSoundService.swift @@ -114,4 +114,9 @@ class AlarmSoundService { } return fileName.replacingOccurrences(of: ".caf", with: "").capitalized } + + /// Get alarm sound by filename + func getAlarmSound(fileName: String) -> Sound? { + return getAlarmSounds().first { $0.fileName == fileName } + } } diff --git a/TheNoiseClock/Features/Alarms/Services/FocusModeService.swift b/TheNoiseClock/Features/Alarms/Services/FocusModeService.swift index f9a2a24..aa28e08 100644 --- a/TheNoiseClock/Features/Alarms/Services/FocusModeService.swift +++ b/TheNoiseClock/Features/Alarms/Services/FocusModeService.swift @@ -65,15 +65,15 @@ class FocusModeService { private func configureNotificationSettings() async { // Create notification categories that work with Focus modes let alarmCategory = UNNotificationCategory( - identifier: "ALARM_CATEGORY", + identifier: AlarmNotificationConstants.categoryIdentifier, actions: [ UNNotificationAction( - identifier: "SNOOZE_ACTION", + identifier: AlarmNotificationConstants.snoozeActionIdentifier, title: "Snooze", options: [] ), UNNotificationAction( - identifier: "STOP_ACTION", + identifier: AlarmNotificationConstants.stopActionIdentifier, title: "Stop", options: [.destructive] ) @@ -96,7 +96,10 @@ class FocusModeService { date: Date, soundName: String, repeats: Bool = false, - respectFocusModes: Bool = true + respectFocusModes: Bool = true, + snoozeDuration: Int? = nil, + isVibrationEnabled: Bool? = nil, + volume: Float? = nil ) { let content = UNMutableNotificationContent() content.title = title @@ -110,16 +113,28 @@ class FocusModeService { Design.debugLog("[settings] Using custom alarm sound: \(soundName)") Design.debugLog("[settings] Sound file should be in main bundle: \(soundName)") } - content.categoryIdentifier = "ALARM_CATEGORY" + content.categoryIdentifier = AlarmNotificationConstants.categoryIdentifier if !respectFocusModes, timeSensitiveSetting == .enabled { content.interruptionLevel = .timeSensitive } - content.userInfo = [ - "alarmId": identifier, - "soundName": soundName, - "repeats": repeats + var userInfo: [AnyHashable: Any] = [ + AlarmNotificationKeys.alarmId: identifier, + AlarmNotificationKeys.soundName: soundName, + AlarmNotificationKeys.repeats: repeats, + AlarmNotificationKeys.label: title, + AlarmNotificationKeys.notificationMessage: body ] + if let snoozeDuration { + userInfo[AlarmNotificationKeys.snoozeDuration] = snoozeDuration + } + if let isVibrationEnabled { + userInfo[AlarmNotificationKeys.isVibrationEnabled] = isVibrationEnabled + } + if let volume { + userInfo[AlarmNotificationKeys.volume] = volume + } + content.userInfo = userInfo // Create trigger let trigger: UNNotificationTrigger diff --git a/TheNoiseClock/Features/Alarms/Services/NotificationDelegate.swift b/TheNoiseClock/Features/Alarms/Services/NotificationDelegate.swift index cad28a0..6af0bb5 100644 --- a/TheNoiseClock/Features/Alarms/Services/NotificationDelegate.swift +++ b/TheNoiseClock/Features/Alarms/Services/NotificationDelegate.swift @@ -50,13 +50,16 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { Design.debugLog("[settings] Notification action received: \(actionIdentifier)") switch actionIdentifier { - case "SNOOZE_ACTION": + case AlarmNotificationConstants.snoozeActionIdentifier: handleSnoozeAction(userInfo: userInfo) - case "STOP_ACTION": + postAlarmAction(name: .alarmDidSnooze, notification: notification) + case AlarmNotificationConstants.stopActionIdentifier: handleStopAction(userInfo: userInfo) + postAlarmAction(name: .alarmDidStop, notification: notification) case UNNotificationDefaultActionIdentifier: // User tapped the notification itself handleNotificationTap(userInfo: userInfo) + postAlarmDidFire(notification: notification) default: Design.debugLog("[settings] Unknown action: \(actionIdentifier)") } @@ -70,14 +73,20 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { - // Show notification even when app is in foreground + if notification.request.content.categoryIdentifier == AlarmNotificationConstants.categoryIdentifier { + postAlarmDidFire(notification: notification) + completionHandler([]) + return + } + + // Show non-alarm notifications even when app is in foreground completionHandler([.banner, .sound, .badge]) } // MARK: - Action Handlers private func handleSnoozeAction(userInfo: [AnyHashable: Any]) { - guard let alarmIdString = userInfo["alarmId"] as? String, + guard let alarmIdString = userInfo[AlarmNotificationKeys.alarmId] as? String, let alarmId = UUID(uuidString: alarmIdString), let alarmService = self.alarmService, let alarm = alarmService.getAlarm(id: alarmId) else { @@ -113,7 +122,7 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { } private func handleStopAction(userInfo: [AnyHashable: Any]) { - guard let alarmIdString = userInfo["alarmId"] as? String, + guard let alarmIdString = userInfo[AlarmNotificationKeys.alarmId] as? String, let alarmId = UUID(uuidString: alarmIdString) else { Design.debugLog("[general] Could not find alarm ID for stop action") return @@ -129,7 +138,7 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { } private func handleNotificationTap(userInfo: [AnyHashable: Any]) { - guard let alarmIdString = userInfo["alarmId"] as? String, + guard let alarmIdString = userInfo[AlarmNotificationKeys.alarmId] as? String, let alarmId = UUID(uuidString: alarmIdString) else { Design.debugLog("[general] Could not find alarm ID for notification tap") return @@ -147,13 +156,22 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { let content = UNMutableNotificationContent() content.title = snoozeAlarm.label content.body = snoozeAlarm.notificationMessage - content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: snoozeAlarm.soundName)) - content.categoryIdentifier = "ALARM_CATEGORY" + if snoozeAlarm.soundName == "default" { + content.sound = .default + } else { + content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: snoozeAlarm.soundName)) + } + content.categoryIdentifier = AlarmNotificationConstants.categoryIdentifier content.userInfo = [ - "alarmId": snoozeAlarm.id.uuidString, - "soundName": snoozeAlarm.soundName, - "isSnooze": true, - "originalAlarmId": userInfo["alarmId"] as? String ?? "" + AlarmNotificationKeys.alarmId: snoozeAlarm.id.uuidString, + AlarmNotificationKeys.soundName: snoozeAlarm.soundName, + AlarmNotificationKeys.isSnooze: true, + AlarmNotificationKeys.originalAlarmId: userInfo[AlarmNotificationKeys.alarmId] as? String ?? "", + AlarmNotificationKeys.label: snoozeAlarm.label, + AlarmNotificationKeys.notificationMessage: snoozeAlarm.notificationMessage, + AlarmNotificationKeys.snoozeDuration: snoozeAlarm.snoozeDuration, + AlarmNotificationKeys.isVibrationEnabled: snoozeAlarm.isVibrationEnabled, + AlarmNotificationKeys.volume: snoozeAlarm.volume ] // Create trigger for snooze time @@ -177,4 +195,20 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { Design.debugLog("[general] Error scheduling snooze notification: \(error)") } } + + // MARK: - Notification Center Bridge + + private func postAlarmDidFire(notification: UNNotification) { + var userInfo = notification.request.content.userInfo + userInfo[AlarmNotificationKeys.title] = notification.request.content.title + userInfo[AlarmNotificationKeys.body] = notification.request.content.body + NotificationCenter.default.post(name: .alarmDidFire, object: nil, userInfo: userInfo) + } + + private func postAlarmAction(name: Notification.Name, notification: UNNotification) { + var userInfo = notification.request.content.userInfo + userInfo[AlarmNotificationKeys.title] = notification.request.content.title + userInfo[AlarmNotificationKeys.body] = notification.request.content.body + NotificationCenter.default.post(name: name, object: nil, userInfo: userInfo) + } } diff --git a/TheNoiseClock/Features/Alarms/Services/NotificationService.swift b/TheNoiseClock/Features/Alarms/Services/NotificationService.swift index 732092c..a8094fd 100644 --- a/TheNoiseClock/Features/Alarms/Services/NotificationService.swift +++ b/TheNoiseClock/Features/Alarms/Services/NotificationService.swift @@ -64,6 +64,12 @@ class NotificationService { body: body, soundName: soundName ) + content.userInfo = [ + AlarmNotificationKeys.alarmId: id, + AlarmNotificationKeys.soundName: soundName, + AlarmNotificationKeys.label: title, + AlarmNotificationKeys.notificationMessage: body + ] let trigger = NotificationUtils.createCalendarTrigger(for: date) return await NotificationUtils.scheduleNotification( diff --git a/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift b/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift index e372188..2c6ba1e 100644 --- a/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift +++ b/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift @@ -7,6 +7,8 @@ import Foundation import Observation +import UserNotifications +import AudioPlaybackKit /// ViewModel for alarm management @Observable @@ -15,6 +17,10 @@ class AlarmViewModel { // MARK: - Properties private let alarmService: AlarmService private let notificationService: NotificationService + private let alarmSoundService = AlarmSoundService.shared + private let soundPlayer = SoundPlayer.shared + + var activeAlarm: Alarm? var alarms: [Alarm] { alarmService.alarms @@ -47,6 +53,7 @@ class AlarmViewModel { soundName: alarm.soundName, date: alarm.time ) + requestKeepAwakePromptIfNeeded() } } @@ -62,6 +69,7 @@ class AlarmViewModel { soundName: alarm.soundName, date: alarm.time ) + requestKeepAwakePromptIfNeeded() } else { notificationService.cancelNotification(id: alarm.id.uuidString) } @@ -90,6 +98,7 @@ class AlarmViewModel { soundName: alarm.soundName, date: alarm.time ) + requestKeepAwakePromptIfNeeded() } else { notificationService.cancelNotification(id: id.uuidString) } @@ -126,4 +135,157 @@ class AlarmViewModel { func requestNotificationPermissions() async -> Bool { return await notificationService.requestPermissions() } + + func requestKeepAwakePromptIfNeeded() { + guard !isKeepAwakeEnabled() else { return } + NotificationCenter.default.post(name: .keepAwakePromptRequested, object: nil) + } + + // MARK: - Active Alarm Handling + + func handleAlarmNotification(userInfo: [AnyHashable: Any]?) { + guard let userInfo else { return } + if let alarm = resolveAlarm(from: userInfo) { + startActiveAlarm(alarm) + } + } + + @MainActor + func stopActiveAlarm() { + soundPlayer.stopSound() + activeAlarm = nil + } + + @MainActor + func snoozeActiveAlarm() { + guard let alarm = activeAlarm else { return } + scheduleSnoozeNotification(for: alarm) + stopActiveAlarm() + } + + @MainActor + private func startActiveAlarm(_ alarm: Alarm) { + if activeAlarm?.id == alarm.id { + return + } + activeAlarm = alarm + playAlarmSound(for: alarm) + } + + private func playAlarmSound(for alarm: Alarm) { + let resolvedSound = alarmSoundService.getAlarmSound(fileName: alarm.soundName) + ?? alarmSoundService.getDefaultAlarmSound() + guard let sound = resolvedSound else { return } + soundPlayer.playSound(sound, volume: alarm.volume) + } + + private func isKeepAwakeEnabled() -> Bool { + guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey), + let style = try? JSONDecoder().decode(ClockStyle.self, from: data) else { + return ClockStyle().keepAwake + } + return style.keepAwake + } + + private func scheduleSnoozeNotification(for alarm: Alarm) { + let snoozeTime = Date().addingTimeInterval(TimeInterval(alarm.snoozeDuration * 60)) + let snoozeAlarm = Alarm( + id: UUID(), + time: snoozeTime, + isEnabled: true, + soundName: alarm.soundName, + label: "\(alarm.label) (Snoozed)", + notificationMessage: "Snoozed: \(alarm.notificationMessage)", + snoozeDuration: alarm.snoozeDuration, + isVibrationEnabled: alarm.isVibrationEnabled, + isLightFlashEnabled: alarm.isLightFlashEnabled, + volume: alarm.volume + ) + + let content = UNMutableNotificationContent() + content.title = snoozeAlarm.label + content.body = snoozeAlarm.notificationMessage + if snoozeAlarm.soundName == "default" { + content.sound = .default + } else { + content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: snoozeAlarm.soundName)) + } + content.categoryIdentifier = AlarmNotificationConstants.categoryIdentifier + content.userInfo = [ + AlarmNotificationKeys.alarmId: snoozeAlarm.id.uuidString, + AlarmNotificationKeys.soundName: snoozeAlarm.soundName, + AlarmNotificationKeys.isSnooze: true, + AlarmNotificationKeys.originalAlarmId: alarm.id.uuidString, + AlarmNotificationKeys.label: snoozeAlarm.label, + AlarmNotificationKeys.notificationMessage: snoozeAlarm.notificationMessage, + AlarmNotificationKeys.snoozeDuration: snoozeAlarm.snoozeDuration, + AlarmNotificationKeys.isVibrationEnabled: snoozeAlarm.isVibrationEnabled, + AlarmNotificationKeys.volume: snoozeAlarm.volume + ] + + let trigger = UNTimeIntervalNotificationTrigger( + timeInterval: snoozeAlarm.time.timeIntervalSinceNow, + repeats: false + ) + + let request = UNNotificationRequest( + identifier: snoozeAlarm.id.uuidString, + content: content, + trigger: trigger + ) + + Task { + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + // Intentionally silent; notification system logs errors + } + } + } + + private func resolveAlarm(from userInfo: [AnyHashable: Any]) -> Alarm? { + if let alarmIdString = userInfo[AlarmNotificationKeys.alarmId] as? String, + let alarmId = UUID(uuidString: alarmIdString), + let alarm = alarmService.getAlarm(id: alarmId) { + return alarm + } + + let title = (userInfo[AlarmNotificationKeys.label] as? String) ?? (userInfo[AlarmNotificationKeys.title] as? String) + let body = (userInfo[AlarmNotificationKeys.notificationMessage] as? String) ?? (userInfo[AlarmNotificationKeys.body] as? String) + let soundName = (userInfo[AlarmNotificationKeys.soundName] as? String) ?? AppConstants.SystemSounds.defaultSound + let snoozeDuration = intValue(from: userInfo[AlarmNotificationKeys.snoozeDuration]) ?? 9 + let isVibrationEnabled = boolValue(from: userInfo[AlarmNotificationKeys.isVibrationEnabled]) ?? true + let volume = floatValue(from: userInfo[AlarmNotificationKeys.volume]) ?? 1.0 + + return Alarm( + time: Date(), + isEnabled: true, + soundName: soundName, + label: title ?? "Alarm", + notificationMessage: body ?? "Your alarm is ringing", + snoozeDuration: snoozeDuration, + isVibrationEnabled: isVibrationEnabled, + isLightFlashEnabled: false, + volume: volume + ) + } + + private func intValue(from value: Any?) -> Int? { + if let intValue = value as? Int { return intValue } + if let number = value as? NSNumber { return number.intValue } + return nil + } + + private func floatValue(from value: Any?) -> Float? { + if let floatValue = value as? Float { return floatValue } + if let doubleValue = value as? Double { return Float(doubleValue) } + if let number = value as? NSNumber { return number.floatValue } + return nil + } + + private func boolValue(from value: Any?) -> Bool? { + if let boolValue = value as? Bool { return boolValue } + if let number = value as? NSNumber { return number.boolValue } + return nil + } } diff --git a/TheNoiseClock/Features/Alarms/Views/AddAlarmView.swift b/TheNoiseClock/Features/Alarms/Views/AddAlarmView.swift index c40d98a..47ad189 100644 --- a/TheNoiseClock/Features/Alarms/Views/AddAlarmView.swift +++ b/TheNoiseClock/Features/Alarms/Views/AddAlarmView.swift @@ -7,6 +7,7 @@ import SwiftUI import AudioPlaybackKit +import Foundation /// View for creating new alarms with iOS-native style interface struct AddAlarmView: View { @@ -14,6 +15,7 @@ struct AddAlarmView: View { // MARK: - Properties let viewModel: AlarmViewModel @Binding var isPresented: Bool + @AppStorage(ClockStyle.appStorageKey) private var clockStyleData: Data = Data() @State private var newAlarmTime = Calendar.current.date(bySettingHour: 6, minute: 0, second: 0, of: Date()) ?? Date() @State private var selectedSoundName = "digital-alarm.caf" @@ -33,6 +35,15 @@ struct AddAlarmView: View { // List for settings below List { + if !isKeepAwakeEnabled { + Section { + AlarmLimitationsBanner() + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + } + // Label Section NavigationLink(destination: LabelEditView(label: $alarmLabel)) { HStack { @@ -127,4 +138,11 @@ struct AddAlarmView: View { private func getSoundDisplayName(_ fileName: String) -> String { return AlarmSoundService.shared.getSoundDisplayName(fileName) } + + private var isKeepAwakeEnabled: Bool { + guard let decoded = try? JSONDecoder().decode(ClockStyle.self, from: clockStyleData) else { + return ClockStyle().keepAwake + } + return decoded.keepAwake + } } diff --git a/TheNoiseClock/Features/Alarms/Views/AlarmScreen.swift b/TheNoiseClock/Features/Alarms/Views/AlarmScreen.swift new file mode 100644 index 0000000..a694a80 --- /dev/null +++ b/TheNoiseClock/Features/Alarms/Views/AlarmScreen.swift @@ -0,0 +1,64 @@ +// +// AlarmScreen.swift +// TheNoiseClock +// +// Created by Matt Bruce on 2/2/26. +// + +import SwiftUI +import Bedrock + +/// Full-screen alarm UI for active alarms +struct AlarmScreen: View { + + let alarm: Alarm + let onSnooze: () -> Void + let onStop: () -> Void + + var body: some View { + ZStack { + Color.Branding.primary + .ignoresSafeArea() + + VStack(spacing: Design.Spacing.large) { + Spacer() + + Text(alarm.formattedTime()) + .font(.system(size: 72, weight: .bold, design: .rounded)) + .foregroundStyle(AppTextColors.primary) + + Text(alarm.label) + .font(.title2.weight(.semibold)) + .foregroundStyle(AppTextColors.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, Design.Spacing.large) + + Spacer() + + HStack(spacing: Design.Spacing.large) { + Button(action: onSnooze) { + Text("Snooze") + .frame(maxWidth: .infinity) + } + .buttonStyle(color: AppAccent.primary) + + Button(action: onStop) { + Text("Stop") + .frame(maxWidth: .infinity) + } + .buttonStyle(color: .red) + } + .padding(.horizontal, Design.Spacing.large) + .padding(.bottom, Design.Spacing.large) + } + } + } +} + +#Preview { + AlarmScreen( + alarm: Alarm(time: Date(), soundName: "digital-alarm.caf", label: "Wake Up", notificationMessage: "Alarm", snoozeDuration: 9, isVibrationEnabled: true, isLightFlashEnabled: false, volume: 1.0), + onSnooze: {}, + onStop: {} + ) +} diff --git a/TheNoiseClock/Features/Alarms/Views/AlarmView.swift b/TheNoiseClock/Features/Alarms/Views/AlarmView.swift index 555df3b..f62bd05 100644 --- a/TheNoiseClock/Features/Alarms/Views/AlarmView.swift +++ b/TheNoiseClock/Features/Alarms/Views/AlarmView.swift @@ -7,31 +7,48 @@ import SwiftUI import Bedrock +import Foundation /// Main alarm management view struct AlarmView: View { // MARK: - Properties - @State private var viewModel = AlarmViewModel() + @Bindable var viewModel: AlarmViewModel @State private var showAddAlarm = false @State private var selectedAlarmForEdit: Alarm? + @AppStorage(ClockStyle.appStorageKey) private var clockStyleData: Data = Data() // MARK: - Body var body: some View { let isPad = UIDevice.current.userInterfaceIdiom == .pad Group { if viewModel.alarms.isEmpty { - EmptyAlarmsView { - showAddAlarm = true - } - .contentShape(Rectangle()) - .onTapGesture { - showAddAlarm = true + VStack(spacing: Design.Spacing.large) { + if !isKeepAwakeEnabled { + AlarmLimitationsBanner() + } + + EmptyAlarmsView { + showAddAlarm = true + } + .contentShape(Rectangle()) + .onTapGesture { + showAddAlarm = true + } } .frame(maxWidth: Design.Size.maxContentWidthPortrait) .frame(maxWidth: .infinity, alignment: .center) } else { List { + if !isKeepAwakeEnabled { + Section { + AlarmLimitationsBanner() + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + } + ForEach(viewModel.alarms) { alarm in AlarmRowView( alarm: alarm, @@ -47,6 +64,7 @@ struct AlarmView: View { } .onDelete(perform: deleteAlarm) } + .listStyle(.insetGrouped) .frame(maxWidth: Design.Size.maxContentWidthPortrait) .frame(maxWidth: .infinity, alignment: .center) } @@ -67,6 +85,7 @@ struct AlarmView: View { Task { await viewModel.requestNotificationPermissions() } + viewModel.requestKeepAwakePromptIfNeeded() } .sheet(isPresented: $showAddAlarm) { AddAlarmView( @@ -91,11 +110,18 @@ struct AlarmView: View { } } } + + private var isKeepAwakeEnabled: Bool { + guard let decoded = try? JSONDecoder().decode(ClockStyle.self, from: clockStyleData) else { + return ClockStyle().keepAwake + } + return decoded.keepAwake + } } // MARK: - Preview #Preview { NavigationStack { - AlarmView() + AlarmView(viewModel: AlarmViewModel()) } } diff --git a/TheNoiseClock/Features/Alarms/Views/Components/AlarmLimitationsBanner.swift b/TheNoiseClock/Features/Alarms/Views/Components/AlarmLimitationsBanner.swift new file mode 100644 index 0000000..4dd84a6 --- /dev/null +++ b/TheNoiseClock/Features/Alarms/Views/Components/AlarmLimitationsBanner.swift @@ -0,0 +1,58 @@ +// +// AlarmLimitationsBanner.swift +// TheNoiseClock +// +// Created by Matt Bruce on 2/2/26. +// + +import SwiftUI +import Bedrock +import Foundation + +/// Banner explaining background alarm limitations and mitigation. +struct AlarmLimitationsBanner: View { + @AppStorage(ClockStyle.appStorageKey) private var clockStyleData: Data = Data() + + var body: some View { + if isKeepAwakeEnabled { + EmptyView() + } else { + SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { + VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { + HStack(spacing: Design.Spacing.xSmall) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(AppStatus.warning) + Text("Alarm reliability") + .typography(.body) + .fontWeight(.semibold) + .foregroundStyle(AppTextColors.primary) + } + + Text("iOS only allows notification sounds when the app is backgrounded. For a full alarm sound and screen, keep TheNoiseClock open in the foreground.") + .typography(.caption) + .foregroundStyle(AppTextColors.secondary) + + Text("Tip: Use the Keep Awake prompt to keep the app on-screen while alarms are active.") + .typography(.caption) + .foregroundStyle(AppTextColors.secondary) + } + .padding(Design.Spacing.medium) + .frame(maxWidth: .infinity, alignment: .leading) + .background(AppSurface.primary) + } + } + } + + private var isKeepAwakeEnabled: Bool { + guard let decoded = try? JSONDecoder().decode(ClockStyle.self, from: clockStyleData) else { + return ClockStyle().keepAwake + } + return decoded.keepAwake + } +} + +#Preview { + AlarmLimitationsBanner() + .padding() + .background(AppSurface.primary) +} diff --git a/TheNoiseClock/Features/Alarms/Views/EditAlarmView.swift b/TheNoiseClock/Features/Alarms/Views/EditAlarmView.swift index f46db49..056c808 100644 --- a/TheNoiseClock/Features/Alarms/Views/EditAlarmView.swift +++ b/TheNoiseClock/Features/Alarms/Views/EditAlarmView.swift @@ -7,6 +7,7 @@ import SwiftUI import AudioPlaybackKit +import Foundation /// View for editing existing alarms struct EditAlarmView: View { @@ -15,6 +16,7 @@ struct EditAlarmView: View { let viewModel: AlarmViewModel let alarm: Alarm @Environment(\.dismiss) private var dismiss + @AppStorage(ClockStyle.appStorageKey) private var clockStyleData: Data = Data() @State private var alarmTime: Date @State private var selectedSoundName: String @@ -51,6 +53,15 @@ struct EditAlarmView: View { // List for settings below List { + if !isKeepAwakeEnabled { + Section { + AlarmLimitationsBanner() + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + } + // Label Section NavigationLink(destination: LabelEditView(label: $alarmLabel)) { HStack { @@ -147,6 +158,13 @@ struct EditAlarmView: View { private func getSoundDisplayName(_ fileName: String) -> String { return AlarmSoundService.shared.getSoundDisplayName(fileName) } + + private var isKeepAwakeEnabled: Bool { + guard let decoded = try? JSONDecoder().decode(ClockStyle.self, from: clockStyleData) else { + return ClockStyle().keepAwake + } + return decoded.keepAwake + } } // MARK: - Preview diff --git a/TheNoiseClock/Features/Clock/State/ClockViewModel.swift b/TheNoiseClock/Features/Clock/State/ClockViewModel.swift index f24a34f..23eef00 100644 --- a/TheNoiseClock/Features/Clock/State/ClockViewModel.swift +++ b/TheNoiseClock/Features/Clock/State/ClockViewModel.swift @@ -61,20 +61,32 @@ class ClockViewModel { // MARK: - Public Interface func toggleDisplayMode() { + let oldValue = isDisplayMode withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) { isDisplayMode.toggle() } + Design.debugLog("[ClockViewModel] toggleDisplayMode: \(oldValue) -> \(isDisplayMode)") // Manage wake lock based on display mode and keep awake setting updateWakeLockState() + if isDisplayMode { + requestKeepAwakePromptIfNeeded() + } } func setDisplayMode(_ enabled: Bool) { - guard isDisplayMode != enabled else { return } + guard isDisplayMode != enabled else { + Design.debugLog("[ClockViewModel] setDisplayMode(\(enabled)) - already at this value, skipping") + return + } + Design.debugLog("[ClockViewModel] setDisplayMode: \(isDisplayMode) -> \(enabled)") withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) { isDisplayMode = enabled } updateWakeLockState() + if enabled { + requestKeepAwakePromptIfNeeded() + } } func updateStyle(_ newStyle: ClockStyle) { @@ -116,6 +128,12 @@ class ClockViewModel { updateBrightness() // Update brightness when style changes } + func setKeepAwakeEnabled(_ enabled: Bool) { + style.keepAwake = enabled + saveStyle() + updateWakeLockState() + } + // MARK: - Private Methods private func loadStyle() { if let decoded = try? JSONDecoder().decode(ClockStyle.self, from: styleJSON) { @@ -193,6 +211,11 @@ class ClockViewModel { } } + private func requestKeepAwakePromptIfNeeded() { + guard !style.keepAwake else { return } + NotificationCenter.default.post(name: .keepAwakePromptRequested, object: nil) + } + /// Update wake lock state based on current settings private func updateWakeLockState() { // Enable wake lock if in display mode and keep awake is enabled diff --git a/TheNoiseClock/Features/Clock/Views/ClockSettingsView.swift b/TheNoiseClock/Features/Clock/Views/ClockSettingsView.swift index 3f7821e..665c50a 100644 --- a/TheNoiseClock/Features/Clock/Views/ClockSettingsView.swift +++ b/TheNoiseClock/Features/Clock/Views/ClockSettingsView.swift @@ -18,7 +18,6 @@ struct ClockSettingsView: View { @State private var digitColor: Color = .white @State private var backgroundColor: Color = .black - @State private var showAdvancedSettings = false // MARK: - Init init( @@ -42,34 +41,17 @@ struct ClockSettingsView: View { backgroundColor: $backgroundColor ) + FontSection(style: $style) + + AdvancedAppearanceSection(style: $style) + BasicDisplaySection(style: $style) - if showAdvancedSettings { - AdvancedAppearanceSection(style: $style) + AdvancedDisplaySection(style: $style) - FontSection(style: $style) + NightModeSection(style: $style) - NightModeSection(style: $style) - - OverlaySection(style: $style) - - AdvancedDisplaySection(style: $style) - } - - SettingsSectionHeader( - title: "Advanced", - systemImage: "gearshape", - accentColor: AppAccent.primary - ) - - SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { - SettingsToggle( - title: "Show Advanced Settings", - subtitle: "Reveal additional customization options", - isOn: $showAdvancedSettings, - accentColor: AppAccent.primary - ) - } + OverlaySection(style: $style) #if DEBUG SettingsSectionHeader( diff --git a/TheNoiseClock/Features/Clock/Views/ClockView.swift b/TheNoiseClock/Features/Clock/Views/ClockView.swift index ba80b8d..c965cef 100644 --- a/TheNoiseClock/Features/Clock/Views/ClockView.swift +++ b/TheNoiseClock/Features/Clock/Views/ClockView.swift @@ -18,6 +18,7 @@ struct ClockView: View { @Bindable var viewModel: ClockViewModel @State private var idleTimer: Timer? @State private var didHandleTouch = false + @State private var isViewActive = false // MARK: - Body var body: some View { @@ -65,15 +66,15 @@ struct ClockView: View { ) } } - .onAppear { - logClockLayout(size: geometry.size, safeAreaInsets: safeInsets) - } - .onChange(of: geometry.size) { _, newSize in - logClockLayout(size: newSize, safeAreaInsets: safeInsets) - } - .onChange(of: safeInsets) { _, newInsets in - logClockLayout(size: geometry.size, safeAreaInsets: newInsets) - } +// .onAppear { +// logClockLayout(size: geometry.size, safeAreaInsets: safeInsets) +// } +// .onChange(of: geometry.size) { _, newSize in +// logClockLayout(size: newSize, safeAreaInsets: safeInsets) +// } +// .onChange(of: safeInsets) { _, newInsets in +// logClockLayout(size: geometry.size, safeAreaInsets: newInsets) +// } } .ignoresSafeArea() // Extend GeometryReader to full screen, we handle safe areas manually .toolbar(.hidden, for: .navigationBar) @@ -94,9 +95,13 @@ struct ClockView: View { } ) .onAppear { + Design.debugLog("[ClockView] onAppear - setting isViewActive = true") + isViewActive = true resetIdleTimer() } .onDisappear { + Design.debugLog("[ClockView] onDisappear - setting isViewActive = false, invalidating timer") + isViewActive = false idleTimer?.invalidate() idleTimer = nil } @@ -121,7 +126,16 @@ struct ClockView: View { } private func enterDisplayModeFromIdle() { - guard !viewModel.isDisplayMode else { return } + // Guard against entering display mode if we're no longer on the clock tab + guard isViewActive else { + Design.debugLog("[ClockView] enterDisplayModeFromIdle - BLOCKED: view is not active (user switched tabs)") + return + } + guard !viewModel.isDisplayMode else { + Design.debugLog("[ClockView] enterDisplayModeFromIdle - BLOCKED: already in display mode") + return + } + Design.debugLog("[ClockView] enterDisplayModeFromIdle - entering display mode") viewModel.toggleDisplayMode() } diff --git a/TheNoiseClock/Features/Clock/Views/Components/ClockTabBarManager.swift b/TheNoiseClock/Features/Clock/Views/Components/ClockTabBarManager.swift index d42927c..a9d8997 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/ClockTabBarManager.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/ClockTabBarManager.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Bedrock /// Component that manages tab bar visibility for display mode /// Uses SwiftUI's native toolbar hiding for proper iPad compatibility @@ -18,6 +19,12 @@ struct ClockTabBarManager: View { var body: some View { EmptyView() .toolbar(isDisplayMode ? .hidden : .automatic, for: .tabBar) + .onAppear { + Design.debugLog("[ClockTabBarManager] onAppear - isDisplayMode: \(isDisplayMode), tabBar: \(isDisplayMode ? "hidden" : "automatic")") + } + .onChange(of: isDisplayMode) { oldValue, newValue in + Design.debugLog("[ClockTabBarManager] isDisplayMode changed: \(oldValue) -> \(newValue), tabBar: \(newValue ? "hidden" : "automatic")") + } } } diff --git a/TheNoiseClock/Features/Clock/Views/Components/Settings/AdvancedDisplaySection.swift b/TheNoiseClock/Features/Clock/Views/Components/Settings/AdvancedDisplaySection.swift index 981b249..285827f 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/Settings/AdvancedDisplaySection.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/Settings/AdvancedDisplaySection.swift @@ -40,7 +40,7 @@ struct AdvancedDisplaySection: View { } } - Text("Advanced display and system integration settings.") + Text("Advanced display and system integration settings. Keep Awake helps alarms stay active while the app remains open.") .font(.caption) .foregroundStyle(AppTextColors.tertiary) diff --git a/TheNoiseClock/Features/Clock/Views/Components/TimeDisplayView.swift b/TheNoiseClock/Features/Clock/Views/Components/TimeDisplayView.swift index 948407d..7f4e278 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/TimeDisplayView.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/TimeDisplayView.swift @@ -263,9 +263,9 @@ struct TimeDisplayView: View { height: max(1, availableHeight / digitRows) ) - Design.debugLog("[clockLayout] calcFont size=\(String(format: "%.1f", containerSize.width))x\(String(format: "%.1f", containerSize.height)) portrait=\(portrait) seconds=\(showSeconds)") - Design.debugLog("[clockLayout] calcFont available=\(String(format: "%.1f", availableWidth))x\(String(format: "%.1f", availableHeight)) columns=\(String(format: "%.1f", digitColumns)) rows=\(String(format: "%.1f", digitRows)) colonCount=\(String(format: "%.1f", colonCount))") - Design.debugLog("[clockLayout] calcFont digitSize=\(String(format: "%.1f", digitSize.width))x\(String(format: "%.1f", digitSize.height)) colonSize=\(String(format: "%.1f", colonSize))") + //Design.debugLog("[clockLayout] calcFont size=\(String(format: "%.1f", containerSize.width))x\(String(format: "%.1f", containerSize.height)) portrait=\(portrait) seconds=\(showSeconds)") + //Design.debugLog("[clockLayout] calcFont available=\(String(format: "%.1f", availableWidth))x\(String(format: "%.1f", availableHeight)) columns=\(String(format: "%.1f", digitColumns)) rows=\(String(format: "%.1f", digitRows)) colonCount=\(String(format: "%.1f", colonCount))") + //Design.debugLog("[clockLayout] calcFont digitSize=\(String(format: "%.1f", digitSize.width))x\(String(format: "%.1f", digitSize.height)) colonSize=\(String(format: "%.1f", colonSize))") return FontUtils.calculateOptimalFontSize( digit: "8", @@ -308,17 +308,17 @@ struct TimeDisplayView: View { if totalWidth > containerSize.width { let scaleFactor = containerSize.width / totalWidth estimated *= scaleFactor * 0.98 // Add 2% margin - Design.debugLog("[clockLayout] width overflow: totalWidth=\(Int(totalWidth)) container=\(Int(containerSize.width)) scaling by \(String(format: "%.2f", scaleFactor))") + //Design.debugLog("[clockLayout] width overflow: totalWidth=\(Int(totalWidth)) container=\(Int(containerSize.width)) scaling by \(String(format: "%.2f", scaleFactor))") } } - Design.debugLog("[clockLayout] calcFont estimatedFontSize=\(String(format: "%.1f", estimated))") + //Design.debugLog("[clockLayout] calcFont estimatedFontSize=\(String(format: "%.1f", estimated))") if abs(estimated - fontSize) > 1 { fontSize = estimated - Design.debugLog("[clockLayout] calcFont updated fontSize \(String(format: "%.1f", previousFontSize)) -> \(String(format: "%.1f", fontSize))") + //Design.debugLog("[clockLayout] calcFont updated fontSize \(String(format: "%.1f", previousFontSize)) -> \(String(format: "%.1f", fontSize))") } else { - Design.debugLog("[clockLayout] calcFont skipped update (current=\(String(format: "%.1f", previousFontSize)))") + // Design.debugLog("[clockLayout] calcFont skipped update (current=\(String(format: "%.1f", previousFontSize)))") } lastCalculatedContainerSize = containerSize } diff --git a/TheNoiseClock/Shared/Utilities/AlarmNotifications.swift b/TheNoiseClock/Shared/Utilities/AlarmNotifications.swift new file mode 100644 index 0000000..be1b476 --- /dev/null +++ b/TheNoiseClock/Shared/Utilities/AlarmNotifications.swift @@ -0,0 +1,36 @@ +// +// AlarmNotifications.swift +// TheNoiseClock +// +// Created by Matt Bruce on 2/2/26. +// + +import Foundation + +enum AlarmNotificationConstants { + static let categoryIdentifier = "ALARM_CATEGORY" + static let snoozeActionIdentifier = "SNOOZE_ACTION" + static let stopActionIdentifier = "STOP_ACTION" +} + +enum AlarmNotificationKeys { + static let alarmId = "alarmId" + static let soundName = "soundName" + static let repeats = "repeats" + static let isSnooze = "isSnooze" + static let originalAlarmId = "originalAlarmId" + static let label = "label" + static let notificationMessage = "notificationMessage" + static let snoozeDuration = "snoozeDuration" + static let isVibrationEnabled = "isVibrationEnabled" + static let volume = "volume" + static let title = "title" + static let body = "body" +} + +extension Notification.Name { + static let alarmDidFire = Notification.Name("alarmDidFire") + static let alarmDidStop = Notification.Name("alarmDidStop") + static let alarmDidSnooze = Notification.Name("alarmDidSnooze") + static let keepAwakePromptRequested = Notification.Name("keepAwakePromptRequested") +} diff --git a/TheNoiseClock/Shared/Utilities/KeepAwakePrompt.swift b/TheNoiseClock/Shared/Utilities/KeepAwakePrompt.swift new file mode 100644 index 0000000..d0839b8 --- /dev/null +++ b/TheNoiseClock/Shared/Utilities/KeepAwakePrompt.swift @@ -0,0 +1,56 @@ +// +// KeepAwakePrompt.swift +// TheNoiseClock +// +// Created by Matt Bruce on 2/2/26. +// + +import SwiftUI +import Bedrock + +struct KeepAwakePrompt: View { + let onEnable: () -> Void + let onDismiss: () -> Void + + var body: some View { + VStack(spacing: Design.Spacing.large) { + Image(systemName: "bolt.fill") + .font(.system(size: 36, weight: .semibold)) + .foregroundStyle(AppAccent.primary) + + VStack(spacing: Design.Spacing.small) { + Text("Keep Awake for Alarms") + .typography(.title2) + .foregroundStyle(AppTextColors.primary) + + Text("Enable Keep Awake so your alarm can play loudly and show the full screen while TheNoiseClock stays open.") + .typography(.body) + .foregroundStyle(AppTextColors.secondary) + .multilineTextAlignment(.center) + } + + VStack(spacing: Design.Spacing.small) { + Button(action: onEnable) { + Text("Enable Keep Awake") + .frame(maxWidth: .infinity) + } + .buttonStyle(color: AppAccent.primary) + + Button(action: onDismiss) { + Text("Not Now") + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + .foregroundStyle(AppTextColors.secondary) + } + } + .padding(Design.Spacing.xLarge) + .frame(maxWidth: .infinity) + .background(AppSurface.primary) + .presentationDetents([.medium]) + } +} + +#Preview { + KeepAwakePrompt(onEnable: {}, onDismiss: {}) +} diff --git a/TheNoiseClock/Shared/Utilities/KeepAwakePromptState.swift b/TheNoiseClock/Shared/Utilities/KeepAwakePromptState.swift new file mode 100644 index 0000000..cd08710 --- /dev/null +++ b/TheNoiseClock/Shared/Utilities/KeepAwakePromptState.swift @@ -0,0 +1,30 @@ +// +// KeepAwakePromptState.swift +// TheNoiseClock +// +// Created by Matt Bruce on 2/2/26. +// + +import Foundation +import Observation + +@Observable +class KeepAwakePromptState { + + var isPresented = false + private var hasShownThisSession = false + + func showIfNeeded(isKeepAwakeEnabled: Bool) { + guard !isKeepAwakeEnabled, !hasShownThisSession else { return } + isPresented = true + hasShownThisSession = true + } + + func dismiss() { + isPresented = false + } + + func resetSessionFlag() { + hasShownThisSession = false + } +} diff --git a/TheNoiseClock/Shared/Utilities/NotificationUtils.swift b/TheNoiseClock/Shared/Utilities/NotificationUtils.swift index 01fddc8..0f1048d 100644 --- a/TheNoiseClock/Shared/Utilities/NotificationUtils.swift +++ b/TheNoiseClock/Shared/Utilities/NotificationUtils.swift @@ -36,6 +36,7 @@ enum NotificationUtils { let content = UNMutableNotificationContent() content.title = title content.body = body + content.categoryIdentifier = AlarmNotificationConstants.categoryIdentifier if soundName == "default" { content.sound = UNNotificationSound.default