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

This commit is contained in:
Matt Bruce 2026-02-02 12:41:00 -06:00
parent 744fe7511b
commit a1cb0f4b1f
26 changed files with 628 additions and 52 deletions

View File

@ -130,7 +130,11 @@ public class SoundPlayer {
return Bundle.main.url(forResource: fileName, withExtension: nil, subdirectory: subfolder)
} else {
// Direct file path (fallback)
return Bundle.main.url(forResource: sound.fileName, withExtension: nil)
if let url = Bundle.main.url(forResource: sound.fileName, withExtension: nil) {
return url
}
// Alarm sounds live in a subdirectory; try that next
return Bundle.main.url(forResource: sound.fileName, withExtension: nil, subdirectory: "AlarmSounds")
}
}

11
PRD.md
View File

@ -106,8 +106,12 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di
- **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
- **Inline alarm warnings**: Enabled alarms show a foreground-only warning when Keep Awake is off
- **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
- **Onboarding enablement**: Onboarding offers a one-tap Keep Awake enable action
- **Live Activity**: Dynamic Island/Lock Screen shows only while an alarm is ringing (user-enabled)
- **Live Activity availability**: Requires Live Activities permission in iOS Settings
- **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
@ -407,6 +411,8 @@ TheNoiseClock/
│ │ │ └── View+Extensions.swift # Common view modifiers and responsive utilities
│ │ ├── Models/
│ │ │ └── SoundCategory.swift # Shared sound category definitions
│ │ ├── LiveActivity/
│ │ │ └── AlarmActivityAttributes.swift # Live Activity attributes shared with widget
│ │ └── Utilities/
│ │ ├── ColorUtils.swift # Color manipulation utilities
│ │ ├── NotificationUtils.swift # Notification helper functions
@ -454,6 +460,7 @@ TheNoiseClock/
│ │ │ ├── Services/
│ │ │ │ ├── AlarmService.swift
│ │ │ │ ├── AlarmSoundService.swift
│ │ │ │ ├── AlarmLiveActivityManager.swift
│ │ │ │ ├── FocusModeService.swift
│ │ │ │ ├── NotificationService.swift
│ │ │ │ └── NotificationDelegate.swift
@ -502,6 +509,10 @@ TheNoiseClock/
│ └── [Asset catalogs]
└── TheNoiseClock.xcodeproj/ # Xcode project with AudioPlaybackKit dependency
└── project.pbxproj # Project configuration with local package reference
TheNoiseClockWidget/ # Widget extension (Live Activity)
├── AlarmLiveActivityWidget.swift # Live Activity UI for Dynamic Island/Lock Screen
├── TheNoiseClockWidgetBundle.swift # Widget bundle entry point
└── Info.plist # Widget extension Info.plist
```
### File Naming Conventions

View File

@ -46,6 +46,9 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and
- 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
- Enabled alarm rows show a foreground-only warning when Keep Awake is off
- Live Activity shows only while an alarm is ringing (user-enabled)
- Live Activity requires Live Activities permission in iOS Settings
**Display Mode**
- Long-press to enter immersive display mode

View File

@ -9,6 +9,8 @@
/* Begin PBXBuildFile section */
EA384E832E6F806200CA7D50 /* AudioPlaybackKit in Frameworks */ = {isa = PBXBuildFile; productRef = EA384D3D2E6F554D00CA7D50 /* AudioPlaybackKit */; };
EAC051B12F2E64AB007F87EA /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EAC051B02F2E64AB007F87EA /* Bedrock */; };
EAF1C0DE2F3A4B5C0011223E /* TheNoiseClockWidget.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = EAF1C0DE2F3A4B5C00112234 /* TheNoiseClockWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
EAF1C0DE2F3A4B5C00112242 /* TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF1C0DE2F3A4B5C00112241 /* TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -26,14 +28,37 @@
remoteGlobalIDString = EA384AFA2E6E6B6000CA7D50;
remoteInfo = TheNoiseClock;
};
EAF1C0DE2F3A4B5C00112240 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = EA384AF32E6E6B6000CA7D50 /* Project object */;
proxyType = 1;
remoteGlobalIDString = EAF1C0DE2F3A4B5C00112233;
remoteInfo = TheNoiseClockWidget;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
EAF1C0DE2F3A4B5C0011223D /* Embed App Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
EAF1C0DE2F3A4B5C0011223E /* TheNoiseClockWidget.appex in Embed App Extensions */,
);
name = "Embed App Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
EA384AFB2E6E6B6000CA7D50 /* TheNoiseClock.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TheNoiseClock.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; };
EAD6E3B05A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = TheNoiseClock/Configuration/Release.xcconfig; sourceTree = SOURCE_ROOT; };
EAF1C0DE2F3A4B5C00112234 /* TheNoiseClockWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TheNoiseClockWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
EAF1C0DE2F3A4B5C00112241 /* TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift; sourceTree = SOURCE_ROOT; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@ -44,6 +69,13 @@
);
target = EA384AFA2E6E6B6000CA7D50 /* TheNoiseClock */;
};
EAF1C0DE2F3A4B5C0011223C /* Exceptions for "TheNoiseClockWidget" folder in "TheNoiseClockWidget" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = EAF1C0DE2F3A4B5C00112233 /* TheNoiseClockWidget */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
@ -65,6 +97,14 @@
path = TheNoiseClockUITests;
sourceTree = "<group>";
};
EAF1C0DE2F3A4B5C0011223B /* TheNoiseClockWidget */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
EAF1C0DE2F3A4B5C0011223C /* Exceptions for "TheNoiseClockWidget" folder in "TheNoiseClockWidget" target */,
);
path = TheNoiseClockWidget;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@ -91,6 +131,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
EAF1C0DE2F3A4B5C0011223A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@ -100,6 +147,7 @@
EA384AFD2E6E6B6000CA7D50 /* TheNoiseClock */,
EA384B0B2E6E6B6100CA7D50 /* TheNoiseClockTests */,
EA384B152E6E6B6100CA7D50 /* TheNoiseClockUITests */,
EAF1C0DE2F3A4B5C0011223B /* TheNoiseClockWidget */,
EA384AFC2E6E6B6000CA7D50 /* Products */,
EAC057642F2E69E8007F87EA /* Recovered References */,
);
@ -111,6 +159,7 @@
EA384AFB2E6E6B6000CA7D50 /* TheNoiseClock.app */,
EA384B082E6E6B6100CA7D50 /* TheNoiseClockTests.xctest */,
EA384B122E6E6B6100CA7D50 /* TheNoiseClockUITests.xctest */,
EAF1C0DE2F3A4B5C00112234 /* TheNoiseClockWidget.appex */,
);
name = Products;
sourceTree = "<group>";
@ -120,6 +169,7 @@
children = (
EAD6E3AF5A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Debug.xcconfig */,
EAD6E3B05A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Release.xcconfig */,
EAF1C0DE2F3A4B5C00112241 /* TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift */,
);
name = "Recovered References";
sourceTree = "<group>";
@ -134,10 +184,12 @@
EA384AF72E6E6B6000CA7D50 /* Sources */,
EA384AF82E6E6B6000CA7D50 /* Frameworks */,
EA384AF92E6E6B6000CA7D50 /* Resources */,
EAF1C0DE2F3A4B5C0011223D /* Embed App Extensions */,
);
buildRules = (
);
dependencies = (
EAF1C0DE2F3A4B5C0011223F /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
EA384AFD2E6E6B6000CA7D50 /* TheNoiseClock */,
@ -197,6 +249,26 @@
productReference = EA384B122E6E6B6100CA7D50 /* TheNoiseClockUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
EAF1C0DE2F3A4B5C00112233 /* TheNoiseClockWidget */ = {
isa = PBXNativeTarget;
buildConfigurationList = EAF1C0DE2F3A4B5C00112235 /* Build configuration list for PBXNativeTarget "TheNoiseClockWidget" */;
buildPhases = (
EAF1C0DE2F3A4B5C00112238 /* Sources */,
EAF1C0DE2F3A4B5C0011223A /* Frameworks */,
EAF1C0DE2F3A4B5C00112239 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
EAF1C0DE2F3A4B5C0011223B /* TheNoiseClockWidget */,
);
name = TheNoiseClockWidget;
productName = TheNoiseClockWidget;
productReference = EAF1C0DE2F3A4B5C00112234 /* TheNoiseClockWidget.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@ -218,6 +290,9 @@
CreatedOnToolsVersion = 26.0;
TestTargetID = EA384AFA2E6E6B6000CA7D50;
};
EAF1C0DE2F3A4B5C00112233 = {
CreatedOnToolsVersion = 26.0;
};
};
};
buildConfigurationList = EA384AF62E6E6B6000CA7D50 /* Build configuration list for PBXProject "TheNoiseClock" */;
@ -241,6 +316,7 @@
EA384AFA2E6E6B6000CA7D50 /* TheNoiseClock */,
EA384B072E6E6B6100CA7D50 /* TheNoiseClockTests */,
EA384B112E6E6B6100CA7D50 /* TheNoiseClockUITests */,
EAF1C0DE2F3A4B5C00112233 /* TheNoiseClockWidget */,
);
};
/* End PBXProject section */
@ -267,6 +343,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
EAF1C0DE2F3A4B5C00112239 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@ -291,6 +374,14 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
EAF1C0DE2F3A4B5C00112238 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
EAF1C0DE2F3A4B5C00112242 /* TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@ -304,6 +395,11 @@
target = EA384AFA2E6E6B6000CA7D50 /* TheNoiseClock */;
targetProxy = EA384B132E6E6B6100CA7D50 /* PBXContainerItemProxy */;
};
EAF1C0DE2F3A4B5C0011223F /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = EAF1C0DE2F3A4B5C00112233 /* TheNoiseClockWidget */;
targetProxy = EAF1C0DE2F3A4B5C00112240 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
@ -586,6 +682,44 @@
};
name = Release;
};
EAF1C0DE2F3A4B5C00112236 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = EAD6E3AF5A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Debug.xcconfig */;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TheNoiseClockWidget/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(WIDGET_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
EAF1C0DE2F3A4B5C00112237 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = EAD6E3B05A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Release.xcconfig */;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TheNoiseClockWidget/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(WIDGET_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@ -625,6 +759,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
EAF1C0DE2F3A4B5C00112235 /* Build configuration list for PBXNativeTarget "TheNoiseClockWidget" */ = {
isa = XCConfigurationList;
buildConfigurations = (
EAF1C0DE2F3A4B5C00112236 /* Debug */,
EAF1C0DE2F3A4B5C00112237 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */

View File

@ -7,7 +7,12 @@
<key>TheNoiseClock.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
<integer>3</integer>
</dict>
<key>TheNoiseClockWidget.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
</dict>
</dict>
</dict>

View File

@ -37,10 +37,9 @@ struct ContentView: View {
// MARK: - Computed Properties
/// Single source of truth for tab bar visibility - prevents race conditions
/// Tab bar is ONLY hidden when on clock tab AND in display mode
private var shouldHideTabBar: Bool {
selectedTab == .clock && clockViewModel.isDisplayMode
/// Whether the clock tab is currently selected - passed to ClockView to prevent race conditions
private var isOnClockTab: Bool {
selectedTab == .clock
}
// MARK: - Body
@ -50,7 +49,10 @@ struct ContentView: View {
// Main tab content
TabView(selection: $selectedTab) {
NavigationStack {
ClockView(viewModel: clockViewModel)
// 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)
}
.tabItem {
Label("Clock", systemImage: "clock")
@ -89,21 +91,15 @@ struct ContentView: View {
}
.tag(Tab.settings)
}
// SINGLE source of truth for tab bar visibility at TabView level
// This eliminates race conditions from multiple views competing
.toolbar(shouldHideTabBar ? .hidden : .visible, for: .tabBar)
.onChange(of: selectedTab) { oldValue, newValue in
Design.debugLog("[ContentView] Tab changed: \(oldValue) -> \(newValue), shouldHideTabBar: \(shouldHideTabBar)")
Design.debugLog("[ContentView] Tab changed: \(oldValue) -> \(newValue)")
if oldValue == .clock && newValue != .clock {
Design.debugLog("[ContentView] Leaving clock tab, setting displayMode to false")
// Immediately disable display mode when leaving clock tab
// This is now a safety net - the computed property already handles visibility
// Safety net: also explicitly disable display mode when leaving clock tab
// The ClockView's toolbar modifier already responds to isOnClockTab changing
clockViewModel.setDisplayMode(false)
}
}
.onChange(of: clockViewModel.isDisplayMode) { oldValue, newValue in
Design.debugLog("[ContentView] isDisplayMode changed: \(oldValue) -> \(newValue), selectedTab: \(selectedTab), shouldHideTabBar: \(shouldHideTabBar)")
}
.accentColor(AppAccent.primary)
.background(Color.Branding.primary.ignoresSafeArea())
.fullScreenCover(item: activeAlarmBinding) { alarm in
@ -148,6 +144,8 @@ struct ContentView: View {
alarmViewModel.stopActiveAlarm()
}
.onReceive(NotificationCenter.default.publisher(for: .keepAwakePromptRequested)) { _ in
guard onboardingState.hasCompletedWelcome else { return }
guard shouldShowKeepAwakePromptForTab() else { return }
keepAwakePromptState.showIfNeeded(isKeepAwakeEnabled: clockViewModel.style.keepAwake)
}
.animation(.easeInOut(duration: 0.3), value: onboardingState.hasCompletedWelcome)
@ -159,6 +157,15 @@ struct ContentView: View {
set: { alarmViewModel.activeAlarm = $0 }
)
}
private func shouldShowKeepAwakePromptForTab() -> Bool {
switch selectedTab {
case .clock, .alarms:
return true
case .noise, .settings:
return false
}
}
}
// MARK: - Preview

View File

@ -0,0 +1,86 @@
//
// AlarmLiveActivityManager.swift
// TheNoiseClock
//
// Created by Matt Bruce on 2/2/26.
//
import ActivityKit
import Bedrock
import Foundation
@MainActor
final class AlarmLiveActivityManager {
private var currentActivity: Activity<AlarmActivityAttributes>?
func startOrUpdate(for alarm: Alarm) {
guard #available(iOS 16.1, *) else { return }
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
Design.debugLog("[alarms] Live Activities not authorized")
return
}
let attributes = AlarmActivityAttributes(id: alarm.id)
let alarmDate = alarm.nextTriggerTime()
let contentState = AlarmActivityAttributes.ContentState(
alarmDate: alarmDate,
label: alarm.label
)
if let activity = currentActivity {
if activity.attributes.id != alarm.id {
Task {
await activity.end(nil, dismissalPolicy: .immediate)
currentActivity = nil
startOrUpdate(for: alarm)
}
return
} else {
Task {
await activity.update(ActivityContent(state: contentState, staleDate: alarmDate))
}
Design.debugLog("[alarms] Live Activity updated for \(alarm.label) at \(alarmDate)")
return
}
}
do {
let activity = try Activity.request(
attributes: attributes,
content: ActivityContent(state: contentState, staleDate: alarmDate),
pushType: nil
)
currentActivity = activity
Design.debugLog("[alarms] Live Activity started for \(alarm.label) at \(alarmDate)")
} catch {
Design.debugLog("[alarms] Live Activity request failed: \(error)")
}
}
func endActivity() {
guard #available(iOS 16.1, *) else { return }
guard let activity = currentActivity else { return }
Task {
await activity.end(nil, dismissalPolicy: .immediate)
currentActivity = nil
Design.debugLog("[alarms] Live Activity ended")
}
}
func refresh(for alarms: [Alarm], isEnabled: Bool) {
guard isEnabled else {
Design.debugLog("[alarms] Live Activities disabled in app settings")
endActivity()
return
}
guard let nextAlarm = alarms
.filter({ $0.isEnabled })
.min(by: { $0.nextTriggerTime() < $1.nextTriggerTime() }) else {
Design.debugLog("[alarms] No enabled alarms; ending Live Activity")
endActivity()
return
}
Design.debugLog("[alarms] Live Activity refresh for \(nextAlarm.label)")
startOrUpdate(for: nextAlarm)
}
}

View File

@ -8,6 +8,7 @@
import Foundation
import UserNotifications
import Observation
import Bedrock
/// Service for managing alarms and notifications
@Observable
@ -82,11 +83,14 @@ class AlarmService {
if alarm.isEnabled {
Task {
let respectFocusModes = currentRespectFocusModes()
let liveActivitiesEnabled = isLiveActivitiesEnabled()
let body = liveActivitiesEnabled ? "" : alarm.notificationMessage
Design.debugLog("[alarms] AlarmService schedule \(alarm.label). LiveActivities=\(liveActivitiesEnabled) body=\(body.isEmpty ? "<empty>" : "present")")
// Use FocusModeService for better Focus mode compatibility
focusModeService.scheduleAlarmNotification(
identifier: alarm.id.uuidString,
title: alarm.label,
body: alarm.notificationMessage,
body: body,
date: alarm.time,
soundName: alarm.soundName,
repeats: false, // For now, set to false since Alarm model doesn't have repeatDays
@ -106,6 +110,14 @@ class AlarmService {
}
return style.respectFocusModes
}
private func isLiveActivitiesEnabled() -> Bool {
guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey),
let style = try? JSONDecoder().decode(ClockStyle.self, from: data) else {
return ClockStyle().liveActivitiesEnabled
}
return style.liveActivitiesEnabled
}
private func saveAlarms() {
persistenceWorkItem?.cancel()

View File

@ -58,7 +58,7 @@ class AlarmSoundService {
do {
let data = try Data(contentsOf: url)
let settings = try JSONDecoder().decode(AudioSettings.self, from: data)
Design.debugLog("[settings] Loaded audio settings for alarms from SoundsSettings.json")
//Design.debugLog("[settings] Loaded audio settings for alarms from SoundsSettings.json")
return settings
} catch {
Design.debugLog("[general] Warning: Error loading audio settings for alarms, using defaults: \(error)")

View File

@ -85,7 +85,7 @@ class FocusModeService {
// Register the category
UNUserNotificationCenter.current().setNotificationCategories([alarmCategory])
Design.debugLog("[settings] Notification settings configured for Focus mode compatibility")
//Design.debugLog("[settings] Notification settings configured for Focus mode compatibility")
}
/// Schedule alarm notification with Focus mode awareness
@ -108,10 +108,13 @@ class FocusModeService {
if soundName == "default" {
content.sound = UNNotificationSound.default
Design.debugLog("[settings] Using default notification sound")
} else {
} else if Bundle.main.url(forResource: soundName, withExtension: nil) != nil {
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)")
}
content.categoryIdentifier = AlarmNotificationConstants.categoryIdentifier

View File

@ -46,6 +46,7 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
let actionIdentifier = response.actionIdentifier
let notification = response.notification
let userInfo = notification.request.content.userInfo
Design.debugLog("[alarms] didReceive notification. category=\(notification.request.content.categoryIdentifier) action=\(actionIdentifier)")
Design.debugLog("[settings] Notification action received: \(actionIdentifier)")
@ -73,6 +74,7 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
Design.debugLog("[alarms] willPresent notification. category=\(notification.request.content.categoryIdentifier)")
if notification.request.content.categoryIdentifier == AlarmNotificationConstants.categoryIdentifier {
postAlarmDidFire(notification: notification)
completionHandler([])

View File

@ -9,6 +9,8 @@ import Foundation
import Observation
import UserNotifications
import AudioPlaybackKit
import Bedrock
import Bedrock
/// ViewModel for alarm management
@Observable
@ -19,6 +21,7 @@ class AlarmViewModel {
private let notificationService: NotificationService
private let alarmSoundService = AlarmSoundService.shared
private let soundPlayer = SoundPlayer.shared
private let liveActivityManager = AlarmLiveActivityManager()
var activeAlarm: Alarm?
@ -29,7 +32,7 @@ class AlarmViewModel {
var systemSounds: [String] {
AppConstants.SystemSounds.availableSounds
}
// MARK: - Initialization
init(alarmService: AlarmService = AlarmService(),
notificationService: NotificationService = NotificationService()) {
@ -46,10 +49,11 @@ class AlarmViewModel {
// Schedule notification if alarm is enabled
if alarm.isEnabled {
Design.debugLog("[alarms] Scheduling alarm notification for \(alarm.label). LiveActivities=\(isLiveActivitiesEnabled())")
await notificationService.scheduleAlarmNotification(
id: alarm.id.uuidString,
title: alarm.label,
body: alarm.notificationMessage,
body: isLiveActivitiesEnabled() ? "" : alarm.notificationMessage,
soundName: alarm.soundName,
date: alarm.time
)
@ -62,10 +66,11 @@ class AlarmViewModel {
// Reschedule notification
if alarm.isEnabled {
Design.debugLog("[alarms] Rescheduling alarm notification for \(alarm.label). LiveActivities=\(isLiveActivitiesEnabled())")
await notificationService.scheduleAlarmNotification(
id: alarm.id.uuidString,
title: alarm.label,
body: alarm.notificationMessage,
body: isLiveActivitiesEnabled() ? "" : alarm.notificationMessage,
soundName: alarm.soundName,
date: alarm.time
)
@ -91,10 +96,11 @@ class AlarmViewModel {
// Schedule or cancel notification based on new state
if alarm.isEnabled {
Design.debugLog("[alarms] Enabling alarm \(alarm.label). LiveActivities=\(isLiveActivitiesEnabled())")
await notificationService.scheduleAlarmNotification(
id: alarm.id.uuidString,
title: alarm.label,
body: alarm.notificationMessage,
body: isLiveActivitiesEnabled() ? "" : alarm.notificationMessage,
soundName: alarm.soundName,
date: alarm.time
)
@ -140,11 +146,12 @@ class AlarmViewModel {
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 }
Design.debugLog("[alarms] handleAlarmNotification userInfo keys: \(Array(userInfo.keys))")
if let alarm = resolveAlarm(from: userInfo) {
startActiveAlarm(alarm)
}
@ -153,7 +160,14 @@ class AlarmViewModel {
@MainActor
func stopActiveAlarm() {
soundPlayer.stopSound()
if let alarm = activeAlarm, let stored = alarmService.getAlarm(id: alarm.id) {
var updated = stored
updated.isEnabled = false
alarmService.updateAlarm(updated)
notificationService.cancelNotification(id: updated.id.uuidString)
}
activeAlarm = nil
liveActivityManager.endActivity()
}
@MainActor
@ -170,6 +184,10 @@ class AlarmViewModel {
}
activeAlarm = alarm
playAlarmSound(for: alarm)
Design.debugLog("[alarms] Alarm fired: \(alarm.label). LiveActivities=\(isLiveActivitiesEnabled())")
if isLiveActivitiesEnabled() {
liveActivityManager.startOrUpdate(for: alarm)
}
}
private func playAlarmSound(for alarm: Alarm) {
@ -186,6 +204,14 @@ class AlarmViewModel {
}
return style.keepAwake
}
private func isLiveActivitiesEnabled() -> Bool {
guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey),
let style = try? JSONDecoder().decode(ClockStyle.self, from: data) else {
return ClockStyle().liveActivitiesEnabled
}
return style.liveActivitiesEnabled
}
private func scheduleSnoozeNotification(for alarm: Alarm) {
let snoozeTime = Date().addingTimeInterval(TimeInterval(alarm.snoozeDuration * 60))
@ -242,6 +268,7 @@ class AlarmViewModel {
}
}
}
private func resolveAlarm(from userInfo: [AnyHashable: Any]) -> Alarm? {
if let alarmIdString = userInfo[AlarmNotificationKeys.alarmId] as? String,

View File

@ -7,6 +7,7 @@
import SwiftUI
import Bedrock
import Foundation
/// Component for displaying individual alarm row
struct AlarmRowView: View {
@ -15,6 +16,7 @@ struct AlarmRowView: View {
let alarm: Alarm
let onToggle: () -> Void
let onEdit: () -> Void
@AppStorage(ClockStyle.appStorageKey) private var clockStyleData: Data = Data()
// MARK: - Body
var body: some View {
@ -31,6 +33,17 @@ struct AlarmRowView: View {
Text("\(AlarmSoundService.shared.getSoundDisplayName(alarm.soundName))")
.font(.caption)
.foregroundColor(AppTextColors.secondary)
if alarm.isEnabled && !isKeepAwakeEnabled {
HStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.caption2)
.foregroundStyle(AppStatus.warning)
Text("Foreground only for full alarm sound")
.font(.caption2)
.foregroundStyle(AppTextColors.tertiary)
}
}
}
Spacer()
@ -47,6 +60,13 @@ struct AlarmRowView: View {
}
}
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

@ -53,6 +53,7 @@ class ClockStyle: Codable, Equatable {
// 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?
@ -87,6 +88,7 @@ class ClockStyle: Codable, Equatable {
case overlayOpacity
case keepAwake
case respectFocusModes
case liveActivitiesEnabled
}
// MARK: - Initialization
@ -138,6 +140,7 @@ 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()
}
@ -171,6 +174,7 @@ 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
@ -341,19 +345,19 @@ class ClockStyle: Codable, Equatable {
/// Get the effective brightness considering color theme and night mode
var effectiveBrightness: Double {
if !autoBrightness {
Design.debugLog("[brightness] effectiveBrightness: Auto-brightness disabled, returning 1.0")
//Design.debugLog("[brightness] effectiveBrightness: Auto-brightness disabled, returning 1.0")
return 1.0 // Full brightness when auto-brightness is disabled
}
if isNightModeActive {
Design.debugLog("[brightness] effectiveBrightness: Night mode active, returning 0.3")
//Design.debugLog("[brightness] effectiveBrightness: Night mode active, returning 0.3")
// Dim the display to 30% brightness in night mode
return 0.3
}
// Color-aware brightness adaptation
let colorAwareBrightness = getColorAwareBrightness()
Design.debugLog("[brightness] effectiveBrightness: Color-aware brightness = \(String(format: "%.2f", colorAwareBrightness))")
//Design.debugLog("[brightness] effectiveBrightness: Color-aware brightness = \(String(format: "%.2f", colorAwareBrightness))")
return colorAwareBrightness
}
@ -463,7 +467,8 @@ class ClockStyle: Codable, Equatable {
lhs.clockOpacity == rhs.clockOpacity &&
lhs.overlayOpacity == rhs.overlayOpacity &&
lhs.keepAwake == rhs.keepAwake &&
lhs.respectFocusModes == rhs.respectFocusModes
lhs.respectFocusModes == rhs.respectFocusModes &&
lhs.liveActivitiesEnabled == rhs.liveActivitiesEnabled
}
}

View File

@ -60,16 +60,16 @@ class AmbientLightService {
let clampedBrightness = max(0.0, min(1.0, brightness))
let previousBrightness = UIScreen.main.brightness
Design.debugLog("[ambient] AmbientLightService.setBrightness:")
Design.debugLog("[ambient] - Requested brightness: \(String(format: "%.2f", brightness))")
Design.debugLog("[ambient] - Clamped brightness: \(String(format: "%.2f", clampedBrightness))")
Design.debugLog("[ambient] - Previous screen brightness: \(String(format: "%.2f", previousBrightness))")
// Design.debugLog("[ambient] AmbientLightService.setBrightness:")
// Design.debugLog("[ambient] - Requested brightness: \(String(format: "%.2f", brightness))")
// Design.debugLog("[ambient] - Clamped brightness: \(String(format: "%.2f", clampedBrightness))")
// Design.debugLog("[ambient] - Previous screen brightness: \(String(format: "%.2f", previousBrightness))")
UIScreen.main.brightness = clampedBrightness
currentBrightness = clampedBrightness
Design.debugLog("[ambient] - New screen brightness: \(String(format: "%.2f", UIScreen.main.brightness))")
Design.debugLog("[ambient] - Service currentBrightness: \(String(format: "%.2f", currentBrightness))")
// Design.debugLog("[ambient] - New screen brightness: \(String(format: "%.2f", UIScreen.main.brightness))")
// Design.debugLog("[ambient] - Service currentBrightness: \(String(format: "%.2f", currentBrightness))")
}
/// Get current screen brightness
@ -90,7 +90,7 @@ class AmbientLightService {
let previousBrightness = currentBrightness
currentBrightness = newBrightness
Design.debugLog("[ambient] AmbientLightService: Brightness changed from \(String(format: "%.2f", previousBrightness)) to \(String(format: "%.2f", newBrightness))")
//Design.debugLog("[ambient] AmbientLightService: Brightness changed from \(String(format: "%.2f", previousBrightness)) to \(String(format: "%.2f", newBrightness))")
// Notify that brightness changed
onBrightnessChange?()

View File

@ -32,6 +32,7 @@ class ClockViewModel {
private var minuteTimer: Timer.TimerPublisher?
private var secondCancellable: AnyCancellable?
private var minuteCancellable: AnyCancellable?
private var styleObserver: NSObjectProtocol?
// Persistence
private var persistenceWorkItem: DispatchWorkItem?
@ -52,11 +53,15 @@ class ClockViewModel {
loadStyle()
setupTimers()
startAmbientLightMonitoring()
observeStyleUpdates()
}
deinit {
stopTimers()
stopAmbientLightMonitoring()
if let styleObserver {
NotificationCenter.default.removeObserver(styleObserver)
}
}
// MARK: - Public Interface
@ -120,6 +125,7 @@ class ClockViewModel {
style.digitAnimationStyle = newStyle.digitAnimationStyle
style.dateFormat = newStyle.dateFormat
style.respectFocusModes = newStyle.respectFocusModes
style.liveActivitiesEnabled = newStyle.liveActivitiesEnabled
saveStyle()
@ -144,6 +150,19 @@ class ClockViewModel {
}
}
private func observeStyleUpdates() {
styleObserver = NotificationCenter.default.addObserver(
forName: .clockStyleDidUpdate,
object: nil,
queue: .main
) { [weak self] _ in
self?.loadStyle()
self?.updateTimersIfNeeded()
self?.updateWakeLockState()
self?.updateBrightness()
}
}
func saveStyle() {
persistenceWorkItem?.cancel()
@ -232,7 +251,7 @@ class ClockViewModel {
// Set up callback to respond to brightness changes
ambientLightService.onBrightnessChange = { [weak self] in
Design.debugLog("[brightness] ClockViewModel: Received brightness change notification")
//Design.debugLog("[brightness] ClockViewModel: Received brightness change notification")
self?.updateBrightness()
}
}
@ -249,21 +268,21 @@ class ClockViewModel {
let currentScreenBrightness = UIScreen.main.brightness
let isNightMode = style.isNightModeActive
Design.debugLog("[brightness] Auto Brightness Debug:")
Design.debugLog("[brightness] - Auto brightness enabled: \(style.autoBrightness)")
Design.debugLog("[brightness] - Current screen brightness: \(String(format: "%.2f", currentScreenBrightness))")
Design.debugLog("[brightness] - Target brightness: \(String(format: "%.2f", targetBrightness))")
Design.debugLog("[brightness] - Night mode active: \(isNightMode)")
Design.debugLog("[brightness] - Color theme: \(style.selectedColorTheme)")
Design.debugLog("[brightness] - Ambient light threshold: \(String(format: "%.2f", style.ambientLightThreshold))")
// Design.debugLog("[brightness] Auto Brightness Debug:")
// Design.debugLog("[brightness] - Auto brightness enabled: \(style.autoBrightness)")
// Design.debugLog("[brightness] - Current screen brightness: \(String(format: "%.2f", currentScreenBrightness))")
// Design.debugLog("[brightness] - Target brightness: \(String(format: "%.2f", targetBrightness))")
// Design.debugLog("[brightness] - Night mode active: \(isNightMode)")
// Design.debugLog("[brightness] - Color theme: \(style.selectedColorTheme)")
// Design.debugLog("[brightness] - Ambient light threshold: \(String(format: "%.2f", style.ambientLightThreshold))")
ambientLightService.setBrightness(targetBrightness)
Design.debugLog("[brightness] - Brightness set to: \(String(format: "%.2f", targetBrightness))")
Design.debugLog("[brightness] - Actual screen brightness now: \(String(format: "%.2f", UIScreen.main.brightness))")
Design.debugLog("[brightness] ---")
} else {
Design.debugLog("[brightness] Auto Brightness: DISABLED")
// Design.debugLog("[brightness] - Brightness set to: \(String(format: "%.2f", targetBrightness))")
// Design.debugLog("[brightness] - Actual screen brightness now: \(String(format: "%.2f", UIScreen.main.brightness))")
// Design.debugLog("[brightness] ---")
// } else {
// Design.debugLog("[brightness] Auto Brightness: DISABLED")
}
}
}

View File

@ -16,10 +16,19 @@ struct ClockView: View {
// MARK: - Properties
@Bindable var viewModel: ClockViewModel
/// Whether this view is currently the selected tab - prevents race conditions on tab switch
let isOnClockTab: Bool
@State private var idleTimer: Timer?
@State private var didHandleTouch = false
@State private var isViewActive = false
/// Tab bar should ONLY be hidden when BOTH conditions are true:
/// 1. We're on the clock tab (prevents hiding when user switches away)
/// 2. Display mode is active
private var shouldHideTabBar: Bool {
isOnClockTab && viewModel.isDisplayMode
}
// MARK: - Body
var body: some View {
GeometryReader { geometry in
@ -79,7 +88,12 @@ struct ClockView: View {
.ignoresSafeArea() // Extend GeometryReader to full screen, we handle safe areas manually
.toolbar(.hidden, for: .navigationBar)
.statusBarHidden(true)
// Tab bar visibility is now controlled at ContentView level to prevent race conditions
// Tab bar visibility controlled here but decision includes isOnClockTab from parent
// This prevents race conditions: when tab changes, isOnClockTab becomes false immediately
.toolbar(shouldHideTabBar ? .hidden : .visible, for: .tabBar)
.onChange(of: shouldHideTabBar) { oldValue, newValue in
Design.debugLog("[ClockView] shouldHideTabBar changed: \(oldValue) -> \(newValue) (isOnClockTab=\(isOnClockTab), isDisplayMode=\(viewModel.isDisplayMode))")
}
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
@ -192,7 +206,7 @@ struct ClockView: View {
// MARK: - Preview
#Preview {
NavigationStack {
ClockView(viewModel: ClockViewModel())
ClockView(viewModel: ClockViewModel(), isOnClockTab: true)
}
.frame(width: 400, height: 600)
.background(Color.black)

View File

@ -27,6 +27,13 @@ struct AdvancedDisplaySection: View {
accentColor: AppAccent.primary
)
SettingsToggle(
title: "Live Activities",
subtitle: "Show alarms on Lock Screen/Dynamic Island while ringing",
isOn: $style.liveActivitiesEnabled,
accentColor: AppAccent.primary
)
if style.autoBrightness {
HStack {
Text("Current Brightness")

View File

@ -9,6 +9,7 @@
import SwiftUI
import Bedrock
import Foundation
/// Streamlined onboarding optimized for activation
struct OnboardingView: View {
@ -20,6 +21,8 @@ struct OnboardingView: View {
@State private var currentPage = 0
@State private var notificationPermissionGranted = false
@State private var showCelebration = false
@State private var keepAwakeEnabled = false
@State private var liveActivitiesEnabled = false
private let totalPages = 3
@ -149,6 +152,54 @@ struct OnboardingView: View {
.multilineTextAlignment(.center)
.padding(.horizontal, Design.Spacing.xxLarge)
VStack(spacing: Design.Spacing.small) {
Text("For reliable alarms, keep TheNoiseClock on-screen.")
.typography(.body)
.foregroundStyle(AppTextColors.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Design.Spacing.xxLarge)
Button {
enableKeepAwake()
} label: {
HStack {
Image(systemName: keepAwakeEnabled ? "checkmark.circle.fill" : "bolt.fill")
Text(keepAwakeEnabled ? "Keep Awake Enabled" : "Enable Keep Awake")
}
.typography(.bodyEmphasis)
.foregroundStyle(keepAwakeEnabled ? AppStatus.success : .white)
.frame(maxWidth: 280)
.padding(Design.Spacing.medium)
.background(keepAwakeEnabled ? AppStatus.success.opacity(0.15) : AppAccent.primary)
.cornerRadius(Design.CornerRadius.medium)
}
.disabled(keepAwakeEnabled)
}
VStack(spacing: Design.Spacing.small) {
Text("Show alarms on the Dynamic Island and Lock Screen while they are ringing.")
.typography(.body)
.foregroundStyle(AppTextColors.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Design.Spacing.xxLarge)
Button {
enableLiveActivities()
} label: {
HStack {
Image(systemName: liveActivitiesEnabled ? "checkmark.circle.fill" : "sparkles")
Text(liveActivitiesEnabled ? "Live Activities Enabled" : "Enable Live Activities")
}
.typography(.bodyEmphasis)
.foregroundStyle(liveActivitiesEnabled ? AppStatus.success : .white)
.frame(maxWidth: 280)
.padding(Design.Spacing.medium)
.background(liveActivitiesEnabled ? AppStatus.success.opacity(0.15) : AppAccent.primary)
.cornerRadius(Design.CornerRadius.medium)
}
.disabled(liveActivitiesEnabled)
}
// Permission button or success state
permissionButton
.padding(.top, Design.Spacing.medium)
@ -157,6 +208,10 @@ struct OnboardingView: View {
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear {
keepAwakeEnabled = isKeepAwakeEnabled()
liveActivitiesEnabled = isLiveActivitiesEnabled()
}
}
private var permissionButton: some View {
@ -344,6 +399,48 @@ struct OnboardingView: View {
}
}
}
private func enableKeepAwake() {
var style = loadClockStyle()
style.keepAwake = true
saveClockStyle(style)
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
withAnimation(.spring(duration: 0.3)) {
keepAwakeEnabled = true
}
}
private func enableLiveActivities() {
var style = loadClockStyle()
style.liveActivitiesEnabled = true
saveClockStyle(style)
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
withAnimation(.spring(duration: 0.3)) {
liveActivitiesEnabled = true
}
}
private func isKeepAwakeEnabled() -> Bool {
loadClockStyle().keepAwake
}
private func isLiveActivitiesEnabled() -> Bool {
loadClockStyle().liveActivitiesEnabled
}
private func loadClockStyle() -> ClockStyle {
guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey),
let decoded = try? JSONDecoder().decode(ClockStyle.self, from: data) else {
return ClockStyle()
}
return decoded
}
private func saveClockStyle(_ style: ClockStyle) {
if let data = try? JSONEncoder().encode(style) {
UserDefaults.standard.set(data, forKey: ClockStyle.appStorageKey)
}
}
private func triggerCelebration() {
withAnimation(.spring(duration: 0.4)) {

View File

@ -12,5 +12,7 @@
<string>$(CLOUDKIT_CONTAINER_IDENTIFIER)</string>
<key>AppClipDomain</key>
<string>$(APPCLIP_DOMAIN)</string>
<key>NSSupportsLiveActivities</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,18 @@
//
// AlarmActivityAttributes.swift
// TheNoiseClock
//
// Created by Matt Bruce on 2/2/26.
//
import ActivityKit
import Foundation
struct AlarmActivityAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
var alarmDate: Date
var label: String
}
var id: UUID
}

View File

@ -33,4 +33,5 @@ extension Notification.Name {
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

@ -41,11 +41,14 @@ enum NotificationUtils {
if soundName == "default" {
content.sound = UNNotificationSound.default
Design.debugLog("[settings] Using default notification sound")
} else {
} 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

View File

@ -0,0 +1,55 @@
//
// AlarmLiveActivityWidget.swift
// TheNoiseClockWidget
//
// Created by Matt Bruce on 2/2/26.
//
import ActivityKit
import SwiftUI
import WidgetKit
struct AlarmLiveActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: AlarmActivityAttributes.self) { context in
VStack(spacing: 8) {
Text("Next Alarm")
.font(.caption)
.foregroundStyle(.secondary)
Text(context.state.label)
.font(.headline)
Text(context.state.alarmDate, style: .time)
.font(.title2.weight(.bold))
}
.padding()
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Text("Alarm")
.font(.caption)
.foregroundStyle(.secondary)
}
DynamicIslandExpandedRegion(.trailing) {
Text(context.state.alarmDate, style: .time)
.font(.caption2)
}
DynamicIslandExpandedRegion(.center) {
Text(context.state.label)
.font(.headline)
}
DynamicIslandExpandedRegion(.bottom) {
Text("Alarm at \(context.state.alarmDate, style: .time)")
.font(.caption)
}
} compactLeading: {
Image(systemName: "alarm")
} compactTrailing: {
Text(context.state.alarmDate, style: .time)
.font(.caption2)
} minimal: {
Image(systemName: "alarm")
}
}
}
}

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>SupportsLiveActivities</key>
<true/>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View File

@ -0,0 +1,16 @@
//
// TheNoiseClockWidgetBundle.swift
// TheNoiseClockWidget
//
// Created by Matt Bruce on 2/2/26.
//
import WidgetKit
import SwiftUI
@main
struct TheNoiseClockWidgetBundle: WidgetBundle {
var body: some Widget {
AlarmLiveActivityWidget()
}
}