From a1cb0f4b1f2420fe86379a3edb39c55cc07fde5d Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Mon, 2 Feb 2026 12:41:00 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- .../Services/SoundPlayer.swift | 6 +- PRD.md | 11 ++ README.md | 3 + TheNoiseClock.xcodeproj/project.pbxproj | 143 ++++++++++++++++++ .../xcschemes/xcschememanagement.plist | 7 +- TheNoiseClock/App/ContentView.swift | 35 +++-- .../Services/AlarmLiveActivityManager.swift | 86 +++++++++++ .../Alarms/Services/AlarmService.swift | 14 +- .../Alarms/Services/AlarmSoundService.swift | 2 +- .../Alarms/Services/FocusModeService.swift | 7 +- .../Services/NotificationDelegate.swift | 2 + .../Alarms/State/AlarmViewModel.swift | 37 ++++- .../Views/Components/AlarmRowView.swift | 20 +++ .../Features/Clock/Models/ClockStyle.swift | 13 +- .../Clock/Services/AmbientLightService.swift | 14 +- .../Features/Clock/State/ClockViewModel.swift | 45 ++++-- .../Features/Clock/Views/ClockView.swift | 18 ++- .../Settings/AdvancedDisplaySection.swift | 7 + .../Onboarding/Views/OnboardingView.swift | 97 ++++++++++++ TheNoiseClock/Info.plist | 2 + .../AlarmActivityAttributes.swift | 18 +++ .../Shared/Utilities/AlarmNotifications.swift | 1 + .../Shared/Utilities/NotificationUtils.swift | 5 +- .../AlarmLiveActivityWidget.swift | 55 +++++++ TheNoiseClockWidget/Info.plist | 16 ++ .../TheNoiseClockWidgetBundle.swift | 16 ++ 26 files changed, 628 insertions(+), 52 deletions(-) create mode 100644 TheNoiseClock/Features/Alarms/Services/AlarmLiveActivityManager.swift create mode 100644 TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift create mode 100644 TheNoiseClockWidget/AlarmLiveActivityWidget.swift create mode 100644 TheNoiseClockWidget/Info.plist create mode 100644 TheNoiseClockWidget/TheNoiseClockWidgetBundle.swift diff --git a/AudioPlaybackKit/Sources/AudioPlaybackKit/Services/SoundPlayer.swift b/AudioPlaybackKit/Sources/AudioPlaybackKit/Services/SoundPlayer.swift index 8d398d4..d7af53d 100644 --- a/AudioPlaybackKit/Sources/AudioPlaybackKit/Services/SoundPlayer.swift +++ b/AudioPlaybackKit/Sources/AudioPlaybackKit/Services/SoundPlayer.swift @@ -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") } } diff --git a/PRD.md b/PRD.md index ac6045c..ff19360 100644 --- a/PRD.md +++ b/PRD.md @@ -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 diff --git a/README.md b/README.md index a20829e..739e6eb 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/TheNoiseClock.xcodeproj/project.pbxproj b/TheNoiseClock.xcodeproj/project.pbxproj index b843864..c2b52f1 100644 --- a/TheNoiseClock.xcodeproj/project.pbxproj +++ b/TheNoiseClock.xcodeproj/project.pbxproj @@ -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 = ""; }; + EAF1C0DE2F3A4B5C0011223B /* TheNoiseClockWidget */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + EAF1C0DE2F3A4B5C0011223C /* Exceptions for "TheNoiseClockWidget" folder in "TheNoiseClockWidget" target */, + ); + path = TheNoiseClockWidget; + sourceTree = ""; + }; /* 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 = ""; @@ -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 = ""; @@ -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 */ diff --git a/TheNoiseClock.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist b/TheNoiseClock.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist index 8bc45f1..79358ba 100644 --- a/TheNoiseClock.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/TheNoiseClock.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,12 @@ TheNoiseClock.xcscheme_^#shared#^_ orderHint - 1 + 3 + + TheNoiseClockWidget.xcscheme_^#shared#^_ + + orderHint + 2 diff --git a/TheNoiseClock/App/ContentView.swift b/TheNoiseClock/App/ContentView.swift index bf99308..ed090c2 100644 --- a/TheNoiseClock/App/ContentView.swift +++ b/TheNoiseClock/App/ContentView.swift @@ -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 diff --git a/TheNoiseClock/Features/Alarms/Services/AlarmLiveActivityManager.swift b/TheNoiseClock/Features/Alarms/Services/AlarmLiveActivityManager.swift new file mode 100644 index 0000000..a489628 --- /dev/null +++ b/TheNoiseClock/Features/Alarms/Services/AlarmLiveActivityManager.swift @@ -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? + + 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) + } +} diff --git a/TheNoiseClock/Features/Alarms/Services/AlarmService.swift b/TheNoiseClock/Features/Alarms/Services/AlarmService.swift index 49fb90f..8f2cc3e 100644 --- a/TheNoiseClock/Features/Alarms/Services/AlarmService.swift +++ b/TheNoiseClock/Features/Alarms/Services/AlarmService.swift @@ -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 ? "" : "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() diff --git a/TheNoiseClock/Features/Alarms/Services/AlarmSoundService.swift b/TheNoiseClock/Features/Alarms/Services/AlarmSoundService.swift index d968052..2929c9e 100644 --- a/TheNoiseClock/Features/Alarms/Services/AlarmSoundService.swift +++ b/TheNoiseClock/Features/Alarms/Services/AlarmSoundService.swift @@ -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)") diff --git a/TheNoiseClock/Features/Alarms/Services/FocusModeService.swift b/TheNoiseClock/Features/Alarms/Services/FocusModeService.swift index aa28e08..f428eef 100644 --- a/TheNoiseClock/Features/Alarms/Services/FocusModeService.swift +++ b/TheNoiseClock/Features/Alarms/Services/FocusModeService.swift @@ -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 diff --git a/TheNoiseClock/Features/Alarms/Services/NotificationDelegate.swift b/TheNoiseClock/Features/Alarms/Services/NotificationDelegate.swift index 6af0bb5..248201d 100644 --- a/TheNoiseClock/Features/Alarms/Services/NotificationDelegate.swift +++ b/TheNoiseClock/Features/Alarms/Services/NotificationDelegate.swift @@ -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([]) diff --git a/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift b/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift index 2c6ba1e..ea179f7 100644 --- a/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift +++ b/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift @@ -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, diff --git a/TheNoiseClock/Features/Alarms/Views/Components/AlarmRowView.swift b/TheNoiseClock/Features/Alarms/Views/Components/AlarmRowView.swift index ed72ae9..e0ad475 100644 --- a/TheNoiseClock/Features/Alarms/Views/Components/AlarmRowView.swift +++ b/TheNoiseClock/Features/Alarms/Views/Components/AlarmRowView.swift @@ -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 diff --git a/TheNoiseClock/Features/Clock/Models/ClockStyle.swift b/TheNoiseClock/Features/Clock/Models/ClockStyle.swift index 3cd0551..92275cc 100644 --- a/TheNoiseClock/Features/Clock/Models/ClockStyle.swift +++ b/TheNoiseClock/Features/Clock/Models/ClockStyle.swift @@ -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 } } diff --git a/TheNoiseClock/Features/Clock/Services/AmbientLightService.swift b/TheNoiseClock/Features/Clock/Services/AmbientLightService.swift index 4cca505..5628b61 100644 --- a/TheNoiseClock/Features/Clock/Services/AmbientLightService.swift +++ b/TheNoiseClock/Features/Clock/Services/AmbientLightService.swift @@ -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?() diff --git a/TheNoiseClock/Features/Clock/State/ClockViewModel.swift b/TheNoiseClock/Features/Clock/State/ClockViewModel.swift index 23eef00..3948b7a 100644 --- a/TheNoiseClock/Features/Clock/State/ClockViewModel.swift +++ b/TheNoiseClock/Features/Clock/State/ClockViewModel.swift @@ -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") } } } diff --git a/TheNoiseClock/Features/Clock/Views/ClockView.swift b/TheNoiseClock/Features/Clock/Views/ClockView.swift index f02d8f0..d3ea80b 100644 --- a/TheNoiseClock/Features/Clock/Views/ClockView.swift +++ b/TheNoiseClock/Features/Clock/Views/ClockView.swift @@ -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) diff --git a/TheNoiseClock/Features/Clock/Views/Components/Settings/AdvancedDisplaySection.swift b/TheNoiseClock/Features/Clock/Views/Components/Settings/AdvancedDisplaySection.swift index 285827f..78fcd29 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/Settings/AdvancedDisplaySection.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/Settings/AdvancedDisplaySection.swift @@ -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") diff --git a/TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift b/TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift index b5f1ff7..e477f74 100644 --- a/TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift +++ b/TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift @@ -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)) { diff --git a/TheNoiseClock/Info.plist b/TheNoiseClock/Info.plist index 935b1a1..8224692 100644 --- a/TheNoiseClock/Info.plist +++ b/TheNoiseClock/Info.plist @@ -12,5 +12,7 @@ $(CLOUDKIT_CONTAINER_IDENTIFIER) AppClipDomain $(APPCLIP_DOMAIN) + NSSupportsLiveActivities + diff --git a/TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift b/TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift new file mode 100644 index 0000000..a805554 --- /dev/null +++ b/TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift @@ -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 +} diff --git a/TheNoiseClock/Shared/Utilities/AlarmNotifications.swift b/TheNoiseClock/Shared/Utilities/AlarmNotifications.swift index be1b476..3e41436 100644 --- a/TheNoiseClock/Shared/Utilities/AlarmNotifications.swift +++ b/TheNoiseClock/Shared/Utilities/AlarmNotifications.swift @@ -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") } diff --git a/TheNoiseClock/Shared/Utilities/NotificationUtils.swift b/TheNoiseClock/Shared/Utilities/NotificationUtils.swift index 0f1048d..6968ead 100644 --- a/TheNoiseClock/Shared/Utilities/NotificationUtils.swift +++ b/TheNoiseClock/Shared/Utilities/NotificationUtils.swift @@ -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 diff --git a/TheNoiseClockWidget/AlarmLiveActivityWidget.swift b/TheNoiseClockWidget/AlarmLiveActivityWidget.swift new file mode 100644 index 0000000..b775693 --- /dev/null +++ b/TheNoiseClockWidget/AlarmLiveActivityWidget.swift @@ -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") + } + } + } +} + diff --git a/TheNoiseClockWidget/Info.plist b/TheNoiseClockWidget/Info.plist new file mode 100644 index 0000000..1f37a25 --- /dev/null +++ b/TheNoiseClockWidget/Info.plist @@ -0,0 +1,16 @@ + + + + + NSExtension + + NSExtensionAttributes + + SupportsLiveActivities + + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/TheNoiseClockWidget/TheNoiseClockWidgetBundle.swift b/TheNoiseClockWidget/TheNoiseClockWidgetBundle.swift new file mode 100644 index 0000000..7f8e032 --- /dev/null +++ b/TheNoiseClockWidget/TheNoiseClockWidgetBundle.swift @@ -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() + } +}