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

This commit is contained in:
Matt Bruce 2026-02-02 09:34:18 -06:00
parent a4eaa187e5
commit e4202d5853
26 changed files with 735 additions and 81 deletions

View File

@ -1,6 +1,7 @@
Use /ios-18-role Use /ios-18-role
read the PRD.md read the PRD.md
read the README.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. Always update the PRD.md and README.md when there are code changes that might cause these files to require those changes documented.

View File

@ -41,6 +41,14 @@ public class SoundPlayer {
} }
public func playSound(_ sound: Sound) { 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)") print("🎵 Attempting to play: \(sound.name)")
// Stop current sound if playing // Stop current sound if playing
@ -63,7 +71,7 @@ public class SoundPlayer {
do { do {
let newPlayer = try AVAudioPlayer(contentsOf: fileUrl) let newPlayer = try AVAudioPlayer(contentsOf: fileUrl)
newPlayer.numberOfLoops = AudioConstants.Playback.numberOfLoops newPlayer.numberOfLoops = AudioConstants.Playback.numberOfLoops
newPlayer.volume = AudioConstants.Volume.default newPlayer.volume = volumeOverride ?? AudioConstants.Volume.default
newPlayer.prepareToPlay() newPlayer.prepareToPlay()
players[sound.fileName] = newPlayer players[sound.fileName] = newPlayer
currentPlayer = newPlayer currentPlayer = newPlayer
@ -77,6 +85,9 @@ public class SoundPlayer {
} }
currentPlayer = player currentPlayer = player
if let volumeOverride {
player.volume = volumeOverride
}
let success = player.play() let success = player.play()
print("🎵 Play result: \(success ? "SUCCESS" : "FAILED")") print("🎵 Play result: \(success ? "SUCCESS" : "FAILED")")
print("🔊 Player isPlaying: \(player.isPlaying)") print("🔊 Player isPlaying: \(player.isPlaying)")

19
PRD.md
View File

@ -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 - **Smart notifications**: Automatic scheduling for one-time and repeating alarms
- **Enable/disable toggles**: Individual alarm control with instant feedback - **Enable/disable toggles**: Individual alarm control with instant feedback
- **Notification integration**: Uses iOS UserNotifications framework with proper scheduling - **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 - **Persistent storage**: Alarms saved to UserDefaults with backward compatibility
- **Alarm management**: Add, edit, delete, and duplicate alarms - **Alarm management**: Add, edit, delete, and duplicate alarms
- **Next trigger preview**: Shows when the next alarm will fire - **Next trigger preview**: Shows when the next alarm will fire
- **Responsive time picker**: Font sizes adapt to available space and orientation - **Responsive time picker**: Font sizes adapt to available space and orientation
- **AlarmSoundService integration**: Dedicated service for alarm-specific sound management - **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 ## 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 - **Real-time updates**: Changes apply immediately with live preview
- **Sheet presentation**: Full-screen settings sheet for uninterrupted editing - **Sheet presentation**: Full-screen settings sheet for uninterrupted editing
- **Enum-based architecture**: Type-safe picker selections eliminate string-based errors - **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 ## File Structure and Organization
@ -401,7 +409,8 @@ TheNoiseClock/
│ │ │ └── SoundCategory.swift # Shared sound category definitions │ │ │ └── SoundCategory.swift # Shared sound category definitions
│ │ └── Utilities/ │ │ └── Utilities/
│ │ ├── ColorUtils.swift # Color manipulation utilities │ │ ├── ColorUtils.swift # Color manipulation utilities
│ │ └── NotificationUtils.swift # Notification helper functions │ │ ├── NotificationUtils.swift # Notification helper functions
│ │ └── AlarmNotifications.swift # Alarm notification constants and events
│ ├── Features/ │ ├── Features/
│ │ ├── Clock/ │ │ ├── Clock/
│ │ │ ├── Models/ │ │ │ ├── Models/
@ -453,6 +462,7 @@ TheNoiseClock/
│ │ │ ├── AlarmView.swift │ │ │ ├── AlarmView.swift
│ │ │ ├── AddAlarmView.swift │ │ │ ├── AddAlarmView.swift
│ │ │ ├── EditAlarmView.swift │ │ │ ├── EditAlarmView.swift
│ │ │ ├── AlarmScreen.swift
│ │ │ └── Components/ │ │ │ └── Components/
│ │ │ ├── AlarmRowView.swift │ │ │ ├── AlarmRowView.swift
│ │ │ ├── EmptyAlarmsView.swift │ │ │ ├── EmptyAlarmsView.swift
@ -562,9 +572,10 @@ The following changes **automatically require** PRD updates:
1. **Time format**: Toggle 24-hour, seconds, AM/PM display 1. **Time format**: Toggle 24-hour, seconds, AM/PM display
2. **Appearance**: Adjust colors, glow, size, opacity 2. **Appearance**: Adjust colors, glow, size, opacity
3. **Display**: Control keep awake functionality for display mode 3. **Display**: Control keep awake functionality for display mode
4. **Focus Modes**: Control how app behaves with Focus modes (Do Not Disturb) 4. **Keep Awake prompt**: Auto-prompt when needed (alarms tab, enabling alarms, display mode)
5. **Overlays**: Control battery and date display 5. **Focus Modes**: Control how app behaves with Focus modes (Do Not Disturb)
6. **Background**: Set background color and use presets 6. **Overlays**: Control battery and date display
7. **Background**: Set background color and use presets
### Alarms Tab ### Alarms Tab
1. **View alarms**: List of all created alarms with labels and repeat schedules 1. **View alarms**: List of all created alarms with labels and repeat schedules

View File

@ -41,6 +41,11 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and
- Alarm sound library with preview - Alarm sound library with preview
- Vibration and volume controls per alarm - Vibration and volume controls per alarm
- Focus-mode aware scheduling - 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** **Display Mode**
- Long-press to enter immersive 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 - Full-screen display mode and Dynamic Island awareness
- White noise playback with categories and previews - White noise playback with categories and previews
- Rich alarm editor with scheduling and snooze controls - Rich alarm editor with scheduling and snooze controls
- Full-screen in-app alarm screen with Snooze/Stop controls
- Bedrock-based theming and branded launch - Bedrock-based theming and branded launch
- iPhone and iPad support with adaptive layouts - iPhone and iPad support with adaptive layouts
- First-launch onboarding with feature highlights and permission setup - First-launch onboarding with feature highlights and permission setup

View File

@ -13,16 +13,27 @@ struct ContentView: View {
// MARK: - Properties // MARK: - Properties
private enum Tab: Hashable { private enum Tab: Hashable, CustomStringConvertible {
case clock case clock
case alarms case alarms
case noise case noise
case settings 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 selectedTab: Tab = .clock
@State private var clockViewModel = ClockViewModel() @State private var clockViewModel = ClockViewModel()
@State private var alarmViewModel = AlarmViewModel()
@State private var onboardingState = OnboardingState(appIdentifier: "TheNoiseClock") @State private var onboardingState = OnboardingState(appIdentifier: "TheNoiseClock")
@State private var keepAwakePromptState = KeepAwakePromptState()
// MARK: - Body // MARK: - Body
@ -39,7 +50,11 @@ struct ContentView: View {
.tag(Tab.clock) .tag(Tab.clock)
NavigationStack { NavigationStack {
AlarmView() AlarmView(viewModel: alarmViewModel)
.toolbar(.visible, for: .tabBar)
.onAppear {
Design.debugLog("[AlarmView] onAppear - forcing tabBar visible")
}
} }
.tabItem { .tabItem {
Label("Alarms", systemImage: "alarm") Label("Alarms", systemImage: "alarm")
@ -48,6 +63,10 @@ struct ContentView: View {
NavigationStack { NavigationStack {
NoiseView() NoiseView()
.toolbar(.visible, for: .tabBar)
.onAppear {
Design.debugLog("[NoiseView] onAppear - forcing tabBar visible")
}
} }
.tabItem { .tabItem {
Label("Noise", systemImage: "waveform") Label("Noise", systemImage: "waveform")
@ -64,6 +83,10 @@ struct ContentView: View {
onboardingState.reset() onboardingState.reset()
} }
) )
.toolbar(.visible, for: .tabBar)
.onAppear {
Design.debugLog("[ClockSettingsView] onAppear - forcing tabBar visible")
}
} }
.tabItem { .tabItem {
Label("Settings", systemImage: "gearshape") Label("Settings", systemImage: "gearshape")
@ -71,12 +94,26 @@ struct ContentView: View {
.tag(Tab.settings) .tag(Tab.settings)
} }
.onChange(of: selectedTab) { oldValue, newValue in .onChange(of: selectedTab) { oldValue, newValue in
Design.debugLog("[ContentView] Tab changed: \(oldValue) -> \(newValue)")
if oldValue == .clock && newValue != .clock { if oldValue == .clock && newValue != .clock {
Design.debugLog("[ContentView] Leaving clock tab, setting displayMode to false")
clockViewModel.setDisplayMode(false) clockViewModel.setDisplayMode(false)
} }
} }
.accentColor(AppAccent.primary) .accentColor(AppAccent.primary)
.background(Color.Branding.primary.ignoresSafeArea()) .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 // Onboarding overlay for first-time users
if !onboardingState.hasCompletedWelcome { if !onboardingState.hasCompletedWelcome {
@ -86,8 +123,38 @@ struct ContentView: View {
.transition(.opacity) .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) .animation(.easeInOut(duration: 0.3), value: onboardingState.hasCompletedWelcome)
} }
private var activeAlarmBinding: Binding<Alarm?> {
Binding(
get: { alarmViewModel.activeAlarm },
set: { alarmViewModel.activeAlarm = $0 }
)
}
} }
// MARK: - Preview // MARK: - Preview

View File

@ -90,7 +90,10 @@ class AlarmService {
date: alarm.time, date: alarm.time,
soundName: alarm.soundName, soundName: alarm.soundName,
repeats: false, // For now, set to false since Alarm model doesn't have repeatDays 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
) )
} }
} }

