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
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.

View File

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

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
- **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

View File

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

View File

@ -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<Alarm?> {
Binding(
get: { alarmViewModel.activeAlarm },
set: { alarmViewModel.activeAlarm = $0 }
)
}
}
// MARK: - Preview

View File

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

View File

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

View File

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

View File

@ -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
if snoozeAlarm.soundName == "default" {
content.sound = .default
} else {
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: snoozeAlarm.soundName))
content.categoryIdentifier = "ALARM_CATEGORY"
}
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)
}
}

View File

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

View File

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

View File

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

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,20 +7,27 @@
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 {
VStack(spacing: Design.Spacing.large) {
if !isKeepAwakeEnabled {
AlarmLimitationsBanner()
}
EmptyAlarmsView {
showAddAlarm = true
}
@ -28,10 +35,20 @@ struct AlarmView: View {
.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())
}
}

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

View File

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

View File

@ -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,35 +41,18 @@ struct ClockSettingsView: View {
backgroundColor: $backgroundColor
)
BasicDisplaySection(style: $style)
FontSection(style: $style)
if showAdvancedSettings {
AdvancedAppearanceSection(style: $style)
FontSection(style: $style)
BasicDisplaySection(style: $style)
AdvancedDisplaySection(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
)
}
#if DEBUG
SettingsSectionHeader(
title: "Debug",

View File

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

View File

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

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

View File

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

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()
content.title = title
content.body = body
content.categoryIdentifier = AlarmNotificationConstants.categoryIdentifier
if soundName == "default" {
content.sound = UNNotificationSound.default