Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
744fe7511b
commit
a1cb0f4b1f
@ -130,7 +130,11 @@ public class SoundPlayer {
|
||||
return Bundle.main.url(forResource: fileName, withExtension: nil, subdirectory: subfolder)
|
||||
} else {
|
||||
// Direct file path (fallback)
|
||||
return Bundle.main.url(forResource: sound.fileName, withExtension: nil)
|
||||
if let url = Bundle.main.url(forResource: sound.fileName, withExtension: nil) {
|
||||
return url
|
||||
}
|
||||
// Alarm sounds live in a subdirectory; try that next
|
||||
return Bundle.main.url(forResource: sound.fileName, withExtension: nil, subdirectory: "AlarmSounds")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
11
PRD.md
11
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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -9,6 +9,8 @@
|
||||
/* Begin PBXBuildFile section */
|
||||
EA384E832E6F806200CA7D50 /* AudioPlaybackKit in Frameworks */ = {isa = PBXBuildFile; productRef = EA384D3D2E6F554D00CA7D50 /* AudioPlaybackKit */; };
|
||||
EAC051B12F2E64AB007F87EA /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EAC051B02F2E64AB007F87EA /* Bedrock */; };
|
||||
EAF1C0DE2F3A4B5C0011223E /* TheNoiseClockWidget.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = EAF1C0DE2F3A4B5C00112234 /* TheNoiseClockWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
EAF1C0DE2F3A4B5C00112242 /* TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF1C0DE2F3A4B5C00112241 /* TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@ -26,14 +28,37 @@
|
||||
remoteGlobalIDString = EA384AFA2E6E6B6000CA7D50;
|
||||
remoteInfo = TheNoiseClock;
|
||||
};
|
||||
EAF1C0DE2F3A4B5C00112240 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = EA384AF32E6E6B6000CA7D50 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = EAF1C0DE2F3A4B5C00112233;
|
||||
remoteInfo = TheNoiseClockWidget;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
EAF1C0DE2F3A4B5C0011223D /* Embed App Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
EAF1C0DE2F3A4B5C0011223E /* TheNoiseClockWidget.appex in Embed App Extensions */,
|
||||
);
|
||||
name = "Embed App Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
EA384AFB2E6E6B6000CA7D50 /* TheNoiseClock.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TheNoiseClock.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EA384B082E6E6B6100CA7D50 /* TheNoiseClockTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TheNoiseClockTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EA384B122E6E6B6100CA7D50 /* TheNoiseClockUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TheNoiseClockUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EAD6E3AF5A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = TheNoiseClock/Configuration/Debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
EAD6E3B05A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = TheNoiseClock/Configuration/Release.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
EAF1C0DE2F3A4B5C00112234 /* TheNoiseClockWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TheNoiseClockWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EAF1C0DE2F3A4B5C00112241 /* TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift; sourceTree = SOURCE_ROOT; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@ -44,6 +69,13 @@
|
||||
);
|
||||
target = EA384AFA2E6E6B6000CA7D50 /* TheNoiseClock */;
|
||||
};
|
||||
EAF1C0DE2F3A4B5C0011223C /* Exceptions for "TheNoiseClockWidget" folder in "TheNoiseClockWidget" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = EAF1C0DE2F3A4B5C00112233 /* TheNoiseClockWidget */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
@ -65,6 +97,14 @@
|
||||
path = TheNoiseClockUITests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
EAF1C0DE2F3A4B5C0011223B /* TheNoiseClockWidget */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
EAF1C0DE2F3A4B5C0011223C /* Exceptions for "TheNoiseClockWidget" folder in "TheNoiseClockWidget" target */,
|
||||
);
|
||||
path = TheNoiseClockWidget;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@ -91,6 +131,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
EAF1C0DE2F3A4B5C0011223A /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@ -100,6 +147,7 @@
|
||||
EA384AFD2E6E6B6000CA7D50 /* TheNoiseClock */,
|
||||
EA384B0B2E6E6B6100CA7D50 /* TheNoiseClockTests */,
|
||||
EA384B152E6E6B6100CA7D50 /* TheNoiseClockUITests */,
|
||||
EAF1C0DE2F3A4B5C0011223B /* TheNoiseClockWidget */,
|
||||
EA384AFC2E6E6B6000CA7D50 /* Products */,
|
||||
EAC057642F2E69E8007F87EA /* Recovered References */,
|
||||
);
|
||||
@ -111,6 +159,7 @@
|
||||
EA384AFB2E6E6B6000CA7D50 /* TheNoiseClock.app */,
|
||||
EA384B082E6E6B6100CA7D50 /* TheNoiseClockTests.xctest */,
|
||||
EA384B122E6E6B6100CA7D50 /* TheNoiseClockUITests.xctest */,
|
||||
EAF1C0DE2F3A4B5C00112234 /* TheNoiseClockWidget.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@ -120,6 +169,7 @@
|
||||
children = (
|
||||
EAD6E3AF5A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Debug.xcconfig */,
|
||||
EAD6E3B05A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Release.xcconfig */,
|
||||
EAF1C0DE2F3A4B5C00112241 /* TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift */,
|
||||
);
|
||||
name = "Recovered References";
|
||||
sourceTree = "<group>";
|
||||
@ -134,10 +184,12 @@
|
||||
EA384AF72E6E6B6000CA7D50 /* Sources */,
|
||||
EA384AF82E6E6B6000CA7D50 /* Frameworks */,
|
||||
EA384AF92E6E6B6000CA7D50 /* Resources */,
|
||||
EAF1C0DE2F3A4B5C0011223D /* Embed App Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
EAF1C0DE2F3A4B5C0011223F /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
EA384AFD2E6E6B6000CA7D50 /* TheNoiseClock */,
|
||||
@ -197,6 +249,26 @@
|
||||
productReference = EA384B122E6E6B6100CA7D50 /* TheNoiseClockUITests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.ui-testing";
|
||||
};
|
||||
EAF1C0DE2F3A4B5C00112233 /* TheNoiseClockWidget */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = EAF1C0DE2F3A4B5C00112235 /* Build configuration list for PBXNativeTarget "TheNoiseClockWidget" */;
|
||||
buildPhases = (
|
||||
EAF1C0DE2F3A4B5C00112238 /* Sources */,
|
||||
EAF1C0DE2F3A4B5C0011223A /* Frameworks */,
|
||||
EAF1C0DE2F3A4B5C00112239 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
EAF1C0DE2F3A4B5C0011223B /* TheNoiseClockWidget */,
|
||||
);
|
||||
name = TheNoiseClockWidget;
|
||||
productName = TheNoiseClockWidget;
|
||||
productReference = EAF1C0DE2F3A4B5C00112234 /* TheNoiseClockWidget.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@ -218,6 +290,9 @@
|
||||
CreatedOnToolsVersion = 26.0;
|
||||
TestTargetID = EA384AFA2E6E6B6000CA7D50;
|
||||
};
|
||||
EAF1C0DE2F3A4B5C00112233 = {
|
||||
CreatedOnToolsVersion = 26.0;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = EA384AF62E6E6B6000CA7D50 /* Build configuration list for PBXProject "TheNoiseClock" */;
|
||||
@ -241,6 +316,7 @@
|
||||
EA384AFA2E6E6B6000CA7D50 /* TheNoiseClock */,
|
||||
EA384B072E6E6B6100CA7D50 /* TheNoiseClockTests */,
|
||||
EA384B112E6E6B6100CA7D50 /* TheNoiseClockUITests */,
|
||||
EAF1C0DE2F3A4B5C00112233 /* TheNoiseClockWidget */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@ -267,6 +343,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
EAF1C0DE2F3A4B5C00112239 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@ -291,6 +374,14 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
EAF1C0DE2F3A4B5C00112238 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
EAF1C0DE2F3A4B5C00112242 /* TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
@ -304,6 +395,11 @@
|
||||
target = EA384AFA2E6E6B6000CA7D50 /* TheNoiseClock */;
|
||||
targetProxy = EA384B132E6E6B6100CA7D50 /* PBXContainerItemProxy */;
|
||||
};
|
||||
EAF1C0DE2F3A4B5C0011223F /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = EAF1C0DE2F3A4B5C00112233 /* TheNoiseClockWidget */;
|
||||
targetProxy = EAF1C0DE2F3A4B5C00112240 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
@ -586,6 +682,44 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
EAF1C0DE2F3A4B5C00112236 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = EAD6E3AF5A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Debug.xcconfig */;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = TheNoiseClockWidget/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(WIDGET_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
EAF1C0DE2F3A4B5C00112237 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = EAD6E3B05A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Release.xcconfig */;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = TheNoiseClockWidget/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(WIDGET_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@ -625,6 +759,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
EAF1C0DE2F3A4B5C00112235 /* Build configuration list for PBXNativeTarget "TheNoiseClockWidget" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
EAF1C0DE2F3A4B5C00112236 /* Debug */,
|
||||
EAF1C0DE2F3A4B5C00112237 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
|
||||
@ -7,7 +7,12 @@
|
||||
<key>TheNoiseClock.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
<integer>3</integer>
|
||||
</dict>
|
||||
<key>TheNoiseClockWidget.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>2</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,7 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
import Observation
|
||||
import Bedrock
|
||||
|
||||
/// Service for managing alarms and notifications
|
||||
@Observable
|
||||
@ -82,11 +83,14 @@ class AlarmService {
|
||||
if alarm.isEnabled {
|
||||
Task {
|
||||
let respectFocusModes = currentRespectFocusModes()
|
||||
let liveActivitiesEnabled = isLiveActivitiesEnabled()
|
||||
let body = liveActivitiesEnabled ? "" : alarm.notificationMessage
|
||||
Design.debugLog("[alarms] AlarmService schedule \(alarm.label). LiveActivities=\(liveActivitiesEnabled) body=\(body.isEmpty ? "<empty>" : "present")")
|
||||
// Use FocusModeService for better Focus mode compatibility
|
||||
focusModeService.scheduleAlarmNotification(
|
||||
identifier: alarm.id.uuidString,
|
||||
title: alarm.label,
|
||||
body: alarm.notificationMessage,
|
||||
body: body,
|
||||
date: alarm.time,
|
||||
soundName: alarm.soundName,
|
||||
repeats: false, // For now, set to false since Alarm model doesn't have repeatDays
|
||||
@ -107,6 +111,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()
|
||||
|
||||
|
||||
@ -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)")
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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([])
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -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
|
||||
)
|
||||
@ -145,6 +151,7 @@ class AlarmViewModel {
|
||||
|
||||
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) {
|
||||
@ -187,6 +205,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))
|
||||
let snoozeAlarm = Alarm(
|
||||
@ -243,6 +269,7 @@ class AlarmViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func resolveAlarm(from userInfo: [AnyHashable: Any]) -> Alarm? {
|
||||
if let alarmIdString = userInfo[AlarmNotificationKeys.alarmId] as? String,
|
||||
let alarmId = UUID(uuidString: alarmIdString),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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?()
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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 {
|
||||
@ -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() {
|
||||
withAnimation(.spring(duration: 0.4)) {
|
||||
showCelebration = true
|
||||
|
||||
@ -12,5 +12,7 @@
|
||||
<string>$(CLOUDKIT_CONTAINER_IDENTIFIER)</string>
|
||||
<key>AppClipDomain</key>
|
||||
<string>$(APPCLIP_DOMAIN)</string>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
55
TheNoiseClockWidget/AlarmLiveActivityWidget.swift
Normal file
55
TheNoiseClockWidget/AlarmLiveActivityWidget.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
16
TheNoiseClockWidget/Info.plist
Normal file
16
TheNoiseClockWidget/Info.plist
Normal 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>
|
||||
16
TheNoiseClockWidget/TheNoiseClockWidgetBundle.swift
Normal file
16
TheNoiseClockWidget/TheNoiseClockWidgetBundle.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user