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)
|
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
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
|
- **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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 */
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 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()
|
||||||
|
|
||||||
|
|||||||
@ -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)")
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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([])
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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?()
|
||||||
|
|||||||
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 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")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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