View File

@ -114,4 +114,9 @@ class AlarmSoundService {
} }
return fileName.replacingOccurrences(of: ".caf", with: "").capitalized return fileName.replacingOccurrences(of: ".caf", with: "").capitalized
} }
/// Get alarm sound by filename
func getAlarmSound(fileName: String) -> Sound? {
return getAlarmSounds().first { $0.fileName == fileName }
}
} }

View File

@ -65,15 +65,15 @@ class FocusModeService {
private func configureNotificationSettings() async { private func configureNotificationSettings() async {
// Create notification categories that work with Focus modes // Create notification categories that work with Focus modes
let alarmCategory = UNNotificationCategory( let alarmCategory = UNNotificationCategory(
identifier: "ALARM_CATEGORY", identifier: AlarmNotificationConstants.categoryIdentifier,
actions: [ actions: [
UNNotificationAction( UNNotificationAction(
identifier: "SNOOZE_ACTION", identifier: AlarmNotificationConstants.snoozeActionIdentifier,
title: "Snooze", title: "Snooze",
options: [] options: []
), ),
UNNotificationAction( UNNotificationAction(
identifier: "STOP_ACTION", identifier: AlarmNotificationConstants.stopActionIdentifier,
title: "Stop", title: "Stop",
options: [.destructive] options: [.destructive]
) )
@ -96,7 +96,10 @@ class FocusModeService {
date: Date, date: Date,
soundName: String, soundName: String,
repeats: Bool = false, repeats: Bool = false,
respectFocusModes: Bool = true respectFocusModes: Bool = true,
snoozeDuration: Int? = nil,
isVibrationEnabled: Bool? = nil,
volume: Float? = nil
) { ) {
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = title content.title = title
@ -110,16 +113,28 @@ class FocusModeService {
Design.debugLog("[settings] Using custom alarm sound: \(soundName)") Design.debugLog("[settings] Using custom alarm sound: \(soundName)")
Design.debugLog("[settings] Sound file should be in main bundle: \(soundName)") Design.debugLog("[settings] Sound file should be in main bundle: \(soundName)")
} }
content.categoryIdentifier = "ALARM_CATEGORY" content.categoryIdentifier = AlarmNotificationConstants.categoryIdentifier
if !respectFocusModes, timeSensitiveSetting == .enabled { if !respectFocusModes, timeSensitiveSetting == .enabled {
content.interruptionLevel = .timeSensitive content.interruptionLevel = .timeSensitive
} }
content.userInfo = [ var userInfo: [AnyHashable: Any] = [
"alarmId": identifier, AlarmNotificationKeys.alarmId: identifier,
"soundName": soundName, AlarmNotificationKeys.soundName: soundName,
"repeats": repeats 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 // Create trigger
let trigger: UNNotificationTrigger let trigger: UNNotificationTrigger

View File

@ -50,13 +50,16 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
Design.debugLog("[settings] Notification action received: \(actionIdentifier)") Design.debugLog("[settings] Notification action received: \(actionIdentifier)")
switch actionIdentifier { switch actionIdentifier {
case "SNOOZE_ACTION": case AlarmNotificationConstants.snoozeActionIdentifier:
handleSnoozeAction(userInfo: userInfo) handleSnoozeAction(userInfo: userInfo)
case "STOP_ACTION": postAlarmAction(name: .alarmDidSnooze, notification: notification)
case AlarmNotificationConstants.stopActionIdentifier:
handleStopAction(userInfo: userInfo) handleStopAction(userInfo: userInfo)
postAlarmAction(name: .alarmDidStop, notification: notification)
case UNNotificationDefaultActionIdentifier: case UNNotificationDefaultActionIdentifier:
// User tapped the notification itself // User tapped the notification itself
handleNotificationTap(userInfo: userInfo) handleNotificationTap(userInfo: userInfo)
postAlarmDidFire(notification: notification)
default: default:
Design.debugLog("[settings] Unknown action: \(actionIdentifier)") Design.debugLog("[settings] Unknown action: \(actionIdentifier)")
} }
@ -70,14 +73,20 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
willPresent notification: UNNotification, willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void 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]) completionHandler([.banner, .sound, .badge])
} }
// MARK: - Action Handlers // MARK: - Action Handlers
private func handleSnoozeAction(userInfo: [AnyHashable: Any]) { 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 alarmId = UUID(uuidString: alarmIdString),
let alarmService = self.alarmService, let alarmService = self.alarmService,
let alarm = alarmService.getAlarm(id: alarmId) else { let alarm = alarmService.getAlarm(id: alarmId) else {
@ -113,7 +122,7 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
} }
private func handleStopAction(userInfo: [AnyHashable: Any]) { 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 { let alarmId = UUID(uuidString: alarmIdString) else {
Design.debugLog("[general] Could not find alarm ID for stop action") Design.debugLog("[general] Could not find alarm ID for stop action")
return return
@ -129,7 +138,7 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
} }
private func handleNotificationTap(userInfo: [AnyHashable: Any]) { 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 { let alarmId = UUID(uuidString: alarmIdString) else {
Design.debugLog("[general] Could not find alarm ID for notification tap") Design.debugLog("[general] Could not find alarm ID for notification tap")
return return
@ -147,13 +156,22 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = snoozeAlarm.label content.title = snoozeAlarm.label
content.body = snoozeAlarm.notificationMessage content.body = snoozeAlarm.notificationMessage
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: snoozeAlarm.soundName)) if snoozeAlarm.soundName == "default" {
content.categoryIdentifier = "ALARM_CATEGORY" content.sound = .default
} else {
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: snoozeAlarm.soundName))
}
content.categoryIdentifier = AlarmNotificationConstants.categoryIdentifier
content.userInfo = [ content.userInfo = [
"alarmId": snoozeAlarm.id.uuidString, AlarmNotificationKeys.alarmId: snoozeAlarm.id.uuidString,
"soundName": snoozeAlarm.soundName, AlarmNotificationKeys.soundName: snoozeAlarm.soundName,
"isSnooze": true, AlarmNotificationKeys.isSnooze: true,
"originalAlarmId": userInfo["alarmId"] as? String ?? "" 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 // Create trigger for snooze time
@ -177,4 +195,20 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
Design.debugLog("[general] Error scheduling snooze notification: \(error)") 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)
}
} }

View File

@ -64,6 +64,12 @@ class NotificationService {
body: body, body: body,
soundName: soundName soundName: soundName
) )
content.userInfo = [
AlarmNotificationKeys.alarmId: id,
AlarmNotificationKeys.soundName: soundName,
AlarmNotificationKeys.label: title,
AlarmNotificationKeys.notificationMessage: body
]
let trigger = NotificationUtils.createCalendarTrigger(for: date) let trigger = NotificationUtils.createCalendarTrigger(for: date)
return await NotificationUtils.scheduleNotification( return await NotificationUtils.scheduleNotification(

View File

@ -7,6 +7,8 @@
import Foundation import Foundation
import Observation import Observation
import UserNotifications
import AudioPlaybackKit
/// ViewModel for alarm management /// ViewModel for alarm management
@Observable @Observable
@ -15,6 +17,10 @@ class AlarmViewModel {
// MARK: - Properties // MARK: - Properties
private let alarmService: AlarmService private let alarmService: AlarmService
private let notificationService: NotificationService private let notificationService: NotificationService
private let alarmSoundService = AlarmSoundService.shared
private let soundPlayer = SoundPlayer.shared
var activeAlarm: Alarm?
var alarms: [Alarm] { var alarms: [Alarm] {
alarmService.alarms alarmService.alarms
@ -47,6 +53,7 @@ class AlarmViewModel {
soundName: alarm.soundName, soundName: alarm.soundName,
date: alarm.time date: alarm.time
) )
requestKeepAwakePromptIfNeeded()
} }
} }
@ -62,6 +69,7 @@ class AlarmViewModel {
soundName: alarm.soundName, soundName: alarm.soundName,
date: alarm.time date: alarm.time
) )
requestKeepAwakePromptIfNeeded()
} else { } else {
notificationService.cancelNotification(id: alarm.id.uuidString) notificationService.cancelNotification(id: alarm.id.uuidString)
} }
@ -90,6 +98,7 @@ class AlarmViewModel {
soundName: alarm.soundName, soundName: alarm.soundName,
date: alarm.time date: alarm.time
) )
requestKeepAwakePromptIfNeeded()
} else { } else {
notificationService.cancelNotification(id: id.uuidString) notificationService.cancelNotification(id: id.uuidString)
} }
@ -126,4 +135,157 @@ class AlarmViewModel {
func requestNotificationPermissions() async -> Bool { func requestNotificationPermissions() async -> Bool {
return await notificationService.requestPermissions() 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
}
} }

