Compare commits
No commits in common. "3844e19b393dde5c4f5d7ee4cbb684b2aed4438e" and "01931244e07dece51b5a65f01ce83432ee14b89f" have entirely different histories.
3844e19b39
...
01931244e0
@ -1,7 +1,6 @@
|
|||||||
Use /ios-18-role
|
Use /ios-18-role
|
||||||
read the PRD.md
|
read the PRD.md
|
||||||
read the README.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.
|
Always update the PRD.md and README.md when there are code changes that might cause these files to require those changes documented.
|
||||||
|
|
||||||
|
|||||||
@ -95,8 +95,7 @@ public class SoundConfigurationService {
|
|||||||
|
|
||||||
/// Load sound configuration from multiple category-specific JSON files
|
/// Load sound configuration from multiple category-specific JSON files
|
||||||
public func loadConfigurationFromBundles(from bundle: Bundle = .main) -> SoundConfiguration {
|
public func loadConfigurationFromBundles(from bundle: Bundle = .main) -> SoundConfiguration {
|
||||||
// Include AlarmSounds bundle for alarm sound preview functionality
|
let bundleNames = ["Colored", "Nature", "Mechanical", "Ambient"]
|
||||||
let bundleNames = ["Colored", "Nature", "Mechanical", "Ambient", "AlarmSounds"]
|
|
||||||
var allSounds: [Sound] = []
|
var allSounds: [Sound] = []
|
||||||
|
|
||||||
for bundleName in bundleNames {
|
for bundleName in bundleNames {
|
||||||
|
|||||||
@ -41,14 +41,6 @@ public class SoundPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func playSound(_ sound: Sound) {
|
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)")
|
print("🎵 Attempting to play: \(sound.name)")
|
||||||
|
|
||||||
// Stop current sound if playing
|
// Stop current sound if playing
|
||||||
@ -71,7 +63,7 @@ public class SoundPlayer {
|
|||||||
do {
|
do {
|
||||||
let newPlayer = try AVAudioPlayer(contentsOf: fileUrl)
|
let newPlayer = try AVAudioPlayer(contentsOf: fileUrl)
|
||||||
newPlayer.numberOfLoops = AudioConstants.Playback.numberOfLoops
|
newPlayer.numberOfLoops = AudioConstants.Playback.numberOfLoops
|
||||||
newPlayer.volume = volumeOverride ?? AudioConstants.Volume.default
|
newPlayer.volume = AudioConstants.Volume.default
|
||||||
newPlayer.prepareToPlay()
|
newPlayer.prepareToPlay()
|
||||||
players[sound.fileName] = newPlayer
|
players[sound.fileName] = newPlayer
|
||||||
currentPlayer = newPlayer
|
currentPlayer = newPlayer
|
||||||
@ -85,9 +77,6 @@ public class SoundPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentPlayer = player
|
currentPlayer = player
|
||||||
if let volumeOverride {
|
|
||||||
player.volume = volumeOverride
|
|
||||||
}
|
|
||||||
let success = player.play()
|
let success = player.play()
|
||||||
print("🎵 Play result: \(success ? "SUCCESS" : "FAILED")")
|
print("🎵 Play result: \(success ? "SUCCESS" : "FAILED")")
|
||||||
print("🔊 Player isPlaying: \(player.isPlaying)")
|
print("🔊 Player isPlaying: \(player.isPlaying)")
|
||||||
@ -130,11 +119,7 @@ public class SoundPlayer {
|
|||||||
return Bundle.main.url(forResource: fileName, withExtension: nil, subdirectory: subfolder)
|
return Bundle.main.url(forResource: fileName, withExtension: nil, subdirectory: subfolder)
|
||||||
} else {
|
} else {
|
||||||
// Direct file path (fallback)
|
// Direct file path (fallback)
|
||||||
if let url = Bundle.main.url(forResource: sound.fileName, withExtension: nil) {
|
return 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,32 +89,27 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di
|
|||||||
- **Responsive layout**: Optimized for both portrait and landscape orientations
|
- **Responsive layout**: Optimized for both portrait and landscape orientations
|
||||||
- **AudioPlaybackKit integration**: Powered by reusable Swift package for audio functionality
|
- **AudioPlaybackKit integration**: Powered by reusable Swift package for audio functionality
|
||||||
|
|
||||||
### 6. Advanced Alarm System (Powered by AlarmKit)
|
### 6. Advanced Alarm System
|
||||||
- **AlarmKit integration**: iOS 26+ AlarmKit framework for reliable alarms that cut through Focus modes and silent mode
|
|
||||||
- **Multiple alarms**: Create and manage unlimited alarms
|
- **Multiple alarms**: Create and manage unlimited alarms
|
||||||
- **Rich alarm editor**: Full-featured alarm creation and editing interface
|
- **Rich alarm editor**: Full-featured alarm creation and editing interface
|
||||||
- **Time selection**: Wheel-style date picker with optimized font sizing for maximum readability
|
- **Time selection**: Wheel-style date picker with optimized font sizing for maximum readability
|
||||||
- **Dynamic alarm sounds**: MP3 alarm sounds loaded from AlarmSounds folder
|
- **Dynamic alarm sounds**: Configurable alarm sounds loaded from dedicated alarm-sounds.json configuration
|
||||||
- **Sound preview**: Play/stop functionality for testing alarm sounds before selection
|
- **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
|
- **Custom labels**: User-defined alarm names and descriptions
|
||||||
- **Repeat schedules**: Set alarms to repeat on specific weekdays or daily
|
- **Repeat schedules**: Set alarms to repeat on specific weekdays or daily
|
||||||
- **Sound selection**: Choose from extensive alarm sounds with live preview
|
- **Sound selection**: Choose from extensive alarm sounds with live preview
|
||||||
- **Volume control**: Adjustable alarm volume (0-100%)
|
- **Volume control**: Adjustable alarm volume (0-100%)
|
||||||
- **Vibration settings**: Enable/disable vibration for each alarm
|
- **Vibration settings**: Enable/disable vibration for each alarm
|
||||||
- **Snooze functionality**: AlarmKit countdown feature for snooze support
|
- **Snooze functionality**: Configurable snooze duration (5, 7, 8, 9, 10, 15, 20 minutes)
|
||||||
- **Live Activity countdown**: Shows 5 minutes before alarm fires on Lock Screen and Dynamic Island
|
- **Smart notifications**: Automatic scheduling for one-time and repeating alarms
|
||||||
- **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
|
- **Enable/disable toggles**: Individual alarm control with instant feedback
|
||||||
- **AlarmKit authorization**: Requires user permission via NSAlarmKitUsageDescription
|
- **Notification integration**: Uses iOS UserNotifications framework with proper scheduling
|
||||||
- **Persistent storage**: Alarms saved to UserDefaults with backward compatibility
|
- **Persistent storage**: Alarms saved to UserDefaults with backward compatibility
|
||||||
- **Alarm management**: Add, edit, delete, and duplicate alarms
|
- **Alarm management**: Add, edit, delete, and duplicate alarms
|
||||||
- **Next trigger preview**: Shows when the next alarm will fire
|
- **Next trigger preview**: Shows when the next alarm will fire
|
||||||
- **Responsive time picker**: Font sizes adapt to available space and orientation
|
- **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
|
- **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
|
## Advanced Clock Display Features
|
||||||
|
|
||||||
@ -356,7 +351,6 @@ These principles are fundamental to the project's long-term success and must be
|
|||||||
- **Real-time updates**: Changes apply immediately with live preview
|
- **Real-time updates**: Changes apply immediately with live preview
|
||||||
- **Sheet presentation**: Full-screen settings sheet for uninterrupted editing
|
- **Sheet presentation**: Full-screen settings sheet for uninterrupted editing
|
||||||
- **Enum-based architecture**: Type-safe picker selections eliminate string-based errors
|
- **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
|
## File Structure and Organization
|
||||||
|
|
||||||
@ -405,12 +399,9 @@ TheNoiseClock/
|
|||||||
│ │ │ └── View+Extensions.swift # Common view modifiers and responsive utilities
|
│ │ │ └── View+Extensions.swift # Common view modifiers and responsive utilities
|
||||||
│ │ ├── Models/
|
│ │ ├── Models/
|
||||||
│ │ │ └── SoundCategory.swift # Shared sound category definitions
|
│ │ │ └── SoundCategory.swift # Shared sound category definitions
|
||||||
│ │ ├── LiveActivity/
|
|
||||||
│ │ │ └── NoiseClockAlarmMetadata.swift # AlarmKit metadata shared with widget
|
|
||||||
│ │ └── Utilities/
|
│ │ └── Utilities/
|
||||||
│ │ ├── ColorUtils.swift # Color manipulation utilities
|
│ │ ├── ColorUtils.swift # Color manipulation utilities
|
||||||
│ │ ├── NotificationUtils.swift # Notification helper functions
|
│ │ └── NotificationUtils.swift # Notification helper functions
|
||||||
│ │ └── AlarmNotifications.swift # Alarm notification constants and events
|
|
||||||
│ ├── Features/
|
│ ├── Features/
|
||||||
│ │ ├── Clock/
|
│ │ ├── Clock/
|
||||||
│ │ │ ├── Models/
|
│ │ │ ├── Models/
|
||||||
@ -435,6 +426,7 @@ TheNoiseClock/
|
|||||||
│ │ │ ├── ClockDisplayContainer.swift
|
│ │ │ ├── ClockDisplayContainer.swift
|
||||||
│ │ │ ├── ClockOverlayContainer.swift
|
│ │ │ ├── ClockOverlayContainer.swift
|
||||||
│ │ │ ├── ClockGestureHandler.swift
|
│ │ │ ├── ClockGestureHandler.swift
|
||||||
|
│ │ │ ├── ClockTabBarManager.swift
|
||||||
│ │ │ ├── ClockToolbar.swift
|
│ │ │ ├── ClockToolbar.swift
|
||||||
│ │ │ ├── FullScreenHintView.swift
|
│ │ │ ├── FullScreenHintView.swift
|
||||||
│ │ │ └── Settings/
|
│ │ │ └── Settings/
|
||||||
@ -452,11 +444,11 @@ TheNoiseClock/
|
|||||||
│ │ │ ├── State/
|
│ │ │ ├── State/
|
||||||
│ │ │ │ └── AlarmViewModel.swift
|
│ │ │ │ └── AlarmViewModel.swift
|
||||||
│ │ │ ├── Services/
|
│ │ │ ├── Services/
|
||||||
│ │ │ │ ├── AlarmService.swift # Alarm persistence
|
│ │ │ │ ├── AlarmService.swift
|
||||||
│ │ │ │ ├── AlarmSoundService.swift # Alarm sound metadata
|
│ │ │ │ ├── AlarmSoundService.swift
|
||||||
│ │ │ │ └── AlarmKitService.swift # AlarmKit integration (iOS 26+)
|
│ │ │ │ ├── FocusModeService.swift
|
||||||
│ │ │ ├── Intents/
|
│ │ │ │ ├── NotificationService.swift
|
||||||
│ │ │ │ └── AlarmIntents.swift # App Intents for Stop/Snooze
|
│ │ │ │ └── NotificationDelegate.swift
|
||||||
│ │ │ └── Views/
|
│ │ │ └── Views/
|
||||||
│ │ │ ├── AlarmView.swift
|
│ │ │ ├── AlarmView.swift
|
||||||
│ │ │ ├── AddAlarmView.swift
|
│ │ │ ├── AddAlarmView.swift
|
||||||
@ -470,17 +462,12 @@ TheNoiseClock/
|
|||||||
│ │ │ ├── SoundSelectionView.swift
|
│ │ │ ├── SoundSelectionView.swift
|
||||||
│ │ │ ├── TimePickerSection.swift
|
│ │ │ ├── TimePickerSection.swift
|
||||||
│ │ │ └── TimeUntilAlarmSection.swift
|
│ │ │ └── TimeUntilAlarmSection.swift
|
||||||
│ │ ├── Noise/
|
│ │ └── Noise/
|
||||||
│ │ │ └── Views/
|
|
||||||
│ │ │ ├── NoiseView.swift
|
|
||||||
│ │ │ └── Components/
|
|
||||||
│ │ │ ├── SoundCategoryView.swift
|
|
||||||
│ │ │ └── SoundControlView.swift
|
|
||||||
│ │ └── Onboarding/
|
|
||||||
│ │ └── Views/
|
│ │ └── Views/
|
||||||
│ │ ├── OnboardingView.swift
|
│ │ ├── NoiseView.swift
|
||||||
│ │ └── Components/
|
│ │ └── Components/
|
||||||
│ │ └── OnboardingPageView.swift
|
│ │ ├── SoundCategoryView.swift
|
||||||
|
│ │ └── SoundControlView.swift
|
||||||
│ └── Resources/
|
│ └── Resources/
|
||||||
│ ├── LaunchScreen.storyboard # Branded native launch screen
|
│ ├── LaunchScreen.storyboard # Branded native launch screen
|
||||||
│ ├── sounds.json # Ambient sound configuration and definitions
|
│ ├── sounds.json # Ambient sound configuration and definitions
|
||||||
@ -501,10 +488,6 @@ TheNoiseClock/
|
|||||||
│ └── [Asset catalogs]
|
│ └── [Asset catalogs]
|
||||||
└── TheNoiseClock.xcodeproj/ # Xcode project with AudioPlaybackKit dependency
|
└── TheNoiseClock.xcodeproj/ # Xcode project with AudioPlaybackKit dependency
|
||||||
└── project.pbxproj # Project configuration with local package reference
|
└── project.pbxproj # Project configuration with local package reference
|
||||||
TheNoiseClockWidget/ # Widget extension (Live Activity)
|
|
||||||
├── AlarmLiveActivityWidget.swift # Live Activity UI for Dynamic Island/Lock Screen
|
|
||||||
├── TheNoiseClockWidgetBundle.swift # Widget bundle entry point
|
|
||||||
└── Info.plist # Widget extension Info.plist
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### File Naming Conventions
|
### File Naming Conventions
|
||||||
@ -552,16 +535,6 @@ The following changes **automatically require** PRD updates:
|
|||||||
- **Version consistency**: Code and documentation must always be in sync
|
- **Version consistency**: Code and documentation must always be in sync
|
||||||
- **No manual requests**: Users should not need to ask for PRD updates
|
- **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
|
## Key User Interactions
|
||||||
|
|
||||||
### Clock Tab
|
### Clock Tab
|
||||||
@ -574,10 +547,9 @@ The following changes **automatically require** PRD updates:
|
|||||||
1. **Time format**: Toggle 24-hour, seconds, AM/PM display
|
1. **Time format**: Toggle 24-hour, seconds, AM/PM display
|
||||||
2. **Appearance**: Adjust colors, glow, size, opacity
|
2. **Appearance**: Adjust colors, glow, size, opacity
|
||||||
3. **Display**: Control keep awake functionality for display mode
|
3. **Display**: Control keep awake functionality for display mode
|
||||||
4. **Keep Awake prompt**: Auto-prompt when needed (alarms tab, enabling alarms, display mode)
|
4. **Focus Modes**: Control how app behaves with Focus modes (Do Not Disturb)
|
||||||
5. **Focus Modes**: Control how app behaves with Focus modes (Do Not Disturb)
|
5. **Overlays**: Control battery and date display
|
||||||
6. **Overlays**: Control battery and date display
|
6. **Background**: Set background color and use presets
|
||||||
7. **Background**: Set background color and use presets
|
|
||||||
|
|
||||||
### Alarms Tab
|
### Alarms Tab
|
||||||
1. **View alarms**: List of all created alarms with labels and repeat schedules
|
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
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -36,16 +36,11 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and
|
|||||||
- Seamless looping with background audio support
|
- Seamless looping with background audio support
|
||||||
- Quick preview on long-press, instant play/stop controls
|
- Quick preview on long-press, instant play/stop controls
|
||||||
|
|
||||||
**Alarms (Powered by AlarmKit)**
|
**Alarms**
|
||||||
- Unlimited alarms with labels, repeat schedules, and snooze options
|
- Unlimited alarms with labels, repeat schedules, and snooze options
|
||||||
- Alarm sound library with preview (MP3 format)
|
- Alarm sound library with preview
|
||||||
- Vibration and volume controls per alarm
|
- Vibration and volume controls per alarm
|
||||||
- AlarmKit integration: alarms cut through Focus modes and silent mode
|
- Focus-mode aware scheduling
|
||||||
- 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**
|
**Display Mode**
|
||||||
- Long-press to enter immersive display mode
|
- Long-press to enter immersive display mode
|
||||||
@ -53,7 +48,6 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and
|
|||||||
- Optional wake-lock to keep the screen on
|
- Optional wake-lock to keep the screen on
|
||||||
|
|
||||||
### What's New
|
### What's New
|
||||||
- First-launch onboarding with feature highlights and notification setup
|
|
||||||
- Branded launch experience with Bedrock theming
|
- Branded launch experience with Bedrock theming
|
||||||
- Redesigned settings interface with cards, toggles, and sliders
|
- Redesigned settings interface with cards, toggles, and sliders
|
||||||
- Centralized build identifiers via xcconfig
|
- Centralized build identifiers via xcconfig
|
||||||
@ -66,17 +60,15 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and
|
|||||||
- Full-screen display mode and Dynamic Island awareness
|
- Full-screen display mode and Dynamic Island awareness
|
||||||
- White noise playback with categories and previews
|
- White noise playback with categories and previews
|
||||||
- Rich alarm editor with scheduling and snooze controls
|
- Rich alarm editor with scheduling and snooze controls
|
||||||
- Full-screen in-app alarm screen with Snooze/Stop controls
|
|
||||||
- Bedrock-based theming and branded launch
|
- Bedrock-based theming and branded launch
|
||||||
- iPhone and iPad support with adaptive layouts
|
- iPhone and iPad support with adaptive layouts
|
||||||
- First-launch onboarding with feature highlights and permission setup
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- iOS 26.0+
|
- iOS 18.0+
|
||||||
- Xcode 26+
|
- Xcode 16+
|
||||||
- Swift 6
|
- Swift 6
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -9,7 +9,6 @@
|
|||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
EA384E832E6F806200CA7D50 /* AudioPlaybackKit in Frameworks */ = {isa = PBXBuildFile; productRef = EA384D3D2E6F554D00CA7D50 /* AudioPlaybackKit */; };
|
EA384E832E6F806200CA7D50 /* AudioPlaybackKit in Frameworks */ = {isa = PBXBuildFile; productRef = EA384D3D2E6F554D00CA7D50 /* AudioPlaybackKit */; };
|
||||||
EAC051B12F2E64AB007F87EA /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EAC051B02F2E64AB007F87EA /* Bedrock */; };
|
EAC051B12F2E64AB007F87EA /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EAC051B02F2E64AB007F87EA /* Bedrock */; };
|
||||||
EAF1C0DE2F3A4B5C0011223E /* TheNoiseClockWidget.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = EAF1C0DE2F3A4B5C00112234 /* TheNoiseClockWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@ -27,36 +26,14 @@
|
|||||||
remoteGlobalIDString = EA384AFA2E6E6B6000CA7D50;
|
remoteGlobalIDString = EA384AFA2E6E6B6000CA7D50;
|
||||||
remoteInfo = TheNoiseClock;
|
remoteInfo = TheNoiseClock;
|
||||||
};
|
};
|
||||||
EAF1C0DE2F3A4B5C00112240 /* PBXContainerItemProxy */ = {
|
|
||||||
isa = PBXContainerItemProxy;
|
|
||||||
containerPortal = EA384AF32E6E6B6000CA7D50 /* Project object */;
|
|
||||||
proxyType = 1;
|
|
||||||
remoteGlobalIDString = EAF1C0DE2F3A4B5C00112233;
|
|
||||||
remoteInfo = TheNoiseClockWidget;
|
|
||||||
};
|
|
||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
|
||||||
EAF1C0DE2F3A4B5C0011223D /* Embed App Extensions */ = {
|
|
||||||
isa = PBXCopyFilesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
dstPath = "";
|
|
||||||
dstSubfolderSpec = 13;
|
|
||||||
files = (
|
|
||||||
EAF1C0DE2F3A4B5C0011223E /* TheNoiseClockWidget.appex in Embed App Extensions */,
|
|
||||||
);
|
|
||||||
name = "Embed App Extensions";
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXCopyFilesBuildPhase section */
|
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
EA384AFB2E6E6B6000CA7D50 /* TheNoiseClock.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TheNoiseClock.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
EA384AFB2E6E6B6000CA7D50 /* TheNoiseClock.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TheNoiseClock.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
EA384B082E6E6B6100CA7D50 /* TheNoiseClockTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TheNoiseClockTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
EA384B082E6E6B6100CA7D50 /* TheNoiseClockTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TheNoiseClockTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
EA384B122E6E6B6100CA7D50 /* TheNoiseClockUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TheNoiseClockUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
EA384B122E6E6B6100CA7D50 /* TheNoiseClockUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TheNoiseClockUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
EAD6E3AF5A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = TheNoiseClock/Configuration/Debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
EAD6E3AF5A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = TheNoiseClock/Configuration/Debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||||
EAD6E3B05A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = TheNoiseClock/Configuration/Release.xcconfig; sourceTree = SOURCE_ROOT; };
|
EAD6E3B05A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = TheNoiseClock/Configuration/Release.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||||
EAF1C0DE2F3A4B5C00112234 /* TheNoiseClockWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TheNoiseClockWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
@ -67,13 +44,6 @@
|
|||||||
);
|
);
|
||||||
target = EA384AFA2E6E6B6000CA7D50 /* TheNoiseClock */;
|
target = EA384AFA2E6E6B6000CA7D50 /* TheNoiseClock */;
|
||||||
};
|
};
|
||||||
EAF1C0DE2F3A4B5C0011223C /* Exceptions for "TheNoiseClockWidget" folder in "TheNoiseClockWidget" target */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
|
||||||
membershipExceptions = (
|
|
||||||
Info.plist,
|
|
||||||
);
|
|
||||||
target = EAF1C0DE2F3A4B5C00112233 /* TheNoiseClockWidget */;
|
|
||||||
};
|
|
||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
@ -95,14 +65,6 @@
|
|||||||
path = TheNoiseClockUITests;
|
path = TheNoiseClockUITests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
EAF1C0DE2F3A4B5C0011223B /* TheNoiseClockWidget */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
|
||||||
exceptions = (
|
|
||||||
EAF1C0DE2F3A4B5C0011223C /* Exceptions for "TheNoiseClockWidget" folder in "TheNoiseClockWidget" target */,
|
|
||||||
);
|
|
||||||
path = TheNoiseClockWidget;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@ -129,13 +91,6 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
EAF1C0DE2F3A4B5C0011223A /* Frameworks */ = {
|
|
||||||
isa = PBXFrameworksBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
@ -145,7 +100,6 @@
|
|||||||
EA384AFD2E6E6B6000CA7D50 /* TheNoiseClock */,
|
EA384AFD2E6E6B6000CA7D50 /* TheNoiseClock */,
|
||||||
EA384B0B2E6E6B6100CA7D50 /* TheNoiseClockTests */,
|
EA384B0B2E6E6B6100CA7D50 /* TheNoiseClockTests */,
|
||||||
EA384B152E6E6B6100CA7D50 /* TheNoiseClockUITests */,
|
EA384B152E6E6B6100CA7D50 /* TheNoiseClockUITests */,
|
||||||
EAF1C0DE2F3A4B5C0011223B /* TheNoiseClockWidget */,
|
|
||||||
EA384AFC2E6E6B6000CA7D50 /* Products */,
|
EA384AFC2E6E6B6000CA7D50 /* Products */,
|
||||||
EAC057642F2E69E8007F87EA /* Recovered References */,
|
EAC057642F2E69E8007F87EA /* Recovered References */,
|
||||||
);
|
);
|
||||||
@ -157,7 +111,6 @@
|
|||||||
EA384AFB2E6E6B6000CA7D50 /* TheNoiseClock.app */,
|
EA384AFB2E6E6B6000CA7D50 /* TheNoiseClock.app */,
|
||||||
EA384B082E6E6B6100CA7D50 /* TheNoiseClockTests.xctest */,
|
EA384B082E6E6B6100CA7D50 /* TheNoiseClockTests.xctest */,
|
||||||
EA384B122E6E6B6100CA7D50 /* TheNoiseClockUITests.xctest */,
|
EA384B122E6E6B6100CA7D50 /* TheNoiseClockUITests.xctest */,
|
||||||
EAF1C0DE2F3A4B5C00112234 /* TheNoiseClockWidget.appex */,
|
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -181,12 +134,10 @@
|
|||||||
EA384AF72E6E6B6000CA7D50 /* Sources */,
|
EA384AF72E6E6B6000CA7D50 /* Sources */,
|
||||||
EA384AF82E6E6B6000CA7D50 /* Frameworks */,
|
EA384AF82E6E6B6000CA7D50 /* Frameworks */,
|
||||||
EA384AF92E6E6B6000CA7D50 /* Resources */,
|
EA384AF92E6E6B6000CA7D50 /* Resources */,
|
||||||
EAF1C0DE2F3A4B5C0011223D /* Embed App Extensions */,
|
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
EAF1C0DE2F3A4B5C0011223F /* PBXTargetDependency */,
|
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
EA384AFD2E6E6B6000CA7D50 /* TheNoiseClock */,
|
EA384AFD2E6E6B6000CA7D50 /* TheNoiseClock */,
|
||||||
@ -246,26 +197,6 @@
|
|||||||
productReference = EA384B122E6E6B6100CA7D50 /* TheNoiseClockUITests.xctest */;
|
productReference = EA384B122E6E6B6100CA7D50 /* TheNoiseClockUITests.xctest */;
|
||||||
productType = "com.apple.product-type.bundle.ui-testing";
|
productType = "com.apple.product-type.bundle.ui-testing";
|
||||||
};
|
};
|
||||||
EAF1C0DE2F3A4B5C00112233 /* TheNoiseClockWidget */ = {
|
|
||||||
isa = PBXNativeTarget;
|
|
||||||
buildConfigurationList = EAF1C0DE2F3A4B5C00112235 /* Build configuration list for PBXNativeTarget "TheNoiseClockWidget" */;
|
|
||||||
buildPhases = (
|
|
||||||
EAF1C0DE2F3A4B5C00112238 /* Sources */,
|
|
||||||
EAF1C0DE2F3A4B5C0011223A /* Frameworks */,
|
|
||||||
EAF1C0DE2F3A4B5C00112239 /* Resources */,
|
|
||||||
);
|
|
||||||
buildRules = (
|
|
||||||
);
|
|
||||||
dependencies = (
|
|
||||||
);
|
|
||||||
fileSystemSynchronizedGroups = (
|
|
||||||
EAF1C0DE2F3A4B5C0011223B /* TheNoiseClockWidget */,
|
|
||||||
);
|
|
||||||
name = TheNoiseClockWidget;
|
|
||||||
productName = TheNoiseClockWidget;
|
|
||||||
productReference = EAF1C0DE2F3A4B5C00112234 /* TheNoiseClockWidget.appex */;
|
|
||||||
productType = "com.apple.product-type.app-extension";
|
|
||||||
};
|
|
||||||
/* End PBXNativeTarget section */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
@ -287,9 +218,6 @@
|
|||||||
CreatedOnToolsVersion = 26.0;
|
CreatedOnToolsVersion = 26.0;
|
||||||
TestTargetID = EA384AFA2E6E6B6000CA7D50;
|
TestTargetID = EA384AFA2E6E6B6000CA7D50;
|
||||||
};
|
};
|
||||||
EAF1C0DE2F3A4B5C00112233 = {
|
|
||||||
CreatedOnToolsVersion = 26.0;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = EA384AF62E6E6B6000CA7D50 /* Build configuration list for PBXProject "TheNoiseClock" */;
|
buildConfigurationList = EA384AF62E6E6B6000CA7D50 /* Build configuration list for PBXProject "TheNoiseClock" */;
|
||||||
@ -313,7 +241,6 @@
|
|||||||
EA384AFA2E6E6B6000CA7D50 /* TheNoiseClock */,
|
EA384AFA2E6E6B6000CA7D50 /* TheNoiseClock */,
|
||||||
EA384B072E6E6B6100CA7D50 /* TheNoiseClockTests */,
|
EA384B072E6E6B6100CA7D50 /* TheNoiseClockTests */,
|
||||||
EA384B112E6E6B6100CA7D50 /* TheNoiseClockUITests */,
|
EA384B112E6E6B6100CA7D50 /* TheNoiseClockUITests */,
|
||||||
EAF1C0DE2F3A4B5C00112233 /* TheNoiseClockWidget */,
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@ -340,13 +267,6 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
EAF1C0DE2F3A4B5C00112239 /* Resources */ = {
|
|
||||||
isa = PBXResourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@ -371,13 +291,6 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
EAF1C0DE2F3A4B5C00112238 /* Sources */ = {
|
|
||||||
isa = PBXSourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
/* Begin PBXTargetDependency section */
|
||||||
@ -391,11 +304,6 @@
|
|||||||
target = EA384AFA2E6E6B6000CA7D50 /* TheNoiseClock */;
|
target = EA384AFA2E6E6B6000CA7D50 /* TheNoiseClock */;
|
||||||
targetProxy = EA384B132E6E6B6100CA7D50 /* PBXContainerItemProxy */;
|
targetProxy = EA384B132E6E6B6100CA7D50 /* PBXContainerItemProxy */;
|
||||||
};
|
};
|
||||||
EAF1C0DE2F3A4B5C0011223F /* PBXTargetDependency */ = {
|
|
||||||
isa = PBXTargetDependency;
|
|
||||||
target = EAF1C0DE2F3A4B5C00112233 /* TheNoiseClockWidget */;
|
|
||||||
targetProxy = EAF1C0DE2F3A4B5C00112240 /* PBXContainerItemProxy */;
|
|
||||||
};
|
|
||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
@ -538,7 +446,7 @@
|
|||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26;
|
IPHONEOS_DEPLOYMENT_TARGET = 18;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@ -572,7 +480,7 @@
|
|||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26;
|
IPHONEOS_DEPLOYMENT_TARGET = 18;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@ -678,44 +586,6 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
EAF1C0DE2F3A4B5C00112236 /* Debug */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
baseConfigurationReference = EAD6E3AF5A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Debug.xcconfig */;
|
|
||||||
buildSettings = {
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
|
||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
|
||||||
INFOPLIST_FILE = TheNoiseClockWidget/Info.plist;
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 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 */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
@ -755,15 +625,6 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
EAF1C0DE2F3A4B5C00112235 /* Build configuration list for PBXNativeTarget "TheNoiseClockWidget" */ = {
|
|
||||||
isa = XCConfigurationList;
|
|
||||||
buildConfigurations = (
|
|
||||||
EAF1C0DE2F3A4B5C00112236 /* Debug */,
|
|
||||||
EAF1C0DE2F3A4B5C00112237 /* Release */,
|
|
||||||
);
|
|
||||||
defaultConfigurationIsVisible = 0;
|
|
||||||
defaultConfigurationName = Release;
|
|
||||||
};
|
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCLocalSwiftPackageReference section */
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
|
|||||||
@ -7,12 +7,7 @@
|
|||||||
<key>TheNoiseClock.xcscheme_^#shared#^_</key>
|
<key>TheNoiseClock.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>2</integer>
|
<integer>1</integer>
|
||||||
</dict>
|
|
||||||
<key>TheNoiseClockWidget.xcscheme_^#shared#^_</key>
|
|
||||||
<dict>
|
|
||||||
<key>orderHint</key>
|
|
||||||
<integer>3</integer>
|
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@ -11,142 +11,60 @@ import Bedrock
|
|||||||
/// Main tab navigation coordinator
|
/// Main tab navigation coordinator
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
|
|
||||||
// MARK: - Properties
|
// MARK: - Body
|
||||||
|
private enum Tab: Hashable {
|
||||||
private enum Tab: Hashable, CustomStringConvertible {
|
|
||||||
case clock
|
case clock
|
||||||
case alarms
|
case alarms
|
||||||
case noise
|
case noise
|
||||||
case settings
|
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 selectedTab: Tab = .clock
|
||||||
@State private var clockViewModel = ClockViewModel()
|
@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 {
|
var body: some View {
|
||||||
ZStack {
|
TabView(selection: $selectedTab) {
|
||||||
// Main tab content
|
NavigationStack {
|
||||||
TabView(selection: $selectedTab) {
|
ClockView(viewModel: clockViewModel)
|
||||||
NavigationStack {
|
}
|
||||||
// Pass isOnClockTab so ClockView can make the right tab bar decision
|
.tabItem {
|
||||||
// Tab bar hides ONLY when: isOnClockTab && isDisplayMode
|
Label("Clock", systemImage: "clock")
|
||||||
// This prevents race conditions on tab switch
|
}
|
||||||
ClockView(viewModel: clockViewModel, isOnClockTab: isOnClockTab)
|
.tag(Tab.clock)
|
||||||
}
|
|
||||||
.tabItem {
|
NavigationStack {
|
||||||
Label("Clock", systemImage: "clock")
|
AlarmView()
|
||||||
}
|
}
|
||||||
.tag(Tab.clock)
|
.tabItem {
|
||||||
|
Label("Alarms", systemImage: "alarm")
|
||||||
NavigationStack {
|
}
|
||||||
AlarmView(viewModel: alarmViewModel)
|
.tag(Tab.alarms)
|
||||||
}
|
|
||||||
.tabItem {
|
NavigationStack {
|
||||||
Label("Alarms", systemImage: "alarm")
|
NoiseView()
|
||||||
}
|
}
|
||||||
.tag(Tab.alarms)
|
.tabItem {
|
||||||
|
Label("Noise", systemImage: "waveform")
|
||||||
NavigationStack {
|
}
|
||||||
NoiseView()
|
.tag(Tab.noise)
|
||||||
}
|
|
||||||
.tabItem {
|
|
||||||
Label("Noise", systemImage: "waveform")
|
|
||||||
}
|
|
||||||
.tag(Tab.noise)
|
|
||||||
|
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ClockSettingsView(
|
ClockSettingsView(style: clockViewModel.style) { newStyle in
|
||||||
style: clockViewModel.style,
|
clockViewModel.updateStyle(newStyle)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.accentColor(AppAccent.primary)
|
.tabItem {
|
||||||
.background(Color.Branding.primary.ignoresSafeArea())
|
Label("Settings", systemImage: "gearshape")
|
||||||
// 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.
|
.tag(Tab.settings)
|
||||||
|
}
|
||||||
// Onboarding overlay for first-time users
|
.onChange(of: selectedTab) { oldValue, newValue in
|
||||||
if !onboardingState.hasCompletedWelcome {
|
if oldValue == .clock && newValue != .clock {
|
||||||
OnboardingView {
|
clockViewModel.setDisplayMode(false)
|
||||||
onboardingState.completeWelcome()
|
|
||||||
}
|
|
||||||
.transition(.opacity)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $keepAwakePromptState.isPresented) {
|
.accentColor(AppAccent.primary)
|
||||||
KeepAwakePrompt(
|
.background(Color.Branding.primary.ignoresSafeArea())
|
||||||
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,9 +4,6 @@
|
|||||||
//
|
//
|
||||||
// Created by Matt Bruce on 9/7/25.
|
// 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 SwiftUI
|
||||||
import Bedrock
|
import Bedrock
|
||||||
@ -15,6 +12,12 @@ import Bedrock
|
|||||||
@main
|
@main
|
||||||
struct TheNoiseClockApp: App {
|
struct TheNoiseClockApp: App {
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
init() {
|
||||||
|
// Initialize notification delegate to handle snooze actions
|
||||||
|
_ = NotificationDelegate.shared
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
|
|||||||
@ -1,118 +0,0 @@
|
|||||||
//
|
|
||||||
// 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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,418 +0,0 @@
|
|||||||
//
|
|
||||||
// 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,17 +4,12 @@
|
|||||||
//
|
//
|
||||||
// Created by Matt Bruce on 9/7/25.
|
// 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 Foundation
|
||||||
|
import UserNotifications
|
||||||
import Observation
|
import Observation
|
||||||
import Bedrock
|
|
||||||
|
|
||||||
/// Service for managing alarm persistence.
|
/// Service for managing alarms and notifications
|
||||||
/// Alarm scheduling is handled by AlarmKitService.
|
|
||||||
@Observable
|
@Observable
|
||||||
class AlarmService {
|
class AlarmService {
|
||||||
|
|
||||||
@ -22,48 +17,48 @@ class AlarmService {
|
|||||||
private(set) var alarms: [Alarm] = []
|
private(set) var alarms: [Alarm] = []
|
||||||
private var alarmLookup: [UUID: Int] = [:]
|
private var alarmLookup: [UUID: Int] = [:]
|
||||||
private var persistenceWorkItem: DispatchWorkItem?
|
private var persistenceWorkItem: DispatchWorkItem?
|
||||||
|
private let focusModeService = FocusModeService.shared
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
init() {
|
init() {
|
||||||
loadAlarms()
|
loadAlarms()
|
||||||
Design.debugLog("[alarms] AlarmService initialized with \(alarms.count) alarms")
|
Task {
|
||||||
|
// Request permissions through FocusModeService for better compatibility
|
||||||
|
let granted = await focusModeService.requestNotificationPermissions()
|
||||||
|
if !granted {
|
||||||
|
// Fallback to original method
|
||||||
|
_ = await NotificationUtils.requestPermissions()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public Interface
|
// MARK: - Public Interface
|
||||||
|
|
||||||
/// Add an alarm to storage. Does NOT schedule - caller should use AlarmKitService.
|
|
||||||
func addAlarm(_ alarm: Alarm) {
|
func addAlarm(_ alarm: Alarm) {
|
||||||
Design.debugLog("[alarms] AlarmService.addAlarm: \(alarm.label) at \(alarm.time)")
|
|
||||||
alarms.append(alarm)
|
alarms.append(alarm)
|
||||||
updateAlarmLookup()
|
updateAlarmLookup()
|
||||||
|
scheduleNotification(for: alarm)
|
||||||
saveAlarms()
|
saveAlarms()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update an alarm in storage. Does NOT reschedule - caller should use AlarmKitService.
|
|
||||||
func updateAlarm(_ alarm: Alarm) {
|
func updateAlarm(_ alarm: Alarm) {
|
||||||
guard let index = alarmLookup[alarm.id] else {
|
guard let index = alarmLookup[alarm.id] else { return }
|
||||||
Design.debugLog("[alarms] AlarmService.updateAlarm: alarm not found \(alarm.id)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Design.debugLog("[alarms] AlarmService.updateAlarm: \(alarm.label) enabled=\(alarm.isEnabled)")
|
|
||||||
alarms[index] = alarm
|
alarms[index] = alarm
|
||||||
updateAlarmLookup()
|
updateAlarmLookup()
|
||||||
|
scheduleNotification(for: alarm)
|
||||||
saveAlarms()
|
saveAlarms()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete an alarm from storage. Does NOT cancel - caller should use AlarmKitService.
|
|
||||||
func deleteAlarm(id: UUID) {
|
func deleteAlarm(id: UUID) {
|
||||||
Design.debugLog("[alarms] AlarmService.deleteAlarm: \(id)")
|
|
||||||
alarms.removeAll { $0.id == id }
|
alarms.removeAll { $0.id == id }
|
||||||
updateAlarmLookup()
|
updateAlarmLookup()
|
||||||
|
NotificationUtils.removeNotification(identifier: id.uuidString)
|
||||||
saveAlarms()
|
saveAlarms()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle an alarm's enabled state. Does NOT reschedule - caller should use AlarmKitService.
|
|
||||||
func toggleAlarm(id: UUID) {
|
func toggleAlarm(id: UUID) {
|
||||||
guard let index = alarmLookup[id] else { return }
|
guard let index = alarmLookup[id] else { return }
|
||||||
alarms[index].isEnabled.toggle()
|
alarms[index].isEnabled.toggle()
|
||||||
Design.debugLog("[alarms] AlarmService.toggleAlarm: \(id) now enabled=\(alarms[index].isEnabled)")
|
scheduleNotification(for: alarms[index])
|
||||||
saveAlarms()
|
saveAlarms()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,6 +74,36 @@ 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() {
|
private func saveAlarms() {
|
||||||
persistenceWorkItem?.cancel()
|
persistenceWorkItem?.cancel()
|
||||||
|
|
||||||
@ -99,37 +124,13 @@ class AlarmService {
|
|||||||
private func loadAlarms() {
|
private func loadAlarms() {
|
||||||
if let savedAlarms = UserDefaults.standard.data(forKey: AppConstants.StorageKeys.savedAlarms),
|
if let savedAlarms = UserDefaults.standard.data(forKey: AppConstants.StorageKeys.savedAlarms),
|
||||||
let decodedAlarms = try? JSONDecoder().decode([Alarm].self, from: savedAlarms) {
|
let decodedAlarms = try? JSONDecoder().decode([Alarm].self, from: savedAlarms) {
|
||||||
// Migrate sound file extensions from .caf to .mp3
|
alarms = decodedAlarms
|
||||||
alarms = decodedAlarms.map { alarm in
|
|
||||||
var migratedAlarm = alarm
|
|
||||||
migratedAlarm.soundName = migrateSoundName(alarm.soundName)
|
|
||||||
return migratedAlarm
|
|
||||||
}
|
|
||||||
updateAlarmLookup()
|
updateAlarmLookup()
|
||||||
Design.debugLog("[alarms] Loaded \(alarms.count) alarms from storage")
|
|
||||||
|
|
||||||
// Save migrated alarms if any changes were made
|
// Reschedule all enabled alarms
|
||||||
let needsMigration = zip(decodedAlarms, alarms).contains { $0.soundName != $1.soundName }
|
for alarm in alarms where alarm.isEnabled {
|
||||||
if needsMigration {
|
scheduleNotification(for: alarm)
|
||||||
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 {
|
do {
|
||||||
let data = try Data(contentsOf: url)
|
let data = try Data(contentsOf: url)
|
||||||
let settings = try JSONDecoder().decode(AudioSettings.self, from: data)
|
let settings = try JSONDecoder().decode(AudioSettings.self, from: data)
|
||||||
//Design.debugLog("[settings] Loaded audio settings for alarms from SoundsSettings.json")
|
Design.debugLog("[settings] Loaded audio settings for alarms from SoundsSettings.json")
|
||||||
return settings
|
return settings
|
||||||
} catch {
|
} catch {
|
||||||
Design.debugLog("[general] Warning: Error loading audio settings for alarms, using defaults: \(error)")
|
Design.debugLog("[general] Warning: Error loading audio settings for alarms, using defaults: \(error)")
|
||||||
@ -114,72 +114,4 @@ class AlarmSoundService {
|
|||||||
}
|
}
|
||||||
return fileName.replacingOccurrences(of: ".caf", with: "").capitalized
|
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
238
TheNoiseClock/Features/Alarms/Services/FocusModeService.swift
Normal file
238
TheNoiseClock/Features/Alarms/Services/FocusModeService.swift
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
//
|
||||||
|
// 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.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,180 @@
|
|||||||
|
//
|
||||||
|
// 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,87 @@
|
|||||||
|
//
|
||||||
|
// 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,25 +5,16 @@
|
|||||||
// Created by Matt Bruce on 9/7/25.
|
// Created by Matt Bruce on 9/7/25.
|
||||||
//
|
//
|
||||||
|
|
||||||
import AlarmKit
|
|
||||||
import Bedrock
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
|
|
||||||
/// ViewModel for alarm management using AlarmKit (iOS 26+).
|
/// ViewModel for alarm management
|
||||||
/// AlarmKit provides alarms that cut through Focus modes and silent mode,
|
|
||||||
/// with built-in Live Activity countdown and system alarm UI.
|
|
||||||
@Observable
|
@Observable
|
||||||
class AlarmViewModel {
|
class AlarmViewModel {
|
||||||
|
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
private let alarmService: AlarmService
|
private let alarmService: AlarmService
|
||||||
private let alarmKitService = AlarmKitService.shared
|
private let notificationService: NotificationService
|
||||||
|
|
||||||
/// Whether AlarmKit is authorized
|
|
||||||
var isAlarmKitAuthorized: Bool {
|
|
||||||
alarmKitService.authorizationState == .authorized
|
|
||||||
}
|
|
||||||
|
|
||||||
var alarms: [Alarm] {
|
var alarms: [Alarm] {
|
||||||
alarmService.alarms
|
alarmService.alarms
|
||||||
@ -32,54 +23,53 @@ class AlarmViewModel {
|
|||||||
var systemSounds: [String] {
|
var systemSounds: [String] {
|
||||||
AppConstants.SystemSounds.availableSounds
|
AppConstants.SystemSounds.availableSounds
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
init(alarmService: AlarmService = AlarmService()) {
|
init(alarmService: AlarmService = AlarmService(),
|
||||||
|
notificationService: NotificationService = NotificationService()) {
|
||||||
self.alarmService = alarmService
|
self.alarmService = alarmService
|
||||||
|
self.notificationService = notificationService
|
||||||
|
|
||||||
|
// Register alarm service with notification delegate for snooze handling
|
||||||
|
NotificationDelegate.shared.setAlarmService(alarmService)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Authorization
|
// MARK: - Public Interface
|
||||||
|
|
||||||
/// 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 {
|
func addAlarm(_ alarm: Alarm) async {
|
||||||
alarmService.addAlarm(alarm)
|
alarmService.addAlarm(alarm)
|
||||||
|
|
||||||
// Schedule with AlarmKit if alarm is enabled
|
// Schedule notification if alarm is enabled
|
||||||
if alarm.isEnabled {
|
if alarm.isEnabled {
|
||||||
Design.debugLog("[alarms] Scheduling AlarmKit alarm for \(alarm.label)")
|
await notificationService.scheduleAlarmNotification(
|
||||||
do {
|
id: alarm.id.uuidString,
|
||||||
try await alarmKitService.scheduleAlarm(alarm)
|
title: alarm.label,
|
||||||
} catch {
|
body: alarm.notificationMessage,
|
||||||
Design.debugLog("[alarms] AlarmKit scheduling failed: \(error)")
|
soundName: alarm.soundName,
|
||||||
}
|
date: alarm.time
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateAlarm(_ alarm: Alarm) async {
|
func updateAlarm(_ alarm: Alarm) async {
|
||||||
alarmService.updateAlarm(alarm)
|
alarmService.updateAlarm(alarm)
|
||||||
|
|
||||||
// Cancel existing and reschedule if enabled
|
// Reschedule notification
|
||||||
alarmKitService.cancelAlarm(id: alarm.id)
|
|
||||||
|
|
||||||
if alarm.isEnabled {
|
if alarm.isEnabled {
|
||||||
Design.debugLog("[alarms] Rescheduling AlarmKit alarm for \(alarm.label)")
|
await notificationService.scheduleAlarmNotification(
|
||||||
do {
|
id: alarm.id.uuidString,
|
||||||
try await alarmKitService.scheduleAlarm(alarm)
|
title: alarm.label,
|
||||||
} catch {
|
body: alarm.notificationMessage,
|
||||||
Design.debugLog("[alarms] AlarmKit rescheduling failed: \(error)")
|
soundName: alarm.soundName,
|
||||||
}
|
date: alarm.time
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
notificationService.cancelNotification(id: alarm.id.uuidString)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteAlarm(id: UUID) async {
|
func deleteAlarm(id: UUID) async {
|
||||||
// Cancel AlarmKit alarm first
|
// Cancel notification first
|
||||||
alarmKitService.cancelAlarm(id: id)
|
notificationService.cancelNotification(id: id.uuidString)
|
||||||
|
|
||||||
// Then delete from storage
|
// Then delete from storage
|
||||||
alarmService.deleteAlarm(id: id)
|
alarmService.deleteAlarm(id: id)
|
||||||
@ -91,16 +81,17 @@ class AlarmViewModel {
|
|||||||
alarm.isEnabled.toggle()
|
alarm.isEnabled.toggle()
|
||||||
alarmService.updateAlarm(alarm)
|
alarmService.updateAlarm(alarm)
|
||||||
|
|
||||||
// Schedule or cancel based on new state
|
// Schedule or cancel notification based on new state
|
||||||
if alarm.isEnabled {
|
if alarm.isEnabled {
|
||||||
Design.debugLog("[alarms] Enabling AlarmKit alarm \(alarm.label)")
|
await notificationService.scheduleAlarmNotification(
|
||||||
do {
|
id: alarm.id.uuidString,
|
||||||
try await alarmKitService.scheduleAlarm(alarm)
|
title: alarm.label,
|
||||||
} catch {
|
body: alarm.notificationMessage,
|
||||||
Design.debugLog("[alarms] AlarmKit scheduling failed: \(error)")
|
soundName: alarm.soundName,
|
||||||
}
|
date: alarm.time
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
alarmKitService.cancelAlarm(id: id)
|
notificationService.cancelNotification(id: id.uuidString)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,26 +123,7 @@ class AlarmViewModel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - App Lifecycle
|
func requestNotificationPermissions() async -> Bool {
|
||||||
|
return await notificationService.requestPermissions()
|
||||||
/// 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,7 +7,6 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AudioPlaybackKit
|
import AudioPlaybackKit
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// View for creating new alarms with iOS-native style interface
|
/// View for creating new alarms with iOS-native style interface
|
||||||
struct AddAlarmView: View {
|
struct AddAlarmView: View {
|
||||||
@ -15,7 +14,6 @@ struct AddAlarmView: View {
|
|||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
let viewModel: AlarmViewModel
|
let viewModel: AlarmViewModel
|
||||||
@Binding var isPresented: Bool
|
@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 newAlarmTime = Calendar.current.date(bySettingHour: 6, minute: 0, second: 0, of: Date()) ?? Date()
|
||||||
@State private var selectedSoundName = "digital-alarm.caf"
|
@State private var selectedSoundName = "digital-alarm.caf"
|
||||||
@ -35,15 +33,6 @@ struct AddAlarmView: View {
|
|||||||
|
|
||||||
// List for settings below
|
// List for settings below
|
||||||
List {
|
List {
|
||||||
if !isKeepAwakeEnabled {
|
|
||||||
Section {
|
|
||||||
AlarmLimitationsBanner()
|
|
||||||
.listRowInsets(EdgeInsets())
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Label Section
|
// Label Section
|
||||||
NavigationLink(destination: LabelEditView(label: $alarmLabel)) {
|
NavigationLink(destination: LabelEditView(label: $alarmLabel)) {
|
||||||
HStack {
|
HStack {
|
||||||
@ -138,11 +127,4 @@ struct AddAlarmView: View {
|
|||||||
private func getSoundDisplayName(_ fileName: String) -> String {
|
private func getSoundDisplayName(_ fileName: String) -> String {
|
||||||
return AlarmSoundService.shared.getSoundDisplayName(fileName)
|
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,48 +7,31 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Bedrock
|
import Bedrock
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Main alarm management view
|
/// Main alarm management view
|
||||||
struct AlarmView: View {
|
struct AlarmView: View {
|
||||||
|
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
@Bindable var viewModel: AlarmViewModel
|
@State private var viewModel = AlarmViewModel()
|
||||||
@State private var showAddAlarm = false
|
@State private var showAddAlarm = false
|
||||||
@State private var selectedAlarmForEdit: Alarm?
|
@State private var selectedAlarmForEdit: Alarm?
|
||||||
@AppStorage(ClockStyle.appStorageKey) private var clockStyleData: Data = Data()
|
|
||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let isPad = UIDevice.current.userInterfaceIdiom == .pad
|
let isPad = UIDevice.current.userInterfaceIdiom == .pad
|
||||||
Group {
|
Group {
|
||||||
if viewModel.alarms.isEmpty {
|
if viewModel.alarms.isEmpty {
|
||||||
VStack(spacing: Design.Spacing.large) {
|
EmptyAlarmsView {
|
||||||
if !isKeepAwakeEnabled {
|
showAddAlarm = true
|
||||||
AlarmLimitationsBanner()
|
}
|
||||||
}
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
EmptyAlarmsView {
|
showAddAlarm = true
|
||||||
showAddAlarm = true
|
|
||||||
}
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
showAddAlarm = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: Design.Size.maxContentWidthPortrait)
|
.frame(maxWidth: Design.Size.maxContentWidthPortrait)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
} else {
|
} else {
|
||||||
List {
|
List {
|
||||||
if !isKeepAwakeEnabled {
|
|
||||||
Section {
|
|
||||||
AlarmLimitationsBanner()
|
|
||||||
.listRowInsets(EdgeInsets())
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ForEach(viewModel.alarms) { alarm in
|
ForEach(viewModel.alarms) { alarm in
|
||||||
AlarmRowView(
|
AlarmRowView(
|
||||||
alarm: alarm,
|
alarm: alarm,
|
||||||
@ -64,7 +47,6 @@ struct AlarmView: View {
|
|||||||
}
|
}
|
||||||
.onDelete(perform: deleteAlarm)
|
.onDelete(perform: deleteAlarm)
|
||||||
}
|
}
|
||||||
.listStyle(.insetGrouped)
|
|
||||||
.frame(maxWidth: Design.Size.maxContentWidthPortrait)
|
.frame(maxWidth: Design.Size.maxContentWidthPortrait)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
}
|
}
|
||||||
@ -83,8 +65,7 @@ struct AlarmView: View {
|
|||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
Task {
|
Task {
|
||||||
// Request AlarmKit authorization when the alarms tab appears
|
await viewModel.requestNotificationPermissions()
|
||||||
await viewModel.requestAlarmKitAuthorization()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showAddAlarm) {
|
.sheet(isPresented: $showAddAlarm) {
|
||||||
@ -110,18 +91,11 @@ 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
|
// MARK: - Preview
|
||||||
#Preview {
|
#Preview {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
AlarmView(viewModel: AlarmViewModel())
|
AlarmView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,58 +0,0 @@
|
|||||||
//
|
|
||||||
// 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,7 +7,6 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Bedrock
|
import Bedrock
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Component for displaying individual alarm row
|
/// Component for displaying individual alarm row
|
||||||
struct AlarmRowView: View {
|
struct AlarmRowView: View {
|
||||||
@ -16,7 +15,6 @@ struct AlarmRowView: View {
|
|||||||
let alarm: Alarm
|
let alarm: Alarm
|
||||||
let onToggle: () -> Void
|
let onToggle: () -> Void
|
||||||
let onEdit: () -> Void
|
let onEdit: () -> Void
|
||||||
@AppStorage(ClockStyle.appStorageKey) private var clockStyleData: Data = Data()
|
|
||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -33,17 +31,6 @@ struct AlarmRowView: View {
|
|||||||
Text("• \(AlarmSoundService.shared.getSoundDisplayName(alarm.soundName))")
|
Text("• \(AlarmSoundService.shared.getSoundDisplayName(alarm.soundName))")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(AppTextColors.secondary)
|
.foregroundColor(AppTextColors.secondary)
|
||||||
|
|
||||||
if alarm.isEnabled && !isKeepAwakeEnabled {
|
|
||||||
HStack(spacing: Design.Spacing.xSmall) {
|
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(AppStatus.warning)
|
|
||||||
Text("Foreground only for full alarm sound")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(AppTextColors.tertiary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
@ -60,13 +47,6 @@ struct AlarmRowView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isKeepAwakeEnabled: Bool {
|
|
||||||
guard let decoded = try? JSONDecoder().decode(ClockStyle.self, from: clockStyleData) else {
|
|
||||||
return ClockStyle().keepAwake
|
|
||||||
}
|
|
||||||
return decoded.keepAwake
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Preview
|
// MARK: - Preview
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AudioPlaybackKit
|
import AudioPlaybackKit
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// View for editing existing alarms
|
/// View for editing existing alarms
|
||||||
struct EditAlarmView: View {
|
struct EditAlarmView: View {
|
||||||
@ -16,7 +15,6 @@ struct EditAlarmView: View {
|
|||||||
let viewModel: AlarmViewModel
|
let viewModel: AlarmViewModel
|
||||||
let alarm: Alarm
|
let alarm: Alarm
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@AppStorage(ClockStyle.appStorageKey) private var clockStyleData: Data = Data()
|
|
||||||
|
|
||||||
@State private var alarmTime: Date
|
@State private var alarmTime: Date
|
||||||
@State private var selectedSoundName: String
|
@State private var selectedSoundName: String
|
||||||
@ -53,15 +51,6 @@ struct EditAlarmView: View {
|
|||||||
|
|
||||||
// List for settings below
|
// List for settings below
|
||||||
List {
|
List {
|
||||||
if !isKeepAwakeEnabled {
|
|
||||||
Section {
|
|
||||||
AlarmLimitationsBanner()
|
|
||||||
.listRowInsets(EdgeInsets())
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Label Section
|
// Label Section
|
||||||
NavigationLink(destination: LabelEditView(label: $alarmLabel)) {
|
NavigationLink(destination: LabelEditView(label: $alarmLabel)) {
|
||||||
HStack {
|
HStack {
|
||||||
@ -158,13 +147,6 @@ struct EditAlarmView: View {
|
|||||||
private func getSoundDisplayName(_ fileName: String) -> String {
|
private func getSoundDisplayName(_ fileName: String) -> String {
|
||||||
return AlarmSoundService.shared.getSoundDisplayName(fileName)
|
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
|
// MARK: - Preview
|
||||||
|
|||||||
@ -14,9 +14,9 @@ import Bedrock
|
|||||||
class ClockStyle: Codable, Equatable {
|
class ClockStyle: Codable, Equatable {
|
||||||
|
|
||||||
// MARK: - Time Format Settings
|
// MARK: - Time Format Settings
|
||||||
var use24Hour: Bool = false
|
var use24Hour: Bool = true
|
||||||
var showSeconds: Bool = false
|
var showSeconds: Bool = false
|
||||||
var showAmPm: Bool = false
|
var showAmPm: Bool = true
|
||||||
var forceHorizontalMode: Bool = false // Force horizontal layout even in portrait
|
var forceHorizontalMode: Bool = false // Force horizontal layout even in portrait
|
||||||
|
|
||||||
// MARK: - Visual Settings
|
// MARK: - Visual Settings
|
||||||
@ -53,7 +53,6 @@ class ClockStyle: Codable, Equatable {
|
|||||||
// MARK: - Display Settings
|
// MARK: - Display Settings
|
||||||
var keepAwake: Bool = false // Keep screen awake in display mode
|
var keepAwake: Bool = false // Keep screen awake in display mode
|
||||||
var respectFocusModes: Bool = true // Respect Focus mode settings for audio
|
var respectFocusModes: Bool = true // Respect Focus mode settings for audio
|
||||||
var liveActivitiesEnabled: Bool = false // Show active alarm in Dynamic Island/Lock Screen
|
|
||||||
|
|
||||||
// MARK: - Cached Colors
|
// MARK: - Cached Colors
|
||||||
private var _cachedDigitColor: Color?
|
private var _cachedDigitColor: Color?
|
||||||
@ -88,7 +87,6 @@ class ClockStyle: Codable, Equatable {
|
|||||||
case overlayOpacity
|
case overlayOpacity
|
||||||
case keepAwake
|
case keepAwake
|
||||||
case respectFocusModes
|
case respectFocusModes
|
||||||
case liveActivitiesEnabled
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
@ -140,7 +138,6 @@ class ClockStyle: Codable, Equatable {
|
|||||||
self.overlayOpacity = try container.decodeIfPresent(Double.self, forKey: .overlayOpacity) ?? self.overlayOpacity
|
self.overlayOpacity = try container.decodeIfPresent(Double.self, forKey: .overlayOpacity) ?? self.overlayOpacity
|
||||||
self.keepAwake = try container.decodeIfPresent(Bool.self, forKey: .keepAwake) ?? self.keepAwake
|
self.keepAwake = try container.decodeIfPresent(Bool.self, forKey: .keepAwake) ?? self.keepAwake
|
||||||
self.respectFocusModes = try container.decodeIfPresent(Bool.self, forKey: .respectFocusModes) ?? self.respectFocusModes
|
self.respectFocusModes = try container.decodeIfPresent(Bool.self, forKey: .respectFocusModes) ?? self.respectFocusModes
|
||||||
self.liveActivitiesEnabled = try container.decodeIfPresent(Bool.self, forKey: .liveActivitiesEnabled) ?? self.liveActivitiesEnabled
|
|
||||||
|
|
||||||
clearColorCache()
|
clearColorCache()
|
||||||
}
|
}
|
||||||
@ -174,7 +171,6 @@ class ClockStyle: Codable, Equatable {
|
|||||||
try container.encode(overlayOpacity, forKey: .overlayOpacity)
|
try container.encode(overlayOpacity, forKey: .overlayOpacity)
|
||||||
try container.encode(keepAwake, forKey: .keepAwake)
|
try container.encode(keepAwake, forKey: .keepAwake)
|
||||||
try container.encode(respectFocusModes, forKey: .respectFocusModes)
|
try container.encode(respectFocusModes, forKey: .respectFocusModes)
|
||||||
try container.encode(liveActivitiesEnabled, forKey: .liveActivitiesEnabled)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
// MARK: - Computed Properties
|
||||||
@ -345,19 +341,19 @@ class ClockStyle: Codable, Equatable {
|
|||||||
/// Get the effective brightness considering color theme and night mode
|
/// Get the effective brightness considering color theme and night mode
|
||||||
var effectiveBrightness: Double {
|
var effectiveBrightness: Double {
|
||||||
if !autoBrightness {
|
if !autoBrightness {
|
||||||
//Design.debugLog("[brightness] effectiveBrightness: Auto-brightness disabled, returning 1.0")
|
Design.debugLog("[brightness] effectiveBrightness: Auto-brightness disabled, returning 1.0")
|
||||||
return 1.0 // Full brightness when auto-brightness is disabled
|
return 1.0 // Full brightness when auto-brightness is disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
if isNightModeActive {
|
if isNightModeActive {
|
||||||
//Design.debugLog("[brightness] effectiveBrightness: Night mode active, returning 0.3")
|
Design.debugLog("[brightness] effectiveBrightness: Night mode active, returning 0.3")
|
||||||
// Dim the display to 30% brightness in night mode
|
// Dim the display to 30% brightness in night mode
|
||||||
return 0.3
|
return 0.3
|
||||||
}
|
}
|
||||||
|
|
||||||
// Color-aware brightness adaptation
|
// Color-aware brightness adaptation
|
||||||
let colorAwareBrightness = getColorAwareBrightness()
|
let colorAwareBrightness = getColorAwareBrightness()
|
||||||
//Design.debugLog("[brightness] effectiveBrightness: Color-aware brightness = \(String(format: "%.2f", colorAwareBrightness))")
|
Design.debugLog("[brightness] effectiveBrightness: Color-aware brightness = \(String(format: "%.2f", colorAwareBrightness))")
|
||||||
return colorAwareBrightness
|
return colorAwareBrightness
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -467,8 +463,7 @@ class ClockStyle: Codable, Equatable {
|
|||||||
lhs.clockOpacity == rhs.clockOpacity &&
|
lhs.clockOpacity == rhs.clockOpacity &&
|
||||||
lhs.overlayOpacity == rhs.overlayOpacity &&
|
lhs.overlayOpacity == rhs.overlayOpacity &&
|
||||||
lhs.keepAwake == rhs.keepAwake &&
|
lhs.keepAwake == rhs.keepAwake &&
|
||||||
lhs.respectFocusModes == rhs.respectFocusModes &&
|
lhs.respectFocusModes == rhs.respectFocusModes
|
||||||
lhs.liveActivitiesEnabled == rhs.liveActivitiesEnabled
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -60,16 +60,16 @@ class AmbientLightService {
|
|||||||
let clampedBrightness = max(0.0, min(1.0, brightness))
|
let clampedBrightness = max(0.0, min(1.0, brightness))
|
||||||
let previousBrightness = UIScreen.main.brightness
|
let previousBrightness = UIScreen.main.brightness
|
||||||
|
|
||||||
// Design.debugLog("[ambient] AmbientLightService.setBrightness:")
|
Design.debugLog("[ambient] AmbientLightService.setBrightness:")
|
||||||
// Design.debugLog("[ambient] - Requested brightness: \(String(format: "%.2f", brightness))")
|
Design.debugLog("[ambient] - Requested brightness: \(String(format: "%.2f", brightness))")
|
||||||
// Design.debugLog("[ambient] - Clamped brightness: \(String(format: "%.2f", clampedBrightness))")
|
Design.debugLog("[ambient] - Clamped brightness: \(String(format: "%.2f", clampedBrightness))")
|
||||||
// Design.debugLog("[ambient] - Previous screen brightness: \(String(format: "%.2f", previousBrightness))")
|
Design.debugLog("[ambient] - Previous screen brightness: \(String(format: "%.2f", previousBrightness))")
|
||||||
|
|
||||||
UIScreen.main.brightness = clampedBrightness
|
UIScreen.main.brightness = clampedBrightness
|
||||||
currentBrightness = clampedBrightness
|
currentBrightness = clampedBrightness
|
||||||
|
|
||||||
// Design.debugLog("[ambient] - New screen brightness: \(String(format: "%.2f", UIScreen.main.brightness))")
|
Design.debugLog("[ambient] - New screen brightness: \(String(format: "%.2f", UIScreen.main.brightness))")
|
||||||
// Design.debugLog("[ambient] - Service currentBrightness: \(String(format: "%.2f", currentBrightness))")
|
Design.debugLog("[ambient] - Service currentBrightness: \(String(format: "%.2f", currentBrightness))")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current screen brightness
|
/// Get current screen brightness
|
||||||
@ -90,7 +90,7 @@ class AmbientLightService {
|
|||||||
let previousBrightness = currentBrightness
|
let previousBrightness = currentBrightness
|
||||||
currentBrightness = newBrightness
|
currentBrightness = newBrightness
|
||||||
|
|
||||||
//Design.debugLog("[ambient] AmbientLightService: Brightness changed from \(String(format: "%.2f", previousBrightness)) to \(String(format: "%.2f", newBrightness))")
|
Design.debugLog("[ambient] AmbientLightService: Brightness changed from \(String(format: "%.2f", previousBrightness)) to \(String(format: "%.2f", newBrightness))")
|
||||||
|
|
||||||
// Notify that brightness changed
|
// Notify that brightness changed
|
||||||
onBrightnessChange?()
|
onBrightnessChange?()
|
||||||
|
|||||||
@ -32,7 +32,6 @@ class ClockViewModel {
|
|||||||
private var minuteTimer: Timer.TimerPublisher?
|
private var minuteTimer: Timer.TimerPublisher?
|
||||||
private var secondCancellable: AnyCancellable?
|
private var secondCancellable: AnyCancellable?
|
||||||
private var minuteCancellable: AnyCancellable?
|
private var minuteCancellable: AnyCancellable?
|
||||||
private var styleObserver: NSObjectProtocol?
|
|
||||||
|
|
||||||
// Persistence
|
// Persistence
|
||||||
private var persistenceWorkItem: DispatchWorkItem?
|
private var persistenceWorkItem: DispatchWorkItem?
|
||||||
@ -53,45 +52,29 @@ class ClockViewModel {
|
|||||||
loadStyle()
|
loadStyle()
|
||||||
setupTimers()
|
setupTimers()
|
||||||
startAmbientLightMonitoring()
|
startAmbientLightMonitoring()
|
||||||
observeStyleUpdates()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
stopTimers()
|
stopTimers()
|
||||||
stopAmbientLightMonitoring()
|
stopAmbientLightMonitoring()
|
||||||
if let styleObserver {
|
|
||||||
NotificationCenter.default.removeObserver(styleObserver)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public Interface
|
// MARK: - Public Interface
|
||||||
func toggleDisplayMode() {
|
func toggleDisplayMode() {
|
||||||
let oldValue = isDisplayMode
|
|
||||||
withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) {
|
withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) {
|
||||||
isDisplayMode.toggle()
|
isDisplayMode.toggle()
|
||||||
}
|
}
|
||||||
Design.debugLog("[ClockViewModel] toggleDisplayMode: \(oldValue) -> \(isDisplayMode)")
|
|
||||||
|
|
||||||
// Manage wake lock based on display mode and keep awake setting
|
// Manage wake lock based on display mode and keep awake setting
|
||||||
updateWakeLockState()
|
updateWakeLockState()
|
||||||
if isDisplayMode {
|
|
||||||
requestKeepAwakePromptIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setDisplayMode(_ enabled: Bool) {
|
func setDisplayMode(_ enabled: Bool) {
|
||||||
guard isDisplayMode != enabled else {
|
guard isDisplayMode != enabled else { return }
|
||||||
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)) {
|
withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) {
|
||||||
isDisplayMode = enabled
|
isDisplayMode = enabled
|
||||||
}
|
}
|
||||||
updateWakeLockState()
|
updateWakeLockState()
|
||||||
if enabled {
|
|
||||||
requestKeepAwakePromptIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateStyle(_ newStyle: ClockStyle) {
|
func updateStyle(_ newStyle: ClockStyle) {
|
||||||
@ -125,7 +108,6 @@ class ClockViewModel {
|
|||||||
style.digitAnimationStyle = newStyle.digitAnimationStyle
|
style.digitAnimationStyle = newStyle.digitAnimationStyle
|
||||||
style.dateFormat = newStyle.dateFormat
|
style.dateFormat = newStyle.dateFormat
|
||||||
style.respectFocusModes = newStyle.respectFocusModes
|
style.respectFocusModes = newStyle.respectFocusModes
|
||||||
style.liveActivitiesEnabled = newStyle.liveActivitiesEnabled
|
|
||||||
|
|
||||||
|
|
||||||
saveStyle()
|
saveStyle()
|
||||||
@ -134,12 +116,6 @@ class ClockViewModel {
|
|||||||
updateBrightness() // Update brightness when style changes
|
updateBrightness() // Update brightness when style changes
|
||||||
}
|
}
|
||||||
|
|
||||||
func setKeepAwakeEnabled(_ enabled: Bool) {
|
|
||||||
style.keepAwake = enabled
|
|
||||||
saveStyle()
|
|
||||||
updateWakeLockState()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
private func loadStyle() {
|
private func loadStyle() {
|
||||||
if let decoded = try? JSONDecoder().decode(ClockStyle.self, from: styleJSON) {
|
if let decoded = try? JSONDecoder().decode(ClockStyle.self, from: styleJSON) {
|
||||||
@ -150,19 +126,6 @@ class ClockViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func observeStyleUpdates() {
|
|
||||||
styleObserver = NotificationCenter.default.addObserver(
|
|
||||||
forName: .clockStyleDidUpdate,
|
|
||||||
object: nil,
|
|
||||||
queue: .main
|
|
||||||
) { [weak self] _ in
|
|
||||||
self?.loadStyle()
|
|
||||||
self?.updateTimersIfNeeded()
|
|
||||||
self?.updateWakeLockState()
|
|
||||||
self?.updateBrightness()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveStyle() {
|
func saveStyle() {
|
||||||
persistenceWorkItem?.cancel()
|
persistenceWorkItem?.cancel()
|
||||||
|
|
||||||
@ -230,11 +193,6 @@ 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
|
/// Update wake lock state based on current settings
|
||||||
private func updateWakeLockState() {
|
private func updateWakeLockState() {
|
||||||
// Enable wake lock if in display mode and keep awake is enabled
|
// Enable wake lock if in display mode and keep awake is enabled
|
||||||
@ -251,7 +209,7 @@ class ClockViewModel {
|
|||||||
|
|
||||||
// Set up callback to respond to brightness changes
|
// Set up callback to respond to brightness changes
|
||||||
ambientLightService.onBrightnessChange = { [weak self] in
|
ambientLightService.onBrightnessChange = { [weak self] in
|
||||||
//Design.debugLog("[brightness] ClockViewModel: Received brightness change notification")
|
Design.debugLog("[brightness] ClockViewModel: Received brightness change notification")
|
||||||
self?.updateBrightness()
|
self?.updateBrightness()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -268,21 +226,21 @@ class ClockViewModel {
|
|||||||
let currentScreenBrightness = UIScreen.main.brightness
|
let currentScreenBrightness = UIScreen.main.brightness
|
||||||
let isNightMode = style.isNightModeActive
|
let isNightMode = style.isNightModeActive
|
||||||
|
|
||||||
// Design.debugLog("[brightness] Auto Brightness Debug:")
|
Design.debugLog("[brightness] Auto Brightness Debug:")
|
||||||
// Design.debugLog("[brightness] - Auto brightness enabled: \(style.autoBrightness)")
|
Design.debugLog("[brightness] - Auto brightness enabled: \(style.autoBrightness)")
|
||||||
// Design.debugLog("[brightness] - Current screen brightness: \(String(format: "%.2f", currentScreenBrightness))")
|
Design.debugLog("[brightness] - Current screen brightness: \(String(format: "%.2f", currentScreenBrightness))")
|
||||||
// Design.debugLog("[brightness] - Target brightness: \(String(format: "%.2f", targetBrightness))")
|
Design.debugLog("[brightness] - Target brightness: \(String(format: "%.2f", targetBrightness))")
|
||||||
// Design.debugLog("[brightness] - Night mode active: \(isNightMode)")
|
Design.debugLog("[brightness] - Night mode active: \(isNightMode)")
|
||||||
// Design.debugLog("[brightness] - Color theme: \(style.selectedColorTheme)")
|
Design.debugLog("[brightness] - Color theme: \(style.selectedColorTheme)")
|
||||||
// Design.debugLog("[brightness] - Ambient light threshold: \(String(format: "%.2f", style.ambientLightThreshold))")
|
Design.debugLog("[brightness] - Ambient light threshold: \(String(format: "%.2f", style.ambientLightThreshold))")
|
||||||
|
|
||||||
ambientLightService.setBrightness(targetBrightness)
|
ambientLightService.setBrightness(targetBrightness)
|
||||||
|
|
||||||
// Design.debugLog("[brightness] - Brightness set to: \(String(format: "%.2f", targetBrightness))")
|
Design.debugLog("[brightness] - Brightness set to: \(String(format: "%.2f", targetBrightness))")
|
||||||
// Design.debugLog("[brightness] - Actual screen brightness now: \(String(format: "%.2f", UIScreen.main.brightness))")
|
Design.debugLog("[brightness] - Actual screen brightness now: \(String(format: "%.2f", UIScreen.main.brightness))")
|
||||||
// Design.debugLog("[brightness] ---")
|
Design.debugLog("[brightness] ---")
|
||||||
// } else {
|
} else {
|
||||||
// Design.debugLog("[brightness] Auto Brightness: DISABLED")
|
Design.debugLog("[brightness] Auto Brightness: DISABLED")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,20 +14,15 @@ struct ClockSettingsView: View {
|
|||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
@State private var style: ClockStyle
|
@State private var style: ClockStyle
|
||||||
let onCommit: (ClockStyle) -> Void
|
let onCommit: (ClockStyle) -> Void
|
||||||
var onResetOnboarding: (() -> Void)?
|
|
||||||
|
|
||||||
@State private var digitColor: Color = .white
|
@State private var digitColor: Color = .white
|
||||||
@State private var backgroundColor: Color = .black
|
@State private var backgroundColor: Color = .black
|
||||||
|
@State private var showAdvancedSettings = false
|
||||||
|
|
||||||
// MARK: - Init
|
// MARK: - Init
|
||||||
init(
|
init(style: ClockStyle, onCommit: @escaping (ClockStyle) -> Void) {
|
||||||
style: ClockStyle,
|
|
||||||
onCommit: @escaping (ClockStyle) -> Void,
|
|
||||||
onResetOnboarding: (() -> Void)? = nil
|
|
||||||
) {
|
|
||||||
self._style = State(initialValue: style)
|
self._style = State(initialValue: style)
|
||||||
self.onCommit = onCommit
|
self.onCommit = onCommit
|
||||||
self.onResetOnboarding = onResetOnboarding
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
@ -41,17 +36,34 @@ struct ClockSettingsView: View {
|
|||||||
backgroundColor: $backgroundColor
|
backgroundColor: $backgroundColor
|
||||||
)
|
)
|
||||||
|
|
||||||
FontSection(style: $style)
|
|
||||||
|
|
||||||
AdvancedAppearanceSection(style: $style)
|
|
||||||
|
|
||||||
BasicDisplaySection(style: $style)
|
BasicDisplaySection(style: $style)
|
||||||
|
|
||||||
AdvancedDisplaySection(style: $style)
|
if showAdvancedSettings {
|
||||||
|
AdvancedAppearanceSection(style: $style)
|
||||||
|
|
||||||
NightModeSection(style: $style)
|
FontSection(style: $style)
|
||||||
|
|
||||||
OverlaySection(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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
SettingsSectionHeader(
|
SettingsSectionHeader(
|
||||||
@ -80,32 +92,6 @@ struct ClockSettingsView: View {
|
|||||||
appName: "TheNoiseClock"
|
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
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,18 +16,8 @@ struct ClockView: View {
|
|||||||
|
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
@Bindable var viewModel: ClockViewModel
|
@Bindable var viewModel: ClockViewModel
|
||||||
/// Whether this view is currently the selected tab - prevents race conditions on tab switch
|
|
||||||
let isOnClockTab: Bool
|
|
||||||
@State private var idleTimer: Timer?
|
@State private var idleTimer: Timer?
|
||||||
@State private var didHandleTouch = false
|
@State private var didHandleTouch = false
|
||||||
@State private var isViewActive = false
|
|
||||||
|
|
||||||
/// Tab bar should ONLY be hidden when BOTH conditions are true:
|
|
||||||
/// 1. We're on the clock tab (prevents hiding when user switches away)
|
|
||||||
/// 2. Display mode is active
|
|
||||||
private var shouldHideTabBar: Bool {
|
|
||||||
isOnClockTab && viewModel.isDisplayMode
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -75,24 +65,22 @@ struct ClockView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// .onAppear {
|
.onAppear {
|
||||||
// logClockLayout(size: geometry.size, safeAreaInsets: safeInsets)
|
logClockLayout(size: geometry.size, safeAreaInsets: safeInsets)
|
||||||
// }
|
}
|
||||||
// .onChange(of: geometry.size) { _, newSize in
|
.onChange(of: geometry.size) { _, newSize in
|
||||||
// logClockLayout(size: newSize, safeAreaInsets: safeInsets)
|
logClockLayout(size: newSize, safeAreaInsets: safeInsets)
|
||||||
// }
|
}
|
||||||
// .onChange(of: safeInsets) { _, newInsets in
|
.onChange(of: safeInsets) { _, newInsets in
|
||||||
// logClockLayout(size: geometry.size, safeAreaInsets: newInsets)
|
logClockLayout(size: geometry.size, safeAreaInsets: newInsets)
|
||||||
// }
|
}
|
||||||
}
|
}
|
||||||
.ignoresSafeArea() // Extend GeometryReader to full screen, we handle safe areas manually
|
.ignoresSafeArea() // Extend GeometryReader to full screen, we handle safe areas manually
|
||||||
.toolbar(.hidden, for: .navigationBar)
|
.toolbar(.hidden, for: .navigationBar)
|
||||||
.statusBarHidden(true)
|
.statusBarHidden(true)
|
||||||
// Tab bar visibility controlled here but decision includes isOnClockTab from parent
|
.overlay {
|
||||||
// This prevents race conditions: when tab changes, isOnClockTab becomes false immediately
|
// Tab bar management overlay
|
||||||
.toolbar(shouldHideTabBar ? .hidden : .visible, for: .tabBar)
|
ClockTabBarManager(isDisplayMode: viewModel.isDisplayMode)
|
||||||
.onChange(of: shouldHideTabBar) { oldValue, newValue in
|
|
||||||
Design.debugLog("[ClockView] shouldHideTabBar changed: \(oldValue) -> \(newValue) (isOnClockTab=\(isOnClockTab), isDisplayMode=\(viewModel.isDisplayMode))")
|
|
||||||
}
|
}
|
||||||
.simultaneousGesture(
|
.simultaneousGesture(
|
||||||
DragGesture(minimumDistance: 0)
|
DragGesture(minimumDistance: 0)
|
||||||
@ -106,13 +94,9 @@ struct ClockView: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
Design.debugLog("[ClockView] onAppear - setting isViewActive = true")
|
|
||||||
isViewActive = true
|
|
||||||
resetIdleTimer()
|
resetIdleTimer()
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
Design.debugLog("[ClockView] onDisappear - setting isViewActive = false, invalidating timer")
|
|
||||||
isViewActive = false
|
|
||||||
idleTimer?.invalidate()
|
idleTimer?.invalidate()
|
||||||
idleTimer = nil
|
idleTimer = nil
|
||||||
}
|
}
|
||||||
@ -137,16 +121,7 @@ struct ClockView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func enterDisplayModeFromIdle() {
|
private func enterDisplayModeFromIdle() {
|
||||||
// Guard against entering display mode if we're no longer on the clock tab
|
guard !viewModel.isDisplayMode else { return }
|
||||||
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()
|
viewModel.toggleDisplayMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -206,7 +181,7 @@ struct ClockView: View {
|
|||||||
// MARK: - Preview
|
// MARK: - Preview
|
||||||
#Preview {
|
#Preview {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ClockView(viewModel: ClockViewModel(), isOnClockTab: true)
|
ClockView(viewModel: ClockViewModel())
|
||||||
}
|
}
|
||||||
.frame(width: 400, height: 600)
|
.frame(width: 400, height: 600)
|
||||||
.background(Color.black)
|
.background(Color.black)
|
||||||
|
|||||||
@ -22,7 +22,7 @@ struct ClockOverlayContainer: View {
|
|||||||
showBattery: style.showBattery,
|
showBattery: style.showBattery,
|
||||||
showDate: style.showDate,
|
showDate: style.showDate,
|
||||||
color: style.effectiveDigitColor,
|
color: style.effectiveDigitColor,
|
||||||
opacity: style.clockOpacity,
|
opacity: style.overlayOpacity,
|
||||||
dateFormat: style.dateFormat
|
dateFormat: style.dateFormat
|
||||||
)
|
)
|
||||||
.padding(.top, Design.Spacing.small)
|
.padding(.top, Design.Spacing.small)
|
||||||
|
|||||||
@ -0,0 +1,27 @@
|
|||||||
|
//
|
||||||
|
// 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,13 +27,6 @@ struct AdvancedDisplaySection: View {
|
|||||||
accentColor: AppAccent.primary
|
accentColor: AppAccent.primary
|
||||||
)
|
)
|
||||||
|
|
||||||
SettingsToggle(
|
|
||||||
title: "Live Activities",
|
|
||||||
subtitle: "Show alarms on Lock Screen/Dynamic Island while ringing",
|
|
||||||
isOn: $style.liveActivitiesEnabled,
|
|
||||||
accentColor: AppAccent.primary
|
|
||||||
)
|
|
||||||
|
|
||||||
if style.autoBrightness {
|
if style.autoBrightness {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Current Brightness")
|
Text("Current Brightness")
|
||||||
@ -47,7 +40,7 @@ struct AdvancedDisplaySection: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text("Advanced display and system integration settings. Keep Awake helps alarms stay active while the app remains open.")
|
Text("Advanced display and system integration settings.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(AppTextColors.tertiary)
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,16 @@ struct OverlaySection: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
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(
|
SettingsToggle(
|
||||||
title: "Battery Level",
|
title: "Battery Level",
|
||||||
subtitle: "Show battery percentage",
|
subtitle: "Show battery percentage",
|
||||||
|
|||||||
@ -138,13 +138,11 @@ struct TimeDisplayView: View {
|
|||||||
design: fontDesign,
|
design: fontDesign,
|
||||||
fontSize: fontSize
|
fontSize: fontSize
|
||||||
)
|
)
|
||||||
let segmentWidth = fixedDigitWidth * 2 // Minutes/seconds always 2 digits
|
let segmentWidth = fixedDigitWidth * 2 // Each segment has 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) {
|
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)
|
TimeSegment(text: hour, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode, animationStyle: animationStyle)
|
||||||
.frame(width: hourSegmentWidth)
|
.frame(width: segmentWidth)
|
||||||
ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: false)
|
ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: false)
|
||||||
.frame(width: dotDiameter)
|
.frame(width: dotDiameter)
|
||||||
TimeSegment(text: minute, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode, animationStyle: animationStyle)
|
TimeSegment(text: minute, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode, animationStyle: animationStyle)
|
||||||
@ -265,9 +263,9 @@ struct TimeDisplayView: View {
|
|||||||
height: max(1, availableHeight / digitRows)
|
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 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 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 digitSize=\(String(format: "%.1f", digitSize.width))x\(String(format: "%.1f", digitSize.height)) colonSize=\(String(format: "%.1f", colonSize))")
|
||||||
|
|
||||||
return FontUtils.calculateOptimalFontSize(
|
return FontUtils.calculateOptimalFontSize(
|
||||||
digit: "8",
|
digit: "8",
|
||||||
@ -310,17 +308,17 @@ struct TimeDisplayView: View {
|
|||||||
if totalWidth > containerSize.width {
|
if totalWidth > containerSize.width {
|
||||||
let scaleFactor = containerSize.width / totalWidth
|
let scaleFactor = containerSize.width / totalWidth
|
||||||
estimated *= scaleFactor * 0.98 // Add 2% margin
|
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 {
|
if abs(estimated - fontSize) > 1 {
|
||||||
fontSize = estimated
|
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 {
|
} 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
|
lastCalculatedContainerSize = containerSize
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,62 +0,0 @@
|
|||||||
//
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
@ -1,481 +0,0 @@
|
|||||||
//
|
|
||||||
// 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,9 +12,5 @@
|
|||||||
<string>$(CLOUDKIT_CONTAINER_IDENTIFIER)</string>
|
<string>$(CLOUDKIT_CONTAINER_IDENTIFIER)</string>
|
||||||
<key>AppClipDomain</key>
|
<key>AppClipDomain</key>
|
||||||
<string>$(APPCLIP_DOMAIN)</string>
|
<string>$(APPCLIP_DOMAIN)</string>
|
||||||
<key>NSSupportsLiveActivities</key>
|
|
||||||
<true/>
|
|
||||||
<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>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@ -3,43 +3,43 @@
|
|||||||
{
|
{
|
||||||
"id": "digital-alarm",
|
"id": "digital-alarm",
|
||||||
"name": "Digital Alarm",
|
"name": "Digital Alarm",
|
||||||
"fileName": "digital-alarm.mp3",
|
"fileName": "digital-alarm.caf",
|
||||||
"description": "Classic digital alarm sound",
|
"description": "Classic digital alarm sound",
|
||||||
"category": "alarm",
|
"category": "alarm",
|
||||||
"bundleName": "AlarmSounds",
|
"bundleName": null,
|
||||||
"isDefault": true
|
"isDefault": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "buzzing-alarm",
|
"id": "buzzing-alarm",
|
||||||
"name": "Buzzing Alarm",
|
"name": "Buzzing Alarm",
|
||||||
"fileName": "buzzing-alarm.mp3",
|
"fileName": "buzzing-alarm.caf",
|
||||||
"description": "Buzzing sound for gentle wake-up",
|
"description": "Buzzing sound for gentle wake-up",
|
||||||
"category": "alarm",
|
"category": "alarm",
|
||||||
"bundleName": "AlarmSounds"
|
"bundleName": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "classic-alarm",
|
"id": "classic-alarm",
|
||||||
"name": "Classic Alarm",
|
"name": "Classic Alarm",
|
||||||
"fileName": "classic-alarm.mp3",
|
"fileName": "classic-alarm.caf",
|
||||||
"description": "Traditional alarm sound",
|
"description": "Traditional alarm sound",
|
||||||
"category": "alarm",
|
"category": "alarm",
|
||||||
"bundleName": "AlarmSounds"
|
"bundleName": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "beep-alarm",
|
"id": "beep-alarm",
|
||||||
"name": "Beep Alarm",
|
"name": "Beep Alarm",
|
||||||
"fileName": "beep-alarm.mp3",
|
"fileName": "beep-alarm.caf",
|
||||||
"description": "Short beep alarm sound",
|
"description": "Short beep alarm sound",
|
||||||
"category": "alarm",
|
"category": "alarm",
|
||||||
"bundleName": "AlarmSounds"
|
"bundleName": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "siren-alarm",
|
"id": "siren-alarm",
|
||||||
"name": "Siren Alarm",
|
"name": "Siren Alarm",
|
||||||
"fileName": "siren-alarm.mp3",
|
"fileName": "siren-alarm.caf",
|
||||||
"description": "Emergency siren alarm for heavy sleepers",
|
"description": "Emergency siren alarm for heavy sleepers",
|
||||||
"category": "alarm",
|
"category": "alarm",
|
||||||
"bundleName": "AlarmSounds"
|
"bundleName": null
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
TheNoiseClock/Resources/AlarmSounds/beep-alarm.caf
Normal file
BIN
TheNoiseClock/Resources/AlarmSounds/beep-alarm.caf
Normal file
Binary file not shown.
Binary file not shown.
BIN
TheNoiseClock/Resources/AlarmSounds/buzzing-alarm.caf
Normal file
BIN
TheNoiseClock/Resources/AlarmSounds/buzzing-alarm.caf
Normal file
Binary file not shown.
Binary file not shown.
BIN
TheNoiseClock/Resources/AlarmSounds/classic-alarm.caf
Normal file
BIN
TheNoiseClock/Resources/AlarmSounds/classic-alarm.caf
Normal file
Binary file not shown.
Binary file not shown.
BIN
TheNoiseClock/Resources/AlarmSounds/digital-alarm.caf
Normal file
BIN
TheNoiseClock/Resources/AlarmSounds/digital-alarm.caf
Normal file
Binary file not shown.
Binary file not shown.
BIN
TheNoiseClock/Resources/AlarmSounds/siren-alarm.caf
Normal file
BIN
TheNoiseClock/Resources/AlarmSounds/siren-alarm.caf
Normal file
Binary file not shown.
Binary file not shown.
@ -12,7 +12,7 @@ enum AppConstants {
|
|||||||
|
|
||||||
// MARK: - App Information
|
// MARK: - App Information
|
||||||
static let appName = "TheNoiseClock"
|
static let appName = "TheNoiseClock"
|
||||||
static let minimumIOSVersion = "26.0"
|
static let minimumIOSVersion = "18.0"
|
||||||
|
|
||||||
// MARK: - Storage Keys
|
// MARK: - Storage Keys
|
||||||
enum StorageKeys {
|
enum StorageKeys {
|
||||||
@ -59,7 +59,7 @@ enum AppConstants {
|
|||||||
|
|
||||||
// MARK: - System Sounds
|
// MARK: - System Sounds
|
||||||
enum SystemSounds {
|
enum SystemSounds {
|
||||||
static let defaultSound = "digital-alarm.mp3"
|
static let defaultSound = "digital-alarm.caf"
|
||||||
static let availableSounds = ["default", "bell", "chimes", "ding", "glass", "silence"]
|
static let availableSounds = ["default", "bell", "chimes", "ding", "glass", "silence"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
//
|
|
||||||
// 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")
|
|
||||||
}
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
//
|
|
||||||
// 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: {})
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
//
|
|
||||||
// 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,19 +36,15 @@ enum NotificationUtils {
|
|||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = title
|
content.title = title
|
||||||
content.body = body
|
content.body = body
|
||||||
content.categoryIdentifier = AlarmNotificationConstants.categoryIdentifier
|
|
||||||
|
|
||||||
if soundName == "default" {
|
if soundName == "default" {
|
||||||
content.sound = UNNotificationSound.default
|
content.sound = UNNotificationSound.default
|
||||||
Design.debugLog("[settings] Using default notification sound")
|
Design.debugLog("[settings] Using default notification sound")
|
||||||
} else if Bundle.main.url(forResource: soundName, withExtension: nil) != nil {
|
} else {
|
||||||
// Use the sound name directly since sounds.json now references CAF files
|
// Use the sound name directly since sounds.json now references CAF files
|
||||||
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: soundName))
|
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: soundName))
|
||||||
Design.debugLog("[settings] Using custom alarm sound: \(soundName)")
|
Design.debugLog("[settings] Using custom alarm sound: \(soundName)")
|
||||||
Design.debugLog("[settings] Sound file should be in main bundle: \(soundName)")
|
Design.debugLog("[settings] Sound file should be in main bundle: \(soundName)")
|
||||||
} else {
|
|
||||||
content.sound = UNNotificationSound.default
|
|
||||||
Design.debugLog("[settings] Alarm sound not found in main bundle, falling back to default: \(soundName)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return content
|
return content
|
||||||
|
|||||||
@ -1,119 +0,0 @@
|
|||||||
//
|
|
||||||
// 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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,238 +0,0 @@
|
|||||||
//
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
//
|
|
||||||
// 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