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

This commit is contained in:
Matt Bruce 2026-02-07 15:32:27 -06:00
parent 2f59f2aaf8
commit 3ed7da63f7
14 changed files with 87 additions and 320 deletions

View File

@ -18,6 +18,7 @@ public class SoundPlayer {
// MARK: - Properties // MARK: - Properties
private var players: [String: AVAudioPlayer] = [:] private var players: [String: AVAudioPlayer] = [:]
private let playersLock = NSLock()
private var currentPlayer: AVAudioPlayer? private var currentPlayer: AVAudioPlayer?
public private(set) var currentSound: Sound? public private(set) var currentSound: Sound?
private var shouldResumeAfterInterruption = false private var shouldResumeAfterInterruption = false
@ -27,8 +28,11 @@ public class SoundPlayer {
// MARK: - Initialization // MARK: - Initialization
private init() { private init() {
setupAudioSession() setupAudioSession()
preloadSounds()
setupAudioInterruptionHandling() setupAudioInterruptionHandling()
// Preload sounds off the main thread to avoid blocking UI during app launch
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.preloadSounds()
}
} }
deinit { deinit {
@ -58,9 +62,16 @@ public class SoundPlayer {
currentSound = sound currentSound = sound
// Get or create player for this sound // Get or create player for this sound
guard let player = players[sound.fileName] else { playersLock.lock()
let player = players[sound.fileName]
playersLock.unlock()
guard let player else {
playersLock.lock()
let availableKeys = Array(players.keys)
playersLock.unlock()
print("❌ Sound not preloaded: \(sound.fileName)") print("❌ Sound not preloaded: \(sound.fileName)")
print("📁 Available sounds: \(players.keys)") print("📁 Available sounds: \(availableKeys)")
// Try to load the sound dynamically as fallback // Try to load the sound dynamically as fallback
guard let fileUrl = getURL(for: sound) else { guard let fileUrl = getURL(for: sound) else {
@ -73,7 +84,9 @@ public class SoundPlayer {
newPlayer.numberOfLoops = AudioConstants.Playback.numberOfLoops newPlayer.numberOfLoops = AudioConstants.Playback.numberOfLoops
newPlayer.volume = volumeOverride ?? AudioConstants.Volume.default newPlayer.volume = volumeOverride ?? AudioConstants.Volume.default
newPlayer.prepareToPlay() newPlayer.prepareToPlay()
playersLock.lock()
players[sound.fileName] = newPlayer players[sound.fileName] = newPlayer
playersLock.unlock()
currentPlayer = newPlayer currentPlayer = newPlayer
let success = newPlayer.play() let success = newPlayer.play()
print("🎵 Fallback play result: \(success ? "SUCCESS" : "FAILED")") print("🎵 Fallback play result: \(success ? "SUCCESS" : "FAILED")")
@ -187,20 +200,27 @@ public class SoundPlayer {
if settings.preloadSounds { if settings.preloadSounds {
player.prepareToPlay() player.prepareToPlay()
} }
playersLock.lock()
players[sound.fileName] = player players[sound.fileName] = player
playersLock.unlock()
print("✅ Loaded: \(sound.name) (\(sound.fileName))") print("✅ Loaded: \(sound.name) (\(sound.fileName))")
} catch { } catch {
print("❌ Error preloading sound \(sound.fileName): \(error)") print("❌ Error preloading sound \(sound.fileName): \(error)")
} }
} }
print("📁 Preloading complete. Loaded \(players.count) sounds.") playersLock.lock()
let count = players.count
playersLock.unlock()
print("📁 Preloading complete. Loaded \(count) sounds.")
} }
private func stopAllSounds() { private func stopAllSounds() {
playersLock.lock()
for player in players.values { for player in players.values {
player.stop() player.stop()
} }
players.removeAll() players.removeAll()
playersLock.unlock()
currentPlayer = nil currentPlayer = nil
currentSound = nil currentSound = nil
shouldResumeAfterInterruption = false shouldResumeAfterInterruption = false

View File

@ -51,7 +51,7 @@
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
EA384AFB2E6E6B6000CA7D50 /* TheNoiseClock.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TheNoiseClock.app; sourceTree = BUILT_PRODUCTS_DIR; }; EA384AFB2E6E6B6000CA7D50 /* The Noise Clock.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "The Noise Clock.app"; sourceTree = BUILT_PRODUCTS_DIR; };
EA384B082E6E6B6100CA7D50 /* TheNoiseClockTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TheNoiseClockTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; EA384B082E6E6B6100CA7D50 /* TheNoiseClockTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TheNoiseClockTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
EA384B122E6E6B6100CA7D50 /* TheNoiseClockUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TheNoiseClockUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; EA384B122E6E6B6100CA7D50 /* TheNoiseClockUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TheNoiseClockUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
EAD6E3AF5A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = TheNoiseClock/Configuration/Debug.xcconfig; sourceTree = SOURCE_ROOT; }; EAD6E3AF5A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = TheNoiseClock/Configuration/Debug.xcconfig; sourceTree = SOURCE_ROOT; };
@ -154,7 +154,7 @@
EA384AFC2E6E6B6000CA7D50 /* Products */ = { EA384AFC2E6E6B6000CA7D50 /* Products */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
EA384AFB2E6E6B6000CA7D50 /* TheNoiseClock.app */, EA384AFB2E6E6B6000CA7D50 /* The Noise Clock.app */,
EA384B082E6E6B6100CA7D50 /* TheNoiseClockTests.xctest */, EA384B082E6E6B6100CA7D50 /* TheNoiseClockTests.xctest */,
EA384B122E6E6B6100CA7D50 /* TheNoiseClockUITests.xctest */, EA384B122E6E6B6100CA7D50 /* TheNoiseClockUITests.xctest */,
EAF1C0DE2F3A4B5C00112234 /* TheNoiseClockWidget.appex */, EAF1C0DE2F3A4B5C00112234 /* TheNoiseClockWidget.appex */,
@ -197,7 +197,7 @@
EAC051B02F2E64AB007F87EA /* Bedrock */, EAC051B02F2E64AB007F87EA /* Bedrock */,
); );
productName = TheNoiseClock; productName = TheNoiseClock;
productReference = EA384AFB2E6E6B6000CA7D50 /* TheNoiseClock.app */; productReference = EA384AFB2E6E6B6000CA7D50 /* The Noise Clock.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
EA384B072E6E6B6100CA7D50 /* TheNoiseClockTests */ = { EA384B072E6E6B6100CA7D50 /* TheNoiseClockTests */ = {

View File

@ -45,14 +45,31 @@ struct ContentView: View {
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
ZStack { Group {
// Main tab content if !onboardingState.hasCompletedWelcome {
// Show ONLY the onboarding no heavy app views behind it.
// This prevents ClockView, NoiseView, etc. from initializing
// and competing for the main thread during page transitions.
OnboardingView {
onboardingState.completeWelcome()
}
.transition(.asymmetric(
insertion: .opacity,
removal: .opacity.combined(with: .move(edge: .bottom)).combined(with: .scale(scale: 0.9))
))
} else {
mainTabView
}
}
.animation(.spring(duration: 0.45, bounce: 0.2), value: onboardingState.hasCompletedWelcome)
}
// MARK: - Main Tab View
private var mainTabView: some View {
TabView(selection: $selectedTab) { TabView(selection: $selectedTab) {
Tab("Clock", systemImage: "clock", value: AppTab.clock) { Tab("Clock", systemImage: "clock", value: AppTab.clock) {
NavigationStack { NavigationStack {
// Pass isOnClockTab so ClockView can make the right tab bar decision
// Tab bar hides ONLY when: isOnClockTab && isDisplayMode
// This prevents race conditions on tab switch
ClockView(viewModel: clockViewModel, isOnClockTab: isOnClockTab) ClockView(viewModel: clockViewModel, isOnClockTab: isOnClockTab)
} }
} }
@ -87,28 +104,11 @@ struct ContentView: View {
Design.debugLog("[ContentView] Tab changed: \(oldValue) -> \(newValue)") Design.debugLog("[ContentView] Tab changed: \(oldValue) -> \(newValue)")
if oldValue == .clock && newValue != .clock { if oldValue == .clock && newValue != .clock {
Design.debugLog("[ContentView] Leaving clock tab, setting fullScreenMode to false") Design.debugLog("[ContentView] Leaving clock tab, setting fullScreenMode to false")
// Safety net: also explicitly disable full-screen mode when leaving clock tab
// The ClockView's toolbar modifier already responds to isOnClockTab changing
clockViewModel.setFullScreenMode(false) clockViewModel.setFullScreenMode(false)
} }
} }
.tint(AppAccent.primary) .tint(AppAccent.primary)
.background(Color.Branding.primary.ignoresSafeArea()) .background(Color.Branding.primary.ignoresSafeArea())
// Note: AlarmKit handles the alarm UI via the system Lock Screen and Dynamic Island.
// No in-app alarm screen is needed - users interact with alarms via the system UI.
// Onboarding overlay for first-time users
if !onboardingState.hasCompletedWelcome {
OnboardingView {
onboardingState.completeWelcome()
}
.transition(.asymmetric(
insertion: .opacity,
removal: .opacity.combined(with: .move(edge: .bottom)).combined(with: .scale(scale: 0.9))
))
.zIndex(1) // Ensure it stays on top during transition
}
}
.sheet(isPresented: $keepAwakePromptState.isPresented) { .sheet(isPresented: $keepAwakePromptState.isPresented) {
KeepAwakePrompt( KeepAwakePrompt(
onEnable: { onEnable: {
@ -122,10 +122,7 @@ struct ContentView: View {
} }
.task { .task {
Design.debugLog("[ContentView] App launched - initializing AlarmKit") Design.debugLog("[ContentView] App launched - initializing AlarmKit")
// Reschedule all enabled alarms with AlarmKit on app launch
await alarmViewModel.rescheduleAllAlarms() await alarmViewModel.rescheduleAllAlarms()
Design.debugLog("[ContentView] AlarmKit initialization complete") Design.debugLog("[ContentView] AlarmKit initialization complete")
} }
.onReceive(NotificationCenter.default.publisher(for: .keepAwakePromptRequested)) { _ in .onReceive(NotificationCenter.default.publisher(for: .keepAwakePromptRequested)) { _ in
@ -133,7 +130,6 @@ struct ContentView: View {
guard shouldShowKeepAwakePromptForTab() else { return } guard shouldShowKeepAwakePromptForTab() else { return }
keepAwakePromptState.showIfNeeded(isKeepAwakeEnabled: clockViewModel.style.keepAwake) keepAwakePromptState.showIfNeeded(isKeepAwakeEnabled: clockViewModel.style.keepAwake)
} }
.animation(.spring(duration: 0.45, bounce: 0.2), value: onboardingState.hasCompletedWelcome)
} }
private func shouldShowKeepAwakePromptForTab() -> Bool { private func shouldShowKeepAwakePromptForTab() -> Bool {

View File

@ -262,19 +262,6 @@ final class AlarmKitService {
} }
} }
/// Log available sound files in Library/Sounds for debugging
private func logLibrarySounds() {
let soundsDirectory = URL.libraryDirectory.appendingPathComponent("Sounds")
Design.debugLog("[alarmkit] ========== LIBRARY/SOUNDS FILES ==========")
do {
let files = try FileManager.default.contentsOfDirectory(atPath: soundsDirectory.path)
Design.debugLog("[alarmkit] Files in Library/Sounds: \(files)")
} catch {
Design.debugLog("[alarmkit] Library/Sounds directory doesn't exist or is empty")
}
}
/// Log available alarm sounds in the bundle for debugging /// Log available alarm sounds in the bundle for debugging
private func logAvailableAlarmSounds() { private func logAvailableAlarmSounds() {
Design.debugLog("[alarmkit] ========== AVAILABLE ALARM SOUNDS ==========") Design.debugLog("[alarmkit] ========== AVAILABLE ALARM SOUNDS ==========")

View File

@ -30,7 +30,7 @@ final class ClockStyle: Codable, Equatable {
// MARK: - Night Mode Settings // MARK: - Night Mode Settings
var nightModeEnabled: Bool = false var nightModeEnabled: Bool = false
var autoNightMode: Bool = false var autoNightMode: Bool = true
var scheduledNightMode: Bool = false var scheduledNightMode: Bool = false
var nightModeStartTime: String = "22:00" // 10:00 PM var nightModeStartTime: String = "22:00" // 10:00 PM
var nightModeEndTime: String = "06:00" // 6:00 AM var nightModeEndTime: String = "06:00" // 6:00 AM
@ -48,14 +48,13 @@ final class ClockStyle: Codable, Equatable {
var showDate: Bool = true var showDate: Bool = true
var showNextAlarm: Bool = true var showNextAlarm: Bool = true
var showNoiseControls: Bool = true var showNoiseControls: Bool = true
var dateFormat: String = "d MMMM EEE" // Default: "7 September Mon" var dateFormat: String = "d MMM yyyy" // Default: "7 Sept 2026"
var clockOpacity: Double = AppConstants.Defaults.clockOpacity var clockOpacity: Double = AppConstants.Defaults.clockOpacity
var overlayOpacity: Double = AppConstants.Defaults.overlayOpacity var overlayOpacity: Double = AppConstants.Defaults.overlayOpacity
// MARK: - Display Settings // MARK: - Display Settings
var keepAwake: Bool = false // Keep screen awake in display mode var keepAwake: Bool = false // Keep screen awake in display mode
var respectFocusModes: Bool = true // Respect Focus mode settings for audio var respectFocusModes: Bool = true // Respect Focus mode settings for audio
var liveActivitiesEnabled: Bool = false // Show active alarm in Dynamic Island/Lock Screen
// MARK: - Cached Colors // MARK: - Cached Colors
private var _cachedDigitColor: Color? private var _cachedDigitColor: Color?
@ -92,7 +91,6 @@ final class ClockStyle: Codable, Equatable {
case overlayOpacity case overlayOpacity
case keepAwake case keepAwake
case respectFocusModes case respectFocusModes
case liveActivitiesEnabled
} }
// MARK: - Initialization // MARK: - Initialization
@ -146,7 +144,6 @@ final class ClockStyle: Codable, Equatable {
self.overlayOpacity = try container.decodeIfPresent(Double.self, forKey: .overlayOpacity) ?? self.overlayOpacity self.overlayOpacity = try container.decodeIfPresent(Double.self, forKey: .overlayOpacity) ?? self.overlayOpacity
self.keepAwake = try container.decodeIfPresent(Bool.self, forKey: .keepAwake) ?? self.keepAwake self.keepAwake = try container.decodeIfPresent(Bool.self, forKey: .keepAwake) ?? self.keepAwake
self.respectFocusModes = try container.decodeIfPresent(Bool.self, forKey: .respectFocusModes) ?? self.respectFocusModes self.respectFocusModes = try container.decodeIfPresent(Bool.self, forKey: .respectFocusModes) ?? self.respectFocusModes
self.liveActivitiesEnabled = try container.decodeIfPresent(Bool.self, forKey: .liveActivitiesEnabled) ?? self.liveActivitiesEnabled
clearColorCache() clearColorCache()
} }
@ -182,7 +179,6 @@ final class ClockStyle: Codable, Equatable {
try container.encode(overlayOpacity, forKey: .overlayOpacity) try container.encode(overlayOpacity, forKey: .overlayOpacity)
try container.encode(keepAwake, forKey: .keepAwake) try container.encode(keepAwake, forKey: .keepAwake)
try container.encode(respectFocusModes, forKey: .respectFocusModes) try container.encode(respectFocusModes, forKey: .respectFocusModes)
try container.encode(liveActivitiesEnabled, forKey: .liveActivitiesEnabled)
} }
// MARK: - Computed Properties // MARK: - Computed Properties
@ -476,8 +472,7 @@ final class ClockStyle: Codable, Equatable {
lhs.clockOpacity == rhs.clockOpacity && lhs.clockOpacity == rhs.clockOpacity &&
lhs.overlayOpacity == rhs.overlayOpacity && lhs.overlayOpacity == rhs.overlayOpacity &&
lhs.keepAwake == rhs.keepAwake && lhs.keepAwake == rhs.keepAwake &&
lhs.respectFocusModes == rhs.respectFocusModes && lhs.respectFocusModes == rhs.respectFocusModes
lhs.liveActivitiesEnabled == rhs.liveActivitiesEnabled
} }
} }

View File

@ -124,8 +124,6 @@ final class ClockViewModel {
style.digitAnimationStyle = newStyle.digitAnimationStyle style.digitAnimationStyle = newStyle.digitAnimationStyle
style.dateFormat = newStyle.dateFormat style.dateFormat = newStyle.dateFormat
style.respectFocusModes = newStyle.respectFocusModes style.respectFocusModes = newStyle.respectFocusModes
style.liveActivitiesEnabled = newStyle.liveActivitiesEnabled
saveStyle() saveStyle()
updateTimersIfNeeded() updateTimersIfNeeded()

View File

@ -28,18 +28,6 @@ struct AdvancedDisplaySection: View {
accentColor: AppAccent.primary accentColor: AppAccent.primary
) )
Rectangle()
.fill(AppBorder.subtle)
.frame(height: 1)
.padding(.horizontal, Design.Spacing.medium)
SettingsToggle(
title: "Live Activities",
subtitle: "Show alarms on Lock Screen/Dynamic Island while ringing",
isOn: $style.liveActivitiesEnabled,
accentColor: AppAccent.primary
)
if style.autoBrightness { if style.autoBrightness {
Rectangle() Rectangle()
.fill(AppBorder.subtle) .fill(AppBorder.subtle)

View File

@ -60,7 +60,6 @@ struct OnboardingView: View {
.tag(3) .tag(3)
} }
.tabViewStyle(.page(indexDisplayMode: .never)) .tabViewStyle(.page(indexDisplayMode: .never))
.animation(.easeInOut(duration: 0.3), value: currentPage)
OnboardingBottomControls( OnboardingBottomControls(
currentPage: $currentPage, currentPage: $currentPage,

View File

@ -152,25 +152,6 @@ struct FontUtils {
) )
} }
private static func tightBoundingBox(
for text: String,
withFont font: UIFont
) -> CGSize {
let attributedString = NSAttributedString(
string: text,
attributes: [.font: font]
)
let rect = attributedString.boundingRect(
with: CGSize(
width: CGFloat.greatestFiniteMagnitude,
height: CGFloat.greatestFiniteMagnitude
),
options: [.usesLineFragmentOrigin, .usesFontLeading],
context: nil
)
return CGSize(width: ceil(rect.width), height: ceil(rect.height))
}
private static func weightedFontName( private static func weightedFontName(
name: FontFamily, name: FontFamily,
weight: Font.Weight, weight: Font.Weight,

View File

@ -10,9 +10,9 @@ import Foundation
extension Date { extension Date {
/// Format date for display in overlay with custom format /// Format date for display in overlay with custom format
/// - Parameter format: Date format string (e.g., "d MMMM EEE") /// - Parameter format: Date format string (e.g., "d MMM yyyy")
/// - Returns: Formatted date string /// - Returns: Formatted date string
func formattedForOverlay(format: String = "d MMMM EEE") -> String { func formattedForOverlay(format: String = "d MMM yyyy") -> String {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateFormat = format formatter.dateFormat = format
return formatter.string(from: self) return formatter.string(from: self)
@ -41,25 +41,13 @@ extension Date {
} }
} }
/// Get time components for alarm scheduling
/// - Returns: DateComponents with hour and minute
func timeComponents() -> DateComponents {
return Calendar.current.dateComponents([.hour, .minute], from: self)
}
/// Check if date is today
/// - Returns: True if date is today
func isToday() -> Bool {
return Calendar.current.isDateInToday(self)
}
/// Get next occurrence of this time /// Get next occurrence of this time
/// - Returns: Next occurrence of this time, or today if time hasn't passed /// - Returns: Next occurrence of this time, or today if time hasn't passed
func nextOccurrence() -> Date { func nextOccurrence() -> Date {
let calendar = Calendar.current let calendar = Calendar.current
let now = Date() let now = Date()
let today = calendar.startOfDay(for: now) let today = calendar.startOfDay(for: now)
let timeComponents = self.timeComponents() let timeComponents = calendar.dateComponents([.hour, .minute], from: self)
guard let todayWithTime = calendar.date(byAdding: timeComponents, to: today) else { guard let todayWithTime = calendar.date(byAdding: timeComponents, to: today) else {
return now return now

View File

@ -10,17 +10,6 @@ import Bedrock
extension View { extension View {
/// Apply standard card styling
/// - Returns: View with card styling applied
func cardStyle() -> some View {
self
.background(AppSurface.overlay, in: RoundedRectangle(cornerRadius: Design.CornerRadius.appLarge))
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.appLarge)
.stroke(AppBorder.subtle, lineWidth: Design.LineWidth.thin)
)
}
/// Apply standard button styling /// Apply standard button styling
/// - Parameters: /// - Parameters:
/// - isEnabled: Whether the button is enabled /// - isEnabled: Whether the button is enabled
@ -35,28 +24,6 @@ extension View {
.disabled(!isEnabled) .disabled(!isEnabled)
} }
/// Apply responsive font sizing that updates on orientation and layout changes
/// - Parameters:
/// - baseSize: Base font size
/// - isPortrait: Whether in portrait orientation
/// - showSeconds: Whether seconds are displayed (for time components)
/// - showAmPm: Whether AM/PM is displayed
/// - Returns: View with responsive font sizing
func responsiveFontSize(
baseSize: CGFloat,
isPortrait: Bool,
showSeconds: Bool = false,
showAmPm: Bool = false
) -> some View {
self.modifier(ResponsiveFontModifier(
baseSize: baseSize,
isPortrait: isPortrait,
showSeconds: showSeconds,
showAmPm: showAmPm
))
}
/// Force view to update on orientation changes /// Force view to update on orientation changes
/// - Returns: View that updates on orientation changes /// - Returns: View that updates on orientation changes
func onOrientationChange() -> some View { func onOrientationChange() -> some View {
@ -81,17 +48,6 @@ extension View {
} }
} }
/// Apply standard section header styling with title
/// - Parameter title: The title text
/// - Returns: View with section header styling
func sectionHeader(title: String) -> some View {
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
Text(title)
.sectionTitleStyle()
self
}
}
/// Apply standard content padding /// Apply standard content padding
/// - Parameters: /// - Parameters:
/// - horizontal: Horizontal padding amount /// - horizontal: Horizontal padding amount
@ -106,22 +62,6 @@ extension View {
// MARK: - View Modifiers // MARK: - View Modifiers
/// Modifier for responsive font sizing that updates on layout changes
struct ResponsiveFontModifier: ViewModifier {
let baseSize: CGFloat
let isPortrait: Bool
let showSeconds: Bool
let showAmPm: Bool
func body(content: Content) -> some View {
content
.font(.system(size: baseSize, weight: .bold, design: .rounded))
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
// Force view update on orientation change
}
}
}
/// Modifier that forces view updates on orientation changes /// Modifier that forces view updates on orientation changes
struct OrientationChangeModifier: ViewModifier { struct OrientationChangeModifier: ViewModifier {
@State private var orientation = UIDevice.current.orientation @State private var orientation = UIDevice.current.orientation

View File

@ -7,31 +7,7 @@
import Foundation 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 { 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") static let keepAwakePromptRequested = Notification.Name("keepAwakePromptRequested")
static let clockStyleDidUpdate = Notification.Name("clockStyleDidUpdate") static let clockStyleDidUpdate = Notification.Name("clockStyleDidUpdate")
} }

View File

@ -23,8 +23,4 @@ final class KeepAwakePromptState {
func dismiss() { func dismiss() {
isPresented = false isPresented = false
} }
func resetSessionFlag() {
hasShownThisSession = false
}
} }

View File

@ -1,97 +0,0 @@
//
// NotificationUtils.swift
// TheNoiseClock
//
// Created by Matt Bruce on 9/7/25.
//
import UserNotifications
import Foundation
import Bedrock
/// Notification helper functions
enum NotificationUtils {
/// Request notification permissions
/// - Returns: True if permission granted
static func requestPermissions() async -> Bool {
do {
let granted = try await UNUserNotificationCenter.current().requestAuthorization(
options: [.alert, .sound, .badge]
)
return granted
} catch {
Design.debugLog("[general] Error requesting notification permissions: \(error)")
return false
}
}
/// Create notification content for alarm
/// - Parameters:
/// - title: Notification title
/// - body: Notification body
/// - soundName: Sound name for notification
/// - Returns: Configured notification content
static func createAlarmContent(title: String, body: String, soundName: String) -> UNMutableNotificationContent {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.categoryIdentifier = AlarmNotificationConstants.categoryIdentifier
if soundName == "default" {
content.sound = UNNotificationSound.default
Design.debugLog("[settings] Using default notification sound")
} else if Bundle.main.url(forResource: soundName, withExtension: nil) != nil {
// Use the sound name directly since sounds.json now references CAF files
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: soundName))
Design.debugLog("[settings] Using custom alarm sound: \(soundName)")
Design.debugLog("[settings] Sound file should be in main bundle: \(soundName)")
} else {
content.sound = UNNotificationSound.default
Design.debugLog("[settings] Alarm sound not found in main bundle, falling back to default: \(soundName)")
}
return content
}
/// Create calendar trigger for alarm
/// - Parameter date: Date for alarm
/// - Returns: Calendar notification trigger
static func createCalendarTrigger(for date: Date) -> UNCalendarNotificationTrigger {
let components = Calendar.current.dateComponents([.hour, .minute], from: date)
return UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
}
/// Schedule notification
/// - Parameters:
/// - identifier: Unique identifier for notification
/// - content: Notification content
/// - trigger: Notification trigger
/// - Returns: True if scheduled successfully
static func scheduleNotification(
identifier: String,
content: UNMutableNotificationContent,
trigger: UNNotificationTrigger
) async -> Bool {
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
do {
try await UNUserNotificationCenter.current().add(request)
return true
} catch {
Design.debugLog("[general] Error scheduling notification: \(error)")
return false
}
}
/// Remove notification by identifier
/// - Parameter identifier: Notification identifier to remove
static func removeNotification(identifier: String) {
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [identifier])
}
/// Remove all pending notifications
static func removeAllNotifications() {
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
}
}