View File

@ -7,6 +7,7 @@
import SwiftUI import SwiftUI
import AudioPlaybackKit import AudioPlaybackKit
import Foundation
/// View for creating new alarms with iOS-native style interface /// View for creating new alarms with iOS-native style interface
struct AddAlarmView: View { struct AddAlarmView: View {
@ -14,6 +15,7 @@ struct AddAlarmView: View {
// MARK: - Properties // MARK: - Properties
let viewModel: AlarmViewModel let viewModel: AlarmViewModel
@Binding var isPresented: Bool @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 newAlarmTime = Calendar.current.date(bySettingHour: 6, minute: 0, second: 0, of: Date()) ?? Date()
@State private var selectedSoundName = "digital-alarm.caf" @State private var selectedSoundName = "digital-alarm.caf"
@ -33,6 +35,15 @@ struct AddAlarmView: View {
// List for settings below // List for settings below
List { List {
if !isKeepAwakeEnabled {
Section {
AlarmLimitationsBanner()
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
// Label Section // Label Section
NavigationLink(destination: LabelEditView(label: $alarmLabel)) { NavigationLink(destination: LabelEditView(label: $alarmLabel)) {
HStack { HStack {
@ -127,4 +138,11 @@ struct AddAlarmView: View {
private func getSoundDisplayName(_ fileName: String) -> String { private func getSoundDisplayName(_ fileName: String) -> String {
return AlarmSoundService.shared.getSoundDisplayName(fileName) 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
}
} }

View File

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

View File

@ -7,31 +7,48 @@
import SwiftUI import SwiftUI
import Bedrock import Bedrock
import Foundation
/// Main alarm management view /// Main alarm management view
struct AlarmView: View { struct AlarmView: View {
// MARK: - Properties // MARK: - Properties
@State private var viewModel = AlarmViewModel() @Bindable var viewModel: AlarmViewModel
@State private var showAddAlarm = false @State private var showAddAlarm = false
@State private var selectedAlarmForEdit: Alarm? @State private var selectedAlarmForEdit: Alarm?
@AppStorage(ClockStyle.appStorageKey) private var clockStyleData: Data = Data()
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
let isPad = UIDevice.current.userInterfaceIdiom == .pad let isPad = UIDevice.current.userInterfaceIdiom == .pad
Group { Group {
if viewModel.alarms.isEmpty { if viewModel.alarms.isEmpty {
EmptyAlarmsView { VStack(spacing: Design.Spacing.large) {
showAddAlarm = true if !isKeepAwakeEnabled {
} AlarmLimitationsBanner()
.contentShape(Rectangle()) }
.onTapGesture {
showAddAlarm = true EmptyAlarmsView {
showAddAlarm = true
}
.contentShape(Rectangle())
.onTapGesture {
showAddAlarm = true
}
} }
.frame(maxWidth: Design.Size.maxContentWidthPortrait) .frame(maxWidth: Design.Size.maxContentWidthPortrait)
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
} else { } else {
List { List {
if !isKeepAwakeEnabled {
Section {
AlarmLimitationsBanner()
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
ForEach(viewModel.alarms) { alarm in ForEach(viewModel.alarms) { alarm in
AlarmRowView( AlarmRowView(
alarm: alarm, alarm: alarm,
@ -47,6 +64,7 @@ struct AlarmView: View {
} }
.onDelete(perform: deleteAlarm) .onDelete(perform: deleteAlarm)
} }
.listStyle(.insetGrouped)
.frame(maxWidth: Design.Size.maxContentWidthPortrait) .frame(maxWidth: Design.Size.maxContentWidthPortrait)
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
} }
@ -67,6 +85,7 @@ struct AlarmView: View {
Task { Task {
await viewModel.requestNotificationPermissions() await viewModel.requestNotificationPermissions()
} }
viewModel.requestKeepAwakePromptIfNeeded()
} }
.sheet(isPresented: $showAddAlarm) { .sheet(isPresented: $showAddAlarm) {
AddAlarmView( 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 // MARK: - Preview
#Preview { #Preview {
NavigationStack { NavigationStack {
AlarmView() AlarmView(viewModel: AlarmViewModel())
} }
} }

View File

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

View File

@ -7,6 +7,7 @@
import SwiftUI import SwiftUI
import AudioPlaybackKit import AudioPlaybackKit
import Foundation
/// View for editing existing alarms /// View for editing existing alarms
struct EditAlarmView: View { struct EditAlarmView: View {
@ -15,6 +16,7 @@ struct EditAlarmView: View {
let viewModel: AlarmViewModel let viewModel: AlarmViewModel
let alarm: Alarm let alarm: Alarm
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@AppStorage(ClockStyle.appStorageKey) private var clockStyleData: Data = Data()
@State private var alarmTime: Date @State private var alarmTime: Date
@State private var selectedSoundName: String @State private var selectedSoundName: String
@ -51,6 +53,15 @@ struct EditAlarmView: View {
// List for settings below // List for settings below
List { List {
if !isKeepAwakeEnabled {
Section {
AlarmLimitationsBanner()
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
// Label Section // Label Section
NavigationLink(destination: LabelEditView(label: $alarmLabel)) { NavigationLink(destination: LabelEditView(label: $alarmLabel)) {
HStack { HStack {
@ -147,6 +158,13 @@ struct EditAlarmView: View {
private func getSoundDisplayName(_ fileName: String) -> String { private func getSoundDisplayName(_ fileName: String) -> String {
return AlarmSoundService.shared.getSoundDisplayName(fileName) 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 // MARK: - Preview

View File

@ -61,20 +61,32 @@ class ClockViewModel {
// MARK: - Public Interface // MARK: - Public Interface
func toggleDisplayMode() { func toggleDisplayMode() {
let oldValue = isDisplayMode
withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) { withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) {
isDisplayMode.toggle() isDisplayMode.toggle()
} }
Design.debugLog("[ClockViewModel] toggleDisplayMode: \(oldValue) -> \(isDisplayMode)")
// Manage wake lock based on display mode and keep awake setting // Manage wake lock based on display mode and keep awake setting
updateWakeLockState() updateWakeLockState()
if isDisplayMode {
requestKeepAwakePromptIfNeeded()
}
} }
func setDisplayMode(_ enabled: Bool) { 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)) { withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) {
isDisplayMode = enabled isDisplayMode = enabled
} }
updateWakeLockState() updateWakeLockState()
if enabled {
requestKeepAwakePromptIfNeeded()
}
} }
func updateStyle(_ newStyle: ClockStyle) { func updateStyle(_ newStyle: ClockStyle) {
@ -116,6 +128,12 @@ class ClockViewModel {
updateBrightness() // Update brightness when style changes updateBrightness() // Update brightness when style changes
} }
func setKeepAwakeEnabled(_ enabled: Bool) {
style.keepAwake = enabled
saveStyle()
updateWakeLockState()
}
// MARK: - Private Methods // MARK: - Private Methods
private func loadStyle() { private func loadStyle() {
if let decoded = try? JSONDecoder().decode(ClockStyle.self, from: styleJSON) { 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 /// Update wake lock state based on current settings
private func updateWakeLockState() { private func updateWakeLockState() {
// Enable wake lock if in display mode and keep awake is enabled // Enable wake lock if in display mode and keep awake is enabled

View File

@ -18,7 +18,6 @@ struct ClockSettingsView: View {
@State private var digitColor: Color = .white @State private var digitColor: Color = .white
@State private var backgroundColor: Color = .black @State private var backgroundColor: Color = .black
@State private var showAdvancedSettings = false
// MARK: - Init // MARK: - Init
init( init(
@ -42,34 +41,17 @@ struct ClockSettingsView: View {
backgroundColor: $backgroundColor backgroundColor: $backgroundColor
) )
FontSection(style: $style)
AdvancedAppearanceSection(style: $style)
BasicDisplaySection(style: $style) BasicDisplaySection(style: $style)
if showAdvancedSettings { AdvancedDisplaySection(style: $style)
AdvancedAppearanceSection(style: $style)
FontSection(style: $style) NightModeSection(style: $style)
NightModeSection(style: $style) OverlaySection(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
)
}
#if DEBUG #if DEBUG
SettingsSectionHeader( SettingsSectionHeader(

View File

@ -18,6 +18,7 @@ struct ClockView: View {
@Bindable var viewModel: ClockViewModel @Bindable var viewModel: ClockViewModel
@State private var idleTimer: Timer? @State private var idleTimer: Timer?
@State private var didHandleTouch = false @State private var didHandleTouch = false
@State private var isViewActive = false
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
@ -65,15 +66,15 @@ struct ClockView: View {
) )
} }
} }
.onAppear { // .onAppear {
logClockLayout(size: geometry.size, safeAreaInsets: safeInsets) // logClockLayout(size: geometry.size, safeAreaInsets: safeInsets)
} // }
.onChange(of: geometry.size) { _, newSize in // .onChange(of: geometry.size) { _, newSize in
logClockLayout(size: newSize, safeAreaInsets: safeInsets) // logClockLayout(size: newSize, safeAreaInsets: safeInsets)
} // }
.onChange(of: safeInsets) { _, newInsets in // .onChange(of: safeInsets) { _, newInsets in
logClockLayout(size: geometry.size, safeAreaInsets: newInsets) // logClockLayout(size: geometry.size, safeAreaInsets: newInsets)
} // }
} }
.ignoresSafeArea() // Extend GeometryReader to full screen, we handle safe areas manually .ignoresSafeArea() // Extend GeometryReader to full screen, we handle safe areas manually
.toolbar(.hidden, for: .navigationBar) .toolbar(.hidden, for: .navigationBar)
@ -94,9 +95,13 @@ struct ClockView: View {
} }
) )
.onAppear { .onAppear {
Design.debugLog("[ClockView] onAppear - setting isViewActive = true")
isViewActive = true
resetIdleTimer() resetIdleTimer()
} }
.onDisappear { .onDisappear {
Design.debugLog("[ClockView] onDisappear - setting isViewActive = false, invalidating timer")
isViewActive = false
idleTimer?.invalidate() idleTimer?.invalidate()
idleTimer = nil idleTimer = nil
} }
@ -121,7 +126,16 @@ struct ClockView: View {
} }
private func enterDisplayModeFromIdle() { 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() viewModel.toggleDisplayMode()
} }

View File

@ -6,6 +6,7 @@
// //
import SwiftUI import SwiftUI
import Bedrock
/// Component that manages tab bar visibility for display mode /// Component that manages tab bar visibility for display mode
/// Uses SwiftUI's native toolbar hiding for proper iPad compatibility /// Uses SwiftUI's native toolbar hiding for proper iPad compatibility
@ -18,6 +19,12 @@ struct ClockTabBarManager: View {
var body: some View { var body: some View {
EmptyView() EmptyView()
.toolbar(isDisplayMode ? .hidden : .automatic, for: .tabBar) .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")")
}
} }
} }

View File

@ -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) .font(.caption)
.foregroundStyle(AppTextColors.tertiary) .foregroundStyle(AppTextColors.tertiary)

View File

@ -263,9 +263,9 @@ struct TimeDisplayView: View {
height: max(1, availableHeight / digitRows) 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 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 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 digitSize=\(String(format: "%.1f", digitSize.width))x\(String(format: "%.1f", digitSize.height)) colonSize=\(String(format: "%.1f", colonSize))")
return FontUtils.calculateOptimalFontSize( return FontUtils.calculateOptimalFontSize(
digit: "8", digit: "8",
@ -308,17 +308,17 @@ struct TimeDisplayView: View {
if totalWidth > containerSize.width { if totalWidth > containerSize.width {
let scaleFactor = containerSize.width / totalWidth let scaleFactor = containerSize.width / totalWidth
estimated *= scaleFactor * 0.98 // Add 2% margin 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 { if abs(estimated - fontSize) > 1 {
fontSize = estimated 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 { } 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 lastCalculatedContainerSize = containerSize
} }

View File

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

View File

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

View File

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

View File

@ -36,6 +36,7 @@ enum NotificationUtils {
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = title content.title = title
content.body = body content.body = body
content.categoryIdentifier = AlarmNotificationConstants.categoryIdentifier
if soundName == "default" { if soundName == "default" {
content.sound = UNNotificationSound.default content.sound = UNNotificationSound.default