Compare commits
9 Commits
01931244e0
...
3844e19b39
| Author | SHA1 | Date | |
|---|---|---|---|
| 3844e19b39 | |||
| b8428ca134 | |||
| f3c98cedb9 | |||
| 5e45be0a2a | |||
| a1cb0f4b1f | |||
| 744fe7511b | |||
| 8e91eed772 | |||
| e4202d5853 | |||
| a4eaa187e5 |
@ -1,6 +1,7 @@
|
||||
Use /ios-18-role
|
||||
read the PRD.md
|
||||
read the README.md
|
||||
read the Bedrock README.md as well
|
||||
|
||||
Always update the PRD.md and README.md when there are code changes that might cause these files to require those changes documented.
|
||||
|
||||
|
||||
@ -95,7 +95,8 @@ public class SoundConfigurationService {
|
||||
|
||||
/// Load sound configuration from multiple category-specific JSON files
|
||||
public func loadConfigurationFromBundles(from bundle: Bundle = .main) -> SoundConfiguration {
|
||||
let bundleNames = ["Colored", "Nature", "Mechanical", "Ambient"]
|
||||
// Include AlarmSounds bundle for alarm sound preview functionality
|
||||
let bundleNames = ["Colored", "Nature", "Mechanical", "Ambient", "AlarmSounds"]
|
||||
var allSounds: [Sound] = []
|
||||
|
||||
for bundleName in bundleNames {
|
||||
|
||||
@ -41,6 +41,14 @@ public class SoundPlayer {
|
||||
}
|
||||
|
||||
public func playSound(_ sound: Sound) {
|
||||
playSound(sound, volumeOverride: nil)
|
||||
}
|
||||
|
||||
public func playSound(_ sound: Sound, volume: Float) {
|
||||
playSound(sound, volumeOverride: volume)
|
||||
}
|
||||
|
||||
private func playSound(_ sound: Sound, volumeOverride: Float?) {
|
||||
print("🎵 Attempting to play: \(sound.name)")
|
||||
|
||||
// Stop current sound if playing
|
||||
@ -63,7 +71,7 @@ public class SoundPlayer {
|
||||
do {
|
||||
let newPlayer = try AVAudioPlayer(contentsOf: fileUrl)
|
||||
newPlayer.numberOfLoops = AudioConstants.Playback.numberOfLoops
|
||||
newPlayer.volume = AudioConstants.Volume.default
|
||||
newPlayer.volume = volumeOverride ?? AudioConstants.Volume.default
|
||||
newPlayer.prepareToPlay()
|
||||
players[sound.fileName] = newPlayer
|
||||
currentPlayer = newPlayer
|
||||
@ -77,6 +85,9 @@ public class SoundPlayer {
|
||||
}
|
||||
|
||||
currentPlayer = player
|
||||
if let volumeOverride {
|
||||
player.volume = volumeOverride
|
||||
}
|
||||
let success = player.play()
|
||||
print("🎵 Play result: \(success ? "SUCCESS" : "FAILED")")
|
||||
print("🔊 Player isPlaying: \(player.isPlaying)")
|
||||
@ -119,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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
68
PRD.md
68
PRD.md
@ -89,27 +89,32 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di
|
||||
- **Responsive layout**: Optimized for both portrait and landscape orientations
|
||||
- **AudioPlaybackKit integration**: Powered by reusable Swift package for audio functionality
|
||||
|
||||
### 6. Advanced Alarm System
|
||||
### 6. Advanced Alarm System (Powered by AlarmKit)
|
||||
- **AlarmKit integration**: iOS 26+ AlarmKit framework for reliable alarms that cut through Focus modes and silent mode
|
||||
- **Multiple alarms**: Create and manage unlimited alarms
|
||||
- **Rich alarm editor**: Full-featured alarm creation and editing interface
|
||||
- **Time selection**: Wheel-style date picker with optimized font sizing for maximum readability
|
||||
- **Dynamic alarm sounds**: Configurable alarm sounds loaded from dedicated alarm-sounds.json configuration
|
||||
- **Dynamic alarm sounds**: MP3 alarm sounds loaded from AlarmSounds folder
|
||||
- **Sound preview**: Play/stop functionality for testing alarm sounds before selection
|
||||
- **Sound organization**: Alarm sounds organized in dedicated AlarmSounds.bundle with categories
|
||||
- **Custom labels**: User-defined alarm names and descriptions
|
||||
- **Repeat schedules**: Set alarms to repeat on specific weekdays or daily
|
||||
- **Sound selection**: Choose from extensive alarm sounds with live preview
|
||||
- **Volume control**: Adjustable alarm volume (0-100%)
|
||||
- **Vibration settings**: Enable/disable vibration for each alarm
|
||||
- **Snooze functionality**: Configurable snooze duration (5, 7, 8, 9, 10, 15, 20 minutes)
|
||||
- **Smart notifications**: Automatic scheduling for one-time and repeating alarms
|
||||
- **Snooze functionality**: AlarmKit countdown feature for snooze support
|
||||
- **Live Activity countdown**: Shows 5 minutes before alarm fires on Lock Screen and Dynamic Island
|
||||
- **Dynamic Island**: Compact and expanded views with countdown timer
|
||||
- **Lock Screen widget**: Full countdown display with alarm label
|
||||
- **Enable/disable toggles**: Individual alarm control with instant feedback
|
||||
- **Notification integration**: Uses iOS UserNotifications framework with proper scheduling
|
||||
- **AlarmKit authorization**: Requires user permission via NSAlarmKitUsageDescription
|
||||
- **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
|
||||
- **Responsive time picker**: Font sizes adapt to available space and orientation
|
||||
- **AlarmKitService**: Centralized service for AlarmKit integration
|
||||
- **AlarmSoundService integration**: Dedicated service for alarm-specific sound management
|
||||
- **In-app alarm screen**: Full-screen alarm UI with Snooze/Stop when the app is active
|
||||
- **App Intents**: StopAlarmIntent and SnoozeAlarmIntent for Live Activity button actions
|
||||
|
||||
## Advanced Clock Display Features
|
||||
|
||||
@ -351,6 +356,7 @@ These principles are fundamental to the project's long-term success and must be
|
||||
- **Real-time updates**: Changes apply immediately with live preview
|
||||
- **Sheet presentation**: Full-screen settings sheet for uninterrupted editing
|
||||
- **Enum-based architecture**: Type-safe picker selections eliminate string-based errors
|
||||
- **Always-visible settings**: Advanced sections are always shown for clarity
|
||||
|
||||
## File Structure and Organization
|
||||
|
||||
@ -399,9 +405,12 @@ TheNoiseClock/
|
||||
│ │ │ └── View+Extensions.swift # Common view modifiers and responsive utilities
|
||||
│ │ ├── Models/
|
||||
│ │ │ └── SoundCategory.swift # Shared sound category definitions
|
||||
│ │ ├── LiveActivity/
|
||||
│ │ │ └── NoiseClockAlarmMetadata.swift # AlarmKit metadata shared with widget
|
||||
│ │ └── Utilities/
|
||||
│ │ ├── ColorUtils.swift # Color manipulation utilities
|
||||
│ │ └── NotificationUtils.swift # Notification helper functions
|
||||
│ │ ├── NotificationUtils.swift # Notification helper functions
|
||||
│ │ └── AlarmNotifications.swift # Alarm notification constants and events
|
||||
│ ├── Features/
|
||||
│ │ ├── Clock/
|
||||
│ │ │ ├── Models/
|
||||
@ -426,7 +435,6 @@ TheNoiseClock/
|
||||
│ │ │ ├── ClockDisplayContainer.swift
|
||||
│ │ │ ├── ClockOverlayContainer.swift
|
||||
│ │ │ ├── ClockGestureHandler.swift
|
||||
│ │ │ ├── ClockTabBarManager.swift
|
||||
│ │ │ ├── ClockToolbar.swift
|
||||
│ │ │ ├── FullScreenHintView.swift
|
||||
│ │ │ └── Settings/
|
||||
@ -444,11 +452,11 @@ TheNoiseClock/
|
||||
│ │ │ ├── State/
|
||||
│ │ │ │ └── AlarmViewModel.swift
|
||||
│ │ │ ├── Services/
|
||||
│ │ │ │ ├── AlarmService.swift
|
||||
│ │ │ │ ├── AlarmSoundService.swift
|
||||
│ │ │ │ ├── FocusModeService.swift
|
||||
│ │ │ │ ├── NotificationService.swift
|
||||
│ │ │ │ └── NotificationDelegate.swift
|
||||
│ │ │ │ ├── AlarmService.swift # Alarm persistence
|
||||
│ │ │ │ ├── AlarmSoundService.swift # Alarm sound metadata
|
||||
│ │ │ │ └── AlarmKitService.swift # AlarmKit integration (iOS 26+)
|
||||
│ │ │ ├── Intents/
|
||||
│ │ │ │ └── AlarmIntents.swift # App Intents for Stop/Snooze
|
||||
│ │ │ └── Views/
|
||||
│ │ │ ├── AlarmView.swift
|
||||
│ │ │ ├── AddAlarmView.swift
|
||||
@ -462,12 +470,17 @@ TheNoiseClock/
|
||||
│ │ │ ├── SoundSelectionView.swift
|
||||
│ │ │ ├── TimePickerSection.swift
|
||||
│ │ │ └── TimeUntilAlarmSection.swift
|
||||
│ │ └── Noise/
|
||||
│ │ ├── Noise/
|
||||
│ │ │ └── Views/
|
||||
│ │ │ ├── NoiseView.swift
|
||||
│ │ │ └── Components/
|
||||
│ │ │ ├── SoundCategoryView.swift
|
||||
│ │ │ └── SoundControlView.swift
|
||||
│ │ └── Onboarding/
|
||||
│ │ └── Views/
|
||||
│ │ ├── NoiseView.swift
|
||||
│ │ ├── OnboardingView.swift
|
||||
│ │ └── Components/
|
||||
│ │ ├── SoundCategoryView.swift
|
||||
│ │ └── SoundControlView.swift
|
||||
│ │ └── OnboardingPageView.swift
|
||||
│ └── Resources/
|
||||
│ ├── LaunchScreen.storyboard # Branded native launch screen
|
||||
│ ├── sounds.json # Ambient sound configuration and definitions
|
||||
@ -488,6 +501,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
|
||||
@ -535,6 +552,16 @@ The following changes **automatically require** PRD updates:
|
||||
- **Version consistency**: Code and documentation must always be in sync
|
||||
- **No manual requests**: Users should not need to ask for PRD updates
|
||||
|
||||
### 7. Onboarding Experience
|
||||
- **First-launch detection**: Uses OnboardingState to detect first-time users
|
||||
- **Welcome screen**: Introduces the app with branded visuals
|
||||
- **Feature highlights**: Dedicated pages for Clock, Alarms, and Noise features
|
||||
- **Permission request**: Notification permission request with contextual explanation
|
||||
- **Skip option**: Users can skip onboarding at any time
|
||||
- **Persistent state**: Onboarding completion saved to UserDefaults
|
||||
- **Smooth transitions**: Animated page transitions with page indicators
|
||||
- **Get Started flow**: Clear call-to-action to complete onboarding
|
||||
|
||||
## Key User Interactions
|
||||
|
||||
### Clock Tab
|
||||
@ -547,9 +574,10 @@ The following changes **automatically require** PRD updates:
|
||||
1. **Time format**: Toggle 24-hour, seconds, AM/PM display
|
||||
2. **Appearance**: Adjust colors, glow, size, opacity
|
||||
3. **Display**: Control keep awake functionality for display mode
|
||||
4. **Focus Modes**: Control how app behaves with Focus modes (Do Not Disturb)
|
||||
5. **Overlays**: Control battery and date display
|
||||
6. **Background**: Set background color and use presets
|
||||
4. **Keep Awake prompt**: Auto-prompt when needed (alarms tab, enabling alarms, display mode)
|
||||
5. **Focus Modes**: Control how app behaves with Focus modes (Do Not Disturb)
|
||||
6. **Overlays**: Control battery and date display
|
||||
7. **Background**: Set background color and use presets
|
||||
|
||||
### Alarms Tab
|
||||
1. **View alarms**: List of all created alarms with labels and repeat schedules
|
||||
|
||||
20
README.md
20
README.md
@ -1,6 +1,6 @@
|
||||
# TheNoiseClock
|
||||
|
||||
TheNoiseClock is a SwiftUI iOS app that blends a bold, full-screen digital clock with white noise playback and a rich alarm system. It is optimized for iOS 18+ with Swift 6, built on a modular architecture, and styled with the Bedrock design system.
|
||||
TheNoiseClock is a SwiftUI iOS app that blends a bold, full-screen digital clock with white noise playback and a rich alarm system. It is optimized for iOS 26+ with Swift 6, built on a modular architecture, and styled with the Bedrock design system. Alarms use AlarmKit for reliable wake-up alerts that cut through Focus modes and silent mode.
|
||||
|
||||
---
|
||||
|
||||
@ -36,11 +36,16 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and
|
||||
- Seamless looping with background audio support
|
||||
- Quick preview on long-press, instant play/stop controls
|
||||
|
||||
**Alarms**
|
||||
**Alarms (Powered by AlarmKit)**
|
||||
- Unlimited alarms with labels, repeat schedules, and snooze options
|
||||
- Alarm sound library with preview
|
||||
- Alarm sound library with preview (MP3 format)
|
||||
- Vibration and volume controls per alarm
|
||||
- Focus-mode aware scheduling
|
||||
- AlarmKit integration: alarms cut through Focus modes and silent mode
|
||||
- Live Activity countdown shows 5 minutes before alarm fires
|
||||
- Dynamic Island displays countdown and alarm status
|
||||
- Lock Screen shows alarm countdown with custom UI
|
||||
- Full-screen in-app alarm screen with Snooze/Stop when active
|
||||
- Snooze support via AlarmKit's countdown feature
|
||||
|
||||
**Display Mode**
|
||||
- Long-press to enter immersive display mode
|
||||
@ -48,6 +53,7 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and
|
||||
- Optional wake-lock to keep the screen on
|
||||
|
||||
### What's New
|
||||
- First-launch onboarding with feature highlights and notification setup
|
||||
- Branded launch experience with Bedrock theming
|
||||
- Redesigned settings interface with cards, toggles, and sliders
|
||||
- Centralized build identifiers via xcconfig
|
||||
@ -60,15 +66,17 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and
|
||||
- Full-screen display mode and Dynamic Island awareness
|
||||
- White noise playback with categories and previews
|
||||
- Rich alarm editor with scheduling and snooze controls
|
||||
- Full-screen in-app alarm screen with Snooze/Stop controls
|
||||
- Bedrock-based theming and branded launch
|
||||
- iPhone and iPad support with adaptive layouts
|
||||
- First-launch onboarding with feature highlights and permission setup
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- iOS 18.0+
|
||||
- Xcode 16+
|
||||
- iOS 26.0+
|
||||
- Xcode 26+
|
||||
- Swift 6
|
||||
|
||||
---
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
/* 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, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@ -26,14 +27,36 @@
|
||||
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; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@ -44,6 +67,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 +95,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 +129,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
EAF1C0DE2F3A4B5C0011223A /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@ -100,6 +145,7 @@
|
||||
EA384AFD2E6E6B6000CA7D50 /* TheNoiseClock */,
|
||||
EA384B0B2E6E6B6100CA7D50 /* TheNoiseClockTests */,
|
||||
EA384B152E6E6B6100CA7D50 /* TheNoiseClockUITests */,
|
||||
EAF1C0DE2F3A4B5C0011223B /* TheNoiseClockWidget */,
|
||||
EA384AFC2E6E6B6000CA7D50 /* Products */,
|
||||
EAC057642F2E69E8007F87EA /* Recovered References */,
|
||||
);
|
||||
@ -111,6 +157,7 @@
|
||||
EA384AFB2E6E6B6000CA7D50 /* TheNoiseClock.app */,
|
||||
EA384B082E6E6B6100CA7D50 /* TheNoiseClockTests.xctest */,
|
||||
EA384B122E6E6B6100CA7D50 /* TheNoiseClockUITests.xctest */,
|
||||
EAF1C0DE2F3A4B5C00112234 /* TheNoiseClockWidget.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@ -134,10 +181,12 @@
|
||||
EA384AF72E6E6B6000CA7D50 /* Sources */,
|
||||
EA384AF82E6E6B6000CA7D50 /* Frameworks */,
|
||||
EA384AF92E6E6B6000CA7D50 /* Resources */,
|
||||
EAF1C0DE2F3A4B5C0011223D /* Embed App Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
EAF1C0DE2F3A4B5C0011223F /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
EA384AFD2E6E6B6000CA7D50 /* TheNoiseClock */,
|
||||
@ -197,6 +246,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 +287,9 @@
|
||||
CreatedOnToolsVersion = 26.0;
|
||||
TestTargetID = EA384AFA2E6E6B6000CA7D50;
|
||||
};
|
||||
EAF1C0DE2F3A4B5C00112233 = {
|
||||
CreatedOnToolsVersion = 26.0;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = EA384AF62E6E6B6000CA7D50 /* Build configuration list for PBXProject "TheNoiseClock" */;
|
||||
@ -241,6 +313,7 @@
|
||||
EA384AFA2E6E6B6000CA7D50 /* TheNoiseClock */,
|
||||
EA384B072E6E6B6100CA7D50 /* TheNoiseClockTests */,
|
||||
EA384B112E6E6B6100CA7D50 /* TheNoiseClockUITests */,
|
||||
EAF1C0DE2F3A4B5C00112233 /* TheNoiseClockWidget */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@ -267,6 +340,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
EAF1C0DE2F3A4B5C00112239 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@ -291,6 +371,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
EAF1C0DE2F3A4B5C00112238 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
@ -304,6 +391,11 @@
|
||||
target = EA384AFA2E6E6B6000CA7D50 /* TheNoiseClock */;
|
||||
targetProxy = EA384B132E6E6B6100CA7D50 /* PBXContainerItemProxy */;
|
||||
};
|
||||
EAF1C0DE2F3A4B5C0011223F /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = EAF1C0DE2F3A4B5C00112233 /* TheNoiseClockWidget */;
|
||||
targetProxy = EAF1C0DE2F3A4B5C00112240 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
@ -446,7 +538,7 @@
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@ -480,7 +572,7 @@
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@ -586,6 +678,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 = 26;
|
||||
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 = 26;
|
||||
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 +755,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>2</integer>
|
||||
</dict>
|
||||
<key>TheNoiseClockWidget.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>3</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
@ -11,60 +11,142 @@ import Bedrock
|
||||
/// Main tab navigation coordinator
|
||||
struct ContentView: View {
|
||||
|
||||
// MARK: - Body
|
||||
private enum Tab: Hashable {
|
||||
// MARK: - Properties
|
||||
|
||||
private enum Tab: Hashable, CustomStringConvertible {
|
||||
case clock
|
||||
case alarms
|
||||
case noise
|
||||
case settings
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .clock: return "clock"
|
||||
case .alarms: return "alarms"
|
||||
case .noise: return "noise"
|
||||
case .settings: return "settings"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@State private var selectedTab: Tab = .clock
|
||||
@State private var clockViewModel = ClockViewModel()
|
||||
@State private var alarmViewModel = AlarmViewModel()
|
||||
@State private var onboardingState = OnboardingState(appIdentifier: "TheNoiseClock")
|
||||
@State private var keepAwakePromptState = KeepAwakePromptState()
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
/// Whether the clock tab is currently selected - passed to ClockView to prevent race conditions
|
||||
private var isOnClockTab: Bool {
|
||||
selectedTab == .clock
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
NavigationStack {
|
||||
ClockView(viewModel: clockViewModel)
|
||||
}
|
||||
.tabItem {
|
||||
Label("Clock", systemImage: "clock")
|
||||
}
|
||||
.tag(Tab.clock)
|
||||
|
||||
NavigationStack {
|
||||
AlarmView()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Alarms", systemImage: "alarm")
|
||||
}
|
||||
.tag(Tab.alarms)
|
||||
|
||||
NavigationStack {
|
||||
NoiseView()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Noise", systemImage: "waveform")
|
||||
}
|
||||
.tag(Tab.noise)
|
||||
ZStack {
|
||||
// Main tab content
|
||||
TabView(selection: $selectedTab) {
|
||||
NavigationStack {
|
||||
// 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")
|
||||
}
|
||||
.tag(Tab.clock)
|
||||
|
||||
NavigationStack {
|
||||
AlarmView(viewModel: alarmViewModel)
|
||||
}
|
||||
.tabItem {
|
||||
Label("Alarms", systemImage: "alarm")
|
||||
}
|
||||
.tag(Tab.alarms)
|
||||
|
||||
NavigationStack {
|
||||
NoiseView()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Noise", systemImage: "waveform")
|
||||
}
|
||||
.tag(Tab.noise)
|
||||
|
||||
NavigationStack {
|
||||
ClockSettingsView(style: clockViewModel.style) { newStyle in
|
||||
clockViewModel.updateStyle(newStyle)
|
||||
NavigationStack {
|
||||
ClockSettingsView(
|
||||
style: clockViewModel.style,
|
||||
onCommit: { newStyle in
|
||||
clockViewModel.updateStyle(newStyle)
|
||||
},
|
||||
onResetOnboarding: {
|
||||
onboardingState.reset()
|
||||
}
|
||||
)
|
||||
}
|
||||
.tabItem {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
}
|
||||
.tag(Tab.settings)
|
||||
}
|
||||
.onChange(of: selectedTab) { oldValue, newValue in
|
||||
Design.debugLog("[ContentView] Tab changed: \(oldValue) -> \(newValue)")
|
||||
if oldValue == .clock && newValue != .clock {
|
||||
Design.debugLog("[ContentView] Leaving clock tab, setting displayMode to false")
|
||||
// Safety net: also explicitly disable display mode when leaving clock tab
|
||||
// The ClockView's toolbar modifier already responds to isOnClockTab changing
|
||||
clockViewModel.setDisplayMode(false)
|
||||
}
|
||||
}
|
||||
.tabItem {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
}
|
||||
.tag(Tab.settings)
|
||||
}
|
||||
.onChange(of: selectedTab) { oldValue, newValue in
|
||||
if oldValue == .clock && newValue != .clock {
|
||||
clockViewModel.setDisplayMode(false)
|
||||
.accentColor(AppAccent.primary)
|
||||
.background(Color.Branding.primary.ignoresSafeArea())
|
||||
// Note: AlarmKit handles the alarm UI via the system Lock Screen and Dynamic Island.
|
||||
// No in-app alarm screen is needed - users interact with alarms via the system UI.
|
||||
|
||||
// Onboarding overlay for first-time users
|
||||
if !onboardingState.hasCompletedWelcome {
|
||||
OnboardingView {
|
||||
onboardingState.completeWelcome()
|
||||
}
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.accentColor(AppAccent.primary)
|
||||
.background(Color.Branding.primary.ignoresSafeArea())
|
||||
.sheet(isPresented: $keepAwakePromptState.isPresented) {
|
||||
KeepAwakePrompt(
|
||||
onEnable: {
|
||||
clockViewModel.setKeepAwakeEnabled(true)
|
||||
keepAwakePromptState.dismiss()
|
||||
},
|
||||
onDismiss: {
|
||||
keepAwakePromptState.dismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
.task {
|
||||
Design.debugLog("[ContentView] App launched - initializing AlarmKit")
|
||||
|
||||
// Reschedule all enabled alarms with AlarmKit on app launch
|
||||
await alarmViewModel.rescheduleAllAlarms()
|
||||
|
||||
Design.debugLog("[ContentView] AlarmKit initialization complete")
|
||||
}
|
||||
.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)
|
||||
}
|
||||
|
||||
private func shouldShowKeepAwakePromptForTab() -> Bool {
|
||||
switch selectedTab {
|
||||
case .clock, .alarms:
|
||||
return true
|
||||
case .noise, .settings:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -4,6 +4,9 @@
|
||||
//
|
||||
// Created by Matt Bruce on 9/7/25.
|
||||
//
|
||||
// AlarmKit handles all alarm UI and sound playback.
|
||||
// No notification delegate is needed with AlarmKit (iOS 26+).
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
@ -12,12 +15,6 @@ import Bedrock
|
||||
@main
|
||||
struct TheNoiseClockApp: App {
|
||||
|
||||
// MARK: - Initialization
|
||||
init() {
|
||||
// Initialize notification delegate to handle snooze actions
|
||||
_ = NotificationDelegate.shared
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
|
||||
118
TheNoiseClock/Features/Alarms/Intents/AlarmIntents.swift
Normal file
118
TheNoiseClock/Features/Alarms/Intents/AlarmIntents.swift
Normal file
@ -0,0 +1,118 @@
|
||||
//
|
||||
// AlarmIntents.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Created by Matt Bruce on 2/2/26.
|
||||
//
|
||||
// App Intents for alarm actions from Live Activity and widget buttons.
|
||||
// Note: These intents are duplicated in TheNoiseClockWidget target.
|
||||
//
|
||||
|
||||
import AlarmKit
|
||||
import AppIntents
|
||||
import Foundation
|
||||
|
||||
// MARK: - Stop Alarm Intent
|
||||
|
||||
/// Intent to stop an active alarm from the Live Activity or notification.
|
||||
struct StopAlarmIntent: LiveActivityIntent {
|
||||
|
||||
static var title: LocalizedStringResource = "Stop Alarm"
|
||||
static var description = IntentDescription("Stops the currently ringing alarm")
|
||||
|
||||
@Parameter(title: "Alarm ID")
|
||||
var alarmId: String
|
||||
|
||||
static var supportedModes: IntentModes { .background }
|
||||
|
||||
init() {
|
||||
self.alarmId = ""
|
||||
}
|
||||
|
||||
init(alarmId: String) {
|
||||
self.alarmId = alarmId
|
||||
}
|
||||
|
||||
func perform() throws -> some IntentResult {
|
||||
guard let uuid = UUID(uuidString: alarmId) else {
|
||||
throw AlarmIntentError.invalidAlarmID
|
||||
}
|
||||
|
||||
try AlarmManager.shared.stop(id: uuid)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Snooze Alarm Intent
|
||||
|
||||
/// Intent to snooze an active alarm from the Live Activity or notification.
|
||||
struct SnoozeAlarmIntent: LiveActivityIntent {
|
||||
|
||||
static var title: LocalizedStringResource = "Snooze Alarm"
|
||||
static var description = IntentDescription("Snoozes the currently ringing alarm")
|
||||
|
||||
@Parameter(title: "Alarm ID")
|
||||
var alarmId: String
|
||||
|
||||
static var supportedModes: IntentModes { .background }
|
||||
|
||||
init() {
|
||||
self.alarmId = ""
|
||||
}
|
||||
|
||||
init(alarmId: String) {
|
||||
self.alarmId = alarmId
|
||||
}
|
||||
|
||||
func perform() throws -> some IntentResult {
|
||||
guard let uuid = UUID(uuidString: alarmId) else {
|
||||
throw AlarmIntentError.invalidAlarmID
|
||||
}
|
||||
|
||||
// Use countdown to postpone the alarm by its configured snooze duration
|
||||
try AlarmManager.shared.countdown(id: uuid)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Open App Intent
|
||||
|
||||
/// Intent to open the app when the user taps the Live Activity.
|
||||
struct OpenAlarmAppIntent: LiveActivityIntent {
|
||||
|
||||
static var title: LocalizedStringResource = "Open TheNoiseClock"
|
||||
static var description = IntentDescription("Opens the app to the alarm screen")
|
||||
static var openAppWhenRun = true
|
||||
|
||||
@Parameter(title: "Alarm ID")
|
||||
var alarmId: String
|
||||
|
||||
init() {
|
||||
self.alarmId = ""
|
||||
}
|
||||
|
||||
init(alarmId: String) {
|
||||
self.alarmId = alarmId
|
||||
}
|
||||
|
||||
func perform() throws -> some IntentResult {
|
||||
// The app will be opened due to openAppWhenRun = true
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum AlarmIntentError: Error, LocalizedError {
|
||||
case invalidAlarmID
|
||||
case alarmNotFound
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidAlarmID:
|
||||
return "Invalid alarm ID"
|
||||
case .alarmNotFound:
|
||||
return "Alarm not found"
|
||||
}
|
||||
}
|
||||
}
|
||||
418
TheNoiseClock/Features/Alarms/Services/AlarmKitService.swift
Normal file
418
TheNoiseClock/Features/Alarms/Services/AlarmKitService.swift
Normal file
@ -0,0 +1,418 @@
|
||||
//
|
||||
// AlarmKitService.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Created by Matt Bruce on 2/2/26.
|
||||
//
|
||||
|
||||
import ActivityKit
|
||||
import AlarmKit
|
||||
import Bedrock
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Service for managing alarms using AlarmKit (iOS 26+).
|
||||
/// AlarmKit alarms cut through Focus modes and silent mode.
|
||||
@MainActor
|
||||
final class AlarmKitService {
|
||||
|
||||
// MARK: - Singleton
|
||||
|
||||
static let shared = AlarmKitService()
|
||||
private let manager = AlarmManager.shared
|
||||
|
||||
private init() {
|
||||
Design.debugLog("[alarmkit] AlarmKitService initialized")
|
||||
Design.debugLog("[alarmkit] Authorization state: \(manager.authorizationState)")
|
||||
}
|
||||
|
||||
// MARK: - Authorization
|
||||
|
||||
/// The current authorization state for AlarmKit
|
||||
var authorizationState: AlarmManager.AuthorizationState {
|
||||
manager.authorizationState
|
||||
}
|
||||
|
||||
/// Request authorization to schedule alarms.
|
||||
/// - Returns: `true` if authorized, `false` otherwise.
|
||||
func requestAuthorization() async -> Bool {
|
||||
Design.debugLog("[alarmkit] Requesting authorization, current state: \(manager.authorizationState)")
|
||||
|
||||
switch manager.authorizationState {
|
||||
case .notDetermined:
|
||||
do {
|
||||
let state = try await manager.requestAuthorization()
|
||||
Design.debugLog("[alarmkit] Authorization result: \(state)")
|
||||
return state == .authorized
|
||||
} catch {
|
||||
Design.debugLog("[alarmkit] Authorization error: \(error)")
|
||||
return false
|
||||
}
|
||||
case .authorized:
|
||||
Design.debugLog("[alarmkit] Already authorized")
|
||||
return true
|
||||
case .denied:
|
||||
Design.debugLog("[alarmkit] Authorization denied - user must enable in Settings")
|
||||
return false
|
||||
@unknown default:
|
||||
Design.debugLog("[alarmkit] Unknown authorization state")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Scheduling
|
||||
|
||||
/// Schedule an alarm using AlarmKit.
|
||||
/// - Parameter alarm: The alarm to schedule.
|
||||
func scheduleAlarm(_ alarm: Alarm) async throws {
|
||||
Design.debugLog("[alarmkit] ========== SCHEDULING ALARM ==========")
|
||||
Design.debugLog("[alarmkit] Label: \(alarm.label)")
|
||||
Design.debugLog("[alarmkit] Time: \(alarm.time)")
|
||||
Design.debugLog("[alarmkit] Sound: \(alarm.soundName)")
|
||||
Design.debugLog("[alarmkit] Volume: \(alarm.volume)")
|
||||
Design.debugLog("[alarmkit] ID: \(alarm.id)")
|
||||
|
||||
// Ensure we're authorized
|
||||
if manager.authorizationState != .authorized {
|
||||
Design.debugLog("[alarmkit] Not authorized, requesting...")
|
||||
let authorized = await requestAuthorization()
|
||||
guard authorized else {
|
||||
Design.debugLog("[alarmkit] Authorization failed, cannot schedule alarm")
|
||||
throw AlarmKitError.notAuthorized
|
||||
}
|
||||
}
|
||||
|
||||
// Create the stop button for the alarm
|
||||
let stopButton = AlarmButton(
|
||||
text: "Stop",
|
||||
textColor: .red,
|
||||
systemImageName: "stop.fill"
|
||||
)
|
||||
|
||||
// Create the snooze button (secondary button with countdown behavior)
|
||||
let snoozeButton = AlarmButton(
|
||||
text: "Snooze",
|
||||
textColor: .white,
|
||||
systemImageName: "moon.zzz"
|
||||
)
|
||||
Design.debugLog("[alarmkit] Created stop and snooze buttons")
|
||||
|
||||
// Create the alert presentation with snooze as secondary button
|
||||
// secondaryButtonBehavior: .countdown enables snooze functionality
|
||||
// Include both label and notification message in the title
|
||||
let alertTitle = alarm.notificationMessage.isEmpty
|
||||
? alarm.label
|
||||
: "\(alarm.label) - \(alarm.notificationMessage)"
|
||||
let alert = AlarmPresentation.Alert(
|
||||
title: LocalizedStringResource(stringLiteral: alertTitle),
|
||||
stopButton: stopButton,
|
||||
secondaryButton: snoozeButton,
|
||||
secondaryButtonBehavior: .countdown
|
||||
)
|
||||
Design.debugLog("[alarmkit] Created alert with title: \(alertTitle)")
|
||||
|
||||
// Create metadata for the alarm
|
||||
let metadata = NoiseClockAlarmMetadata(
|
||||
alarmId: alarm.id.uuidString,
|
||||
soundName: alarm.soundName,
|
||||
snoozeDuration: alarm.snoozeDuration,
|
||||
label: alarm.label,
|
||||
message: alarm.notificationMessage,
|
||||
volume: alarm.volume
|
||||
)
|
||||
Design.debugLog("[alarmkit] Created metadata: alarmId=\(metadata.alarmId), sound=\(metadata.soundName), message=\(metadata.message)")
|
||||
|
||||
// Create alarm attributes
|
||||
let attributes = AlarmAttributes<NoiseClockAlarmMetadata>(
|
||||
presentation: AlarmPresentation(alert: alert),
|
||||
metadata: metadata,
|
||||
tintColor: Color.pink
|
||||
)
|
||||
Design.debugLog("[alarmkit] Created attributes with tint color")
|
||||
|
||||
// Create the schedule - use fixed date for one-time alarms
|
||||
let schedule = createSchedule(for: alarm)
|
||||
Design.debugLog("[alarmkit] Created schedule")
|
||||
|
||||
// CountdownDuration for snooze support:
|
||||
// - preAlert: nil = no countdown before alarm (fires immediately at scheduled time)
|
||||
// - postAlert: snooze duration (how long until alarm fires again after snooze)
|
||||
// If no snooze, set countdownDuration to nil
|
||||
let snoozeDurationSeconds = TimeInterval(alarm.snoozeDuration * 60)
|
||||
let countdownDuration: AlarmKit.Alarm.CountdownDuration? = snoozeDurationSeconds > 0
|
||||
? AlarmKit.Alarm.CountdownDuration(preAlert: nil, postAlert: snoozeDurationSeconds)
|
||||
: nil
|
||||
Design.debugLog("[alarmkit] Countdown duration: preAlert=nil (immediate), postAlert=\(snoozeDurationSeconds)s (snooze)")
|
||||
|
||||
// Create the sound
|
||||
let soundName = getSoundNameForAlarmKit(alarm.soundName)
|
||||
let alarmSound = AlertConfiguration.AlertSound.named(soundName)
|
||||
Design.debugLog("[alarmkit] Created sound: \(soundName)")
|
||||
|
||||
// Create the alarm configuration with sound
|
||||
let configuration = AlarmManager.AlarmConfiguration<NoiseClockAlarmMetadata>(
|
||||
countdownDuration: countdownDuration,
|
||||
schedule: schedule,
|
||||
attributes: attributes,
|
||||
sound: alarmSound
|
||||
)
|
||||
Design.debugLog("[alarmkit] Created configuration with sound")
|
||||
|
||||
// Schedule the alarm
|
||||
do {
|
||||
let scheduledAlarm = try await manager.schedule(
|
||||
id: alarm.id,
|
||||
configuration: configuration
|
||||
)
|
||||
Design.debugLog("[alarmkit] ✅ ALARM SCHEDULED SUCCESSFULLY")
|
||||
Design.debugLog("[alarmkit] Scheduled ID: \(scheduledAlarm.id)")
|
||||
Design.debugLog("[alarmkit] Scheduled state: \(scheduledAlarm.state)")
|
||||
} catch {
|
||||
Design.debugLog("[alarmkit] ❌ SCHEDULING FAILED: \(error)")
|
||||
throw AlarmKitError.schedulingFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sound Configuration
|
||||
|
||||
/// Get the sound name for AlarmKit and ensure it's in Library/Sounds
|
||||
/// AlarmKit can only play sounds from the main bundle root or Library/Sounds
|
||||
private func getSoundNameForAlarmKit(_ soundName: String) -> String {
|
||||
Design.debugLog("[alarmkit] Preparing sound for AlarmKit: \(soundName)")
|
||||
|
||||
// Copy sound to Library/Sounds so AlarmKit can access it
|
||||
if copySoundToLibrarySounds(soundName) {
|
||||
Design.debugLog("[alarmkit] ✅ Sound ready in Library/Sounds: \(soundName)")
|
||||
} else {
|
||||
Design.debugLog("[alarmkit] ⚠️ Failed to copy sound to Library/Sounds, alarm may use default sound")
|
||||
}
|
||||
|
||||
// AlarmKit expects just the filename (with extension) when in Library/Sounds
|
||||
return soundName
|
||||
}
|
||||
|
||||
/// Copy a sound file from AlarmSounds folder to Library/Sounds
|
||||
/// Returns true if successful or file already exists
|
||||
private func copySoundToLibrarySounds(_ soundName: String) -> Bool {
|
||||
let fileManager = FileManager.default
|
||||
let nameWithoutExtension = (soundName as NSString).deletingPathExtension
|
||||
let ext = (soundName as NSString).pathExtension
|
||||
|
||||
// Try multiple locations for the source sound file
|
||||
var sourceURL: URL?
|
||||
|
||||
// 1. Try AlarmSounds subfolder in main bundle (Resources/AlarmSounds/)
|
||||
if let url = Bundle.main.url(forResource: nameWithoutExtension, withExtension: ext, subdirectory: "AlarmSounds") {
|
||||
sourceURL = url
|
||||
Design.debugLog("[alarmkit] Found sound in AlarmSounds subfolder: \(soundName)")
|
||||
}
|
||||
// 2. Try AlarmSounds.bundle
|
||||
else if let bundleURL = Bundle.main.url(forResource: "AlarmSounds", withExtension: "bundle"),
|
||||
let alarmBundle = Bundle(url: bundleURL),
|
||||
let url = alarmBundle.url(forResource: nameWithoutExtension, withExtension: ext) {
|
||||
sourceURL = url
|
||||
Design.debugLog("[alarmkit] Found sound in AlarmSounds.bundle: \(soundName)")
|
||||
}
|
||||
// 3. Try main bundle root
|
||||
else if let url = Bundle.main.url(forResource: nameWithoutExtension, withExtension: ext) {
|
||||
sourceURL = url
|
||||
Design.debugLog("[alarmkit] Found sound in main bundle root: \(soundName)")
|
||||
}
|
||||
|
||||
guard let sourceURL = sourceURL else {
|
||||
Design.debugLog("[alarmkit] ❌ Sound file not found anywhere: \(soundName)")
|
||||
logAvailableAlarmSounds()
|
||||
return false
|
||||
}
|
||||
|
||||
// Get destination URL in Library/Sounds
|
||||
guard let libraryURL = fileManager.urls(for: .libraryDirectory, in: .userDomainMask).first else {
|
||||
Design.debugLog("[alarmkit] ❌ Could not get Library directory")
|
||||
return false
|
||||
}
|
||||
|
||||
let soundsDirectory = libraryURL.appendingPathComponent("Sounds")
|
||||
let destinationURL = soundsDirectory.appendingPathComponent(soundName)
|
||||
|
||||
// Create Sounds directory if it doesn't exist
|
||||
if !fileManager.fileExists(atPath: soundsDirectory.path) {
|
||||
do {
|
||||
try fileManager.createDirectory(at: soundsDirectory, withIntermediateDirectories: true)
|
||||
Design.debugLog("[alarmkit] Created Library/Sounds directory")
|
||||
} catch {
|
||||
Design.debugLog("[alarmkit] ❌ Failed to create Sounds directory: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Copy file if it doesn't exist or is different
|
||||
if fileManager.fileExists(atPath: destinationURL.path) {
|
||||
// Check if source is newer (simple check - could compare file sizes/hashes)
|
||||
Design.debugLog("[alarmkit] Sound already exists in Library/Sounds: \(soundName)")
|
||||
return true
|
||||
}
|
||||
|
||||
do {
|
||||
try fileManager.copyItem(at: sourceURL, to: destinationURL)
|
||||
Design.debugLog("[alarmkit] ✅ Copied sound to Library/Sounds: \(soundName)")
|
||||
return true
|
||||
} catch {
|
||||
Design.debugLog("[alarmkit] ❌ Failed to copy sound: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Log available sound files in Library/Sounds for debugging
|
||||
private func logLibrarySounds() {
|
||||
guard let libraryURL = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first else {
|
||||
return
|
||||
}
|
||||
|
||||
let soundsDirectory = libraryURL.appendingPathComponent("Sounds")
|
||||
Design.debugLog("[alarmkit] ========== LIBRARY/SOUNDS FILES ==========")
|
||||
|
||||
do {
|
||||
let files = try FileManager.default.contentsOfDirectory(atPath: soundsDirectory.path)
|
||||
Design.debugLog("[alarmkit] Files in Library/Sounds: \(files)")
|
||||
} catch {
|
||||
Design.debugLog("[alarmkit] Library/Sounds directory doesn't exist or is empty")
|
||||
}
|
||||
}
|
||||
|
||||
/// Log available alarm sounds in the bundle for debugging
|
||||
private func logAvailableAlarmSounds() {
|
||||
Design.debugLog("[alarmkit] ========== AVAILABLE ALARM SOUNDS ==========")
|
||||
|
||||
// Check AlarmSounds subfolder
|
||||
if let resourcePath = Bundle.main.resourcePath {
|
||||
let alarmSoundsPath = (resourcePath as NSString).appendingPathComponent("AlarmSounds")
|
||||
if FileManager.default.fileExists(atPath: alarmSoundsPath) {
|
||||
do {
|
||||
let files = try FileManager.default.contentsOfDirectory(atPath: alarmSoundsPath)
|
||||
Design.debugLog("[alarmkit] Files in AlarmSounds folder: \(files)")
|
||||
} catch {
|
||||
Design.debugLog("[alarmkit] Error reading AlarmSounds folder: \(error)")
|
||||
}
|
||||
} else {
|
||||
Design.debugLog("[alarmkit] AlarmSounds folder doesn't exist")
|
||||
}
|
||||
}
|
||||
|
||||
// Check AlarmSounds.bundle
|
||||
if let bundleURL = Bundle.main.url(forResource: "AlarmSounds", withExtension: "bundle"),
|
||||
let alarmBundle = Bundle(url: bundleURL),
|
||||
let bundlePath = alarmBundle.resourcePath {
|
||||
do {
|
||||
let files = try FileManager.default.contentsOfDirectory(atPath: bundlePath)
|
||||
Design.debugLog("[alarmkit] Files in AlarmSounds.bundle: \(files)")
|
||||
} catch {
|
||||
Design.debugLog("[alarmkit] Error reading AlarmSounds.bundle: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel a scheduled alarm.
|
||||
/// - Parameter id: The UUID of the alarm to cancel.
|
||||
func cancelAlarm(id: UUID) {
|
||||
Design.debugLog("[alarmkit] Cancelling alarm: \(id)")
|
||||
do {
|
||||
try manager.cancel(id: id)
|
||||
Design.debugLog("[alarmkit] ✅ Alarm cancelled: \(id)")
|
||||
} catch {
|
||||
Design.debugLog("[alarmkit] ❌ Cancel error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop an active alarm that is currently alerting.
|
||||
/// - Parameter id: The UUID of the alarm to stop.
|
||||
func stopAlarm(id: UUID) {
|
||||
Design.debugLog("[alarmkit] Stopping alarm: \(id)")
|
||||
do {
|
||||
try manager.stop(id: id)
|
||||
Design.debugLog("[alarmkit] ✅ Alarm stopped: \(id)")
|
||||
} catch {
|
||||
Design.debugLog("[alarmkit] ❌ Stop error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Snooze an active alarm by starting its countdown again.
|
||||
/// - Parameter id: The UUID of the alarm to snooze.
|
||||
func snoozeAlarm(id: UUID) {
|
||||
Design.debugLog("[alarmkit] Snoozing alarm: \(id)")
|
||||
do {
|
||||
try manager.countdown(id: id)
|
||||
Design.debugLog("[alarmkit] ✅ Alarm snoozed: \(id)")
|
||||
} catch {
|
||||
Design.debugLog("[alarmkit] ❌ Snooze error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Alarm Updates
|
||||
|
||||
/// Async sequence that emits the current set of alarms whenever changes occur.
|
||||
var alarmUpdates: some AsyncSequence<[AlarmKit.Alarm], Never> {
|
||||
manager.alarmUpdates
|
||||
}
|
||||
|
||||
/// Log current state of all scheduled alarms
|
||||
func logCurrentAlarms() {
|
||||
Design.debugLog("[alarmkit] ========== CURRENT ALARMS ==========")
|
||||
Task {
|
||||
for await alarms in manager.alarmUpdates {
|
||||
Design.debugLog("[alarmkit] Found \(alarms.count) alarm(s) in AlarmKit")
|
||||
for alarm in alarms {
|
||||
Design.debugLog("[alarmkit] - ID: \(alarm.id)")
|
||||
Design.debugLog("[alarmkit] State: \(alarm.state)")
|
||||
}
|
||||
break // Just log once
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
/// Create an AlarmKit schedule from an Alarm model.
|
||||
private func createSchedule(for alarm: Alarm) -> AlarmKit.Alarm.Schedule {
|
||||
// Log the raw alarm time
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss Z"
|
||||
Design.debugLog("[alarmkit] Raw alarm.time: \(formatter.string(from: alarm.time))")
|
||||
|
||||
// Calculate the next trigger time
|
||||
let triggerDate = alarm.nextTriggerTime()
|
||||
|
||||
Design.debugLog("[alarmkit] Next trigger date: \(formatter.string(from: triggerDate))")
|
||||
Design.debugLog("[alarmkit] Current time: \(formatter.string(from: Date.now))")
|
||||
|
||||
let secondsUntil = triggerDate.timeIntervalSinceNow
|
||||
let minutesUntil = secondsUntil / 60
|
||||
Design.debugLog("[alarmkit] Time until alarm: \(Int(secondsUntil)) seconds (\(String(format: "%.1f", minutesUntil)) minutes)")
|
||||
|
||||
// Warn if the alarm is too far in the future (might indicate wrong date calculation)
|
||||
if secondsUntil > 86400 {
|
||||
Design.debugLog("[alarmkit] ⚠️ WARNING: Alarm is more than 24 hours away!")
|
||||
}
|
||||
|
||||
// Use fixed schedule for one-time alarms
|
||||
let schedule = AlarmKit.Alarm.Schedule.fixed(triggerDate)
|
||||
|
||||
Design.debugLog("[alarmkit] Schedule created: fixed at \(formatter.string(from: triggerDate))")
|
||||
return schedule
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum AlarmKitError: Error, LocalizedError {
|
||||
case notAuthorized
|
||||
case schedulingFailed(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notAuthorized:
|
||||
return "AlarmKit is not authorized. Please enable alarm permissions in Settings."
|
||||
case .schedulingFailed(let error):
|
||||
return "Failed to schedule alarm: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,12 +4,17 @@
|
||||
//
|
||||
// Created by Matt Bruce on 9/7/25.
|
||||
//
|
||||
// NOTE: This service now only handles alarm persistence.
|
||||
// Alarm scheduling is handled by AlarmKitService (iOS 26+).
|
||||
// The old notification scheduling code has been removed.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
import Observation
|
||||
import Bedrock
|
||||
|
||||
/// Service for managing alarms and notifications
|
||||
/// Service for managing alarm persistence.
|
||||
/// Alarm scheduling is handled by AlarmKitService.
|
||||
@Observable
|
||||
class AlarmService {
|
||||
|
||||
@ -17,48 +22,48 @@ class AlarmService {
|
||||
private(set) var alarms: [Alarm] = []
|
||||
private var alarmLookup: [UUID: Int] = [:]
|
||||
private var persistenceWorkItem: DispatchWorkItem?
|
||||
private let focusModeService = FocusModeService.shared
|
||||
|
||||
// MARK: - Initialization
|
||||
init() {
|
||||
loadAlarms()
|
||||
Task {
|
||||
// Request permissions through FocusModeService for better compatibility
|
||||
let granted = await focusModeService.requestNotificationPermissions()
|
||||
if !granted {
|
||||
// Fallback to original method
|
||||
_ = await NotificationUtils.requestPermissions()
|
||||
}
|
||||
}
|
||||
Design.debugLog("[alarms] AlarmService initialized with \(alarms.count) alarms")
|
||||
}
|
||||
|
||||
// MARK: - Public Interface
|
||||
|
||||
/// Add an alarm to storage. Does NOT schedule - caller should use AlarmKitService.
|
||||
func addAlarm(_ alarm: Alarm) {
|
||||
Design.debugLog("[alarms] AlarmService.addAlarm: \(alarm.label) at \(alarm.time)")
|
||||
alarms.append(alarm)
|
||||
updateAlarmLookup()
|
||||
scheduleNotification(for: alarm)
|
||||
saveAlarms()
|
||||
}
|
||||
|
||||
/// Update an alarm in storage. Does NOT reschedule - caller should use AlarmKitService.
|
||||
func updateAlarm(_ alarm: Alarm) {
|
||||
guard let index = alarmLookup[alarm.id] else { return }
|
||||
guard let index = alarmLookup[alarm.id] else {
|
||||
Design.debugLog("[alarms] AlarmService.updateAlarm: alarm not found \(alarm.id)")
|
||||
return
|
||||
}
|
||||
Design.debugLog("[alarms] AlarmService.updateAlarm: \(alarm.label) enabled=\(alarm.isEnabled)")
|
||||
alarms[index] = alarm
|
||||
updateAlarmLookup()
|
||||
scheduleNotification(for: alarm)
|
||||
saveAlarms()
|
||||
}
|
||||
|
||||
/// Delete an alarm from storage. Does NOT cancel - caller should use AlarmKitService.
|
||||
func deleteAlarm(id: UUID) {
|
||||
Design.debugLog("[alarms] AlarmService.deleteAlarm: \(id)")
|
||||
alarms.removeAll { $0.id == id }
|
||||
updateAlarmLookup()
|
||||
NotificationUtils.removeNotification(identifier: id.uuidString)
|
||||
saveAlarms()
|
||||
}
|
||||
|
||||
/// Toggle an alarm's enabled state. Does NOT reschedule - caller should use AlarmKitService.
|
||||
func toggleAlarm(id: UUID) {
|
||||
guard let index = alarmLookup[id] else { return }
|
||||
alarms[index].isEnabled.toggle()
|
||||
scheduleNotification(for: alarms[index])
|
||||
Design.debugLog("[alarms] AlarmService.toggleAlarm: \(id) now enabled=\(alarms[index].isEnabled)")
|
||||
saveAlarms()
|
||||
}
|
||||
|
||||
@ -74,36 +79,6 @@ class AlarmService {
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleNotification(for alarm: Alarm) {
|
||||
// Remove existing notification
|
||||
NotificationUtils.removeNotification(identifier: alarm.id.uuidString)
|
||||
|
||||
// Schedule new notification if enabled
|
||||
if alarm.isEnabled {
|
||||
Task {
|
||||
let respectFocusModes = currentRespectFocusModes()
|
||||
// Use FocusModeService for better Focus mode compatibility
|
||||
focusModeService.scheduleAlarmNotification(
|
||||
identifier: alarm.id.uuidString,
|
||||
title: alarm.label,
|
||||
body: alarm.notificationMessage,
|
||||
date: alarm.time,
|
||||
soundName: alarm.soundName,
|
||||
repeats: false, // For now, set to false since Alarm model doesn't have repeatDays
|
||||
respectFocusModes: respectFocusModes
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func currentRespectFocusModes() -> Bool {
|
||||
guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey),
|
||||
let style = try? JSONDecoder().decode(ClockStyle.self, from: data) else {
|
||||
return ClockStyle().respectFocusModes
|
||||
}
|
||||
return style.respectFocusModes
|
||||
}
|
||||
|
||||
private func saveAlarms() {
|
||||
persistenceWorkItem?.cancel()
|
||||
|
||||
@ -124,13 +99,37 @@ class AlarmService {
|
||||
private func loadAlarms() {
|
||||
if let savedAlarms = UserDefaults.standard.data(forKey: AppConstants.StorageKeys.savedAlarms),
|
||||
let decodedAlarms = try? JSONDecoder().decode([Alarm].self, from: savedAlarms) {
|
||||
alarms = decodedAlarms
|
||||
updateAlarmLookup()
|
||||
|
||||
// Reschedule all enabled alarms
|
||||
for alarm in alarms where alarm.isEnabled {
|
||||
scheduleNotification(for: alarm)
|
||||
// Migrate sound file extensions from .caf to .mp3
|
||||
alarms = decodedAlarms.map { alarm in
|
||||
var migratedAlarm = alarm
|
||||
migratedAlarm.soundName = migrateSoundName(alarm.soundName)
|
||||
return migratedAlarm
|
||||
}
|
||||
updateAlarmLookup()
|
||||
Design.debugLog("[alarms] Loaded \(alarms.count) alarms from storage")
|
||||
|
||||
// Save migrated alarms if any changes were made
|
||||
let needsMigration = zip(decodedAlarms, alarms).contains { $0.soundName != $1.soundName }
|
||||
if needsMigration {
|
||||
Design.debugLog("[alarms] Sound file migration applied, saving...")
|
||||
saveAlarms()
|
||||
}
|
||||
// Note: AlarmKit scheduling is handled by AlarmViewModel.rescheduleAllAlarms()
|
||||
}
|
||||
}
|
||||
|
||||
/// Migrate sound file names from .caf to .mp3
|
||||
private func migrateSoundName(_ soundName: String) -> String {
|
||||
if soundName.hasSuffix(".caf") {
|
||||
let migrated = soundName.replacingOccurrences(of: ".caf", with: ".mp3")
|
||||
Design.debugLog("[alarms] Migrating sound: \(soundName) -> \(migrated)")
|
||||
return migrated
|
||||
}
|
||||
return soundName
|
||||
}
|
||||
|
||||
/// Get all enabled alarms (for rescheduling with AlarmKit)
|
||||
func getEnabledAlarms() -> [Alarm] {
|
||||
return alarms.filter { $0.isEnabled }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)")
|
||||
@ -114,4 +114,72 @@ class AlarmSoundService {
|
||||
}
|
||||
return fileName.replacingOccurrences(of: ".caf", with: "").capitalized
|
||||
}
|
||||
|
||||
/// Get alarm sound by filename
|
||||
func getAlarmSound(fileName: String) -> Sound? {
|
||||
return getAlarmSounds().first { $0.fileName == fileName }
|
||||
}
|
||||
|
||||
/// Get the file path URL for a sound by filename
|
||||
/// - Parameter fileName: The sound filename (e.g., "classic-alarm.mp3")
|
||||
/// - Returns: The file URL if found, nil otherwise
|
||||
func getSoundPath(for fileName: String) -> URL? {
|
||||
Design.debugLog("[audio] Looking for sound file: \(fileName)")
|
||||
|
||||
// Try AlarmSounds.bundle first
|
||||
if let bundleURL = Bundle.main.url(forResource: "AlarmSounds", withExtension: "bundle"),
|
||||
let alarmBundle = Bundle(url: bundleURL) {
|
||||
|
||||
// Try with full filename
|
||||
let nameWithoutExtension = (fileName as NSString).deletingPathExtension
|
||||
let ext = (fileName as NSString).pathExtension
|
||||
|
||||
if let url = alarmBundle.url(forResource: nameWithoutExtension, withExtension: ext) {
|
||||
Design.debugLog("[audio] ✅ Found in AlarmSounds.bundle: \(url)")
|
||||
return url
|
||||
}
|
||||
Design.debugLog("[audio] Not found in AlarmSounds.bundle with extension '\(ext)'")
|
||||
|
||||
// Try common extensions
|
||||
for tryExt in ["mp3", "caf", "wav", "m4a"] {
|
||||
if let url = alarmBundle.url(forResource: nameWithoutExtension, withExtension: tryExt) {
|
||||
Design.debugLog("[audio] ✅ Found in AlarmSounds.bundle with .\(tryExt): \(url)")
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try AlarmSounds folder in main bundle
|
||||
let nameWithoutExtension = (fileName as NSString).deletingPathExtension
|
||||
let ext = (fileName as NSString).pathExtension
|
||||
|
||||
if let url = Bundle.main.url(forResource: "AlarmSounds/\(nameWithoutExtension)", withExtension: ext) {
|
||||
Design.debugLog("[audio] ✅ Found in AlarmSounds folder: \(url)")
|
||||
return url
|
||||
}
|
||||
|
||||
// Try main bundle directly
|
||||
if let url = Bundle.main.url(forResource: nameWithoutExtension, withExtension: ext) {
|
||||
Design.debugLog("[audio] ✅ Found in main bundle: \(url)")
|
||||
return url
|
||||
}
|
||||
|
||||
Design.debugLog("[audio] ❌ Sound file not found: \(fileName)")
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Log all available alarm sounds
|
||||
func logAvailableSounds() {
|
||||
Design.debugLog("[audio] ========== AVAILABLE ALARM SOUNDS ==========")
|
||||
let sounds = getAlarmSounds()
|
||||
Design.debugLog("[audio] Found \(sounds.count) alarm sound(s)")
|
||||
for sound in sounds {
|
||||
Design.debugLog("[audio] - \(sound.name): \(sound.fileName)")
|
||||
if let path = getSoundPath(for: sound.fileName) {
|
||||
Design.debugLog("[audio] Path: \(path.lastPathComponent)")
|
||||
} else {
|
||||
Design.debugLog("[audio] ⚠️ FILE NOT FOUND")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,238 +0,0 @@
|
||||
//
|
||||
// FocusModeService.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Created by Matt Bruce on 9/7/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
import Bedrock
|
||||
|
||||
/// Service to align notifications with Focus mode behavior
|
||||
@Observable
|
||||
class FocusModeService {
|
||||
|
||||
// MARK: - Singleton
|
||||
static let shared = FocusModeService()
|
||||
|
||||
// MARK: - Properties
|
||||
private(set) var notificationAuthorizationStatus: UNAuthorizationStatus = .notDetermined
|
||||
private(set) var timeSensitiveSetting: UNNotificationSetting = .notSupported
|
||||
private(set) var scheduledDeliverySetting: UNNotificationSetting = .notSupported
|
||||
private var notificationSettingsObserver: NSObjectProtocol?
|
||||
|
||||
// MARK: - Initialization
|
||||
private init() {
|
||||
setupFocusModeMonitoring()
|
||||
}
|
||||
|
||||
deinit {
|
||||
removeFocusModeObserver()
|
||||
}
|
||||
|
||||
// MARK: - Public Interface
|
||||
|
||||
/// Check if Focus mode is currently active
|
||||
var isAuthorized: Bool {
|
||||
notificationAuthorizationStatus == .authorized
|
||||
}
|
||||
|
||||
/// Request notification permissions that work with Focus modes
|
||||
func requestNotificationPermissions() async -> Bool {
|
||||
do {
|
||||
let granted = try await UNUserNotificationCenter.current().requestAuthorization(
|
||||
options: [.alert, .sound, .badge, .provisional]
|
||||
)
|
||||
|
||||
if granted {
|
||||
// Configure notification settings for Focus mode compatibility
|
||||
await configureNotificationSettings()
|
||||
}
|
||||
|
||||
await refreshNotificationSettings()
|
||||
|
||||
return granted
|
||||
} catch {
|
||||
Design.debugLog("[general] Error requesting notification permissions: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure notification settings to work with Focus modes
|
||||
private func configureNotificationSettings() async {
|
||||
// Create notification categories that work with Focus modes
|
||||
let alarmCategory = UNNotificationCategory(
|
||||
identifier: "ALARM_CATEGORY",
|
||||
actions: [
|
||||
UNNotificationAction(
|
||||
identifier: "SNOOZE_ACTION",
|
||||
title: "Snooze",
|
||||
options: []
|
||||
),
|
||||
UNNotificationAction(
|
||||
identifier: "STOP_ACTION",
|
||||
title: "Stop",
|
||||
options: [.destructive]
|
||||
)
|
||||
],
|
||||
intentIdentifiers: [],
|
||||
options: [.customDismissAction]
|
||||
)
|
||||
|
||||
// Register the category
|
||||
UNUserNotificationCenter.current().setNotificationCategories([alarmCategory])
|
||||
|
||||
Design.debugLog("[settings] Notification settings configured for Focus mode compatibility")
|
||||
}
|
||||
|
||||
/// Schedule alarm notification with Focus mode awareness
|
||||
func scheduleAlarmNotification(
|
||||
identifier: String,
|
||||
title: String,
|
||||
body: String,
|
||||
date: Date,
|
||||
soundName: String,
|
||||
repeats: Bool = false,
|
||||
respectFocusModes: Bool = true
|
||||
) {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
// Use the sound name directly since sounds.json now references CAF files
|
||||
if soundName == "default" {
|
||||
content.sound = UNNotificationSound.default
|
||||
Design.debugLog("[settings] Using default notification sound")
|
||||
} else {
|
||||
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)")
|
||||
}
|
||||
content.categoryIdentifier = "ALARM_CATEGORY"
|
||||
|
||||
if !respectFocusModes, timeSensitiveSetting == .enabled {
|
||||
content.interruptionLevel = .timeSensitive
|
||||
}
|
||||
content.userInfo = [
|
||||
"alarmId": identifier,
|
||||
"soundName": soundName,
|
||||
"repeats": repeats
|
||||
]
|
||||
|
||||
// Create trigger
|
||||
let trigger: UNNotificationTrigger
|
||||
if repeats {
|
||||
let calendar = Calendar.current
|
||||
let components = calendar.dateComponents([.hour, .minute], from: date)
|
||||
trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
|
||||
} else {
|
||||
// Use calendar trigger for one-time alarms to avoid time interval issues
|
||||
let calendar = Calendar.current
|
||||
let components = calendar.dateComponents([.hour, .minute], from: date)
|
||||
trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
|
||||
}
|
||||
|
||||
// Create request
|
||||
let request = UNNotificationRequest(
|
||||
identifier: identifier,
|
||||
content: content,
|
||||
trigger: trigger
|
||||
)
|
||||
|
||||
// Schedule notification
|
||||
UNUserNotificationCenter.current().add(request) { error in
|
||||
if let error = error {
|
||||
Design.debugLog("[general] Error scheduling alarm notification: \(error)")
|
||||
} else {
|
||||
Design.debugLog("[settings] Alarm notification scheduled for \(date)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel alarm notification
|
||||
func cancelAlarmNotification(identifier: String) {
|
||||
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [identifier])
|
||||
Design.debugLog("[settings] Cancelled alarm notification: \(identifier)")
|
||||
}
|
||||
|
||||
/// Cancel all alarm notifications
|
||||
func cancelAllAlarmNotifications() {
|
||||
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
|
||||
Design.debugLog("[settings] Cancelled all alarm notifications")
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
/// Set up monitoring for Focus mode changes
|
||||
private func setupFocusModeMonitoring() {
|
||||
notificationSettingsObserver = NotificationCenter.default.addObserver(
|
||||
forName: UIApplication.willEnterForegroundNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { await self?.refreshNotificationSettings() }
|
||||
}
|
||||
|
||||
Task { await refreshNotificationSettings() }
|
||||
}
|
||||
|
||||
/// Remove Focus mode observer
|
||||
private func removeFocusModeObserver() {
|
||||
if let observer = notificationSettingsObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
notificationSettingsObserver = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh notification settings to align with Focus mode behavior.
|
||||
@MainActor
|
||||
func refreshNotificationSettings() async {
|
||||
let settings = await UNUserNotificationCenter.current().notificationSettings()
|
||||
notificationAuthorizationStatus = settings.authorizationStatus
|
||||
timeSensitiveSetting = settings.timeSensitiveSetting
|
||||
scheduledDeliverySetting = settings.scheduledDeliverySetting
|
||||
|
||||
Design.debugLog("[settings] Notification settings updated: auth=\(settings.authorizationStatus), timeSensitive=\(settings.timeSensitiveSetting), scheduledDelivery=\(settings.scheduledDeliverySetting)")
|
||||
}
|
||||
|
||||
/// Get notification authorization status
|
||||
func getNotificationAuthorizationStatus() async -> UNAuthorizationStatus {
|
||||
let settings = await UNUserNotificationCenter.current().notificationSettings()
|
||||
return settings.authorizationStatus
|
||||
}
|
||||
|
||||
/// Check if notifications are allowed in current Focus mode
|
||||
func areNotificationsAllowed() async -> Bool {
|
||||
let status = await getNotificationAuthorizationStatus()
|
||||
return status == .authorized
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Focus Mode Configuration
|
||||
extension FocusModeService {
|
||||
|
||||
/// Configure app to work optimally with Focus modes
|
||||
func configureForFocusModes() {
|
||||
// Set up notification categories that work well with Focus modes
|
||||
Task {
|
||||
await configureNotificationSettings()
|
||||
}
|
||||
|
||||
Design.debugLog("[settings] App configured for Focus mode compatibility")
|
||||
}
|
||||
|
||||
/// Provide user guidance for Focus mode settings
|
||||
func getFocusModeGuidance() -> String {
|
||||
return """
|
||||
For the best experience with TheNoiseClock:
|
||||
|
||||
1. Allow notifications in your Focus mode settings
|
||||
2. Enable "Time Sensitive" notifications for alarms
|
||||
3. Consider adding TheNoiseClock to your Focus mode allowlist
|
||||
|
||||
This ensures alarms will work even when Focus mode is active.
|
||||
"""
|
||||
}
|
||||
}
|
||||
@ -1,180 +0,0 @@
|
||||
//
|
||||
// NotificationDelegate.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Created by Matt Bruce on 9/8/25.
|
||||
//
|
||||
|
||||
import UserNotifications
|
||||
import Foundation
|
||||
import Bedrock
|
||||
|
||||
/// Delegate to handle notification actions (snooze, stop, etc.)
|
||||
class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
|
||||
// MARK: - Singleton
|
||||
static let shared = NotificationDelegate()
|
||||
|
||||
// MARK: - Properties
|
||||
private var alarmService: AlarmService?
|
||||
|
||||
// MARK: - Initialization
|
||||
private override init() {
|
||||
super.init()
|
||||
setupNotificationCenter()
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
private func setupNotificationCenter() {
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
Design.debugLog("[settings] Notification delegate configured")
|
||||
}
|
||||
|
||||
/// Set the alarm service instance (called from AlarmViewModel)
|
||||
func setAlarmService(_ service: AlarmService) {
|
||||
self.alarmService = service
|
||||
}
|
||||
|
||||
// MARK: - UNUserNotificationCenterDelegate
|
||||
|
||||
/// Handle notification actions when app is in foreground
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void
|
||||
) {
|
||||
let actionIdentifier = response.actionIdentifier
|
||||
let notification = response.notification
|
||||
let userInfo = notification.request.content.userInfo
|
||||
|
||||
Design.debugLog("[settings] Notification action received: \(actionIdentifier)")
|
||||
|
||||
switch actionIdentifier {
|
||||
case "SNOOZE_ACTION":
|
||||
handleSnoozeAction(userInfo: userInfo)
|
||||
case "STOP_ACTION":
|
||||
handleStopAction(userInfo: userInfo)
|
||||
case UNNotificationDefaultActionIdentifier:
|
||||
// User tapped the notification itself
|
||||
handleNotificationTap(userInfo: userInfo)
|
||||
default:
|
||||
Design.debugLog("[settings] Unknown action: \(actionIdentifier)")
|
||||
}
|
||||
|
||||
completionHandler()
|
||||
}
|
||||
|
||||
/// Handle notifications when app is in foreground
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||
) {
|
||||
// Show notification even when app is in foreground
|
||||
completionHandler([.banner, .sound, .badge])
|
||||
}
|
||||
|
||||
// MARK: - Action Handlers
|
||||
|
||||
private func handleSnoozeAction(userInfo: [AnyHashable: Any]) {
|
||||
guard let alarmIdString = userInfo["alarmId"] as? String,
|
||||
let alarmId = UUID(uuidString: alarmIdString),
|
||||
let alarmService = self.alarmService,
|
||||
let alarm = alarmService.getAlarm(id: alarmId) else {
|
||||
Design.debugLog("[general] Could not find alarm for snooze action")
|
||||
return
|
||||
}
|
||||
|
||||
Design.debugLog("[settings] Snoozing alarm: \(alarm.label) for \(alarm.snoozeDuration) minutes")
|
||||
|
||||
// Calculate snooze time (current time + snooze duration)
|
||||
let snoozeTime = Date().addingTimeInterval(TimeInterval(alarm.snoozeDuration * 60))
|
||||
Design.debugLog("[settings] Snooze time: \(snoozeTime)")
|
||||
Design.debugLog("[settings] Current time: \(Date())")
|
||||
|
||||
// Create a temporary alarm for the snooze
|
||||
let snoozeAlarm = Alarm(
|
||||
id: UUID(), // New ID for snooze alarm
|
||||
time: snoozeTime,
|
||||
isEnabled: true,
|
||||
soundName: alarm.soundName,
|
||||
label: "\(alarm.label) (Snoozed)",
|
||||
notificationMessage: "Snoozed: \(alarm.notificationMessage)",
|
||||
snoozeDuration: alarm.snoozeDuration,
|
||||
isVibrationEnabled: alarm.isVibrationEnabled,
|
||||
isLightFlashEnabled: alarm.isLightFlashEnabled,
|
||||
volume: alarm.volume
|
||||
)
|
||||
|
||||
// Schedule the snooze notification
|
||||
Task {
|
||||
await scheduleSnoozeNotification(snoozeAlarm, userInfo: userInfo)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleStopAction(userInfo: [AnyHashable: Any]) {
|
||||
guard let alarmIdString = userInfo["alarmId"] as? String,
|
||||
let alarmId = UUID(uuidString: alarmIdString) else {
|
||||
Design.debugLog("[general] Could not find alarm ID for stop action")
|
||||
return
|
||||
}
|
||||
|
||||
Design.debugLog("[settings] Stopping alarm: \(alarmId)")
|
||||
|
||||
// Cancel any pending notifications for this alarm
|
||||
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [alarmIdString])
|
||||
|
||||
// If this was a snooze alarm, we don't want to disable the original alarm
|
||||
// Just cancel the current notification
|
||||
}
|
||||
|
||||
private func handleNotificationTap(userInfo: [AnyHashable: Any]) {
|
||||
guard let alarmIdString = userInfo["alarmId"] as? String,
|
||||
let alarmId = UUID(uuidString: alarmIdString) else {
|
||||
Design.debugLog("[general] Could not find alarm ID for notification tap")
|
||||
return
|
||||
}
|
||||
|
||||
Design.debugLog("[settings] Notification tapped for alarm: \(alarmId)")
|
||||
|
||||
// For now, just log the tap. In the future, this could open the alarm details
|
||||
// or perform some other action when the user taps the notification
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func scheduleSnoozeNotification(_ snoozeAlarm: Alarm, userInfo: [AnyHashable: Any]) async {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = snoozeAlarm.label
|
||||
content.body = snoozeAlarm.notificationMessage
|
||||
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: snoozeAlarm.soundName))
|
||||
content.categoryIdentifier = "ALARM_CATEGORY"
|
||||
content.userInfo = [
|
||||
"alarmId": snoozeAlarm.id.uuidString,
|
||||
"soundName": snoozeAlarm.soundName,
|
||||
"isSnooze": true,
|
||||
"originalAlarmId": userInfo["alarmId"] as? String ?? ""
|
||||
]
|
||||
|
||||
// Create trigger for snooze time
|
||||
let trigger = UNTimeIntervalNotificationTrigger(
|
||||
timeInterval: snoozeAlarm.time.timeIntervalSinceNow,
|
||||
repeats: false
|
||||
)
|
||||
|
||||
// Create request
|
||||
let request = UNNotificationRequest(
|
||||
identifier: snoozeAlarm.id.uuidString,
|
||||
content: content,
|
||||
trigger: trigger
|
||||
)
|
||||
|
||||
// Schedule notification
|
||||
do {
|
||||
try await UNUserNotificationCenter.current().add(request)
|
||||
Design.debugLog("[settings] Snooze notification scheduled for \(snoozeAlarm.time)")
|
||||
} catch {
|
||||
Design.debugLog("[general] Error scheduling snooze notification: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,87 +0,0 @@
|
||||
//
|
||||
// NotificationService.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Created by Matt Bruce on 9/7/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
import Observation
|
||||
import Bedrock
|
||||
|
||||
/// Service for managing system notifications
|
||||
@Observable
|
||||
class NotificationService {
|
||||
|
||||
// MARK: - Properties
|
||||
private(set) var isAuthorized = false
|
||||
|
||||
// MARK: - Initialization
|
||||
init() {
|
||||
checkAuthorizationStatus()
|
||||
}
|
||||
|
||||
// MARK: - Public Interface
|
||||
func requestPermissions() async -> Bool {
|
||||
do {
|
||||
let granted = try await UNUserNotificationCenter.current().requestAuthorization(
|
||||
options: [.alert, .sound, .badge]
|
||||
)
|
||||
isAuthorized = granted
|
||||
return granted
|
||||
} catch {
|
||||
Design.debugLog("[general] Error requesting notification permissions: \(error)")
|
||||
isAuthorized = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func checkAuthorizationStatus() {
|
||||
UNUserNotificationCenter.current().getNotificationSettings { settings in
|
||||
DispatchQueue.main.async {
|
||||
self.isAuthorized = settings.authorizationStatus == .authorized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule a single alarm notification
|
||||
@discardableResult
|
||||
func scheduleAlarmNotification(
|
||||
id: String,
|
||||
title: String,
|
||||
body: String,
|
||||
soundName: String,
|
||||
date: Date
|
||||
) async -> Bool {
|
||||
guard isAuthorized else {
|
||||
Design.debugLog("[settings] Notifications not authorized")
|
||||
return false
|
||||
}
|
||||
|
||||
let content = NotificationUtils.createAlarmContent(
|
||||
title: title,
|
||||
body: body,
|
||||
soundName: soundName
|
||||
)
|
||||
let trigger = NotificationUtils.createCalendarTrigger(for: date)
|
||||
|
||||
return await NotificationUtils.scheduleNotification(
|
||||
identifier: id,
|
||||
content: content,
|
||||
trigger: trigger
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/// Cancel a single notification
|
||||
func cancelNotification(id: String) {
|
||||
NotificationUtils.removeNotification(identifier: id)
|
||||
}
|
||||
|
||||
/// Cancel all notifications
|
||||
func cancelAllNotifications() {
|
||||
NotificationUtils.removeAllNotifications()
|
||||
}
|
||||
|
||||
}
|
||||
@ -5,16 +5,25 @@
|
||||
// Created by Matt Bruce on 9/7/25.
|
||||
//
|
||||
|
||||
import AlarmKit
|
||||
import Bedrock
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// ViewModel for alarm management
|
||||
/// ViewModel for alarm management using AlarmKit (iOS 26+).
|
||||
/// AlarmKit provides alarms that cut through Focus modes and silent mode,
|
||||
/// with built-in Live Activity countdown and system alarm UI.
|
||||
@Observable
|
||||
class AlarmViewModel {
|
||||
|
||||
// MARK: - Properties
|
||||
private let alarmService: AlarmService
|
||||
private let notificationService: NotificationService
|
||||
private let alarmKitService = AlarmKitService.shared
|
||||
|
||||
/// Whether AlarmKit is authorized
|
||||
var isAlarmKitAuthorized: Bool {
|
||||
alarmKitService.authorizationState == .authorized
|
||||
}
|
||||
|
||||
var alarms: [Alarm] {
|
||||
alarmService.alarms
|
||||
@ -23,53 +32,54 @@ class AlarmViewModel {
|
||||
var systemSounds: [String] {
|
||||
AppConstants.SystemSounds.availableSounds
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Initialization
|
||||
init(alarmService: AlarmService = AlarmService(),
|
||||
notificationService: NotificationService = NotificationService()) {
|
||||
init(alarmService: AlarmService = AlarmService()) {
|
||||
self.alarmService = alarmService
|
||||
self.notificationService = notificationService
|
||||
|
||||
// Register alarm service with notification delegate for snooze handling
|
||||
NotificationDelegate.shared.setAlarmService(alarmService)
|
||||
}
|
||||
|
||||
// MARK: - Public Interface
|
||||
// MARK: - Authorization
|
||||
|
||||
/// Request AlarmKit authorization. Should be called during onboarding.
|
||||
func requestAlarmKitAuthorization() async -> Bool {
|
||||
return await alarmKitService.requestAuthorization()
|
||||
}
|
||||
|
||||
// MARK: - Alarm CRUD Operations
|
||||
|
||||
func addAlarm(_ alarm: Alarm) async {
|
||||
alarmService.addAlarm(alarm)
|
||||
|
||||
// Schedule notification if alarm is enabled
|
||||
// Schedule with AlarmKit if alarm is enabled
|
||||
if alarm.isEnabled {
|
||||
await notificationService.scheduleAlarmNotification(
|
||||
id: alarm.id.uuidString,
|
||||
title: alarm.label,
|
||||
body: alarm.notificationMessage,
|
||||
soundName: alarm.soundName,
|
||||
date: alarm.time
|
||||
)
|
||||
Design.debugLog("[alarms] Scheduling AlarmKit alarm for \(alarm.label)")
|
||||
do {
|
||||
try await alarmKitService.scheduleAlarm(alarm)
|
||||
} catch {
|
||||
Design.debugLog("[alarms] AlarmKit scheduling failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateAlarm(_ alarm: Alarm) async {
|
||||
alarmService.updateAlarm(alarm)
|
||||
|
||||
// Reschedule notification
|
||||
// Cancel existing and reschedule if enabled
|
||||
alarmKitService.cancelAlarm(id: alarm.id)
|
||||
|
||||
if alarm.isEnabled {
|
||||
await notificationService.scheduleAlarmNotification(
|
||||
id: alarm.id.uuidString,
|
||||
title: alarm.label,
|
||||
body: alarm.notificationMessage,
|
||||
soundName: alarm.soundName,
|
||||
date: alarm.time
|
||||
)
|
||||
} else {
|
||||
notificationService.cancelNotification(id: alarm.id.uuidString)
|
||||
Design.debugLog("[alarms] Rescheduling AlarmKit alarm for \(alarm.label)")
|
||||
do {
|
||||
try await alarmKitService.scheduleAlarm(alarm)
|
||||
} catch {
|
||||
Design.debugLog("[alarms] AlarmKit rescheduling failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteAlarm(id: UUID) async {
|
||||
// Cancel notification first
|
||||
notificationService.cancelNotification(id: id.uuidString)
|
||||
// Cancel AlarmKit alarm first
|
||||
alarmKitService.cancelAlarm(id: id)
|
||||
|
||||
// Then delete from storage
|
||||
alarmService.deleteAlarm(id: id)
|
||||
@ -81,17 +91,16 @@ class AlarmViewModel {
|
||||
alarm.isEnabled.toggle()
|
||||
alarmService.updateAlarm(alarm)
|
||||
|
||||
// Schedule or cancel notification based on new state
|
||||
// Schedule or cancel based on new state
|
||||
if alarm.isEnabled {
|
||||
await notificationService.scheduleAlarmNotification(
|
||||
id: alarm.id.uuidString,
|
||||
title: alarm.label,
|
||||
body: alarm.notificationMessage,
|
||||
soundName: alarm.soundName,
|
||||
date: alarm.time
|
||||
)
|
||||
Design.debugLog("[alarms] Enabling AlarmKit alarm \(alarm.label)")
|
||||
do {
|
||||
try await alarmKitService.scheduleAlarm(alarm)
|
||||
} catch {
|
||||
Design.debugLog("[alarms] AlarmKit scheduling failed: \(error)")
|
||||
}
|
||||
} else {
|
||||
notificationService.cancelNotification(id: id.uuidString)
|
||||
alarmKitService.cancelAlarm(id: id)
|
||||
}
|
||||
}
|
||||
|
||||
@ -123,7 +132,26 @@ class AlarmViewModel {
|
||||
)
|
||||
}
|
||||
|
||||
func requestNotificationPermissions() async -> Bool {
|
||||
return await notificationService.requestPermissions()
|
||||
// MARK: - App Lifecycle
|
||||
|
||||
/// Reschedule all enabled alarms with AlarmKit.
|
||||
/// Call this on app launch to ensure alarms are registered.
|
||||
func rescheduleAllAlarms() async {
|
||||
Design.debugLog("[alarmkit] ========== RESCHEDULING ALL ALARMS ==========")
|
||||
|
||||
let enabledAlarms = alarmService.getEnabledAlarms()
|
||||
Design.debugLog("[alarmkit] Found \(enabledAlarms.count) enabled alarm(s)")
|
||||
|
||||
for alarm in enabledAlarms {
|
||||
Design.debugLog("[alarmkit] Scheduling: \(alarm.label) at \(alarm.time)")
|
||||
do {
|
||||
try await alarmKitService.scheduleAlarm(alarm)
|
||||
} catch {
|
||||
Design.debugLog("[alarmkit] ❌ Failed to reschedule \(alarm.label): \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
Design.debugLog("[alarmkit] ========== RESCHEDULING COMPLETE ==========")
|
||||
alarmKitService.logCurrentAlarms()
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
|
||||
import SwiftUI
|
||||
import AudioPlaybackKit
|
||||
import Foundation
|
||||
|
||||
/// View for creating new alarms with iOS-native style interface
|
||||
struct AddAlarmView: View {
|
||||
@ -14,6 +15,7 @@ struct AddAlarmView: View {
|
||||
// MARK: - Properties
|
||||
let viewModel: AlarmViewModel
|
||||
@Binding var isPresented: Bool
|
||||
@AppStorage(ClockStyle.appStorageKey) private var clockStyleData: Data = Data()
|
||||
|
||||
@State private var newAlarmTime = Calendar.current.date(bySettingHour: 6, minute: 0, second: 0, of: Date()) ?? Date()
|
||||
@State private var selectedSoundName = "digital-alarm.caf"
|
||||
@ -33,6 +35,15 @@ struct AddAlarmView: View {
|
||||
|
||||
// List for settings below
|
||||
List {
|
||||
if !isKeepAwakeEnabled {
|
||||
Section {
|
||||
AlarmLimitationsBanner()
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
// Label Section
|
||||
NavigationLink(destination: LabelEditView(label: $alarmLabel)) {
|
||||
HStack {
|
||||
@ -127,4 +138,11 @@ struct AddAlarmView: View {
|
||||
private func getSoundDisplayName(_ fileName: String) -> String {
|
||||
return AlarmSoundService.shared.getSoundDisplayName(fileName)
|
||||
}
|
||||
|
||||
private var isKeepAwakeEnabled: Bool {
|
||||
guard let decoded = try? JSONDecoder().decode(ClockStyle.self, from: clockStyleData) else {
|
||||
return ClockStyle().keepAwake
|
||||
}
|
||||
return decoded.keepAwake
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,31 +7,48 @@
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
import Foundation
|
||||
|
||||
/// Main alarm management view
|
||||
struct AlarmView: View {
|
||||
|
||||
// MARK: - Properties
|
||||
@State private var viewModel = AlarmViewModel()
|
||||
@Bindable var viewModel: AlarmViewModel
|
||||
@State private var showAddAlarm = false
|
||||
@State private var selectedAlarmForEdit: Alarm?
|
||||
@AppStorage(ClockStyle.appStorageKey) private var clockStyleData: Data = Data()
|
||||
|
||||
// MARK: - Body
|
||||
var body: some View {
|
||||
let isPad = UIDevice.current.userInterfaceIdiom == .pad
|
||||
Group {
|
||||
if viewModel.alarms.isEmpty {
|
||||
EmptyAlarmsView {
|
||||
showAddAlarm = true
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
showAddAlarm = true
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
if !isKeepAwakeEnabled {
|
||||
AlarmLimitationsBanner()
|
||||
}
|
||||
|
||||
EmptyAlarmsView {
|
||||
showAddAlarm = true
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
showAddAlarm = true
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: Design.Size.maxContentWidthPortrait)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
} else {
|
||||
List {
|
||||
if !isKeepAwakeEnabled {
|
||||
Section {
|
||||
AlarmLimitationsBanner()
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(viewModel.alarms) { alarm in
|
||||
AlarmRowView(
|
||||
alarm: alarm,
|
||||
@ -47,6 +64,7 @@ struct AlarmView: View {
|
||||
}
|
||||
.onDelete(perform: deleteAlarm)
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.frame(maxWidth: Design.Size.maxContentWidthPortrait)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
@ -65,7 +83,8 @@ struct AlarmView: View {
|
||||
}
|
||||
.onAppear {
|
||||
Task {
|
||||
await viewModel.requestNotificationPermissions()
|
||||
// Request AlarmKit authorization when the alarms tab appears
|
||||
await viewModel.requestAlarmKitAuthorization()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAddAlarm) {
|
||||
@ -91,11 +110,18 @@ struct AlarmView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var isKeepAwakeEnabled: Bool {
|
||||
guard let decoded = try? JSONDecoder().decode(ClockStyle.self, from: clockStyleData) else {
|
||||
return ClockStyle().keepAwake
|
||||
}
|
||||
return decoded.keepAwake
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
AlarmView()
|
||||
AlarmView(viewModel: AlarmViewModel())
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,58 @@
|
||||
//
|
||||
// AlarmLimitationsBanner.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Created by Matt Bruce on 2/2/26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
import Foundation
|
||||
|
||||
/// Banner explaining background alarm limitations and mitigation.
|
||||
struct AlarmLimitationsBanner: View {
|
||||
@AppStorage(ClockStyle.appStorageKey) private var clockStyleData: Data = Data()
|
||||
|
||||
var body: some View {
|
||||
if isKeepAwakeEnabled {
|
||||
EmptyView()
|
||||
} else {
|
||||
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||
HStack(spacing: Design.Spacing.xSmall) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(AppStatus.warning)
|
||||
Text("Alarm reliability")
|
||||
.typography(.body)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
}
|
||||
|
||||
Text("iOS only allows notification sounds when the app is backgrounded. For a full alarm sound and screen, keep TheNoiseClock open in the foreground.")
|
||||
.typography(.caption)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
|
||||
Text("Tip: Use the Keep Awake prompt to keep the app on-screen while alarms are active.")
|
||||
.typography(.caption)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
}
|
||||
.padding(Design.Spacing.medium)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(AppSurface.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var isKeepAwakeEnabled: Bool {
|
||||
guard let decoded = try? JSONDecoder().decode(ClockStyle.self, from: clockStyleData) else {
|
||||
return ClockStyle().keepAwake
|
||||
}
|
||||
return decoded.keepAwake
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AlarmLimitationsBanner()
|
||||
.padding()
|
||||
.background(AppSurface.primary)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
|
||||
import SwiftUI
|
||||
import AudioPlaybackKit
|
||||
import Foundation
|
||||
|
||||
/// View for editing existing alarms
|
||||
struct EditAlarmView: View {
|
||||
@ -15,6 +16,7 @@ struct EditAlarmView: View {
|
||||
let viewModel: AlarmViewModel
|
||||
let alarm: Alarm
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@AppStorage(ClockStyle.appStorageKey) private var clockStyleData: Data = Data()
|
||||
|
||||
@State private var alarmTime: Date
|
||||
@State private var selectedSoundName: String
|
||||
@ -51,6 +53,15 @@ struct EditAlarmView: View {
|
||||
|
||||
// List for settings below
|
||||
List {
|
||||
if !isKeepAwakeEnabled {
|
||||
Section {
|
||||
AlarmLimitationsBanner()
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
// Label Section
|
||||
NavigationLink(destination: LabelEditView(label: $alarmLabel)) {
|
||||
HStack {
|
||||
@ -147,6 +158,13 @@ struct EditAlarmView: View {
|
||||
private func getSoundDisplayName(_ fileName: String) -> String {
|
||||
return AlarmSoundService.shared.getSoundDisplayName(fileName)
|
||||
}
|
||||
|
||||
private var isKeepAwakeEnabled: Bool {
|
||||
guard let decoded = try? JSONDecoder().decode(ClockStyle.self, from: clockStyleData) else {
|
||||
return ClockStyle().keepAwake
|
||||
}
|
||||
return decoded.keepAwake
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
@ -14,9 +14,9 @@ import Bedrock
|
||||
class ClockStyle: Codable, Equatable {
|
||||
|
||||
// MARK: - Time Format Settings
|
||||
var use24Hour: Bool = true
|
||||
var use24Hour: Bool = false
|
||||
var showSeconds: Bool = false
|
||||
var showAmPm: Bool = true
|
||||
var showAmPm: Bool = false
|
||||
var forceHorizontalMode: Bool = false // Force horizontal layout even in portrait
|
||||
|
||||
// MARK: - Visual Settings
|
||||
@ -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,29 +53,45 @@ class ClockViewModel {
|
||||
loadStyle()
|
||||
setupTimers()
|
||||
startAmbientLightMonitoring()
|
||||
observeStyleUpdates()
|
||||
}
|
||||
|
||||
deinit {
|
||||
stopTimers()
|
||||
stopAmbientLightMonitoring()
|
||||
if let styleObserver {
|
||||
NotificationCenter.default.removeObserver(styleObserver)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public Interface
|
||||
func toggleDisplayMode() {
|
||||
let oldValue = isDisplayMode
|
||||
withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) {
|
||||
isDisplayMode.toggle()
|
||||
}
|
||||
Design.debugLog("[ClockViewModel] toggleDisplayMode: \(oldValue) -> \(isDisplayMode)")
|
||||
|
||||
// Manage wake lock based on display mode and keep awake setting
|
||||
updateWakeLockState()
|
||||
if isDisplayMode {
|
||||
requestKeepAwakePromptIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
func setDisplayMode(_ enabled: Bool) {
|
||||
guard isDisplayMode != enabled else { return }
|
||||
guard isDisplayMode != enabled else {
|
||||
Design.debugLog("[ClockViewModel] setDisplayMode(\(enabled)) - already at this value, skipping")
|
||||
return
|
||||
}
|
||||
Design.debugLog("[ClockViewModel] setDisplayMode: \(isDisplayMode) -> \(enabled)")
|
||||
withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) {
|
||||
isDisplayMode = enabled
|
||||
}
|
||||
updateWakeLockState()
|
||||
if enabled {
|
||||
requestKeepAwakePromptIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
func updateStyle(_ newStyle: ClockStyle) {
|
||||
@ -108,6 +125,7 @@ class ClockViewModel {
|
||||
style.digitAnimationStyle = newStyle.digitAnimationStyle
|
||||
style.dateFormat = newStyle.dateFormat
|
||||
style.respectFocusModes = newStyle.respectFocusModes
|
||||
style.liveActivitiesEnabled = newStyle.liveActivitiesEnabled
|
||||
|
||||
|
||||
saveStyle()
|
||||
@ -116,6 +134,12 @@ class ClockViewModel {
|
||||
updateBrightness() // Update brightness when style changes
|
||||
}
|
||||
|
||||
func setKeepAwakeEnabled(_ enabled: Bool) {
|
||||
style.keepAwake = enabled
|
||||
saveStyle()
|
||||
updateWakeLockState()
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
private func loadStyle() {
|
||||
if let decoded = try? JSONDecoder().decode(ClockStyle.self, from: styleJSON) {
|
||||
@ -126,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()
|
||||
|
||||
@ -193,6 +230,11 @@ class ClockViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func requestKeepAwakePromptIfNeeded() {
|
||||
guard !style.keepAwake else { return }
|
||||
NotificationCenter.default.post(name: .keepAwakePromptRequested, object: nil)
|
||||
}
|
||||
|
||||
/// Update wake lock state based on current settings
|
||||
private func updateWakeLockState() {
|
||||
// Enable wake lock if in display mode and keep awake is enabled
|
||||
@ -209,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()
|
||||
}
|
||||
}
|
||||
@ -226,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,15 +14,20 @@ struct ClockSettingsView: View {
|
||||
// MARK: - Properties
|
||||
@State private var style: ClockStyle
|
||||
let onCommit: (ClockStyle) -> Void
|
||||
var onResetOnboarding: (() -> Void)?
|
||||
|
||||
@State private var digitColor: Color = .white
|
||||
@State private var backgroundColor: Color = .black
|
||||
@State private var showAdvancedSettings = false
|
||||
|
||||
// MARK: - Init
|
||||
init(style: ClockStyle, onCommit: @escaping (ClockStyle) -> Void) {
|
||||
init(
|
||||
style: ClockStyle,
|
||||
onCommit: @escaping (ClockStyle) -> Void,
|
||||
onResetOnboarding: (() -> Void)? = nil
|
||||
) {
|
||||
self._style = State(initialValue: style)
|
||||
self.onCommit = onCommit
|
||||
self.onResetOnboarding = onResetOnboarding
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
@ -36,34 +41,17 @@ struct ClockSettingsView: View {
|
||||
backgroundColor: $backgroundColor
|
||||
)
|
||||
|
||||
FontSection(style: $style)
|
||||
|
||||
AdvancedAppearanceSection(style: $style)
|
||||
|
||||
BasicDisplaySection(style: $style)
|
||||
|
||||
if showAdvancedSettings {
|
||||
AdvancedAppearanceSection(style: $style)
|
||||
AdvancedDisplaySection(style: $style)
|
||||
|
||||
FontSection(style: $style)
|
||||
NightModeSection(style: $style)
|
||||
|
||||
NightModeSection(style: $style)
|
||||
|
||||
OverlaySection(style: $style)
|
||||
|
||||
AdvancedDisplaySection(style: $style)
|
||||
}
|
||||
|
||||
SettingsSectionHeader(
|
||||
title: "Advanced",
|
||||
systemImage: "gearshape",
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
|
||||
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
||||
SettingsToggle(
|
||||
title: "Show Advanced Settings",
|
||||
subtitle: "Reveal additional customization options",
|
||||
isOn: $showAdvancedSettings,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
}
|
||||
OverlaySection(style: $style)
|
||||
|
||||
#if DEBUG
|
||||
SettingsSectionHeader(
|
||||
@ -92,6 +80,32 @@ struct ClockSettingsView: View {
|
||||
appName: "TheNoiseClock"
|
||||
)
|
||||
}
|
||||
|
||||
if let onResetOnboarding {
|
||||
Divider()
|
||||
.background(AppBorder.subtle)
|
||||
|
||||
Button {
|
||||
onResetOnboarding()
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||
Text("Reset Onboarding")
|
||||
.typography(.body)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
Text("Show onboarding screens again on next launch")
|
||||
.typography(.caption)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "arrow.counterclockwise")
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
}
|
||||
.padding(Design.Spacing.medium)
|
||||
.background(AppSurface.primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ -16,8 +16,18 @@ 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 {
|
||||
@ -65,22 +75,24 @@ struct ClockView: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
logClockLayout(size: geometry.size, safeAreaInsets: safeInsets)
|
||||
}
|
||||
.onChange(of: geometry.size) { _, newSize in
|
||||
logClockLayout(size: newSize, safeAreaInsets: safeInsets)
|
||||
}
|
||||
.onChange(of: safeInsets) { _, newInsets in
|
||||
logClockLayout(size: geometry.size, safeAreaInsets: newInsets)
|
||||
}
|
||||
// .onAppear {
|
||||
// logClockLayout(size: geometry.size, safeAreaInsets: safeInsets)
|
||||
// }
|
||||
// .onChange(of: geometry.size) { _, newSize in
|
||||
// logClockLayout(size: newSize, safeAreaInsets: safeInsets)
|
||||
// }
|
||||
// .onChange(of: safeInsets) { _, newInsets in
|
||||
// logClockLayout(size: geometry.size, safeAreaInsets: newInsets)
|
||||
// }
|
||||
}
|
||||
.ignoresSafeArea() // Extend GeometryReader to full screen, we handle safe areas manually
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.statusBarHidden(true)
|
||||
.overlay {
|
||||
// Tab bar management overlay
|
||||
ClockTabBarManager(isDisplayMode: viewModel.isDisplayMode)
|
||||
// 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)
|
||||
@ -94,9 +106,13 @@ struct ClockView: View {
|
||||
}
|
||||
)
|
||||
.onAppear {
|
||||
Design.debugLog("[ClockView] onAppear - setting isViewActive = true")
|
||||
isViewActive = true
|
||||
resetIdleTimer()
|
||||
}
|
||||
.onDisappear {
|
||||
Design.debugLog("[ClockView] onDisappear - setting isViewActive = false, invalidating timer")
|
||||
isViewActive = false
|
||||
idleTimer?.invalidate()
|
||||
idleTimer = nil
|
||||
}
|
||||
@ -121,7 +137,16 @@ struct ClockView: View {
|
||||
}
|
||||
|
||||
private func enterDisplayModeFromIdle() {
|
||||
guard !viewModel.isDisplayMode else { return }
|
||||
// Guard against entering display mode if we're no longer on the clock tab
|
||||
guard isViewActive else {
|
||||
Design.debugLog("[ClockView] enterDisplayModeFromIdle - BLOCKED: view is not active (user switched tabs)")
|
||||
return
|
||||
}
|
||||
guard !viewModel.isDisplayMode else {
|
||||
Design.debugLog("[ClockView] enterDisplayModeFromIdle - BLOCKED: already in display mode")
|
||||
return
|
||||
}
|
||||
Design.debugLog("[ClockView] enterDisplayModeFromIdle - entering display mode")
|
||||
viewModel.toggleDisplayMode()
|
||||
}
|
||||
|
||||
@ -181,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)
|
||||
|
||||
@ -22,7 +22,7 @@ struct ClockOverlayContainer: View {
|
||||
showBattery: style.showBattery,
|
||||
showDate: style.showDate,
|
||||
color: style.effectiveDigitColor,
|
||||
opacity: style.overlayOpacity,
|
||||
opacity: style.clockOpacity,
|
||||
dateFormat: style.dateFormat
|
||||
)
|
||||
.padding(.top, Design.Spacing.small)
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
//
|
||||
// ClockTabBarManager.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Created by Matt Bruce on 9/8/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Component that manages tab bar visibility for display mode
|
||||
/// Uses SwiftUI's native toolbar hiding for proper iPad compatibility
|
||||
struct ClockTabBarManager: View {
|
||||
|
||||
// MARK: - Properties
|
||||
let isDisplayMode: Bool
|
||||
|
||||
// MARK: - Body
|
||||
var body: some View {
|
||||
EmptyView()
|
||||
.toolbar(isDisplayMode ? .hidden : .automatic, for: .tabBar)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
#Preview {
|
||||
ClockTabBarManager(isDisplayMode: false)
|
||||
}
|
||||
@ -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")
|
||||
@ -40,7 +47,7 @@ struct AdvancedDisplaySection: View {
|
||||
}
|
||||
}
|
||||
|
||||
Text("Advanced display and system integration settings.")
|
||||
Text("Advanced display and system integration settings. Keep Awake helps alarms stay active while the app remains open.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppTextColors.tertiary)
|
||||
|
||||
|
||||
@ -22,16 +22,6 @@ struct OverlaySection: View {
|
||||
)
|
||||
|
||||
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
||||
SettingsSlider(
|
||||
title: "Overlay Opacity",
|
||||
subtitle: "Adjust battery and date visibility",
|
||||
value: $style.overlayOpacity,
|
||||
in: 0.0...1.0,
|
||||
step: 0.01,
|
||||
format: SliderFormat.percentage,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
|
||||
SettingsToggle(
|
||||
title: "Battery Level",
|
||||
subtitle: "Show battery percentage",
|
||||
|
||||
@ -138,11 +138,13 @@ struct TimeDisplayView: View {
|
||||
design: fontDesign,
|
||||
fontSize: fontSize
|
||||
)
|
||||
let segmentWidth = fixedDigitWidth * 2 // Each segment has 2 digits
|
||||
let segmentWidth = fixedDigitWidth * 2 // Minutes/seconds always 2 digits
|
||||
// Hour width is dynamic based on actual digit count (1 or 2 digits)
|
||||
let hourSegmentWidth = fixedDigitWidth * CGFloat(hour.count)
|
||||
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
TimeSegment(text: hour, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode, animationStyle: animationStyle)
|
||||
.frame(width: segmentWidth)
|
||||
.frame(width: hourSegmentWidth)
|
||||
ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: false)
|
||||
.frame(width: dotDiameter)
|
||||
TimeSegment(text: minute, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode, animationStyle: animationStyle)
|
||||
@ -263,9 +265,9 @@ struct TimeDisplayView: View {
|
||||
height: max(1, availableHeight / digitRows)
|
||||
)
|
||||
|
||||
Design.debugLog("[clockLayout] calcFont size=\(String(format: "%.1f", containerSize.width))x\(String(format: "%.1f", containerSize.height)) portrait=\(portrait) seconds=\(showSeconds)")
|
||||
Design.debugLog("[clockLayout] calcFont available=\(String(format: "%.1f", availableWidth))x\(String(format: "%.1f", availableHeight)) columns=\(String(format: "%.1f", digitColumns)) rows=\(String(format: "%.1f", digitRows)) colonCount=\(String(format: "%.1f", colonCount))")
|
||||
Design.debugLog("[clockLayout] calcFont digitSize=\(String(format: "%.1f", digitSize.width))x\(String(format: "%.1f", digitSize.height)) colonSize=\(String(format: "%.1f", colonSize))")
|
||||
//Design.debugLog("[clockLayout] calcFont size=\(String(format: "%.1f", containerSize.width))x\(String(format: "%.1f", containerSize.height)) portrait=\(portrait) seconds=\(showSeconds)")
|
||||
//Design.debugLog("[clockLayout] calcFont available=\(String(format: "%.1f", availableWidth))x\(String(format: "%.1f", availableHeight)) columns=\(String(format: "%.1f", digitColumns)) rows=\(String(format: "%.1f", digitRows)) colonCount=\(String(format: "%.1f", colonCount))")
|
||||
//Design.debugLog("[clockLayout] calcFont digitSize=\(String(format: "%.1f", digitSize.width))x\(String(format: "%.1f", digitSize.height)) colonSize=\(String(format: "%.1f", colonSize))")
|
||||
|
||||
return FontUtils.calculateOptimalFontSize(
|
||||
digit: "8",
|
||||
@ -308,17 +310,17 @@ struct TimeDisplayView: View {
|
||||
if totalWidth > containerSize.width {
|
||||
let scaleFactor = containerSize.width / totalWidth
|
||||
estimated *= scaleFactor * 0.98 // Add 2% margin
|
||||
Design.debugLog("[clockLayout] width overflow: totalWidth=\(Int(totalWidth)) container=\(Int(containerSize.width)) scaling by \(String(format: "%.2f", scaleFactor))")
|
||||
//Design.debugLog("[clockLayout] width overflow: totalWidth=\(Int(totalWidth)) container=\(Int(containerSize.width)) scaling by \(String(format: "%.2f", scaleFactor))")
|
||||
}
|
||||
}
|
||||
|
||||
Design.debugLog("[clockLayout] calcFont estimatedFontSize=\(String(format: "%.1f", estimated))")
|
||||
//Design.debugLog("[clockLayout] calcFont estimatedFontSize=\(String(format: "%.1f", estimated))")
|
||||
|
||||
if abs(estimated - fontSize) > 1 {
|
||||
fontSize = estimated
|
||||
Design.debugLog("[clockLayout] calcFont updated fontSize \(String(format: "%.1f", previousFontSize)) -> \(String(format: "%.1f", fontSize))")
|
||||
//Design.debugLog("[clockLayout] calcFont updated fontSize \(String(format: "%.1f", previousFontSize)) -> \(String(format: "%.1f", fontSize))")
|
||||
} else {
|
||||
Design.debugLog("[clockLayout] calcFont skipped update (current=\(String(format: "%.1f", previousFontSize)))")
|
||||
// Design.debugLog("[clockLayout] calcFont skipped update (current=\(String(format: "%.1f", previousFontSize)))")
|
||||
}
|
||||
lastCalculatedContainerSize = containerSize
|
||||
}
|
||||
|
||||
@ -0,0 +1,62 @@
|
||||
//
|
||||
// OnboardingPageView.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Reusable onboarding page component with icon, title, and description.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
/// A single onboarding page with icon, title, and description
|
||||
struct OnboardingPageView: View {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
let icon: String
|
||||
let iconColor: Color
|
||||
let title: String
|
||||
let description: String
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Design.Spacing.xxLarge) {
|
||||
Spacer()
|
||||
|
||||
// Icon
|
||||
SymbolIcon(icon, size: .hero, color: iconColor, weight: .medium)
|
||||
.padding(.bottom, Design.Spacing.medium)
|
||||
|
||||
// Title
|
||||
Text(title)
|
||||
.typography(.heroBold)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
// Description
|
||||
Text(description)
|
||||
.typography(.body)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
OnboardingPageView(
|
||||
icon: "clock.fill",
|
||||
iconColor: AppAccent.primary,
|
||||
title: "Beautiful Clock Display",
|
||||
description: "A stunning full-screen digital clock with customizable fonts, colors, and animations."
|
||||
)
|
||||
.background(AppSurface.primary)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
481
TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift
Normal file
481
TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift
Normal file
@ -0,0 +1,481 @@
|
||||
//
|
||||
// OnboardingView.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Streamlined onboarding flow optimized for time-to-value.
|
||||
// Shows real clock immediately, requests AlarmKit permission,
|
||||
// and gets users to their "aha moment" fast.
|
||||
//
|
||||
// Updated for AlarmKit (iOS 26+) - alarms now cut through
|
||||
// Focus modes and silent mode automatically.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
import Foundation
|
||||
|
||||
/// Streamlined onboarding optimized for activation
|
||||
struct OnboardingView: View {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
let onComplete: () -> Void
|
||||
|
||||
@State private var currentPage = 0
|
||||
@State private var alarmKitPermissionGranted = false
|
||||
@State private var keepAwakeEnabled = false
|
||||
@State private var showCelebration = false
|
||||
|
||||
private let totalPages = 3
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Background
|
||||
AppSurface.primary
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Page content
|
||||
TabView(selection: $currentPage) {
|
||||
welcomeWithClockPage
|
||||
.tag(0)
|
||||
|
||||
permissionsPage
|
||||
.tag(1)
|
||||
|
||||
getStartedPage
|
||||
.tag(2)
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.animation(.easeInOut(duration: 0.3), value: currentPage)
|
||||
|
||||
// Bottom controls
|
||||
bottomControls
|
||||
.padding(.horizontal, Design.Spacing.xLarge)
|
||||
.padding(.bottom, Design.Spacing.xxLarge)
|
||||
}
|
||||
|
||||
// Celebration overlay
|
||||
if showCelebration {
|
||||
celebrationOverlay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Page 1: Welcome with Live Clock Preview
|
||||
|
||||
private var welcomeWithClockPage: some View {
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
Spacer()
|
||||
|
||||
// Live clock preview - immediate value using TimelineView
|
||||
liveClockPreview
|
||||
.padding(.bottom, Design.Spacing.medium)
|
||||
|
||||
Text("The Noise Clock")
|
||||
.typography(.heroBold)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
|
||||
Text("Your beautiful bedside companion")
|
||||
.typography(.title3)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
|
||||
// Quick feature highlights - benefit focused
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
featureHighlight(
|
||||
icon: "moon.stars.fill",
|
||||
text: "Fall asleep to soothing sounds"
|
||||
)
|
||||
featureHighlight(
|
||||
icon: "alarm.fill",
|
||||
text: "Wake up gently, on your terms"
|
||||
)
|
||||
featureHighlight(
|
||||
icon: "hand.tap.fill",
|
||||
text: "Long-press for immersive mode"
|
||||
)
|
||||
}
|
||||
.padding(.top, Design.Spacing.large)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var liveClockPreview: some View {
|
||||
TimelineView(.periodic(from: .now, by: 1.0)) { context in
|
||||
OnboardingClockText(date: context.date)
|
||||
}
|
||||
}
|
||||
|
||||
private func featureHighlight(icon: String, text: String) -> some View {
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.frame(width: 28)
|
||||
|
||||
Text(text)
|
||||
.typography(.body)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
}
|
||||
|
||||
// MARK: - Page 2: AlarmKit Permissions
|
||||
|
||||
private var permissionsPage: some View {
|
||||
VStack(spacing: Design.Spacing.xxLarge) {
|
||||
Spacer()
|
||||
|
||||
// Alarm icon with animated waves
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(AppAccent.primary.opacity(0.15))
|
||||
.frame(width: 120, height: 120)
|
||||
|
||||
Image(systemName: "alarm.waves.left.and.right.fill")
|
||||
.font(.system(size: 50, weight: .medium))
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.symbolEffect(.pulse, options: .repeating)
|
||||
}
|
||||
|
||||
Text("Alarms that actually work")
|
||||
.typography(.heroBold)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("Works in silent mode, Focus mode, and even when your phone is locked.")
|
||||
.typography(.body)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
|
||||
// Feature bullets
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||
alarmFeatureRow(icon: "moon.zzz.fill", text: "Cuts through Do Not Disturb")
|
||||
alarmFeatureRow(icon: "lock.iphone", text: "Shows countdown on Lock Screen")
|
||||
alarmFeatureRow(icon: "iphone.badge.play", text: "Works when app is closed")
|
||||
}
|
||||
.padding(.top, Design.Spacing.medium)
|
||||
|
||||
// Permission button or success state
|
||||
permissionButton
|
||||
.padding(.top, Design.Spacing.large)
|
||||
|
||||
// Optional: Keep Awake for bedside clock mode
|
||||
keepAwakeSection
|
||||
.padding(.top, Design.Spacing.medium)
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.onAppear {
|
||||
keepAwakeEnabled = isKeepAwakeEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
private var keepAwakeSection: some View {
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
Text("Want the clock always visible?")
|
||||
.typography(.callout)
|
||||
.foregroundStyle(AppTextColors.tertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Button {
|
||||
enableKeepAwake()
|
||||
} label: {
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
Image(systemName: keepAwakeEnabled ? "checkmark.circle.fill" : "bolt.fill")
|
||||
Text(keepAwakeEnabled ? "Keep Awake Enabled" : "Enable Keep Awake")
|
||||
}
|
||||
.typography(.callout)
|
||||
.foregroundStyle(keepAwakeEnabled ? AppStatus.success : AppTextColors.secondary)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
.padding(.vertical, Design.Spacing.small)
|
||||
.background(keepAwakeEnabled ? AppStatus.success.opacity(0.15) : AppSurface.secondary)
|
||||
.cornerRadius(Design.CornerRadius.medium)
|
||||
}
|
||||
.disabled(keepAwakeEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
private func alarmFeatureRow(icon: String, text: String) -> some View {
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.frame(width: 28)
|
||||
|
||||
Text(text)
|
||||
.typography(.body)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
}
|
||||
|
||||
private var permissionButton: some View {
|
||||
Group {
|
||||
if alarmKitPermissionGranted {
|
||||
// Success state
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 24))
|
||||
Text("Alarms enabled!")
|
||||
}
|
||||
.foregroundStyle(AppStatus.success)
|
||||
.typography(.bodyEmphasis)
|
||||
.padding(Design.Spacing.medium)
|
||||
.background(AppStatus.success.opacity(0.15))
|
||||
.cornerRadius(Design.CornerRadius.medium)
|
||||
} else {
|
||||
// Request AlarmKit authorization
|
||||
Button {
|
||||
requestAlarmKitPermission()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "alarm.fill")
|
||||
Text("Enable Alarms")
|
||||
}
|
||||
.typography(.bodyEmphasis)
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: 280)
|
||||
.padding(Design.Spacing.medium)
|
||||
.background(AppAccent.primary)
|
||||
.cornerRadius(Design.CornerRadius.medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Page 3: Get Started (Quick Win)
|
||||
|
||||
private var getStartedPage: some View {
|
||||
VStack(spacing: Design.Spacing.xxLarge) {
|
||||
Spacer()
|
||||
|
||||
// Celebration icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(AppStatus.success.opacity(0.15))
|
||||
.frame(width: 120, height: 120)
|
||||
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 60, weight: .medium))
|
||||
.foregroundStyle(AppStatus.success)
|
||||
}
|
||||
|
||||
Text("You're ready!")
|
||||
.typography(.heroBold)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
|
||||
Text("Your alarms will work even in silent mode and Focus mode. Try long-pressing the clock for immersive mode!")
|
||||
.typography(.body)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
|
||||
// Quick tips
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||
tipRow(icon: "alarm.fill", text: "Create your first alarm")
|
||||
tipRow(icon: "hand.tap", text: "Long-press clock for full screen")
|
||||
tipRow(icon: "speaker.wave.2", text: "Tap Noise to play sounds")
|
||||
}
|
||||
.padding(.top, Design.Spacing.medium)
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private func tipRow(icon: String, text: String) -> some View {
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.frame(width: 24)
|
||||
|
||||
Text(text)
|
||||
.typography(.callout)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
}
|
||||
|
||||
// MARK: - Bottom Controls
|
||||
|
||||
private var bottomControls: some View {
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
// Page indicators
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
ForEach(0..<totalPages, id: \.self) { index in
|
||||
Capsule()
|
||||
.fill(index == currentPage ? AppAccent.primary : AppTextColors.tertiary)
|
||||
.frame(width: index == currentPage ? 24 : 8, height: 8)
|
||||
.animation(.easeInOut(duration: 0.2), value: currentPage)
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation buttons
|
||||
HStack(spacing: Design.Spacing.large) {
|
||||
// Back / Skip button
|
||||
Button {
|
||||
if currentPage > 0 {
|
||||
withAnimation {
|
||||
currentPage -= 1
|
||||
}
|
||||
} else {
|
||||
onComplete()
|
||||
}
|
||||
} label: {
|
||||
Text(currentPage == 0 ? "Skip" : "Back")
|
||||
.typography(.bodyEmphasis)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(Design.Spacing.medium)
|
||||
}
|
||||
|
||||
// Next / Get Started button
|
||||
Button {
|
||||
if currentPage < totalPages - 1 {
|
||||
withAnimation {
|
||||
currentPage += 1
|
||||
}
|
||||
} else {
|
||||
triggerCelebration()
|
||||
}
|
||||
} label: {
|
||||
Text(currentPage == totalPages - 1 ? "Get Started" : "Next")
|
||||
.typography(.bodyEmphasis)
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(Design.Spacing.medium)
|
||||
.background(AppAccent.primary)
|
||||
.cornerRadius(Design.CornerRadius.medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Celebration
|
||||
|
||||
private var celebrationOverlay: some View {
|
||||
ZStack {
|
||||
Color.black.opacity(0.3)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
Image(systemName: "party.popper.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
|
||||
Text("Let's go!")
|
||||
.typography(.heroBold)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
}
|
||||
.padding(Design.Spacing.xxxLarge)
|
||||
.background(AppSurface.overlay)
|
||||
.cornerRadius(Design.CornerRadius.xxLarge)
|
||||
.shadow(radius: 20)
|
||||
}
|
||||
.transition(.opacity.combined(with: .scale))
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func requestAlarmKitPermission() {
|
||||
Task {
|
||||
// Request AlarmKit authorization (iOS 26+)
|
||||
let granted = await AlarmKitService.shared.requestAuthorization()
|
||||
withAnimation(.spring(duration: 0.3)) {
|
||||
alarmKitPermissionGranted = granted
|
||||
}
|
||||
// Auto-advance after permission granted
|
||||
if granted {
|
||||
try? await Task.sleep(for: .milliseconds(800))
|
||||
withAnimation {
|
||||
currentPage = 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 isKeepAwakeEnabled() -> Bool {
|
||||
loadClockStyle().keepAwake
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Dismiss after short celebration
|
||||
Task {
|
||||
try? await Task.sleep(for: .milliseconds(1200))
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Onboarding Clock Text
|
||||
|
||||
/// Separate view for TimelineView content to avoid view builder issues
|
||||
private struct OnboardingClockText: View {
|
||||
let date: Date
|
||||
|
||||
private var timeString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "h:mm"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Text(timeString)
|
||||
.font(.system(size: 80, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.contentTransition(.numericText())
|
||||
.animation(.snappy(duration: 0.3), value: timeString)
|
||||
.shadow(color: AppAccent.primary.opacity(0.5), radius: 20)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
OnboardingView {
|
||||
print("Onboarding complete")
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
@ -12,5 +12,9 @@
|
||||
<string>$(CLOUDKIT_CONTAINER_IDENTIFIER)</string>
|
||||
<key>AppClipDomain</key>
|
||||
<string>$(APPCLIP_DOMAIN)</string>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>NSAlarmKitUsageDescription</key>
|
||||
<string>TheNoiseClock uses alarms to wake you up at your scheduled time, even when your device is in silent mode or Focus mode.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@ -3,43 +3,43 @@
|
||||
{
|
||||
"id": "digital-alarm",
|
||||
"name": "Digital Alarm",
|
||||
"fileName": "digital-alarm.caf",
|
||||
"fileName": "digital-alarm.mp3",
|
||||
"description": "Classic digital alarm sound",
|
||||
"category": "alarm",
|
||||
"bundleName": null,
|
||||
"bundleName": "AlarmSounds",
|
||||
"isDefault": true
|
||||
},
|
||||
{
|
||||
"id": "buzzing-alarm",
|
||||
"name": "Buzzing Alarm",
|
||||
"fileName": "buzzing-alarm.caf",
|
||||
"fileName": "buzzing-alarm.mp3",
|
||||
"description": "Buzzing sound for gentle wake-up",
|
||||
"category": "alarm",
|
||||
"bundleName": null
|
||||
"bundleName": "AlarmSounds"
|
||||
},
|
||||
{
|
||||
"id": "classic-alarm",
|
||||
"name": "Classic Alarm",
|
||||
"fileName": "classic-alarm.caf",
|
||||
"fileName": "classic-alarm.mp3",
|
||||
"description": "Traditional alarm sound",
|
||||
"category": "alarm",
|
||||
"bundleName": null
|
||||
"bundleName": "AlarmSounds"
|
||||
},
|
||||
{
|
||||
"id": "beep-alarm",
|
||||
"name": "Beep Alarm",
|
||||
"fileName": "beep-alarm.caf",
|
||||
"fileName": "beep-alarm.mp3",
|
||||
"description": "Short beep alarm sound",
|
||||
"category": "alarm",
|
||||
"bundleName": null
|
||||
"bundleName": "AlarmSounds"
|
||||
},
|
||||
{
|
||||
"id": "siren-alarm",
|
||||
"name": "Siren Alarm",
|
||||
"fileName": "siren-alarm.caf",
|
||||
"fileName": "siren-alarm.mp3",
|
||||
"description": "Emergency siren alarm for heavy sleepers",
|
||||
"category": "alarm",
|
||||
"bundleName": null
|
||||
"bundleName": "AlarmSounds"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Binary file not shown.
BIN
TheNoiseClock/Resources/AlarmSounds/beep-alarm.mp3
Normal file
BIN
TheNoiseClock/Resources/AlarmSounds/beep-alarm.mp3
Normal file
Binary file not shown.
Binary file not shown.
BIN
TheNoiseClock/Resources/AlarmSounds/buzzing-alarm.mp3
Normal file
BIN
TheNoiseClock/Resources/AlarmSounds/buzzing-alarm.mp3
Normal file
Binary file not shown.
Binary file not shown.
BIN
TheNoiseClock/Resources/AlarmSounds/classic-alarm.mp3
Normal file
BIN
TheNoiseClock/Resources/AlarmSounds/classic-alarm.mp3
Normal file
Binary file not shown.
Binary file not shown.
BIN
TheNoiseClock/Resources/AlarmSounds/digital-alarm.mp3
Normal file
BIN
TheNoiseClock/Resources/AlarmSounds/digital-alarm.mp3
Normal file
Binary file not shown.
Binary file not shown.
BIN
TheNoiseClock/Resources/AlarmSounds/siren-alarm.mp3
Normal file
BIN
TheNoiseClock/Resources/AlarmSounds/siren-alarm.mp3
Normal file
Binary file not shown.
@ -12,7 +12,7 @@ enum AppConstants {
|
||||
|
||||
// MARK: - App Information
|
||||
static let appName = "TheNoiseClock"
|
||||
static let minimumIOSVersion = "18.0"
|
||||
static let minimumIOSVersion = "26.0"
|
||||
|
||||
// MARK: - Storage Keys
|
||||
enum StorageKeys {
|
||||
@ -59,7 +59,7 @@ enum AppConstants {
|
||||
|
||||
// MARK: - System Sounds
|
||||
enum SystemSounds {
|
||||
static let defaultSound = "digital-alarm.caf"
|
||||
static let defaultSound = "digital-alarm.mp3"
|
||||
static let availableSounds = ["default", "bell", "chimes", "ding", "glass", "silence"]
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
//
|
||||
// NoiseClockAlarmMetadata.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Created by Matt Bruce on 2/2/26.
|
||||
//
|
||||
|
||||
import AlarmKit
|
||||
import Foundation
|
||||
|
||||
/// Metadata for alarm Live Activities, shared between app and widget extension.
|
||||
/// Must conform to AlarmMetadata and be nonisolated for cross-actor use.
|
||||
nonisolated struct NoiseClockAlarmMetadata: AlarmMetadata {
|
||||
/// The unique identifier for the alarm
|
||||
var alarmId: String
|
||||
|
||||
/// The sound file name to play when the alarm fires
|
||||
var soundName: String
|
||||
|
||||
/// The snooze duration in minutes
|
||||
var snoozeDuration: Int
|
||||
|
||||
/// The alarm label to display
|
||||
var label: String
|
||||
|
||||
/// The custom notification message
|
||||
var message: String
|
||||
|
||||
/// Volume level (0.0 to 1.0)
|
||||
var volume: Float
|
||||
}
|
||||
37
TheNoiseClock/Shared/Utilities/AlarmNotifications.swift
Normal file
37
TheNoiseClock/Shared/Utilities/AlarmNotifications.swift
Normal file
@ -0,0 +1,37 @@
|
||||
//
|
||||
// AlarmNotifications.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Created by Matt Bruce on 2/2/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum AlarmNotificationConstants {
|
||||
static let categoryIdentifier = "ALARM_CATEGORY"
|
||||
static let snoozeActionIdentifier = "SNOOZE_ACTION"
|
||||
static let stopActionIdentifier = "STOP_ACTION"
|
||||
}
|
||||
|
||||
enum AlarmNotificationKeys {
|
||||
static let alarmId = "alarmId"
|
||||
static let soundName = "soundName"
|
||||
static let repeats = "repeats"
|
||||
static let isSnooze = "isSnooze"
|
||||
static let originalAlarmId = "originalAlarmId"
|
||||
static let label = "label"
|
||||
static let notificationMessage = "notificationMessage"
|
||||
static let snoozeDuration = "snoozeDuration"
|
||||
static let isVibrationEnabled = "isVibrationEnabled"
|
||||
static let volume = "volume"
|
||||
static let title = "title"
|
||||
static let body = "body"
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let alarmDidFire = Notification.Name("alarmDidFire")
|
||||
static let alarmDidStop = Notification.Name("alarmDidStop")
|
||||
static let alarmDidSnooze = Notification.Name("alarmDidSnooze")
|
||||
static let keepAwakePromptRequested = Notification.Name("keepAwakePromptRequested")
|
||||
static let clockStyleDidUpdate = Notification.Name("clockStyleDidUpdate")
|
||||
}
|
||||
56
TheNoiseClock/Shared/Utilities/KeepAwakePrompt.swift
Normal file
56
TheNoiseClock/Shared/Utilities/KeepAwakePrompt.swift
Normal file
@ -0,0 +1,56 @@
|
||||
//
|
||||
// KeepAwakePrompt.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Created by Matt Bruce on 2/2/26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
struct KeepAwakePrompt: View {
|
||||
let onEnable: () -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
Image(systemName: "bolt.fill")
|
||||
.font(.system(size: 36, weight: .semibold))
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
Text("Keep Awake for Alarms")
|
||||
.typography(.title2)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
|
||||
Text("Enable Keep Awake so your alarm can play loudly and show the full screen while TheNoiseClock stays open.")
|
||||
.typography(.body)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
Button(action: onEnable) {
|
||||
Text("Enable Keep Awake")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(color: AppAccent.primary)
|
||||
|
||||
Button(action: onDismiss) {
|
||||
Text("Not Now")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
}
|
||||
}
|
||||
.padding(Design.Spacing.xLarge)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(AppSurface.primary)
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
KeepAwakePrompt(onEnable: {}, onDismiss: {})
|
||||
}
|
||||
30
TheNoiseClock/Shared/Utilities/KeepAwakePromptState.swift
Normal file
30
TheNoiseClock/Shared/Utilities/KeepAwakePromptState.swift
Normal file
@ -0,0 +1,30 @@
|
||||
//
|
||||
// KeepAwakePromptState.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Created by Matt Bruce on 2/2/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
class KeepAwakePromptState {
|
||||
|
||||
var isPresented = false
|
||||
private var hasShownThisSession = false
|
||||
|
||||
func showIfNeeded(isKeepAwakeEnabled: Bool) {
|
||||
guard !isKeepAwakeEnabled, !hasShownThisSession else { return }
|
||||
isPresented = true
|
||||
hasShownThisSession = true
|
||||
}
|
||||
|
||||
func dismiss() {
|
||||
isPresented = false
|
||||
}
|
||||
|
||||
func resetSessionFlag() {
|
||||
hasShownThisSession = false
|
||||
}
|
||||
}
|
||||
@ -36,15 +36,19 @@ enum NotificationUtils {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
content.categoryIdentifier = AlarmNotificationConstants.categoryIdentifier
|
||||
|
||||
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
|
||||
|
||||
119
TheNoiseClockWidget/AlarmIntents.swift
Normal file
119
TheNoiseClockWidget/AlarmIntents.swift
Normal file
@ -0,0 +1,119 @@
|
||||
//
|
||||
// AlarmIntents.swift
|
||||
// TheNoiseClockWidget
|
||||
//
|
||||
// Created by Matt Bruce on 2/2/26.
|
||||
//
|
||||
// App Intents for alarm actions from Live Activity and widget buttons.
|
||||
// These intents are duplicated in the widget target for compilation.
|
||||
// Note: Must be kept in sync with TheNoiseClock/Features/Alarms/Intents/AlarmIntents.swift
|
||||
//
|
||||
|
||||
import AlarmKit
|
||||
import AppIntents
|
||||
import Foundation
|
||||
|
||||
// MARK: - Stop Alarm Intent
|
||||
|
||||
/// Intent to stop an active alarm from the Live Activity or notification.
|
||||
struct StopAlarmIntent: LiveActivityIntent {
|
||||
|
||||
static var title: LocalizedStringResource = "Stop Alarm"
|
||||
static var description = IntentDescription("Stops the currently ringing alarm")
|
||||
|
||||
@Parameter(title: "Alarm ID")
|
||||
var alarmId: String
|
||||
|
||||
static var supportedModes: IntentModes { .background }
|
||||
|
||||
init() {
|
||||
self.alarmId = ""
|
||||
}
|
||||
|
||||
init(alarmId: String) {
|
||||
self.alarmId = alarmId
|
||||
}
|
||||
|
||||
func perform() throws -> some IntentResult {
|
||||
guard let uuid = UUID(uuidString: alarmId) else {
|
||||
throw AlarmIntentError.invalidAlarmID
|
||||
}
|
||||
|
||||
try AlarmManager.shared.stop(id: uuid)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Snooze Alarm Intent
|
||||
|
||||
/// Intent to snooze an active alarm from the Live Activity or notification.
|
||||
struct SnoozeAlarmIntent: LiveActivityIntent {
|
||||
|
||||
static var title: LocalizedStringResource = "Snooze Alarm"
|
||||
static var description = IntentDescription("Snoozes the currently ringing alarm")
|
||||
|
||||
@Parameter(title: "Alarm ID")
|
||||
var alarmId: String
|
||||
|
||||
static var supportedModes: IntentModes { .background }
|
||||
|
||||
init() {
|
||||
self.alarmId = ""
|
||||
}
|
||||
|
||||
init(alarmId: String) {
|
||||
self.alarmId = alarmId
|
||||
}
|
||||
|
||||
func perform() throws -> some IntentResult {
|
||||
guard let uuid = UUID(uuidString: alarmId) else {
|
||||
throw AlarmIntentError.invalidAlarmID
|
||||
}
|
||||
|
||||
// Use countdown to postpone the alarm by its configured snooze duration
|
||||
try AlarmManager.shared.countdown(id: uuid)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Open App Intent
|
||||
|
||||
/// Intent to open the app when the user taps the Live Activity.
|
||||
struct OpenAlarmAppIntent: LiveActivityIntent {
|
||||
|
||||
static var title: LocalizedStringResource = "Open TheNoiseClock"
|
||||
static var description = IntentDescription("Opens the app to the alarm screen")
|
||||
static var openAppWhenRun = true
|
||||
|
||||
@Parameter(title: "Alarm ID")
|
||||
var alarmId: String
|
||||
|
||||
init() {
|
||||
self.alarmId = ""
|
||||
}
|
||||
|
||||
init(alarmId: String) {
|
||||
self.alarmId = alarmId
|
||||
}
|
||||
|
||||
func perform() throws -> some IntentResult {
|
||||
// The app will be opened due to openAppWhenRun = true
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum AlarmIntentError: Error, LocalizedError {
|
||||
case invalidAlarmID
|
||||
case alarmNotFound
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidAlarmID:
|
||||
return "Invalid alarm ID"
|
||||
case .alarmNotFound:
|
||||
return "Alarm not found"
|
||||
}
|
||||
}
|
||||
}
|
||||
238
TheNoiseClockWidget/AlarmLiveActivityWidget.swift
Normal file
238
TheNoiseClockWidget/AlarmLiveActivityWidget.swift
Normal file
@ -0,0 +1,238 @@
|
||||
//
|
||||
// AlarmLiveActivityWidget.swift
|
||||
// TheNoiseClockWidget
|
||||
//
|
||||
// Created by Matt Bruce on 2/2/26.
|
||||
//
|
||||
|
||||
import AlarmKit
|
||||
import AppIntents
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
/// Live Activity widget for alarm countdown and alerting states.
|
||||
/// Uses AlarmKit's AlarmAttributes for automatic countdown management.
|
||||
struct AlarmLiveActivityWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: AlarmAttributes<NoiseClockAlarmMetadata>.self) { context in
|
||||
// Lock Screen presentation
|
||||
LockScreenAlarmView(
|
||||
attributes: context.attributes,
|
||||
state: context.state
|
||||
)
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
// Expanded regions - shown when long-pressed or alerting
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
if let metadata = context.attributes.metadata {
|
||||
AlarmTitleView(metadata: metadata)
|
||||
} else {
|
||||
Text("Alarm")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
AlarmProgressView(state: context.state)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
ExpandedAlarmView(
|
||||
attributes: context.attributes,
|
||||
state: context.state
|
||||
)
|
||||
}
|
||||
} compactLeading: {
|
||||
// Compact leading - alarm icon during countdown
|
||||
Image(systemName: "alarm.fill")
|
||||
.foregroundStyle(context.attributes.tintColor)
|
||||
} compactTrailing: {
|
||||
// Compact trailing - countdown text
|
||||
CountdownTextView(state: context.state)
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
} minimal: {
|
||||
// Minimal - just an alarm icon
|
||||
Image(systemName: "alarm.fill")
|
||||
.foregroundStyle(context.attributes.tintColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lock Screen View
|
||||
|
||||
struct LockScreenAlarmView: View {
|
||||
let attributes: AlarmAttributes<NoiseClockAlarmMetadata>
|
||||
let state: AlarmPresentationState
|
||||
|
||||
private var alarmLabel: String {
|
||||
attributes.metadata?.label ?? "Alarm"
|
||||
}
|
||||
|
||||
private var alarmId: String {
|
||||
attributes.metadata?.alarmId ?? ""
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
// Alarm label
|
||||
Text(alarmLabel)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
// Content based on state
|
||||
if case .countdown(let countdown) = state.mode {
|
||||
// Countdown state - show timer
|
||||
VStack(spacing: 4) {
|
||||
Text("Alarm in")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(timerInterval: Date.now...countdown.fireDate, countsDown: true)
|
||||
.font(.system(size: 48, weight: .bold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(attributes.tintColor)
|
||||
}
|
||||
} else if case .paused = state.mode {
|
||||
Text("Paused")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
// Alerting state - show ringing UI with action buttons
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "alarm.waves.left.and.right.fill")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(attributes.tintColor)
|
||||
.symbolEffect(.bounce.byLayer, options: .repeating)
|
||||
|
||||
Text("Alarm Ringing")
|
||||
.font(.title3.weight(.semibold))
|
||||
|
||||
// Action buttons
|
||||
HStack(spacing: 20) {
|
||||
// Snooze button
|
||||
Button(intent: SnoozeAlarmIntent(alarmId: alarmId)) {
|
||||
Label("Snooze", systemImage: "zzz")
|
||||
.font(.callout.weight(.medium))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.blue)
|
||||
|
||||
// Stop button
|
||||
Button(intent: StopAlarmIntent(alarmId: alarmId)) {
|
||||
Label("Stop", systemImage: "stop.fill")
|
||||
.font(.callout.weight(.medium))
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Expanded Dynamic Island View
|
||||
|
||||
struct ExpandedAlarmView: View {
|
||||
let attributes: AlarmAttributes<NoiseClockAlarmMetadata>
|
||||
let state: AlarmPresentationState
|
||||
|
||||
private var alarmId: String {
|
||||
attributes.metadata?.alarmId ?? ""
|
||||
}
|
||||
|
||||
private var isAlerting: Bool {
|
||||
if case .countdown = state.mode { return false }
|
||||
if case .paused = state.mode { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if isAlerting {
|
||||
// Alerting state - show action buttons
|
||||
HStack(spacing: 16) {
|
||||
Button(intent: SnoozeAlarmIntent(alarmId: alarmId)) {
|
||||
Text("Snooze")
|
||||
.font(.caption.weight(.medium))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.blue)
|
||||
|
||||
Button(intent: StopAlarmIntent(alarmId: alarmId)) {
|
||||
Text("Stop")
|
||||
.font(.caption.weight(.medium))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.red)
|
||||
}
|
||||
} else {
|
||||
// Countdown state - show countdown info
|
||||
HStack {
|
||||
CountdownTextView(state: state)
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
AlarmProgressView(state: state)
|
||||
.frame(maxHeight: 30)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Countdown Text View
|
||||
|
||||
struct CountdownTextView: View {
|
||||
let state: AlarmPresentationState
|
||||
|
||||
var body: some View {
|
||||
if case .countdown(let countdown) = state.mode {
|
||||
Text(timerInterval: Date.now...countdown.fireDate, countsDown: true)
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
} else if case .paused = state.mode {
|
||||
Text("Paused")
|
||||
} else {
|
||||
Text("Now!")
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Progress View
|
||||
|
||||
struct AlarmProgressView: View {
|
||||
let state: AlarmPresentationState
|
||||
|
||||
var body: some View {
|
||||
if case .countdown(let countdown) = state.mode {
|
||||
ProgressView(
|
||||
timerInterval: Date.now...countdown.fireDate,
|
||||
label: { EmptyView() },
|
||||
currentValueLabel: { Text("") }
|
||||
)
|
||||
.progressViewStyle(.circular)
|
||||
} else if case .paused = state.mode {
|
||||
Image(systemName: "pause.fill")
|
||||
} else {
|
||||
Image(systemName: "alarm.waves.left.and.right.fill")
|
||||
.symbolEffect(.pulse)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Title View
|
||||
|
||||
struct AlarmTitleView: View {
|
||||
let metadata: NoiseClockAlarmMetadata
|
||||
|
||||
var body: some View {
|
||||
Text(metadata.label)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
35
TheNoiseClockWidget/NoiseClockAlarmMetadata.swift
Normal file
35
TheNoiseClockWidget/NoiseClockAlarmMetadata.swift
Normal file
@ -0,0 +1,35 @@
|
||||
//
|
||||
// NoiseClockAlarmMetadata.swift
|
||||
// TheNoiseClockWidget
|
||||
//
|
||||
// Created by Matt Bruce on 2/2/26.
|
||||
//
|
||||
// NOTE: This file must be kept in sync with the main app's version.
|
||||
// In Xcode, add the main app's NoiseClockAlarmMetadata.swift to both targets
|
||||
// and remove this file, or use a shared Swift package.
|
||||
//
|
||||
|
||||
import AlarmKit
|
||||
import Foundation
|
||||
|
||||
/// Metadata for alarm Live Activities, shared between app and widget extension.
|
||||
/// Must conform to AlarmMetadata and be nonisolated for cross-actor use.
|
||||
nonisolated struct NoiseClockAlarmMetadata: AlarmMetadata {
|
||||
/// The unique identifier for the alarm
|
||||
var alarmId: String
|
||||
|
||||
/// The sound file name to play when the alarm fires
|
||||
var soundName: String
|
||||
|
||||
/// The snooze duration in minutes
|
||||
var snoozeDuration: Int
|
||||
|
||||
/// The alarm label to display
|
||||
var label: String
|
||||
|
||||
/// The custom notification message
|
||||
var message: String
|
||||
|
||||
/// Volume level (0.0 to 1.0)
|
||||
var volume: Float
|
||||
}
|
||||
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