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) return Bundle.main.url(forResource: fileName, withExtension: nil, subdirectory: subfolder)
} else { } else {
// Direct file path (fallback) // 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 - **Enable/disable toggles**: Individual alarm control with instant feedback
- **Notification integration**: Uses iOS UserNotifications framework with proper scheduling - **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 - **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 prompt**: In-app popup enables Keep Awake without digging into settings
- **Keep Awake guidance**: Banner messaging explains why Keep Awake improves alarm reliability - **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 - **Persistent storage**: Alarms saved to UserDefaults with backward compatibility
- **Alarm management**: Add, edit, delete, and duplicate alarms - **Alarm management**: Add, edit, delete, and duplicate alarms
- **Next trigger preview**: Shows when the next alarm will fire - **Next trigger preview**: Shows when the next alarm will fire
@ -407,6 +411,8 @@ TheNoiseClock/
│ │ │ └── View+Extensions.swift # Common view modifiers and responsive utilities │ │ │ └── View+Extensions.swift # Common view modifiers and responsive utilities
│ │ ├── Models/ │ │ ├── Models/
│ │ │ └── SoundCategory.swift # Shared sound category definitions │ │ │ └── SoundCategory.swift # Shared sound category definitions
│ │ ├── LiveActivity/
│ │ │ └── AlarmActivityAttributes.swift # Live Activity attributes shared with widget
│ │ └── Utilities/ │ │ └── Utilities/
│ │ ├── ColorUtils.swift # Color manipulation utilities │ │ ├── ColorUtils.swift # Color manipulation utilities
│ │ ├── NotificationUtils.swift # Notification helper functions │ │ ├── NotificationUtils.swift # Notification helper functions
@ -454,6 +460,7 @@ TheNoiseClock/
│ │ │ ├── Services/ │ │ │ ├── Services/
│ │ │ │ ├── AlarmService.swift │ │ │ │ ├── AlarmService.swift
│ │ │ │ ├── AlarmSoundService.swift │ │ │ │ ├── AlarmSoundService.swift
│ │ │ │ ├── AlarmLiveActivityManager.swift
│ │ │ │ ├── FocusModeService.swift │ │ │ │ ├── FocusModeService.swift
│ │ │ │ ├── NotificationService.swift │ │ │ │ ├── NotificationService.swift
│ │ │ │ └── NotificationDelegate.swift │ │ │ │ └── NotificationDelegate.swift
@ -502,6 +509,10 @@ TheNoiseClock/
│ └── [Asset catalogs] │ └── [Asset catalogs]
└── TheNoiseClock.xcodeproj/ # Xcode project with AudioPlaybackKit dependency └── TheNoiseClock.xcodeproj/ # Xcode project with AudioPlaybackKit dependency
└── project.pbxproj # Project configuration with local package reference └── 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 ### 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 - Tapping alarm notifications opens the alarm screen
- Background limitations: full alarm sound/screen requires the app to be open in the foreground - Background limitations: full alarm sound/screen requires the app to be open in the foreground
- Keep Awake prompt enables staying on-screen for alarms - 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** **Display Mode**
- Long-press to enter immersive display mode - Long-press to enter immersive display mode

View File

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

View File

@ -7,7 +7,12 @@
<key>TheNoiseClock.xcscheme_^#shared#^_</key> <key>TheNoiseClock.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <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> </dict>
</dict> </dict>

View File

@ -37,10 +37,9 @@ struct ContentView: View {
// MARK: - Computed Properties // MARK: - Computed Properties
/// Single source of truth for tab bar visibility - prevents race conditions /// Whether the clock tab is currently selected - passed to ClockView to prevent race conditions
/// Tab bar is ONLY hidden when on clock tab AND in display mode private var isOnClockTab: Bool {
private var shouldHideTabBar: Bool { selectedTab == .clock
selectedTab == .clock && clockViewModel.isDisplayMode
} }
// MARK: - Body // MARK: - Body
@ -50,7 +49,10 @@ struct ContentView: View {
// Main tab content // Main tab content
TabView(selection: $selectedTab) { TabView(selection: $selectedTab) {
NavigationStack { 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 { .tabItem {
Label("Clock", systemImage: "clock") Label("Clock", systemImage: "clock")
@ -89,21 +91,15 @@ struct ContentView: View {
} }
.tag(Tab.settings) .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 .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 { if oldValue == .clock && newValue != .clock {
Design.debugLog("[ContentView] Leaving clock tab, setting displayMode to false") Design.debugLog("[ContentView] Leaving clock tab, setting displayMode to false")
// Immediately disable display mode when leaving clock tab // Safety net: also explicitly disable display mode when leaving clock tab
// This is now a safety net - the computed property already handles visibility // The ClockView's toolbar modifier already responds to isOnClockTab changing
clockViewModel.setDisplayMode(false) clockViewModel.setDisplayMode(false)
} }
} }
.onChange(of: clockViewModel.isDisplayMode) { oldValue, newValue in
Design.debugLog("[ContentView] isDisplayMode changed: \(oldValue) -> \(newValue), selectedTab: \(selectedTab), shouldHideTabBar: \(shouldHideTabBar)")
}
.accentColor(AppAccent.primary) .accentColor(AppAccent.primary)
.background(Color.Branding.primary.ignoresSafeArea()) .background(Color.Branding.primary.ignoresSafeArea())
.fullScreenCover(item: activeAlarmBinding) { alarm in .fullScreenCover(item: activeAlarmBinding) { alarm in
@ -148,6 +144,8 @@ struct ContentView: View {
alarmViewModel.stopActiveAlarm() alarmViewModel.stopActiveAlarm()
} }
.onReceive(NotificationCenter.default.publisher(for: .keepAwakePromptRequested)) { _ in .onReceive(NotificationCenter.default.publisher(for: .keepAwakePromptRequested)) { _ in
guard onboardingState.hasCompletedWelcome else { return }
guard shouldShowKeepAwakePromptForTab() else { return }
keepAwakePromptState.showIfNeeded(isKeepAwakeEnabled: clockViewModel.style.keepAwake) keepAwakePromptState.showIfNeeded(isKeepAwakeEnabled: clockViewModel.style.keepAwake)
} }
.animation(.easeInOut(duration: 0.3), value: onboardingState.hasCompletedWelcome) .animation(.easeInOut(duration: 0.3), value: onboardingState.hasCompletedWelcome)
@ -159,6 +157,15 @@ struct ContentView: View {
set: { alarmViewModel.activeAlarm = $0 } set: { alarmViewModel.activeAlarm = $0 }
) )
} }
private func shouldShowKeepAwakePromptForTab() -> Bool {
switch selectedTab {
case .clock, .alarms:
return true
case .noise, .settings:
return false
}
}
} }
// MARK: - Preview // 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 Foundation
import UserNotifications import UserNotifications
import Observation import Observation
import Bedrock
/// Service for managing alarms and notifications /// Service for managing alarms and notifications
@Observable @Observable
@ -82,11 +83,14 @@ class AlarmService {
if alarm.isEnabled { if alarm.isEnabled {
Task { Task {
let respectFocusModes = currentRespectFocusModes() 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 // Use FocusModeService for better Focus mode compatibility
focusModeService.scheduleAlarmNotification( focusModeService.scheduleAlarmNotification(
identifier: alarm.id.uuidString, identifier: alarm.id.uuidString,
title: alarm.label, title: alarm.label,
body: alarm.notificationMessage, body: body,
date: alarm.time, date: alarm.time,
soundName: alarm.soundName, soundName: alarm.soundName,
repeats: false, // For now, set to false since Alarm model doesn't have repeatDays repeats: false, // For now, set to false since Alarm model doesn't have repeatDays
@ -107,6 +111,14 @@ class AlarmService {
return style.respectFocusModes 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() { private func saveAlarms() {
persistenceWorkItem?.cancel() persistenceWorkItem?.cancel()

View File

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

View File

@ -85,7 +85,7 @@ class FocusModeService {
// Register the category // Register the category
UNUserNotificationCenter.current().setNotificationCategories([alarmCategory]) 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 /// Schedule alarm notification with Focus mode awareness
@ -108,10 +108,13 @@ class FocusModeService {
if soundName == "default" { if soundName == "default" {
content.sound = UNNotificationSound.default content.sound = UNNotificationSound.default
Design.debugLog("[settings] Using default notification sound") 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)) content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: soundName))
Design.debugLog("[settings] Using custom alarm sound: \(soundName)") Design.debugLog("[settings] Using custom alarm sound: \(soundName)")
Design.debugLog("[settings] Sound file should be in main bundle: \(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 content.categoryIdentifier = AlarmNotificationConstants.categoryIdentifier

View File

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

View File

@ -9,6 +9,8 @@ import Foundation
import Observation import Observation
import UserNotifications import UserNotifications
import AudioPlaybackKit import AudioPlaybackKit
import Bedrock
import Bedrock
/// ViewModel for alarm management /// ViewModel for alarm management
@Observable @Observable
@ -19,6 +21,7 @@ class AlarmViewModel {
private let notificationService: NotificationService private let notificationService: NotificationService
private let alarmSoundService = AlarmSoundService.shared private let alarmSoundService = AlarmSoundService.shared
private let soundPlayer = SoundPlayer.shared private let soundPlayer = SoundPlayer.shared
private let liveActivityManager = AlarmLiveActivityManager()
var activeAlarm: Alarm? var activeAlarm: Alarm?
@ -46,10 +49,11 @@ class AlarmViewModel {
// Schedule notification if alarm is enabled // Schedule notification if alarm is enabled
if alarm.isEnabled { if alarm.isEnabled {
Design.debugLog("[alarms] Scheduling alarm notification for \(alarm.label). LiveActivities=\(isLiveActivitiesEnabled())")
await notificationService.scheduleAlarmNotification( await notificationService.scheduleAlarmNotification(
id: alarm.id.uuidString, id: alarm.id.uuidString,
title: alarm.label, title: alarm.label,
body: alarm.notificationMessage, body: isLiveActivitiesEnabled() ? "" : alarm.notificationMessage,
soundName: alarm.soundName, soundName: alarm.soundName,
date: alarm.time date: alarm.time
) )
@ -62,10 +66,11 @@ class AlarmViewModel {
// Reschedule notification // Reschedule notification
if alarm.isEnabled { if alarm.isEnabled {
Design.debugLog("[alarms] Rescheduling alarm notification for \(alarm.label). LiveActivities=\(isLiveActivitiesEnabled())")
await notificationService.scheduleAlarmNotification( await notificationService.scheduleAlarmNotification(
id: alarm.id.uuidString, id: alarm.id.uuidString,
title: alarm.label, title: alarm.label,
body: alarm.notificationMessage, body: isLiveActivitiesEnabled() ? "" : alarm.notificationMessage,
soundName: alarm.soundName, soundName: alarm.soundName,
date: alarm.time date: alarm.time
) )
@ -91,10 +96,11 @@ class AlarmViewModel {
// Schedule or cancel notification based on new state // Schedule or cancel notification based on new state
if alarm.isEnabled { if alarm.isEnabled {
Design.debugLog("[alarms] Enabling alarm \(alarm.label). LiveActivities=\(isLiveActivitiesEnabled())")
await notificationService.scheduleAlarmNotification( await notificationService.scheduleAlarmNotification(
id: alarm.id.uuidString, id: alarm.id.uuidString,
title: alarm.label, title: alarm.label,
body: alarm.notificationMessage, body: isLiveActivitiesEnabled() ? "" : alarm.notificationMessage,
soundName: alarm.soundName, soundName: alarm.soundName,
date: alarm.time date: alarm.time
) )
@ -145,6 +151,7 @@ class AlarmViewModel {
func handleAlarmNotification(userInfo: [AnyHashable: Any]?) { func handleAlarmNotification(userInfo: [AnyHashable: Any]?) {
guard let userInfo else { return } guard let userInfo else { return }
Design.debugLog("[alarms] handleAlarmNotification userInfo keys: \(Array(userInfo.keys))")
if let alarm = resolveAlarm(from: userInfo) { if let alarm = resolveAlarm(from: userInfo) {
startActiveAlarm(alarm) startActiveAlarm(alarm)
} }
@ -153,7 +160,14 @@ class AlarmViewModel {
@MainActor @MainActor
func stopActiveAlarm() { func stopActiveAlarm() {
soundPlayer.stopSound() 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 activeAlarm = nil
liveActivityManager.endActivity()
} }
@MainActor @MainActor
@ -170,6 +184,10 @@ class AlarmViewModel {
} }
activeAlarm = alarm activeAlarm = alarm
playAlarmSound(for: 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) { private func playAlarmSound(for alarm: Alarm) {
@ -187,6 +205,14 @@ class AlarmViewModel {
return style.keepAwake 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) { private func scheduleSnoozeNotification(for alarm: Alarm) {
let snoozeTime = Date().addingTimeInterval(TimeInterval(alarm.snoozeDuration * 60)) let snoozeTime = Date().addingTimeInterval(TimeInterval(alarm.snoozeDuration * 60))
let snoozeAlarm = Alarm( let snoozeAlarm = Alarm(
@ -243,6 +269,7 @@ class AlarmViewModel {
} }
} }
private func resolveAlarm(from userInfo: [AnyHashable: Any]) -> Alarm? { private func resolveAlarm(from userInfo: [AnyHashable: Any]) -> Alarm? {
if let alarmIdString = userInfo[AlarmNotificationKeys.alarmId] as? String, if let alarmIdString = userInfo[AlarmNotificationKeys.alarmId] as? String,
let alarmId = UUID(uuidString: alarmIdString), let alarmId = UUID(uuidString: alarmIdString),

View File

@ -7,6 +7,7 @@
import SwiftUI import SwiftUI
import Bedrock import Bedrock
import Foundation
/// Component for displaying individual alarm row /// Component for displaying individual alarm row
struct AlarmRowView: View { struct AlarmRowView: View {
@ -15,6 +16,7 @@ struct AlarmRowView: View {
let alarm: Alarm let alarm: Alarm
let onToggle: () -> Void let onToggle: () -> Void
let onEdit: () -> Void let onEdit: () -> Void
@AppStorage(ClockStyle.appStorageKey) private var clockStyleData: Data = Data()
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
@ -31,6 +33,17 @@ struct AlarmRowView: View {
Text("\(AlarmSoundService.shared.getSoundDisplayName(alarm.soundName))") Text("\(AlarmSoundService.shared.getSoundDisplayName(alarm.soundName))")
.font(.caption) .font(.caption)
.foregroundColor(AppTextColors.secondary) .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() 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 // MARK: - Preview

View File

@ -53,6 +53,7 @@ class ClockStyle: Codable, Equatable {
// MARK: - Display Settings // MARK: - Display Settings
var keepAwake: Bool = false // Keep screen awake in display mode var keepAwake: Bool = false // Keep screen awake in display mode
var respectFocusModes: Bool = true // Respect Focus mode settings for audio var respectFocusModes: Bool = true // Respect Focus mode settings for audio
var liveActivitiesEnabled: Bool = false // Show active alarm in Dynamic Island/Lock Screen
// MARK: - Cached Colors // MARK: - Cached Colors
private var _cachedDigitColor: Color? private var _cachedDigitColor: Color?
@ -87,6 +88,7 @@ class ClockStyle: Codable, Equatable {
case overlayOpacity case overlayOpacity
case keepAwake case keepAwake
case respectFocusModes case respectFocusModes
case liveActivitiesEnabled
} }
// MARK: - Initialization // MARK: - Initialization
@ -138,6 +140,7 @@ class ClockStyle: Codable, Equatable {
self.overlayOpacity = try container.decodeIfPresent(Double.self, forKey: .overlayOpacity) ?? self.overlayOpacity self.overlayOpacity = try container.decodeIfPresent(Double.self, forKey: .overlayOpacity) ?? self.overlayOpacity
self.keepAwake = try container.decodeIfPresent(Bool.self, forKey: .keepAwake) ?? self.keepAwake self.keepAwake = try container.decodeIfPresent(Bool.self, forKey: .keepAwake) ?? self.keepAwake
self.respectFocusModes = try container.decodeIfPresent(Bool.self, forKey: .respectFocusModes) ?? self.respectFocusModes self.respectFocusModes = try container.decodeIfPresent(Bool.self, forKey: .respectFocusModes) ?? self.respectFocusModes
self.liveActivitiesEnabled = try container.decodeIfPresent(Bool.self, forKey: .liveActivitiesEnabled) ?? self.liveActivitiesEnabled
clearColorCache() clearColorCache()
} }
@ -171,6 +174,7 @@ class ClockStyle: Codable, Equatable {
try container.encode(overlayOpacity, forKey: .overlayOpacity) try container.encode(overlayOpacity, forKey: .overlayOpacity)
try container.encode(keepAwake, forKey: .keepAwake) try container.encode(keepAwake, forKey: .keepAwake)
try container.encode(respectFocusModes, forKey: .respectFocusModes) try container.encode(respectFocusModes, forKey: .respectFocusModes)
try container.encode(liveActivitiesEnabled, forKey: .liveActivitiesEnabled)
} }
// MARK: - Computed Properties // MARK: - Computed Properties
@ -341,19 +345,19 @@ class ClockStyle: Codable, Equatable {
/// Get the effective brightness considering color theme and night mode /// Get the effective brightness considering color theme and night mode
var effectiveBrightness: Double { var effectiveBrightness: Double {
if !autoBrightness { 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 return 1.0 // Full brightness when auto-brightness is disabled
} }
if isNightModeActive { 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 // Dim the display to 30% brightness in night mode
return 0.3 return 0.3
} }
// Color-aware brightness adaptation // Color-aware brightness adaptation
let colorAwareBrightness = getColorAwareBrightness() 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 return colorAwareBrightness
} }
@ -463,7 +467,8 @@ class ClockStyle: Codable, Equatable {
lhs.clockOpacity == rhs.clockOpacity && lhs.clockOpacity == rhs.clockOpacity &&
lhs.overlayOpacity == rhs.overlayOpacity && lhs.overlayOpacity == rhs.overlayOpacity &&
lhs.keepAwake == rhs.keepAwake && lhs.keepAwake == rhs.keepAwake &&
lhs.respectFocusModes == rhs.respectFocusModes lhs.respectFocusModes == rhs.respectFocusModes &&
lhs.liveActivitiesEnabled == rhs.liveActivitiesEnabled
} }
} }

View File

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

View File

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

View File

@ -16,10 +16,19 @@ struct ClockView: View {
// MARK: - Properties // MARK: - Properties
@Bindable var viewModel: ClockViewModel @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 idleTimer: Timer?
@State private var didHandleTouch = false @State private var didHandleTouch = false
@State private var isViewActive = 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 // MARK: - Body
var body: some View { var body: some View {
GeometryReader { geometry in GeometryReader { geometry in
@ -79,7 +88,12 @@ struct ClockView: View {
.ignoresSafeArea() // Extend GeometryReader to full screen, we handle safe areas manually .ignoresSafeArea() // Extend GeometryReader to full screen, we handle safe areas manually
.toolbar(.hidden, for: .navigationBar) .toolbar(.hidden, for: .navigationBar)
.statusBarHidden(true) .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( .simultaneousGesture(
DragGesture(minimumDistance: 0) DragGesture(minimumDistance: 0)
.onChanged { _ in .onChanged { _ in
@ -192,7 +206,7 @@ struct ClockView: View {
// MARK: - Preview // MARK: - Preview
#Preview { #Preview {
NavigationStack { NavigationStack {
ClockView(viewModel: ClockViewModel()) ClockView(viewModel: ClockViewModel(), isOnClockTab: true)
} }
.frame(width: 400, height: 600) .frame(width: 400, height: 600)
.background(Color.black) .background(Color.black)

View File

@ -27,6 +27,13 @@ struct AdvancedDisplaySection: View {
accentColor: AppAccent.primary 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 { if style.autoBrightness {
HStack { HStack {
Text("Current Brightness") Text("Current Brightness")

View File

@ -9,6 +9,7 @@
import SwiftUI import SwiftUI
import Bedrock import Bedrock
import Foundation
/// Streamlined onboarding optimized for activation /// Streamlined onboarding optimized for activation
struct OnboardingView: View { struct OnboardingView: View {
@ -20,6 +21,8 @@ struct OnboardingView: View {
@State private var currentPage = 0 @State private var currentPage = 0
@State private var notificationPermissionGranted = false @State private var notificationPermissionGranted = false
@State private var showCelebration = false @State private var showCelebration = false
@State private var keepAwakeEnabled = false
@State private var liveActivitiesEnabled = false
private let totalPages = 3 private let totalPages = 3
@ -149,6 +152,54 @@ struct OnboardingView: View {
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal, Design.Spacing.xxLarge) .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 // Permission button or success state
permissionButton permissionButton
.padding(.top, Design.Spacing.medium) .padding(.top, Design.Spacing.medium)
@ -157,6 +208,10 @@ struct OnboardingView: View {
Spacer() Spacer()
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear {
keepAwakeEnabled = isKeepAwakeEnabled()
liveActivitiesEnabled = isLiveActivitiesEnabled()
}
} }
private var permissionButton: some View { private var permissionButton: some View {
@ -345,6 +400,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() { private func triggerCelebration() {
withAnimation(.spring(duration: 0.4)) { withAnimation(.spring(duration: 0.4)) {
showCelebration = true showCelebration = true

View File

@ -12,5 +12,7 @@
<string>$(CLOUDKIT_CONTAINER_IDENTIFIER)</string> <string>$(CLOUDKIT_CONTAINER_IDENTIFIER)</string>
<key>AppClipDomain</key> <key>AppClipDomain</key>
<string>$(APPCLIP_DOMAIN)</string> <string>$(APPCLIP_DOMAIN)</string>
<key>NSSupportsLiveActivities</key>
<true/>
</dict> </dict>
</plist> </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 alarmDidStop = Notification.Name("alarmDidStop")
static let alarmDidSnooze = Notification.Name("alarmDidSnooze") static let alarmDidSnooze = Notification.Name("alarmDidSnooze")
static let keepAwakePromptRequested = Notification.Name("keepAwakePromptRequested") static let keepAwakePromptRequested = Notification.Name("keepAwakePromptRequested")
static let clockStyleDidUpdate = Notification.Name("clockStyleDidUpdate")
} }

View File

@ -41,11 +41,14 @@ enum NotificationUtils {
if soundName == "default" { if soundName == "default" {
content.sound = UNNotificationSound.default content.sound = UNNotificationSound.default
Design.debugLog("[settings] Using default notification sound") 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 // Use the sound name directly since sounds.json now references CAF files
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: soundName)) content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: soundName))
Design.debugLog("[settings] Using custom alarm sound: \(soundName)") Design.debugLog("[settings] Using custom alarm sound: \(soundName)")
Design.debugLog("[settings] Sound file should be in main bundle: \(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 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()
}
}