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
private var players: [String: AVAudioPlayer] = [:]
private let playersLock = NSLock()
private var currentPlayer: AVAudioPlayer?
public private(set) var currentSound: Sound?
private var shouldResumeAfterInterruption = false
@ -27,8 +28,11 @@ public class SoundPlayer {
// MARK: - Initialization
private init() {
setupAudioSession()
preloadSounds()
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 {
@ -58,9 +62,16 @@ public class SoundPlayer {
currentSound = 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("📁 Available sounds: \(players.keys)")
print("📁 Available sounds: \(availableKeys)")
// Try to load the sound dynamically as fallback
guard let fileUrl = getURL(for: sound) else {
@ -73,7 +84,9 @@ public class SoundPlayer {
newPlayer.numberOfLoops = AudioConstants.Playback.numberOfLoops
newPlayer.volume = volumeOverride ?? AudioConstants.Volume.default
newPlayer.prepareToPlay()
playersLock.lock()
players[sound.fileName] = newPlayer
playersLock.unlock()
currentPlayer = newPlayer
let success = newPlayer.play()
print("🎵 Fallback play result: \(success ? "SUCCESS" : "FAILED")")
@ -187,20 +200,27 @@ public class SoundPlayer {
if settings.preloadSounds {
player.prepareToPlay()
}
playersLock.lock()
players[sound.fileName] = player
playersLock.unlock()
print("✅ Loaded: \(sound.name) (\(sound.fileName))")
} catch {
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() {
playersLock.lock()
for player in players.values {
player.stop()
}
players.removeAll()
playersLock.unlock()
currentPlayer = nil
currentSound = nil
shouldResumeAfterInterruption = false

View File

@ -51,7 +51,7 @@
/* End PBXCopyFilesBuildPhase 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; };
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; };
@ -154,7 +154,7 @@
EA384AFC2E6E6B6000CA7D50 /* Products */ = {
isa = PBXGroup;
children = (
EA384AFB2E6E6B6000CA7D50 /* TheNoiseClock.app */,
EA384AFB2E6E6B6000CA7D50 /* The Noise Clock.app */,
EA384B082E6E6B6100CA7D50 /* TheNoiseClockTests.xctest */,
EA384B122E6E6B6100CA7D50 /* TheNoiseClockUITests.xctest */,
EAF1C0DE2F3A4B5C00112234 /* TheNoiseClockWidget.appex */,
@ -197,7 +197,7 @@
EAC051B02F2E64AB007F87EA /* Bedrock */,
);
productName = TheNoiseClock;
productReference = EA384AFB2E6E6B6000CA7D50 /* TheNoiseClock.app */;
productReference = EA384AFB2E6E6B6000CA7D50 /* The Noise Clock.app */;
productType = "com.apple.product-type.application";
};
EA384B072E6E6B6100CA7D50 /* TheNoiseClockTests */ = {

View File

@ -45,60 +45,11 @@ struct ContentView: View {
// MARK: - Body
var body: some View {
ZStack {
// Main tab content
TabView(selection: $selectedTab) {
Tab("Clock", systemImage: "clock", value: AppTab.clock) {
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)
}
}
Tab("Alarms", systemImage: "alarm", value: AppTab.alarms) {
NavigationStack {
AlarmView(viewModel: alarmViewModel)
}
}
Tab("Noise", systemImage: "waveform", value: AppTab.noise) {
NavigationStack {
NoiseView()
}
}
Tab("Settings", systemImage: "gearshape", value: AppTab.settings) {
NavigationStack {
ClockSettingsView(
style: clockViewModel.style,
onCommit: { newStyle in
clockViewModel.updateStyle(newStyle)
},
onResetOnboarding: {
onboardingState.reset()
}
)
}
}
}
.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 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)
}
}
.tint(AppAccent.primary)
.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
Group {
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()
}
@ -106,9 +57,58 @@ struct ContentView: View {
insertion: .opacity,
removal: .opacity.combined(with: .move(edge: .bottom)).combined(with: .scale(scale: 0.9))
))
.zIndex(1) // Ensure it stays on top during transition
} 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) {
Tab("Clock", systemImage: "clock", value: AppTab.clock) {
NavigationStack {
ClockView(viewModel: clockViewModel, isOnClockTab: isOnClockTab)
}
}
Tab("Alarms", systemImage: "alarm", value: AppTab.alarms) {
NavigationStack {
AlarmView(viewModel: alarmViewModel)
}
}
Tab("Noise", systemImage: "waveform", value: AppTab.noise) {
NavigationStack {
NoiseView()
}
}
Tab("Settings", systemImage: "gearshape", value: AppTab.settings) {
NavigationStack {
ClockSettingsView(
style: clockViewModel.style,
onCommit: { newStyle in
clockViewModel.updateStyle(newStyle)
},
onResetOnboarding: {
onboardingState.reset()
}
)
}
}
}
.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 fullScreenMode to false")
clockViewModel.setFullScreenMode(false)
}
}
.tint(AppAccent.primary)
.background(Color.Branding.primary.ignoresSafeArea())
.sheet(isPresented: $keepAwakePromptState.isPresented) {
KeepAwakePrompt(
onEnable: {
@ -122,10 +122,7 @@ struct ContentView: View {
}
.task {
Design.debugLog("[ContentView] App launched - initializing AlarmKit")
// Reschedule all enabled alarms with AlarmKit on app launch
await alarmViewModel.rescheduleAllAlarms()
Design.debugLog("[ContentView] AlarmKit initialization complete")
}
.onReceive(NotificationCenter.default.publisher(for: .keepAwakePromptRequested)) { _ in
@ -133,7 +130,6 @@ struct ContentView: View {
guard shouldShowKeepAwakePromptForTab() else { return }
keepAwakePromptState.showIfNeeded(isKeepAwakeEnabled: clockViewModel.style.keepAwake)
}
.animation(.spring(duration: 0.45, bounce: 0.2), value: onboardingState.hasCompletedWelcome)
}
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
private func logAvailableAlarmSounds() {
Design.debugLog("[alarmkit] ========== AVAILABLE ALARM SOUNDS ==========")

View File

@ -30,7 +30,7 @@ final class ClockStyle: Codable, Equatable {
// MARK: - Night Mode Settings
var nightModeEnabled: Bool = false
var autoNightMode: Bool = false
var autoNightMode: Bool = true
var scheduledNightMode: Bool = false
var nightModeStartTime: String = "22:00" // 10:00 PM
var nightModeEndTime: String = "06:00" // 6:00 AM
@ -48,14 +48,13 @@ final class ClockStyle: Codable, Equatable {
var showDate: Bool = true
var showNextAlarm: 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 overlayOpacity: Double = AppConstants.Defaults.overlayOpacity
// MARK: - Display Settings
var keepAwake: Bool = false // Keep screen awake in display mode
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
private var _cachedDigitColor: Color?
@ -92,7 +91,6 @@ final class ClockStyle: Codable, Equatable {
case overlayOpacity
case keepAwake
case respectFocusModes
case liveActivitiesEnabled
}
// MARK: - Initialization
@ -146,7 +144,6 @@ final class ClockStyle: Codable, Equatable {
self.overlayOpacity = try container.decodeIfPresent(Double.self, forKey: .overlayOpacity) ?? self.overlayOpacity
self.keepAwake = try container.decodeIfPresent(Bool.self, forKey: .keepAwake) ?? self.keepAwake
self.respectFocusModes = try container.decodeIfPresent(Bool.self, forKey: .respectFocusModes) ?? self.respectFocusModes
self.liveActivitiesEnabled = try container.decodeIfPresent(Bool.self, forKey: .liveActivitiesEnabled) ?? self.liveActivitiesEnabled
clearColorCache()
}
@ -182,7 +179,6 @@ final class ClockStyle: Codable, Equatable {
try container.encode(overlayOpacity, forKey: .overlayOpacity)
try container.encode(keepAwake, forKey: .keepAwake)
try container.encode(respectFocusModes, forKey: .respectFocusModes)
try container.encode(liveActivitiesEnabled, forKey: .liveActivitiesEnabled)
}
// MARK: - Computed Properties
@ -476,8 +472,7 @@ final class ClockStyle: Codable, Equatable {
lhs.clockOpacity == rhs.clockOpacity &&
lhs.overlayOpacity == rhs.overlayOpacity &&
lhs.keepAwake == rhs.keepAwake &&
lhs.respectFocusModes == rhs.respectFocusModes &&
lhs.liveActivitiesEnabled == rhs.liveActivitiesEnabled
lhs.respectFocusModes == rhs.respectFocusModes
}
}

View File

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

View File

@ -28,18 +28,6 @@ struct AdvancedDisplaySection: View {
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 {
Rectangle()
.fill(AppBorder.subtle)

View File

@ -60,7 +60,6 @@ struct OnboardingView: View {
.tag(3)
}
.tabViewStyle(.page(indexDisplayMode: .never))
.animation(.easeInOut(duration: 0.3), value: currentPage)
OnboardingBottomControls(
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(
name: FontFamily,
weight: Font.Weight,

View File

@ -10,9 +10,9 @@ import Foundation
extension Date {
/// 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
func formattedForOverlay(format: String = "d MMMM EEE") -> String {
func formattedForOverlay(format: String = "d MMM yyyy") -> String {
let formatter = DateFormatter()
formatter.dateFormat = format
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
/// - Returns: Next occurrence of this time, or today if time hasn't passed
func nextOccurrence() -> Date {
let calendar = Calendar.current
let now = Date()
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 {
return now

View File

@ -10,17 +10,6 @@ import Bedrock
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
/// - Parameters:
/// - isEnabled: Whether the button is enabled
@ -35,28 +24,6 @@ extension View {
.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
/// - Returns: View that updates on orientation changes
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
/// - Parameters:
/// - horizontal: Horizontal padding amount
@ -106,22 +62,6 @@ extension View {
// 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
struct OrientationChangeModifier: ViewModifier {
@State private var orientation = UIDevice.current.orientation

View File

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

View File

@ -23,8 +23,4 @@ final class KeepAwakePromptState {
func dismiss() {
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()
}
}