Compare commits
9 Commits
01931244e0
...
3844e19b39
| Author | SHA1 | Date | |
|---|---|---|---|
| 3844e19b39 | |||
| b8428ca134 | |||
| f3c98cedb9 | |||
| 5e45be0a2a | |||
| a1cb0f4b1f | |||
| 744fe7511b | |||
| 8e91eed772 | |||
| e4202d5853 | |||
| a4eaa187e5 |
@ -1,6 +1,7 @@
|
|||||||
Use /ios-18-role
|
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,7 +95,8 @@ 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 {
|
||||||
let bundleNames = ["Colored", "Nature", "Mechanical", "Ambient"]
|
// Include AlarmSounds bundle for alarm sound preview functionality
|
||||||
|
let bundleNames = ["Colored", "Nature", "Mechanical", "Ambient", "AlarmSounds"]
|
||||||
var allSounds: [Sound] = []
|
var allSounds: [Sound] = []
|
||||||
|
|
||||||
for bundleName in bundleNames {
|
for bundleName in bundleNames {
|
||||||
|
|||||||
@ -41,6 +41,14 @@ 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
|
||||||
@ -63,7 +71,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 = AudioConstants.Volume.default
|
newPlayer.volume = volumeOverride ?? AudioConstants.Volume.default
|
||||||
newPlayer.prepareToPlay()
|
newPlayer.prepareToPlay()
|
||||||
players[sound.fileName] = newPlayer
|
players[sound.fileName] = newPlayer
|
||||||
currentPlayer = newPlayer
|
currentPlayer = newPlayer
|
||||||
@ -77,6 +85,9 @@ 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)")
|
||||||
@ -119,7 +130,11 @@ public class SoundPlayer {
|
|||||||
return Bundle.main.url(forResource: fileName, withExtension: nil, subdirectory: subfolder)
|
return Bundle.main.url(forResource: fileName, withExtension: nil, subdirectory: subfolder)
|
||||||
} else {
|
} else {
|
||||||
// Direct file path (fallback)
|
// Direct file path (fallback)
|
||||||
return Bundle.main.url(forResource: sound.fileName, withExtension: nil)
|
if let url = Bundle.main.url(forResource: sound.fileName, withExtension: nil) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
// Alarm sounds live in a subdirectory; try that next
|
||||||
|
return Bundle.main.url(forResource: sound.fileName, withExtension: nil, subdirectory: "AlarmSounds")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
68
PRD.md
68
PRD.md
@ -89,27 +89,32 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di
|
|||||||
- **Responsive layout**: Optimized for both portrait and landscape orientations
|
- **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
|
### 6. Advanced Alarm System (Powered by AlarmKit)
|
||||||
|
- **AlarmKit integration**: iOS 26+ AlarmKit framework for reliable alarms that cut through Focus modes and silent mode
|
||||||
- **Multiple alarms**: Create and manage unlimited alarms
|
- **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**: Configurable alarm sounds loaded from dedicated alarm-sounds.json configuration
|
- **Dynamic alarm sounds**: MP3 alarm sounds loaded from AlarmSounds folder
|
||||||
- **Sound preview**: Play/stop functionality for testing alarm sounds before selection
|
- **Sound 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**: Configurable snooze duration (5, 7, 8, 9, 10, 15, 20 minutes)
|
- **Snooze functionality**: AlarmKit countdown feature for snooze support
|
||||||
- **Smart notifications**: Automatic scheduling for one-time and repeating alarms
|
- **Live Activity countdown**: Shows 5 minutes before alarm fires on Lock Screen and Dynamic Island
|
||||||
|
- **Dynamic Island**: Compact and expanded views with countdown timer
|
||||||
|
- **Lock Screen widget**: Full countdown display with alarm label
|
||||||
- **Enable/disable toggles**: Individual alarm control with instant feedback
|
- **Enable/disable toggles**: Individual alarm control with instant feedback
|
||||||
- **Notification integration**: Uses iOS UserNotifications framework with proper scheduling
|
- **AlarmKit authorization**: Requires user permission via NSAlarmKitUsageDescription
|
||||||
- **Persistent storage**: Alarms saved to UserDefaults with backward compatibility
|
- **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
|
||||||
|
|
||||||
@ -351,6 +356,7 @@ These principles are fundamental to the project's long-term success and must be
|
|||||||
- **Real-time updates**: Changes apply immediately with live preview
|
- **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
|
||||||
|
|
||||||
@ -399,9 +405,12 @@ 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/
|
||||||
@ -426,7 +435,6 @@ 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/
|
||||||
@ -444,11 +452,11 @@ TheNoiseClock/
|
|||||||
│ │ │ ├── State/
|
│ │ │ ├── State/
|
||||||
│ │ │ │ └── AlarmViewModel.swift
|
│ │ │ │ └── AlarmViewModel.swift
|
||||||
│ │ │ ├── Services/
|
│ │ │ ├── Services/
|
||||||
│ │ │ │ ├── AlarmService.swift
|
│ │ │ │ ├── AlarmService.swift # Alarm persistence
|
||||||
│ │ │ │ ├── AlarmSoundService.swift
|
│ │ │ │ ├── AlarmSoundService.swift # Alarm sound metadata
|
||||||
│ │ │ │ ├── FocusModeService.swift
|
│ │ │ │ └── AlarmKitService.swift # AlarmKit integration (iOS 26+)
|
||||||
│ │ │ │ ├── NotificationService.swift
|
│ │ │ ├── Intents/
|
||||||
│ │ │ │ └── NotificationDelegate.swift
|
│ │ │ │ └── AlarmIntents.swift # App Intents for Stop/Snooze
|
||||||
│ │ │ └── Views/
|
│ │ │ └── Views/
|
||||||
│ │ │ ├── AlarmView.swift
|
│ │ │ ├── AlarmView.swift
|
||||||
│ │ │ ├── AddAlarmView.swift
|
│ │ │ ├── AddAlarmView.swift
|
||||||
@ -462,12 +470,17 @@ 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/
|
||||||
│ │ ├── NoiseView.swift
|
│ │ ├── OnboardingView.swift
|
||||||
│ │ └── Components/
|
│ │ └── Components/
|
||||||
│ │ ├── SoundCategoryView.swift
|
│ │ └── OnboardingPageView.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
|
||||||
@ -488,6 +501,10 @@ TheNoiseClock/
|
|||||||
│ └── [Asset catalogs]
|
│ └── [Asset catalogs]
|
||||||
└── TheNoiseClock.xcodeproj/ # Xcode project with AudioPlaybackKit dependency
|
└── TheNoiseClock.xcodeproj/ # Xcode project with AudioPlaybackKit dependency
|
||||||
└── project.pbxproj # Project configuration with local package reference
|
└── project.pbxproj # Project configuration with local package reference
|
||||||
|
TheNoiseClockWidget/ # Widget extension (Live Activity)
|
||||||
|
├── AlarmLiveActivityWidget.swift # Live Activity UI for Dynamic Island/Lock Screen
|
||||||
|
├── TheNoiseClockWidgetBundle.swift # Widget bundle entry point
|
||||||
|
└── Info.plist # Widget extension Info.plist
|
||||||
```
|
```
|
||||||
|
|
||||||
### File Naming Conventions
|
### File Naming Conventions
|
||||||
@ -535,6 +552,16 @@ 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
|
||||||
@ -547,9 +574,10 @@ 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. **Focus Modes**: Control how app behaves with Focus modes (Do Not Disturb)
|
4. **Keep Awake prompt**: Auto-prompt when needed (alarms tab, enabling alarms, display mode)
|
||||||
5. **Overlays**: Control battery and date display
|
5. **Focus Modes**: Control how app behaves with Focus modes (Do Not Disturb)
|
||||||
6. **Background**: Set background color and use presets
|
6. **Overlays**: Control battery and date display
|
||||||
|
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 18+ with Swift 6, built on a modular architecture, and styled with the Bedrock design system.
|
TheNoiseClock is a SwiftUI iOS app that blends a bold, full-screen digital clock with white noise playback and a rich alarm system. It is optimized for iOS 26+ with Swift 6, built on a modular architecture, and styled with the Bedrock design system. Alarms use AlarmKit for reliable wake-up alerts that cut through Focus modes and silent mode.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -36,11 +36,16 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and
|
|||||||
- Seamless looping with background audio support
|
- 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**
|
**Alarms (Powered by AlarmKit)**
|
||||||
- Unlimited alarms with labels, repeat schedules, and snooze options
|
- Unlimited alarms with labels, repeat schedules, and snooze options
|
||||||
- Alarm sound library with preview
|
- Alarm sound library with preview (MP3 format)
|
||||||
- Vibration and volume controls per alarm
|
- Vibration and volume controls per alarm
|
||||||
- Focus-mode aware scheduling
|
- AlarmKit integration: alarms cut through Focus modes and silent mode
|
||||||
|
- Live Activity countdown shows 5 minutes before alarm fires
|
||||||
|
- Dynamic Island displays countdown and alarm status
|
||||||
|
- Lock Screen shows alarm countdown with custom UI
|
||||||
|
- Full-screen in-app alarm screen with Snooze/Stop when active
|
||||||
|
- Snooze support via AlarmKit's countdown feature
|
||||||
|
|
||||||
**Display Mode**
|
**Display Mode**
|
||||||
- Long-press to enter immersive display mode
|
- Long-press to enter immersive display mode
|
||||||
@ -48,6 +53,7 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and
|
|||||||
- Optional wake-lock to keep the screen on
|
- 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
|
||||||
@ -60,15 +66,17 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and
|
|||||||
- Full-screen display mode and Dynamic Island awareness
|
- 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 18.0+
|
- iOS 26.0+
|
||||||
- Xcode 16+
|
- Xcode 26+
|
||||||
- Swift 6
|
- Swift 6
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
/* 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 */
|
||||||
@ -26,14 +27,36 @@
|
|||||||
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 */
|
||||||
@ -44,6 +67,13 @@
|
|||||||
);
|
);
|
||||||
target = EA384AFA2E6E6B6000CA7D50 /* TheNoiseClock */;
|
target = EA384AFA2E6E6B6000CA7D50 /* TheNoiseClock */;
|
||||||
};
|
};
|
||||||
|
EAF1C0DE2F3A4B5C0011223C /* Exceptions for "TheNoiseClockWidget" folder in "TheNoiseClockWidget" target */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
Info.plist,
|
||||||
|
);
|
||||||
|
target = EAF1C0DE2F3A4B5C00112233 /* TheNoiseClockWidget */;
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
@ -65,6 +95,14 @@
|
|||||||
path = TheNoiseClockUITests;
|
path = TheNoiseClockUITests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
EAF1C0DE2F3A4B5C0011223B /* TheNoiseClockWidget */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
EAF1C0DE2F3A4B5C0011223C /* Exceptions for "TheNoiseClockWidget" folder in "TheNoiseClockWidget" target */,
|
||||||
|
);
|
||||||
|
path = TheNoiseClockWidget;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@ -91,6 +129,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
EAF1C0DE2F3A4B5C0011223A /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
@ -100,6 +145,7 @@
|
|||||||
EA384AFD2E6E6B6000CA7D50 /* TheNoiseClock */,
|
EA384AFD2E6E6B6000CA7D50 /* TheNoiseClock */,
|
||||||
EA384B0B2E6E6B6100CA7D50 /* TheNoiseClockTests */,
|
EA384B0B2E6E6B6100CA7D50 /* TheNoiseClockTests */,
|
||||||
EA384B152E6E6B6100CA7D50 /* TheNoiseClockUITests */,
|
EA384B152E6E6B6100CA7D50 /* TheNoiseClockUITests */,
|
||||||
|
EAF1C0DE2F3A4B5C0011223B /* TheNoiseClockWidget */,
|
||||||
EA384AFC2E6E6B6000CA7D50 /* Products */,
|
EA384AFC2E6E6B6000CA7D50 /* Products */,
|
||||||
EAC057642F2E69E8007F87EA /* Recovered References */,
|
EAC057642F2E69E8007F87EA /* Recovered References */,
|
||||||
);
|
);
|
||||||
@ -111,6 +157,7 @@
|
|||||||
EA384AFB2E6E6B6000CA7D50 /* TheNoiseClock.app */,
|
EA384AFB2E6E6B6000CA7D50 /* TheNoiseClock.app */,
|
||||||
EA384B082E6E6B6100CA7D50 /* TheNoiseClockTests.xctest */,
|
EA384B082E6E6B6100CA7D50 /* TheNoiseClockTests.xctest */,
|
||||||
EA384B122E6E6B6100CA7D50 /* TheNoiseClockUITests.xctest */,
|
EA384B122E6E6B6100CA7D50 /* TheNoiseClockUITests.xctest */,
|
||||||
|
EAF1C0DE2F3A4B5C00112234 /* TheNoiseClockWidget.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -134,10 +181,12 @@
|
|||||||
EA384AF72E6E6B6000CA7D50 /* Sources */,
|
EA384AF72E6E6B6000CA7D50 /* Sources */,
|
||||||
EA384AF82E6E6B6000CA7D50 /* Frameworks */,
|
EA384AF82E6E6B6000CA7D50 /* Frameworks */,
|
||||||
EA384AF92E6E6B6000CA7D50 /* Resources */,
|
EA384AF92E6E6B6000CA7D50 /* Resources */,
|
||||||
|
EAF1C0DE2F3A4B5C0011223D /* Embed App Extensions */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
|
EAF1C0DE2F3A4B5C0011223F /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
EA384AFD2E6E6B6000CA7D50 /* TheNoiseClock */,
|
EA384AFD2E6E6B6000CA7D50 /* TheNoiseClock */,
|
||||||
@ -197,6 +246,26 @@
|
|||||||
productReference = EA384B122E6E6B6100CA7D50 /* TheNoiseClockUITests.xctest */;
|
productReference = EA384B122E6E6B6100CA7D50 /* TheNoiseClockUITests.xctest */;
|
||||||
productType = "com.apple.product-type.bundle.ui-testing";
|
productType = "com.apple.product-type.bundle.ui-testing";
|
||||||
};
|
};
|
||||||
|
EAF1C0DE2F3A4B5C00112233 /* TheNoiseClockWidget */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = EAF1C0DE2F3A4B5C00112235 /* Build configuration list for PBXNativeTarget "TheNoiseClockWidget" */;
|
||||||
|
buildPhases = (
|
||||||
|
EAF1C0DE2F3A4B5C00112238 /* Sources */,
|
||||||
|
EAF1C0DE2F3A4B5C0011223A /* Frameworks */,
|
||||||
|
EAF1C0DE2F3A4B5C00112239 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
EAF1C0DE2F3A4B5C0011223B /* TheNoiseClockWidget */,
|
||||||
|
);
|
||||||
|
name = TheNoiseClockWidget;
|
||||||
|
productName = TheNoiseClockWidget;
|
||||||
|
productReference = EAF1C0DE2F3A4B5C00112234 /* TheNoiseClockWidget.appex */;
|
||||||
|
productType = "com.apple.product-type.app-extension";
|
||||||
|
};
|
||||||
/* End PBXNativeTarget section */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
@ -218,6 +287,9 @@
|
|||||||
CreatedOnToolsVersion = 26.0;
|
CreatedOnToolsVersion = 26.0;
|
||||||
TestTargetID = EA384AFA2E6E6B6000CA7D50;
|
TestTargetID = EA384AFA2E6E6B6000CA7D50;
|
||||||
};
|
};
|
||||||
|
EAF1C0DE2F3A4B5C00112233 = {
|
||||||
|
CreatedOnToolsVersion = 26.0;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = EA384AF62E6E6B6000CA7D50 /* Build configuration list for PBXProject "TheNoiseClock" */;
|
buildConfigurationList = EA384AF62E6E6B6000CA7D50 /* Build configuration list for PBXProject "TheNoiseClock" */;
|
||||||
@ -241,6 +313,7 @@
|
|||||||
EA384AFA2E6E6B6000CA7D50 /* TheNoiseClock */,
|
EA384AFA2E6E6B6000CA7D50 /* TheNoiseClock */,
|
||||||
EA384B072E6E6B6100CA7D50 /* TheNoiseClockTests */,
|
EA384B072E6E6B6100CA7D50 /* TheNoiseClockTests */,
|
||||||
EA384B112E6E6B6100CA7D50 /* TheNoiseClockUITests */,
|
EA384B112E6E6B6100CA7D50 /* TheNoiseClockUITests */,
|
||||||
|
EAF1C0DE2F3A4B5C00112233 /* TheNoiseClockWidget */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@ -267,6 +340,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
EAF1C0DE2F3A4B5C00112239 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@ -291,6 +371,13 @@
|
|||||||
);
|
);
|
||||||
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 */
|
||||||
@ -304,6 +391,11 @@
|
|||||||
target = EA384AFA2E6E6B6000CA7D50 /* TheNoiseClock */;
|
target = EA384AFA2E6E6B6000CA7D50 /* TheNoiseClock */;
|
||||||
targetProxy = EA384B132E6E6B6100CA7D50 /* PBXContainerItemProxy */;
|
targetProxy = EA384B132E6E6B6100CA7D50 /* PBXContainerItemProxy */;
|
||||||
};
|
};
|
||||||
|
EAF1C0DE2F3A4B5C0011223F /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = EAF1C0DE2F3A4B5C00112233 /* TheNoiseClockWidget */;
|
||||||
|
targetProxy = EAF1C0DE2F3A4B5C00112240 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
@ -446,7 +538,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 = 18;
|
IPHONEOS_DEPLOYMENT_TARGET = 26;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@ -480,7 +572,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 = 18;
|
IPHONEOS_DEPLOYMENT_TARGET = 26;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@ -586,6 +678,44 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
EAF1C0DE2F3A4B5C00112236 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = EAD6E3AF5A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Debug.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = TheNoiseClockWidget/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 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 */
|
||||||
@ -625,6 +755,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
EAF1C0DE2F3A4B5C00112235 /* Build configuration list for PBXNativeTarget "TheNoiseClockWidget" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
EAF1C0DE2F3A4B5C00112236 /* Debug */,
|
||||||
|
EAF1C0DE2F3A4B5C00112237 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCLocalSwiftPackageReference section */
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
|
|||||||
@ -7,7 +7,12 @@
|
|||||||
<key>TheNoiseClock.xcscheme_^#shared#^_</key>
|
<key>TheNoiseClock.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>1</integer>
|
<integer>2</integer>
|
||||||
|
</dict>
|
||||||
|
<key>TheNoiseClockWidget.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>3</integer>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@ -11,60 +11,142 @@ import Bedrock
|
|||||||
/// Main tab navigation coordinator
|
/// Main tab navigation coordinator
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Properties
|
||||||
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 {
|
||||||
TabView(selection: $selectedTab) {
|
ZStack {
|
||||||
NavigationStack {
|
// Main tab content
|
||||||
ClockView(viewModel: clockViewModel)
|
TabView(selection: $selectedTab) {
|
||||||
}
|
NavigationStack {
|
||||||
.tabItem {
|
// Pass isOnClockTab so ClockView can make the right tab bar decision
|
||||||
Label("Clock", systemImage: "clock")
|
// Tab bar hides ONLY when: isOnClockTab && isDisplayMode
|
||||||
}
|
// This prevents race conditions on tab switch
|
||||||
.tag(Tab.clock)
|
ClockView(viewModel: clockViewModel, isOnClockTab: isOnClockTab)
|
||||||
|
}
|
||||||
NavigationStack {
|
.tabItem {
|
||||||
AlarmView()
|
Label("Clock", systemImage: "clock")
|
||||||
}
|
}
|
||||||
.tabItem {
|
.tag(Tab.clock)
|
||||||
Label("Alarms", systemImage: "alarm")
|
|
||||||
}
|
NavigationStack {
|
||||||
.tag(Tab.alarms)
|
AlarmView(viewModel: alarmViewModel)
|
||||||
|
}
|
||||||
NavigationStack {
|
.tabItem {
|
||||||
NoiseView()
|
Label("Alarms", systemImage: "alarm")
|
||||||
}
|
}
|
||||||
.tabItem {
|
.tag(Tab.alarms)
|
||||||
Label("Noise", systemImage: "waveform")
|
|
||||||
}
|
NavigationStack {
|
||||||
.tag(Tab.noise)
|
NoiseView()
|
||||||
|
}
|
||||||
|
.tabItem {
|
||||||
|
Label("Noise", systemImage: "waveform")
|
||||||
|
}
|
||||||
|
.tag(Tab.noise)
|
||||||
|
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ClockSettingsView(style: clockViewModel.style) { newStyle in
|
ClockSettingsView(
|
||||||
clockViewModel.updateStyle(newStyle)
|
style: clockViewModel.style,
|
||||||
|
onCommit: { newStyle in
|
||||||
|
clockViewModel.updateStyle(newStyle)
|
||||||
|
},
|
||||||
|
onResetOnboarding: {
|
||||||
|
onboardingState.reset()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.tabItem {
|
||||||
|
Label("Settings", systemImage: "gearshape")
|
||||||
|
}
|
||||||
|
.tag(Tab.settings)
|
||||||
|
}
|
||||||
|
.onChange(of: selectedTab) { oldValue, newValue in
|
||||||
|
Design.debugLog("[ContentView] Tab changed: \(oldValue) -> \(newValue)")
|
||||||
|
if oldValue == .clock && newValue != .clock {
|
||||||
|
Design.debugLog("[ContentView] Leaving clock tab, setting displayMode to false")
|
||||||
|
// Safety net: also explicitly disable display mode when leaving clock tab
|
||||||
|
// The ClockView's toolbar modifier already responds to isOnClockTab changing
|
||||||
|
clockViewModel.setDisplayMode(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tabItem {
|
.accentColor(AppAccent.primary)
|
||||||
Label("Settings", systemImage: "gearshape")
|
.background(Color.Branding.primary.ignoresSafeArea())
|
||||||
}
|
// Note: AlarmKit handles the alarm UI via the system Lock Screen and Dynamic Island.
|
||||||
.tag(Tab.settings)
|
// No in-app alarm screen is needed - users interact with alarms via the system UI.
|
||||||
}
|
|
||||||
.onChange(of: selectedTab) { oldValue, newValue in
|
// Onboarding overlay for first-time users
|
||||||
if oldValue == .clock && newValue != .clock {
|
if !onboardingState.hasCompletedWelcome {
|
||||||
clockViewModel.setDisplayMode(false)
|
OnboardingView {
|
||||||
|
onboardingState.completeWelcome()
|
||||||
|
}
|
||||||
|
.transition(.opacity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.accentColor(AppAccent.primary)
|
.sheet(isPresented: $keepAwakePromptState.isPresented) {
|
||||||
.background(Color.Branding.primary.ignoresSafeArea())
|
KeepAwakePrompt(
|
||||||
|
onEnable: {
|
||||||
|
clockViewModel.setKeepAwakeEnabled(true)
|
||||||
|
keepAwakePromptState.dismiss()
|
||||||
|
},
|
||||||
|
onDismiss: {
|
||||||
|
keepAwakePromptState.dismiss()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
Design.debugLog("[ContentView] App launched - initializing AlarmKit")
|
||||||
|
|
||||||
|
// Reschedule all enabled alarms with AlarmKit on app launch
|
||||||
|
await alarmViewModel.rescheduleAllAlarms()
|
||||||
|
|
||||||
|
Design.debugLog("[ContentView] AlarmKit initialization complete")
|
||||||
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .keepAwakePromptRequested)) { _ in
|
||||||
|
guard onboardingState.hasCompletedWelcome else { return }
|
||||||
|
guard shouldShowKeepAwakePromptForTab() else { return }
|
||||||
|
keepAwakePromptState.showIfNeeded(isKeepAwakeEnabled: clockViewModel.style.keepAwake)
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.3), value: onboardingState.hasCompletedWelcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldShowKeepAwakePromptForTab() -> Bool {
|
||||||
|
switch selectedTab {
|
||||||
|
case .clock, .alarms:
|
||||||
|
return true
|
||||||
|
case .noise, .settings:
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,9 @@
|
|||||||
//
|
//
|
||||||
// Created by Matt Bruce on 9/7/25.
|
// 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
|
||||||
@ -12,12 +15,6 @@ 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 {
|
||||||
|
|||||||
118
TheNoiseClock/Features/Alarms/Intents/AlarmIntents.swift
Normal file
118
TheNoiseClock/Features/Alarms/Intents/AlarmIntents.swift
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
//
|
||||||
|
// AlarmIntents.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 2/2/26.
|
||||||
|
//
|
||||||
|
// App Intents for alarm actions from Live Activity and widget buttons.
|
||||||
|
// Note: These intents are duplicated in TheNoiseClockWidget target.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AlarmKit
|
||||||
|
import AppIntents
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Stop Alarm Intent
|
||||||
|
|
||||||
|
/// Intent to stop an active alarm from the Live Activity or notification.
|
||||||
|
struct StopAlarmIntent: LiveActivityIntent {
|
||||||
|
|
||||||
|
static var title: LocalizedStringResource = "Stop Alarm"
|
||||||
|
static var description = IntentDescription("Stops the currently ringing alarm")
|
||||||
|
|
||||||
|
@Parameter(title: "Alarm ID")
|
||||||
|
var alarmId: String
|
||||||
|
|
||||||
|
static var supportedModes: IntentModes { .background }
|
||||||
|
|
||||||
|
init() {
|
||||||
|
self.alarmId = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
init(alarmId: String) {
|
||||||
|
self.alarmId = alarmId
|
||||||
|
}
|
||||||
|
|
||||||
|
func perform() throws -> some IntentResult {
|
||||||
|
guard let uuid = UUID(uuidString: alarmId) else {
|
||||||
|
throw AlarmIntentError.invalidAlarmID
|
||||||
|
}
|
||||||
|
|
||||||
|
try AlarmManager.shared.stop(id: uuid)
|
||||||
|
return .result()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Snooze Alarm Intent
|
||||||
|
|
||||||
|
/// Intent to snooze an active alarm from the Live Activity or notification.
|
||||||
|
struct SnoozeAlarmIntent: LiveActivityIntent {
|
||||||
|
|
||||||
|
static var title: LocalizedStringResource = "Snooze Alarm"
|
||||||
|
static var description = IntentDescription("Snoozes the currently ringing alarm")
|
||||||
|
|
||||||
|
@Parameter(title: "Alarm ID")
|
||||||
|
var alarmId: String
|
||||||
|
|
||||||
|
static var supportedModes: IntentModes { .background }
|
||||||
|
|
||||||
|
init() {
|
||||||
|
self.alarmId = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
init(alarmId: String) {
|
||||||
|
self.alarmId = alarmId
|
||||||
|
}
|
||||||
|
|
||||||
|
func perform() throws -> some IntentResult {
|
||||||
|
guard let uuid = UUID(uuidString: alarmId) else {
|
||||||
|
throw AlarmIntentError.invalidAlarmID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use countdown to postpone the alarm by its configured snooze duration
|
||||||
|
try AlarmManager.shared.countdown(id: uuid)
|
||||||
|
return .result()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Open App Intent
|
||||||
|
|
||||||
|
/// Intent to open the app when the user taps the Live Activity.
|
||||||
|
struct OpenAlarmAppIntent: LiveActivityIntent {
|
||||||
|
|
||||||
|
static var title: LocalizedStringResource = "Open TheNoiseClock"
|
||||||
|
static var description = IntentDescription("Opens the app to the alarm screen")
|
||||||
|
static var openAppWhenRun = true
|
||||||
|
|
||||||
|
@Parameter(title: "Alarm ID")
|
||||||
|
var alarmId: String
|
||||||
|
|
||||||
|
init() {
|
||||||
|
self.alarmId = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
init(alarmId: String) {
|
||||||
|
self.alarmId = alarmId
|
||||||
|
}
|
||||||
|
|
||||||
|
func perform() throws -> some IntentResult {
|
||||||
|
// The app will be opened due to openAppWhenRun = true
|
||||||
|
return .result()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Errors
|
||||||
|
|
||||||
|
enum AlarmIntentError: Error, LocalizedError {
|
||||||
|
case invalidAlarmID
|
||||||
|
case alarmNotFound
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidAlarmID:
|
||||||
|
return "Invalid alarm ID"
|
||||||
|
case .alarmNotFound:
|
||||||
|
return "Alarm not found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
418
TheNoiseClock/Features/Alarms/Services/AlarmKitService.swift
Normal file
418
TheNoiseClock/Features/Alarms/Services/AlarmKitService.swift
Normal file
@ -0,0 +1,418 @@
|
|||||||
|
//
|
||||||
|
// AlarmKitService.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 2/2/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import ActivityKit
|
||||||
|
import AlarmKit
|
||||||
|
import Bedrock
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Service for managing alarms using AlarmKit (iOS 26+).
|
||||||
|
/// AlarmKit alarms cut through Focus modes and silent mode.
|
||||||
|
@MainActor
|
||||||
|
final class AlarmKitService {
|
||||||
|
|
||||||
|
// MARK: - Singleton
|
||||||
|
|
||||||
|
static let shared = AlarmKitService()
|
||||||
|
private let manager = AlarmManager.shared
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
Design.debugLog("[alarmkit] AlarmKitService initialized")
|
||||||
|
Design.debugLog("[alarmkit] Authorization state: \(manager.authorizationState)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Authorization
|
||||||
|
|
||||||
|
/// The current authorization state for AlarmKit
|
||||||
|
var authorizationState: AlarmManager.AuthorizationState {
|
||||||
|
manager.authorizationState
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request authorization to schedule alarms.
|
||||||
|
/// - Returns: `true` if authorized, `false` otherwise.
|
||||||
|
func requestAuthorization() async -> Bool {
|
||||||
|
Design.debugLog("[alarmkit] Requesting authorization, current state: \(manager.authorizationState)")
|
||||||
|
|
||||||
|
switch manager.authorizationState {
|
||||||
|
case .notDetermined:
|
||||||
|
do {
|
||||||
|
let state = try await manager.requestAuthorization()
|
||||||
|
Design.debugLog("[alarmkit] Authorization result: \(state)")
|
||||||
|
return state == .authorized
|
||||||
|
} catch {
|
||||||
|
Design.debugLog("[alarmkit] Authorization error: \(error)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case .authorized:
|
||||||
|
Design.debugLog("[alarmkit] Already authorized")
|
||||||
|
return true
|
||||||
|
case .denied:
|
||||||
|
Design.debugLog("[alarmkit] Authorization denied - user must enable in Settings")
|
||||||
|
return false
|
||||||
|
@unknown default:
|
||||||
|
Design.debugLog("[alarmkit] Unknown authorization state")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Scheduling
|
||||||
|
|
||||||
|
/// Schedule an alarm using AlarmKit.
|
||||||
|
/// - Parameter alarm: The alarm to schedule.
|
||||||
|
func scheduleAlarm(_ alarm: Alarm) async throws {
|
||||||
|
Design.debugLog("[alarmkit] ========== SCHEDULING ALARM ==========")
|
||||||
|
Design.debugLog("[alarmkit] Label: \(alarm.label)")
|
||||||
|
Design.debugLog("[alarmkit] Time: \(alarm.time)")
|
||||||
|
Design.debugLog("[alarmkit] Sound: \(alarm.soundName)")
|
||||||
|
Design.debugLog("[alarmkit] Volume: \(alarm.volume)")
|
||||||
|
Design.debugLog("[alarmkit] ID: \(alarm.id)")
|
||||||
|
|
||||||
|
// Ensure we're authorized
|
||||||
|
if manager.authorizationState != .authorized {
|
||||||
|
Design.debugLog("[alarmkit] Not authorized, requesting...")
|
||||||
|
let authorized = await requestAuthorization()
|
||||||
|
guard authorized else {
|
||||||
|
Design.debugLog("[alarmkit] Authorization failed, cannot schedule alarm")
|
||||||
|
throw AlarmKitError.notAuthorized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the stop button for the alarm
|
||||||
|
let stopButton = AlarmButton(
|
||||||
|
text: "Stop",
|
||||||
|
textColor: .red,
|
||||||
|
systemImageName: "stop.fill"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create the snooze button (secondary button with countdown behavior)
|
||||||
|
let snoozeButton = AlarmButton(
|
||||||
|
text: "Snooze",
|
||||||
|
textColor: .white,
|
||||||
|
systemImageName: "moon.zzz"
|
||||||
|
)
|
||||||
|
Design.debugLog("[alarmkit] Created stop and snooze buttons")
|
||||||
|
|
||||||
|
// Create the alert presentation with snooze as secondary button
|
||||||
|
// secondaryButtonBehavior: .countdown enables snooze functionality
|
||||||
|
// Include both label and notification message in the title
|
||||||
|
let alertTitle = alarm.notificationMessage.isEmpty
|
||||||
|
? alarm.label
|
||||||
|
: "\(alarm.label) - \(alarm.notificationMessage)"
|
||||||
|
let alert = AlarmPresentation.Alert(
|
||||||
|
title: LocalizedStringResource(stringLiteral: alertTitle),
|
||||||
|
stopButton: stopButton,
|
||||||
|
secondaryButton: snoozeButton,
|
||||||
|
secondaryButtonBehavior: .countdown
|
||||||
|
)
|
||||||
|
Design.debugLog("[alarmkit] Created alert with title: \(alertTitle)")
|
||||||
|
|
||||||
|
// Create metadata for the alarm
|
||||||
|
let metadata = NoiseClockAlarmMetadata(
|
||||||
|
alarmId: alarm.id.uuidString,
|
||||||
|
soundName: alarm.soundName,
|
||||||
|
snoozeDuration: alarm.snoozeDuration,
|
||||||
|
label: alarm.label,
|
||||||
|
message: alarm.notificationMessage,
|
||||||
|
volume: alarm.volume
|
||||||
|
)
|
||||||
|
Design.debugLog("[alarmkit] Created metadata: alarmId=\(metadata.alarmId), sound=\(metadata.soundName), message=\(metadata.message)")
|
||||||
|
|
||||||
|
// Create alarm attributes
|
||||||
|
let attributes = AlarmAttributes<NoiseClockAlarmMetadata>(
|
||||||
|
presentation: AlarmPresentation(alert: alert),
|
||||||
|
metadata: metadata,
|
||||||
|
tintColor: Color.pink
|
||||||
|
)
|
||||||
|
Design.debugLog("[alarmkit] Created attributes with tint color")
|
||||||
|
|
||||||
|
// Create the schedule - use fixed date for one-time alarms
|
||||||
|
let schedule = createSchedule(for: alarm)
|
||||||
|
Design.debugLog("[alarmkit] Created schedule")
|
||||||
|
|
||||||
|
// CountdownDuration for snooze support:
|
||||||
|
// - preAlert: nil = no countdown before alarm (fires immediately at scheduled time)
|
||||||
|
// - postAlert: snooze duration (how long until alarm fires again after snooze)
|
||||||
|
// If no snooze, set countdownDuration to nil
|
||||||
|
let snoozeDurationSeconds = TimeInterval(alarm.snoozeDuration * 60)
|
||||||
|
let countdownDuration: AlarmKit.Alarm.CountdownDuration? = snoozeDurationSeconds > 0
|
||||||
|
? AlarmKit.Alarm.CountdownDuration(preAlert: nil, postAlert: snoozeDurationSeconds)
|
||||||
|
: nil
|
||||||
|
Design.debugLog("[alarmkit] Countdown duration: preAlert=nil (immediate), postAlert=\(snoozeDurationSeconds)s (snooze)")
|
||||||
|
|
||||||
|
// Create the sound
|
||||||
|
let soundName = getSoundNameForAlarmKit(alarm.soundName)
|
||||||
|
let alarmSound = AlertConfiguration.AlertSound.named(soundName)
|
||||||
|
Design.debugLog("[alarmkit] Created sound: \(soundName)")
|
||||||
|
|
||||||
|
// Create the alarm configuration with sound
|
||||||
|
let configuration = AlarmManager.AlarmConfiguration<NoiseClockAlarmMetadata>(
|
||||||
|
countdownDuration: countdownDuration,
|
||||||
|
schedule: schedule,
|
||||||
|
attributes: attributes,
|
||||||
|
sound: alarmSound
|
||||||
|
)
|
||||||
|
Design.debugLog("[alarmkit] Created configuration with sound")
|
||||||
|
|
||||||
|
// Schedule the alarm
|
||||||
|
do {
|
||||||
|
let scheduledAlarm = try await manager.schedule(
|
||||||
|
id: alarm.id,
|
||||||
|
configuration: configuration
|
||||||
|
)
|
||||||
|
Design.debugLog("[alarmkit] ✅ ALARM SCHEDULED SUCCESSFULLY")
|
||||||
|
Design.debugLog("[alarmkit] Scheduled ID: \(scheduledAlarm.id)")
|
||||||
|
Design.debugLog("[alarmkit] Scheduled state: \(scheduledAlarm.state)")
|
||||||
|
} catch {
|
||||||
|
Design.debugLog("[alarmkit] ❌ SCHEDULING FAILED: \(error)")
|
||||||
|
throw AlarmKitError.schedulingFailed(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sound Configuration
|
||||||
|
|
||||||
|
/// Get the sound name for AlarmKit and ensure it's in Library/Sounds
|
||||||
|
/// AlarmKit can only play sounds from the main bundle root or Library/Sounds
|
||||||
|
private func getSoundNameForAlarmKit(_ soundName: String) -> String {
|
||||||
|
Design.debugLog("[alarmkit] Preparing sound for AlarmKit: \(soundName)")
|
||||||
|
|
||||||
|
// Copy sound to Library/Sounds so AlarmKit can access it
|
||||||
|
if copySoundToLibrarySounds(soundName) {
|
||||||
|
Design.debugLog("[alarmkit] ✅ Sound ready in Library/Sounds: \(soundName)")
|
||||||
|
} else {
|
||||||
|
Design.debugLog("[alarmkit] ⚠️ Failed to copy sound to Library/Sounds, alarm may use default sound")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlarmKit expects just the filename (with extension) when in Library/Sounds
|
||||||
|
return soundName
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy a sound file from AlarmSounds folder to Library/Sounds
|
||||||
|
/// Returns true if successful or file already exists
|
||||||
|
private func copySoundToLibrarySounds(_ soundName: String) -> Bool {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let nameWithoutExtension = (soundName as NSString).deletingPathExtension
|
||||||
|
let ext = (soundName as NSString).pathExtension
|
||||||
|
|
||||||
|
// Try multiple locations for the source sound file
|
||||||
|
var sourceURL: URL?
|
||||||
|
|
||||||
|
// 1. Try AlarmSounds subfolder in main bundle (Resources/AlarmSounds/)
|
||||||
|
if let url = Bundle.main.url(forResource: nameWithoutExtension, withExtension: ext, subdirectory: "AlarmSounds") {
|
||||||
|
sourceURL = url
|
||||||
|
Design.debugLog("[alarmkit] Found sound in AlarmSounds subfolder: \(soundName)")
|
||||||
|
}
|
||||||
|
// 2. Try AlarmSounds.bundle
|
||||||
|
else if let bundleURL = Bundle.main.url(forResource: "AlarmSounds", withExtension: "bundle"),
|
||||||
|
let alarmBundle = Bundle(url: bundleURL),
|
||||||
|
let url = alarmBundle.url(forResource: nameWithoutExtension, withExtension: ext) {
|
||||||
|
sourceURL = url
|
||||||
|
Design.debugLog("[alarmkit] Found sound in AlarmSounds.bundle: \(soundName)")
|
||||||
|
}
|
||||||
|
// 3. Try main bundle root
|
||||||
|
else if let url = Bundle.main.url(forResource: nameWithoutExtension, withExtension: ext) {
|
||||||
|
sourceURL = url
|
||||||
|
Design.debugLog("[alarmkit] Found sound in main bundle root: \(soundName)")
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let sourceURL = sourceURL else {
|
||||||
|
Design.debugLog("[alarmkit] ❌ Sound file not found anywhere: \(soundName)")
|
||||||
|
logAvailableAlarmSounds()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get destination URL in Library/Sounds
|
||||||
|
guard let libraryURL = fileManager.urls(for: .libraryDirectory, in: .userDomainMask).first else {
|
||||||
|
Design.debugLog("[alarmkit] ❌ Could not get Library directory")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let soundsDirectory = libraryURL.appendingPathComponent("Sounds")
|
||||||
|
let destinationURL = soundsDirectory.appendingPathComponent(soundName)
|
||||||
|
|
||||||
|
// Create Sounds directory if it doesn't exist
|
||||||
|
if !fileManager.fileExists(atPath: soundsDirectory.path) {
|
||||||
|
do {
|
||||||
|
try fileManager.createDirectory(at: soundsDirectory, withIntermediateDirectories: true)
|
||||||
|
Design.debugLog("[alarmkit] Created Library/Sounds directory")
|
||||||
|
} catch {
|
||||||
|
Design.debugLog("[alarmkit] ❌ Failed to create Sounds directory: \(error)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy file if it doesn't exist or is different
|
||||||
|
if fileManager.fileExists(atPath: destinationURL.path) {
|
||||||
|
// Check if source is newer (simple check - could compare file sizes/hashes)
|
||||||
|
Design.debugLog("[alarmkit] Sound already exists in Library/Sounds: \(soundName)")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try fileManager.copyItem(at: sourceURL, to: destinationURL)
|
||||||
|
Design.debugLog("[alarmkit] ✅ Copied sound to Library/Sounds: \(soundName)")
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
Design.debugLog("[alarmkit] ❌ Failed to copy sound: \(error)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log available sound files in Library/Sounds for debugging
|
||||||
|
private func logLibrarySounds() {
|
||||||
|
guard let libraryURL = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let soundsDirectory = libraryURL.appendingPathComponent("Sounds")
|
||||||
|
Design.debugLog("[alarmkit] ========== LIBRARY/SOUNDS FILES ==========")
|
||||||
|
|
||||||
|
do {
|
||||||
|
let files = try FileManager.default.contentsOfDirectory(atPath: soundsDirectory.path)
|
||||||
|
Design.debugLog("[alarmkit] Files in Library/Sounds: \(files)")
|
||||||
|
} catch {
|
||||||
|
Design.debugLog("[alarmkit] Library/Sounds directory doesn't exist or is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log available alarm sounds in the bundle for debugging
|
||||||
|
private func logAvailableAlarmSounds() {
|
||||||
|
Design.debugLog("[alarmkit] ========== AVAILABLE ALARM SOUNDS ==========")
|
||||||
|
|
||||||
|
// Check AlarmSounds subfolder
|
||||||
|
if let resourcePath = Bundle.main.resourcePath {
|
||||||
|
let alarmSoundsPath = (resourcePath as NSString).appendingPathComponent("AlarmSounds")
|
||||||
|
if FileManager.default.fileExists(atPath: alarmSoundsPath) {
|
||||||
|
do {
|
||||||
|
let files = try FileManager.default.contentsOfDirectory(atPath: alarmSoundsPath)
|
||||||
|
Design.debugLog("[alarmkit] Files in AlarmSounds folder: \(files)")
|
||||||
|
} catch {
|
||||||
|
Design.debugLog("[alarmkit] Error reading AlarmSounds folder: \(error)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Design.debugLog("[alarmkit] AlarmSounds folder doesn't exist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check AlarmSounds.bundle
|
||||||
|
if let bundleURL = Bundle.main.url(forResource: "AlarmSounds", withExtension: "bundle"),
|
||||||
|
let alarmBundle = Bundle(url: bundleURL),
|
||||||
|
let bundlePath = alarmBundle.resourcePath {
|
||||||
|
do {
|
||||||
|
let files = try FileManager.default.contentsOfDirectory(atPath: bundlePath)
|
||||||
|
Design.debugLog("[alarmkit] Files in AlarmSounds.bundle: \(files)")
|
||||||
|
} catch {
|
||||||
|
Design.debugLog("[alarmkit] Error reading AlarmSounds.bundle: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel a scheduled alarm.
|
||||||
|
/// - Parameter id: The UUID of the alarm to cancel.
|
||||||
|
func cancelAlarm(id: UUID) {
|
||||||
|
Design.debugLog("[alarmkit] Cancelling alarm: \(id)")
|
||||||
|
do {
|
||||||
|
try manager.cancel(id: id)
|
||||||
|
Design.debugLog("[alarmkit] ✅ Alarm cancelled: \(id)")
|
||||||
|
} catch {
|
||||||
|
Design.debugLog("[alarmkit] ❌ Cancel error: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop an active alarm that is currently alerting.
|
||||||
|
/// - Parameter id: The UUID of the alarm to stop.
|
||||||
|
func stopAlarm(id: UUID) {
|
||||||
|
Design.debugLog("[alarmkit] Stopping alarm: \(id)")
|
||||||
|
do {
|
||||||
|
try manager.stop(id: id)
|
||||||
|
Design.debugLog("[alarmkit] ✅ Alarm stopped: \(id)")
|
||||||
|
} catch {
|
||||||
|
Design.debugLog("[alarmkit] ❌ Stop error: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snooze an active alarm by starting its countdown again.
|
||||||
|
/// - Parameter id: The UUID of the alarm to snooze.
|
||||||
|
func snoozeAlarm(id: UUID) {
|
||||||
|
Design.debugLog("[alarmkit] Snoozing alarm: \(id)")
|
||||||
|
do {
|
||||||
|
try manager.countdown(id: id)
|
||||||
|
Design.debugLog("[alarmkit] ✅ Alarm snoozed: \(id)")
|
||||||
|
} catch {
|
||||||
|
Design.debugLog("[alarmkit] ❌ Snooze error: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Alarm Updates
|
||||||
|
|
||||||
|
/// Async sequence that emits the current set of alarms whenever changes occur.
|
||||||
|
var alarmUpdates: some AsyncSequence<[AlarmKit.Alarm], Never> {
|
||||||
|
manager.alarmUpdates
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log current state of all scheduled alarms
|
||||||
|
func logCurrentAlarms() {
|
||||||
|
Design.debugLog("[alarmkit] ========== CURRENT ALARMS ==========")
|
||||||
|
Task {
|
||||||
|
for await alarms in manager.alarmUpdates {
|
||||||
|
Design.debugLog("[alarmkit] Found \(alarms.count) alarm(s) in AlarmKit")
|
||||||
|
for alarm in alarms {
|
||||||
|
Design.debugLog("[alarmkit] - ID: \(alarm.id)")
|
||||||
|
Design.debugLog("[alarmkit] State: \(alarm.state)")
|
||||||
|
}
|
||||||
|
break // Just log once
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
|
||||||
|
/// Create an AlarmKit schedule from an Alarm model.
|
||||||
|
private func createSchedule(for alarm: Alarm) -> AlarmKit.Alarm.Schedule {
|
||||||
|
// Log the raw alarm time
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss Z"
|
||||||
|
Design.debugLog("[alarmkit] Raw alarm.time: \(formatter.string(from: alarm.time))")
|
||||||
|
|
||||||
|
// Calculate the next trigger time
|
||||||
|
let triggerDate = alarm.nextTriggerTime()
|
||||||
|
|
||||||
|
Design.debugLog("[alarmkit] Next trigger date: \(formatter.string(from: triggerDate))")
|
||||||
|
Design.debugLog("[alarmkit] Current time: \(formatter.string(from: Date.now))")
|
||||||
|
|
||||||
|
let secondsUntil = triggerDate.timeIntervalSinceNow
|
||||||
|
let minutesUntil = secondsUntil / 60
|
||||||
|
Design.debugLog("[alarmkit] Time until alarm: \(Int(secondsUntil)) seconds (\(String(format: "%.1f", minutesUntil)) minutes)")
|
||||||
|
|
||||||
|
// Warn if the alarm is too far in the future (might indicate wrong date calculation)
|
||||||
|
if secondsUntil > 86400 {
|
||||||
|
Design.debugLog("[alarmkit] ⚠️ WARNING: Alarm is more than 24 hours away!")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use fixed schedule for one-time alarms
|
||||||
|
let schedule = AlarmKit.Alarm.Schedule.fixed(triggerDate)
|
||||||
|
|
||||||
|
Design.debugLog("[alarmkit] Schedule created: fixed at \(formatter.string(from: triggerDate))")
|
||||||
|
return schedule
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Errors
|
||||||
|
|
||||||
|
enum AlarmKitError: Error, LocalizedError {
|
||||||
|
case notAuthorized
|
||||||
|
case schedulingFailed(Error)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .notAuthorized:
|
||||||
|
return "AlarmKit is not authorized. Please enable alarm permissions in Settings."
|
||||||
|
case .schedulingFailed(let error):
|
||||||
|
return "Failed to schedule alarm: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,12 +4,17 @@
|
|||||||
//
|
//
|
||||||
// Created by Matt Bruce on 9/7/25.
|
// 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 alarms and notifications
|
/// Service for managing alarm persistence.
|
||||||
|
/// Alarm scheduling is handled by AlarmKitService.
|
||||||
@Observable
|
@Observable
|
||||||
class AlarmService {
|
class AlarmService {
|
||||||
|
|
||||||
@ -17,48 +22,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()
|
||||||
Task {
|
Design.debugLog("[alarms] AlarmService initialized with \(alarms.count) alarms")
|
||||||
// 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 { return }
|
guard let index = alarmLookup[alarm.id] else {
|
||||||
|
Design.debugLog("[alarms] AlarmService.updateAlarm: alarm not found \(alarm.id)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Design.debugLog("[alarms] AlarmService.updateAlarm: \(alarm.label) enabled=\(alarm.isEnabled)")
|
||||||
alarms[index] = alarm
|
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()
|
||||||
scheduleNotification(for: alarms[index])
|
Design.debugLog("[alarms] AlarmService.toggleAlarm: \(id) now enabled=\(alarms[index].isEnabled)")
|
||||||
saveAlarms()
|
saveAlarms()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,36 +79,6 @@ class AlarmService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scheduleNotification(for alarm: Alarm) {
|
|
||||||
// Remove existing notification
|
|
||||||
NotificationUtils.removeNotification(identifier: alarm.id.uuidString)
|
|
||||||
|
|
||||||
// Schedule new notification if enabled
|
|
||||||
if alarm.isEnabled {
|
|
||||||
Task {
|
|
||||||
let respectFocusModes = currentRespectFocusModes()
|
|
||||||
// Use FocusModeService for better Focus mode compatibility
|
|
||||||
focusModeService.scheduleAlarmNotification(
|
|
||||||
identifier: alarm.id.uuidString,
|
|
||||||
title: alarm.label,
|
|
||||||
body: alarm.notificationMessage,
|
|
||||||
date: alarm.time,
|
|
||||||
soundName: alarm.soundName,
|
|
||||||
repeats: false, // For now, set to false since Alarm model doesn't have repeatDays
|
|
||||||
respectFocusModes: respectFocusModes
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func currentRespectFocusModes() -> Bool {
|
|
||||||
guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey),
|
|
||||||
let style = try? JSONDecoder().decode(ClockStyle.self, from: data) else {
|
|
||||||
return ClockStyle().respectFocusModes
|
|
||||||
}
|
|
||||||
return style.respectFocusModes
|
|
||||||
}
|
|
||||||
|
|
||||||
private func saveAlarms() {
|
private func saveAlarms() {
|
||||||
persistenceWorkItem?.cancel()
|
persistenceWorkItem?.cancel()
|
||||||
|
|
||||||
@ -124,13 +99,37 @@ 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) {
|
||||||
alarms = decodedAlarms
|
// Migrate sound file extensions from .caf to .mp3
|
||||||
updateAlarmLookup()
|
alarms = decodedAlarms.map { alarm in
|
||||||
|
var migratedAlarm = alarm
|
||||||
// Reschedule all enabled alarms
|
migratedAlarm.soundName = migrateSoundName(alarm.soundName)
|
||||||
for alarm in alarms where alarm.isEnabled {
|
return migratedAlarm
|
||||||
scheduleNotification(for: alarm)
|
|
||||||
}
|
}
|
||||||
|
updateAlarmLookup()
|
||||||
|
Design.debugLog("[alarms] Loaded \(alarms.count) alarms from storage")
|
||||||
|
|
||||||
|
// Save migrated alarms if any changes were made
|
||||||
|
let needsMigration = zip(decodedAlarms, alarms).contains { $0.soundName != $1.soundName }
|
||||||
|
if needsMigration {
|
||||||
|
Design.debugLog("[alarms] Sound file migration applied, saving...")
|
||||||
|
saveAlarms()
|
||||||
|
}
|
||||||
|
// Note: AlarmKit scheduling is handled by AlarmViewModel.rescheduleAllAlarms()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Migrate sound file names from .caf to .mp3
|
||||||
|
private func migrateSoundName(_ soundName: String) -> String {
|
||||||
|
if soundName.hasSuffix(".caf") {
|
||||||
|
let migrated = soundName.replacingOccurrences(of: ".caf", with: ".mp3")
|
||||||
|
Design.debugLog("[alarms] Migrating sound: \(soundName) -> \(migrated)")
|
||||||
|
return migrated
|
||||||
|
}
|
||||||
|
return soundName
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all enabled alarms (for rescheduling with AlarmKit)
|
||||||
|
func getEnabledAlarms() -> [Alarm] {
|
||||||
|
return alarms.filter { $0.isEnabled }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,7 +58,7 @@ class AlarmSoundService {
|
|||||||
do {
|
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,4 +114,72 @@ 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,238 +0,0 @@
|
|||||||
//
|
|
||||||
// FocusModeService.swift
|
|
||||||
// TheNoiseClock
|
|
||||||
//
|
|
||||||
// Created by Matt Bruce on 9/7/25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Observation
|
|
||||||
import UIKit
|
|
||||||
import UserNotifications
|
|
||||||
import Bedrock
|
|
||||||
|
|
||||||
/// Service to align notifications with Focus mode behavior
|
|
||||||
@Observable
|
|
||||||
class FocusModeService {
|
|
||||||
|
|
||||||
// MARK: - Singleton
|
|
||||||
static let shared = FocusModeService()
|
|
||||||
|
|
||||||
// MARK: - Properties
|
|
||||||
private(set) var notificationAuthorizationStatus: UNAuthorizationStatus = .notDetermined
|
|
||||||
private(set) var timeSensitiveSetting: UNNotificationSetting = .notSupported
|
|
||||||
private(set) var scheduledDeliverySetting: UNNotificationSetting = .notSupported
|
|
||||||
private var notificationSettingsObserver: NSObjectProtocol?
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
|
||||||
private init() {
|
|
||||||
setupFocusModeMonitoring()
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
removeFocusModeObserver()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public Interface
|
|
||||||
|
|
||||||
/// Check if Focus mode is currently active
|
|
||||||
var isAuthorized: Bool {
|
|
||||||
notificationAuthorizationStatus == .authorized
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Request notification permissions that work with Focus modes
|
|
||||||
func requestNotificationPermissions() async -> Bool {
|
|
||||||
do {
|
|
||||||
let granted = try await UNUserNotificationCenter.current().requestAuthorization(
|
|
||||||
options: [.alert, .sound, .badge, .provisional]
|
|
||||||
)
|
|
||||||
|
|
||||||
if granted {
|
|
||||||
// Configure notification settings for Focus mode compatibility
|
|
||||||
await configureNotificationSettings()
|
|
||||||
}
|
|
||||||
|
|
||||||
await refreshNotificationSettings()
|
|
||||||
|
|
||||||
return granted
|
|
||||||
} catch {
|
|
||||||
Design.debugLog("[general] Error requesting notification permissions: \(error)")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Configure notification settings to work with Focus modes
|
|
||||||
private func configureNotificationSettings() async {
|
|
||||||
// Create notification categories that work with Focus modes
|
|
||||||
let alarmCategory = UNNotificationCategory(
|
|
||||||
identifier: "ALARM_CATEGORY",
|
|
||||||
actions: [
|
|
||||||
UNNotificationAction(
|
|
||||||
identifier: "SNOOZE_ACTION",
|
|
||||||
title: "Snooze",
|
|
||||||
options: []
|
|
||||||
),
|
|
||||||
UNNotificationAction(
|
|
||||||
identifier: "STOP_ACTION",
|
|
||||||
title: "Stop",
|
|
||||||
options: [.destructive]
|
|
||||||
)
|
|
||||||
],
|
|
||||||
intentIdentifiers: [],
|
|
||||||
options: [.customDismissAction]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Register the category
|
|
||||||
UNUserNotificationCenter.current().setNotificationCategories([alarmCategory])
|
|
||||||
|
|
||||||
Design.debugLog("[settings] Notification settings configured for Focus mode compatibility")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Schedule alarm notification with Focus mode awareness
|
|
||||||
func scheduleAlarmNotification(
|
|
||||||
identifier: String,
|
|
||||||
title: String,
|
|
||||||
body: String,
|
|
||||||
date: Date,
|
|
||||||
soundName: String,
|
|
||||||
repeats: Bool = false,
|
|
||||||
respectFocusModes: Bool = true
|
|
||||||
) {
|
|
||||||
let content = UNMutableNotificationContent()
|
|
||||||
content.title = title
|
|
||||||
content.body = body
|
|
||||||
// Use the sound name directly since sounds.json now references CAF files
|
|
||||||
if soundName == "default" {
|
|
||||||
content.sound = UNNotificationSound.default
|
|
||||||
Design.debugLog("[settings] Using default notification sound")
|
|
||||||
} else {
|
|
||||||
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: soundName))
|
|
||||||
Design.debugLog("[settings] Using custom alarm sound: \(soundName)")
|
|
||||||
Design.debugLog("[settings] Sound file should be in main bundle: \(soundName)")
|
|
||||||
}
|
|
||||||
content.categoryIdentifier = "ALARM_CATEGORY"
|
|
||||||
|
|
||||||
if !respectFocusModes, timeSensitiveSetting == .enabled {
|
|
||||||
content.interruptionLevel = .timeSensitive
|
|
||||||
}
|
|
||||||
content.userInfo = [
|
|
||||||
"alarmId": identifier,
|
|
||||||
"soundName": soundName,
|
|
||||||
"repeats": repeats
|
|
||||||
]
|
|
||||||
|
|
||||||
// Create trigger
|
|
||||||
let trigger: UNNotificationTrigger
|
|
||||||
if repeats {
|
|
||||||
let calendar = Calendar.current
|
|
||||||
let components = calendar.dateComponents([.hour, .minute], from: date)
|
|
||||||
trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
|
|
||||||
} else {
|
|
||||||
// Use calendar trigger for one-time alarms to avoid time interval issues
|
|
||||||
let calendar = Calendar.current
|
|
||||||
let components = calendar.dateComponents([.hour, .minute], from: date)
|
|
||||||
trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create request
|
|
||||||
let request = UNNotificationRequest(
|
|
||||||
identifier: identifier,
|
|
||||||
content: content,
|
|
||||||
trigger: trigger
|
|
||||||
)
|
|
||||||
|
|
||||||
// Schedule notification
|
|
||||||
UNUserNotificationCenter.current().add(request) { error in
|
|
||||||
if let error = error {
|
|
||||||
Design.debugLog("[general] Error scheduling alarm notification: \(error)")
|
|
||||||
} else {
|
|
||||||
Design.debugLog("[settings] Alarm notification scheduled for \(date)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cancel alarm notification
|
|
||||||
func cancelAlarmNotification(identifier: String) {
|
|
||||||
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [identifier])
|
|
||||||
Design.debugLog("[settings] Cancelled alarm notification: \(identifier)")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cancel all alarm notifications
|
|
||||||
func cancelAllAlarmNotifications() {
|
|
||||||
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
|
|
||||||
Design.debugLog("[settings] Cancelled all alarm notifications")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Methods
|
|
||||||
|
|
||||||
/// Set up monitoring for Focus mode changes
|
|
||||||
private func setupFocusModeMonitoring() {
|
|
||||||
notificationSettingsObserver = NotificationCenter.default.addObserver(
|
|
||||||
forName: UIApplication.willEnterForegroundNotification,
|
|
||||||
object: nil,
|
|
||||||
queue: .main
|
|
||||||
) { [weak self] _ in
|
|
||||||
Task { await self?.refreshNotificationSettings() }
|
|
||||||
}
|
|
||||||
|
|
||||||
Task { await refreshNotificationSettings() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove Focus mode observer
|
|
||||||
private func removeFocusModeObserver() {
|
|
||||||
if let observer = notificationSettingsObserver {
|
|
||||||
NotificationCenter.default.removeObserver(observer)
|
|
||||||
notificationSettingsObserver = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Refresh notification settings to align with Focus mode behavior.
|
|
||||||
@MainActor
|
|
||||||
func refreshNotificationSettings() async {
|
|
||||||
let settings = await UNUserNotificationCenter.current().notificationSettings()
|
|
||||||
notificationAuthorizationStatus = settings.authorizationStatus
|
|
||||||
timeSensitiveSetting = settings.timeSensitiveSetting
|
|
||||||
scheduledDeliverySetting = settings.scheduledDeliverySetting
|
|
||||||
|
|
||||||
Design.debugLog("[settings] Notification settings updated: auth=\(settings.authorizationStatus), timeSensitive=\(settings.timeSensitiveSetting), scheduledDelivery=\(settings.scheduledDeliverySetting)")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get notification authorization status
|
|
||||||
func getNotificationAuthorizationStatus() async -> UNAuthorizationStatus {
|
|
||||||
let settings = await UNUserNotificationCenter.current().notificationSettings()
|
|
||||||
return settings.authorizationStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if notifications are allowed in current Focus mode
|
|
||||||
func areNotificationsAllowed() async -> Bool {
|
|
||||||
let status = await getNotificationAuthorizationStatus()
|
|
||||||
return status == .authorized
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Focus Mode Configuration
|
|
||||||
extension FocusModeService {
|
|
||||||
|
|
||||||
/// Configure app to work optimally with Focus modes
|
|
||||||
func configureForFocusModes() {
|
|
||||||
// Set up notification categories that work well with Focus modes
|
|
||||||
Task {
|
|
||||||
await configureNotificationSettings()
|
|
||||||
}
|
|
||||||
|
|
||||||
Design.debugLog("[settings] App configured for Focus mode compatibility")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Provide user guidance for Focus mode settings
|
|
||||||
func getFocusModeGuidance() -> String {
|
|
||||||
return """
|
|
||||||
For the best experience with TheNoiseClock:
|
|
||||||
|
|
||||||
1. Allow notifications in your Focus mode settings
|
|
||||||
2. Enable "Time Sensitive" notifications for alarms
|
|
||||||
3. Consider adding TheNoiseClock to your Focus mode allowlist
|
|
||||||
|
|
||||||
This ensures alarms will work even when Focus mode is active.
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,180 +0,0 @@
|
|||||||
//
|
|
||||||
// NotificationDelegate.swift
|
|
||||||
// TheNoiseClock
|
|
||||||
//
|
|
||||||
// Created by Matt Bruce on 9/8/25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UserNotifications
|
|
||||||
import Foundation
|
|
||||||
import Bedrock
|
|
||||||
|
|
||||||
/// Delegate to handle notification actions (snooze, stop, etc.)
|
|
||||||
class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|
||||||
|
|
||||||
// MARK: - Singleton
|
|
||||||
static let shared = NotificationDelegate()
|
|
||||||
|
|
||||||
// MARK: - Properties
|
|
||||||
private var alarmService: AlarmService?
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
|
||||||
private override init() {
|
|
||||||
super.init()
|
|
||||||
setupNotificationCenter()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Setup
|
|
||||||
private func setupNotificationCenter() {
|
|
||||||
UNUserNotificationCenter.current().delegate = self
|
|
||||||
Design.debugLog("[settings] Notification delegate configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the alarm service instance (called from AlarmViewModel)
|
|
||||||
func setAlarmService(_ service: AlarmService) {
|
|
||||||
self.alarmService = service
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - UNUserNotificationCenterDelegate
|
|
||||||
|
|
||||||
/// Handle notification actions when app is in foreground
|
|
||||||
func userNotificationCenter(
|
|
||||||
_ center: UNUserNotificationCenter,
|
|
||||||
didReceive response: UNNotificationResponse,
|
|
||||||
withCompletionHandler completionHandler: @escaping () -> Void
|
|
||||||
) {
|
|
||||||
let actionIdentifier = response.actionIdentifier
|
|
||||||
let notification = response.notification
|
|
||||||
let userInfo = notification.request.content.userInfo
|
|
||||||
|
|
||||||
Design.debugLog("[settings] Notification action received: \(actionIdentifier)")
|
|
||||||
|
|
||||||
switch actionIdentifier {
|
|
||||||
case "SNOOZE_ACTION":
|
|
||||||
handleSnoozeAction(userInfo: userInfo)
|
|
||||||
case "STOP_ACTION":
|
|
||||||
handleStopAction(userInfo: userInfo)
|
|
||||||
case UNNotificationDefaultActionIdentifier:
|
|
||||||
// User tapped the notification itself
|
|
||||||
handleNotificationTap(userInfo: userInfo)
|
|
||||||
default:
|
|
||||||
Design.debugLog("[settings] Unknown action: \(actionIdentifier)")
|
|
||||||
}
|
|
||||||
|
|
||||||
completionHandler()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle notifications when app is in foreground
|
|
||||||
func userNotificationCenter(
|
|
||||||
_ center: UNUserNotificationCenter,
|
|
||||||
willPresent notification: UNNotification,
|
|
||||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
|
||||||
) {
|
|
||||||
// Show notification even when app is in foreground
|
|
||||||
completionHandler([.banner, .sound, .badge])
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Action Handlers
|
|
||||||
|
|
||||||
private func handleSnoozeAction(userInfo: [AnyHashable: Any]) {
|
|
||||||
guard let alarmIdString = userInfo["alarmId"] as? String,
|
|
||||||
let alarmId = UUID(uuidString: alarmIdString),
|
|
||||||
let alarmService = self.alarmService,
|
|
||||||
let alarm = alarmService.getAlarm(id: alarmId) else {
|
|
||||||
Design.debugLog("[general] Could not find alarm for snooze action")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Design.debugLog("[settings] Snoozing alarm: \(alarm.label) for \(alarm.snoozeDuration) minutes")
|
|
||||||
|
|
||||||
// Calculate snooze time (current time + snooze duration)
|
|
||||||
let snoozeTime = Date().addingTimeInterval(TimeInterval(alarm.snoozeDuration * 60))
|
|
||||||
Design.debugLog("[settings] Snooze time: \(snoozeTime)")
|
|
||||||
Design.debugLog("[settings] Current time: \(Date())")
|
|
||||||
|
|
||||||
// Create a temporary alarm for the snooze
|
|
||||||
let snoozeAlarm = Alarm(
|
|
||||||
id: UUID(), // New ID for snooze alarm
|
|
||||||
time: snoozeTime,
|
|
||||||
isEnabled: true,
|
|
||||||
soundName: alarm.soundName,
|
|
||||||
label: "\(alarm.label) (Snoozed)",
|
|
||||||
notificationMessage: "Snoozed: \(alarm.notificationMessage)",
|
|
||||||
snoozeDuration: alarm.snoozeDuration,
|
|
||||||
isVibrationEnabled: alarm.isVibrationEnabled,
|
|
||||||
isLightFlashEnabled: alarm.isLightFlashEnabled,
|
|
||||||
volume: alarm.volume
|
|
||||||
)
|
|
||||||
|
|
||||||
// Schedule the snooze notification
|
|
||||||
Task {
|
|
||||||
await scheduleSnoozeNotification(snoozeAlarm, userInfo: userInfo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleStopAction(userInfo: [AnyHashable: Any]) {
|
|
||||||
guard let alarmIdString = userInfo["alarmId"] as? String,
|
|
||||||
let alarmId = UUID(uuidString: alarmIdString) else {
|
|
||||||
Design.debugLog("[general] Could not find alarm ID for stop action")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Design.debugLog("[settings] Stopping alarm: \(alarmId)")
|
|
||||||
|
|
||||||
// Cancel any pending notifications for this alarm
|
|
||||||
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [alarmIdString])
|
|
||||||
|
|
||||||
// If this was a snooze alarm, we don't want to disable the original alarm
|
|
||||||
// Just cancel the current notification
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleNotificationTap(userInfo: [AnyHashable: Any]) {
|
|
||||||
guard let alarmIdString = userInfo["alarmId"] as? String,
|
|
||||||
let alarmId = UUID(uuidString: alarmIdString) else {
|
|
||||||
Design.debugLog("[general] Could not find alarm ID for notification tap")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Design.debugLog("[settings] Notification tapped for alarm: \(alarmId)")
|
|
||||||
|
|
||||||
// For now, just log the tap. In the future, this could open the alarm details
|
|
||||||
// or perform some other action when the user taps the notification
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Methods
|
|
||||||
|
|
||||||
private func scheduleSnoozeNotification(_ snoozeAlarm: Alarm, userInfo: [AnyHashable: Any]) async {
|
|
||||||
let content = UNMutableNotificationContent()
|
|
||||||
content.title = snoozeAlarm.label
|
|
||||||
content.body = snoozeAlarm.notificationMessage
|
|
||||||
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: snoozeAlarm.soundName))
|
|
||||||
content.categoryIdentifier = "ALARM_CATEGORY"
|
|
||||||
content.userInfo = [
|
|
||||||
"alarmId": snoozeAlarm.id.uuidString,
|
|
||||||
"soundName": snoozeAlarm.soundName,
|
|
||||||
"isSnooze": true,
|
|
||||||
"originalAlarmId": userInfo["alarmId"] as? String ?? ""
|
|
||||||
]
|
|
||||||
|
|
||||||
// Create trigger for snooze time
|
|
||||||
let trigger = UNTimeIntervalNotificationTrigger(
|
|
||||||
timeInterval: snoozeAlarm.time.timeIntervalSinceNow,
|
|
||||||
repeats: false
|
|
||||||
)
|
|
||||||
|
|
||||||
// Create request
|
|
||||||
let request = UNNotificationRequest(
|
|
||||||
identifier: snoozeAlarm.id.uuidString,
|
|
||||||
content: content,
|
|
||||||
trigger: trigger
|
|
||||||
)
|
|
||||||
|
|
||||||
// Schedule notification
|
|
||||||
do {
|
|
||||||
try await UNUserNotificationCenter.current().add(request)
|
|
||||||
Design.debugLog("[settings] Snooze notification scheduled for \(snoozeAlarm.time)")
|
|
||||||
} catch {
|
|
||||||
Design.debugLog("[general] Error scheduling snooze notification: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
//
|
|
||||||
// NotificationService.swift
|
|
||||||
// TheNoiseClock
|
|
||||||
//
|
|
||||||
// Created by Matt Bruce on 9/7/25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import UserNotifications
|
|
||||||
import Observation
|
|
||||||
import Bedrock
|
|
||||||
|
|
||||||
/// Service for managing system notifications
|
|
||||||
@Observable
|
|
||||||
class NotificationService {
|
|
||||||
|
|
||||||
// MARK: - Properties
|
|
||||||
private(set) var isAuthorized = false
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
|
||||||
init() {
|
|
||||||
checkAuthorizationStatus()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public Interface
|
|
||||||
func requestPermissions() async -> Bool {
|
|
||||||
do {
|
|
||||||
let granted = try await UNUserNotificationCenter.current().requestAuthorization(
|
|
||||||
options: [.alert, .sound, .badge]
|
|
||||||
)
|
|
||||||
isAuthorized = granted
|
|
||||||
return granted
|
|
||||||
} catch {
|
|
||||||
Design.debugLog("[general] Error requesting notification permissions: \(error)")
|
|
||||||
isAuthorized = false
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkAuthorizationStatus() {
|
|
||||||
UNUserNotificationCenter.current().getNotificationSettings { settings in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.isAuthorized = settings.authorizationStatus == .authorized
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Schedule a single alarm notification
|
|
||||||
@discardableResult
|
|
||||||
func scheduleAlarmNotification(
|
|
||||||
id: String,
|
|
||||||
title: String,
|
|
||||||
body: String,
|
|
||||||
soundName: String,
|
|
||||||
date: Date
|
|
||||||
) async -> Bool {
|
|
||||||
guard isAuthorized else {
|
|
||||||
Design.debugLog("[settings] Notifications not authorized")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = NotificationUtils.createAlarmContent(
|
|
||||||
title: title,
|
|
||||||
body: body,
|
|
||||||
soundName: soundName
|
|
||||||
)
|
|
||||||
let trigger = NotificationUtils.createCalendarTrigger(for: date)
|
|
||||||
|
|
||||||
return await NotificationUtils.scheduleNotification(
|
|
||||||
identifier: id,
|
|
||||||
content: content,
|
|
||||||
trigger: trigger
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Cancel a single notification
|
|
||||||
func cancelNotification(id: String) {
|
|
||||||
NotificationUtils.removeNotification(identifier: id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cancel all notifications
|
|
||||||
func cancelAllNotifications() {
|
|
||||||
NotificationUtils.removeAllNotifications()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -5,16 +5,25 @@
|
|||||||
// Created by Matt Bruce on 9/7/25.
|
// Created by Matt Bruce on 9/7/25.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import AlarmKit
|
||||||
|
import Bedrock
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
|
|
||||||
/// ViewModel for alarm management
|
/// ViewModel for alarm management using AlarmKit (iOS 26+).
|
||||||
|
/// AlarmKit provides alarms that cut through Focus modes and silent mode,
|
||||||
|
/// with built-in Live Activity countdown and system alarm UI.
|
||||||
@Observable
|
@Observable
|
||||||
class AlarmViewModel {
|
class AlarmViewModel {
|
||||||
|
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
private let alarmService: AlarmService
|
private let alarmService: AlarmService
|
||||||
private let notificationService: NotificationService
|
private let alarmKitService = AlarmKitService.shared
|
||||||
|
|
||||||
|
/// Whether AlarmKit is authorized
|
||||||
|
var isAlarmKitAuthorized: Bool {
|
||||||
|
alarmKitService.authorizationState == .authorized
|
||||||
|
}
|
||||||
|
|
||||||
var alarms: [Alarm] {
|
var alarms: [Alarm] {
|
||||||
alarmService.alarms
|
alarmService.alarms
|
||||||
@ -23,53 +32,54 @@ 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: - Public Interface
|
// MARK: - Authorization
|
||||||
|
|
||||||
|
/// Request AlarmKit authorization. Should be called during onboarding.
|
||||||
|
func requestAlarmKitAuthorization() async -> Bool {
|
||||||
|
return await alarmKitService.requestAuthorization()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Alarm CRUD Operations
|
||||||
|
|
||||||
func addAlarm(_ alarm: Alarm) async {
|
func addAlarm(_ alarm: Alarm) async {
|
||||||
alarmService.addAlarm(alarm)
|
alarmService.addAlarm(alarm)
|
||||||
|
|
||||||
// Schedule notification if alarm is enabled
|
// Schedule with AlarmKit if alarm is enabled
|
||||||
if alarm.isEnabled {
|
if alarm.isEnabled {
|
||||||
await notificationService.scheduleAlarmNotification(
|
Design.debugLog("[alarms] Scheduling AlarmKit alarm for \(alarm.label)")
|
||||||
id: alarm.id.uuidString,
|
do {
|
||||||
title: alarm.label,
|
try await alarmKitService.scheduleAlarm(alarm)
|
||||||
body: alarm.notificationMessage,
|
} catch {
|
||||||
soundName: alarm.soundName,
|
Design.debugLog("[alarms] AlarmKit scheduling failed: \(error)")
|
||||||
date: alarm.time
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateAlarm(_ alarm: Alarm) async {
|
func updateAlarm(_ alarm: Alarm) async {
|
||||||
alarmService.updateAlarm(alarm)
|
alarmService.updateAlarm(alarm)
|
||||||
|
|
||||||
// Reschedule notification
|
// Cancel existing and reschedule if enabled
|
||||||
|
alarmKitService.cancelAlarm(id: alarm.id)
|
||||||
|
|
||||||
if alarm.isEnabled {
|
if alarm.isEnabled {
|
||||||
await notificationService.scheduleAlarmNotification(
|
Design.debugLog("[alarms] Rescheduling AlarmKit alarm for \(alarm.label)")
|
||||||
id: alarm.id.uuidString,
|
do {
|
||||||
title: alarm.label,
|
try await alarmKitService.scheduleAlarm(alarm)
|
||||||
body: alarm.notificationMessage,
|
} catch {
|
||||||
soundName: alarm.soundName,
|
Design.debugLog("[alarms] AlarmKit rescheduling failed: \(error)")
|
||||||
date: alarm.time
|
}
|
||||||
)
|
|
||||||
} else {
|
|
||||||
notificationService.cancelNotification(id: alarm.id.uuidString)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteAlarm(id: UUID) async {
|
func deleteAlarm(id: UUID) async {
|
||||||
// Cancel notification first
|
// Cancel AlarmKit alarm first
|
||||||
notificationService.cancelNotification(id: id.uuidString)
|
alarmKitService.cancelAlarm(id: id)
|
||||||
|
|
||||||
// Then delete from storage
|
// Then delete from storage
|
||||||
alarmService.deleteAlarm(id: id)
|
alarmService.deleteAlarm(id: id)
|
||||||
@ -81,17 +91,16 @@ class AlarmViewModel {
|
|||||||
alarm.isEnabled.toggle()
|
alarm.isEnabled.toggle()
|
||||||
alarmService.updateAlarm(alarm)
|
alarmService.updateAlarm(alarm)
|
||||||
|
|
||||||
// Schedule or cancel notification based on new state
|
// Schedule or cancel based on new state
|
||||||
if alarm.isEnabled {
|
if alarm.isEnabled {
|
||||||
await notificationService.scheduleAlarmNotification(
|
Design.debugLog("[alarms] Enabling AlarmKit alarm \(alarm.label)")
|
||||||
id: alarm.id.uuidString,
|
do {
|
||||||
title: alarm.label,
|
try await alarmKitService.scheduleAlarm(alarm)
|
||||||
body: alarm.notificationMessage,
|
} catch {
|
||||||
soundName: alarm.soundName,
|
Design.debugLog("[alarms] AlarmKit scheduling failed: \(error)")
|
||||||
date: alarm.time
|
}
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
notificationService.cancelNotification(id: id.uuidString)
|
alarmKitService.cancelAlarm(id: id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -123,7 +132,26 @@ class AlarmViewModel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestNotificationPermissions() async -> Bool {
|
// MARK: - App Lifecycle
|
||||||
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,6 +7,7 @@
|
|||||||
|
|
||||||
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 {
|
||||||
@ -14,6 +15,7 @@ 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"
|
||||||
@ -33,6 +35,15 @@ 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 {
|
||||||
@ -127,4 +138,11 @@ 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,31 +7,48 @@
|
|||||||
|
|
||||||
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
|
||||||
@State private var viewModel = AlarmViewModel()
|
@Bindable 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 {
|
||||||
EmptyAlarmsView {
|
VStack(spacing: Design.Spacing.large) {
|
||||||
showAddAlarm = true
|
if !isKeepAwakeEnabled {
|
||||||
}
|
AlarmLimitationsBanner()
|
||||||
.contentShape(Rectangle())
|
}
|
||||||
.onTapGesture {
|
|
||||||
showAddAlarm = true
|
EmptyAlarmsView {
|
||||||
|
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,
|
||||||
@ -47,6 +64,7 @@ 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)
|
||||||
}
|
}
|
||||||
@ -65,7 +83,8 @@ struct AlarmView: View {
|
|||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
Task {
|
Task {
|
||||||
await viewModel.requestNotificationPermissions()
|
// Request AlarmKit authorization when the alarms tab appears
|
||||||
|
await viewModel.requestAlarmKitAuthorization()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showAddAlarm) {
|
.sheet(isPresented: $showAddAlarm) {
|
||||||
@ -91,11 +110,18 @@ struct AlarmView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isKeepAwakeEnabled: Bool {
|
||||||
|
guard let decoded = try? JSONDecoder().decode(ClockStyle.self, from: clockStyleData) else {
|
||||||
|
return ClockStyle().keepAwake
|
||||||
|
}
|
||||||
|
return decoded.keepAwake
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Preview
|
// MARK: - Preview
|
||||||
#Preview {
|
#Preview {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
AlarmView()
|
AlarmView(viewModel: AlarmViewModel())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,58 @@
|
|||||||
|
//
|
||||||
|
// AlarmLimitationsBanner.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 2/2/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Banner explaining background alarm limitations and mitigation.
|
||||||
|
struct AlarmLimitationsBanner: View {
|
||||||
|
@AppStorage(ClockStyle.appStorageKey) private var clockStyleData: Data = Data()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if isKeepAwakeEnabled {
|
||||||
|
EmptyView()
|
||||||
|
} else {
|
||||||
|
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||||
|
HStack(spacing: Design.Spacing.xSmall) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(AppStatus.warning)
|
||||||
|
Text("Alarm reliability")
|
||||||
|
.typography(.body)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("iOS only allows notification sounds when the app is backgrounded. For a full alarm sound and screen, keep TheNoiseClock open in the foreground.")
|
||||||
|
.typography(.caption)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
|
||||||
|
Text("Tip: Use the Keep Awake prompt to keep the app on-screen while alarms are active.")
|
||||||
|
.typography(.caption)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.medium)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(AppSurface.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isKeepAwakeEnabled: Bool {
|
||||||
|
guard let decoded = try? JSONDecoder().decode(ClockStyle.self, from: clockStyleData) else {
|
||||||
|
return ClockStyle().keepAwake
|
||||||
|
}
|
||||||
|
return decoded.keepAwake
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
AlarmLimitationsBanner()
|
||||||
|
.padding()
|
||||||
|
.background(AppSurface.primary)
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Bedrock
|
import Bedrock
|
||||||
|
import Foundation
|
||||||
|
|
||||||
/// Component for displaying individual alarm row
|
/// Component for displaying individual alarm row
|
||||||
struct AlarmRowView: View {
|
struct AlarmRowView: View {
|
||||||
@ -15,6 +16,7 @@ struct AlarmRowView: View {
|
|||||||
let alarm: Alarm
|
let alarm: Alarm
|
||||||
let onToggle: () -> Void
|
let onToggle: () -> Void
|
||||||
let onEdit: () -> Void
|
let onEdit: () -> Void
|
||||||
|
@AppStorage(ClockStyle.appStorageKey) private var clockStyleData: Data = Data()
|
||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -31,6 +33,17 @@ struct AlarmRowView: View {
|
|||||||
Text("• \(AlarmSoundService.shared.getSoundDisplayName(alarm.soundName))")
|
Text("• \(AlarmSoundService.shared.getSoundDisplayName(alarm.soundName))")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(AppTextColors.secondary)
|
.foregroundColor(AppTextColors.secondary)
|
||||||
|
|
||||||
|
if alarm.isEnabled && !isKeepAwakeEnabled {
|
||||||
|
HStack(spacing: Design.Spacing.xSmall) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(AppStatus.warning)
|
||||||
|
Text("Foreground only for full alarm sound")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
@ -47,6 +60,13 @@ struct AlarmRowView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isKeepAwakeEnabled: Bool {
|
||||||
|
guard let decoded = try? JSONDecoder().decode(ClockStyle.self, from: clockStyleData) else {
|
||||||
|
return ClockStyle().keepAwake
|
||||||
|
}
|
||||||
|
return decoded.keepAwake
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Preview
|
// MARK: - Preview
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
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 {
|
||||||
@ -15,6 +16,7 @@ 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
|
||||||
@ -51,6 +53,15 @@ 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 {
|
||||||
@ -147,6 +158,13 @@ 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 = true
|
var use24Hour: Bool = false
|
||||||
var showSeconds: Bool = false
|
var showSeconds: Bool = false
|
||||||
var showAmPm: Bool = true
|
var showAmPm: Bool = false
|
||||||
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,6 +53,7 @@ class ClockStyle: Codable, Equatable {
|
|||||||
// MARK: - Display Settings
|
// MARK: - Display Settings
|
||||||
var keepAwake: Bool = false // Keep screen awake in display mode
|
var keepAwake: Bool = false // Keep screen awake in display mode
|
||||||
var respectFocusModes: Bool = true // Respect Focus mode settings for audio
|
var respectFocusModes: Bool = true // Respect Focus mode settings for audio
|
||||||
|
var liveActivitiesEnabled: Bool = false // Show active alarm in Dynamic Island/Lock Screen
|
||||||
|
|
||||||
// MARK: - Cached Colors
|
// MARK: - Cached Colors
|
||||||
private var _cachedDigitColor: Color?
|
private var _cachedDigitColor: Color?
|
||||||
@ -87,6 +88,7 @@ class ClockStyle: Codable, Equatable {
|
|||||||
case overlayOpacity
|
case overlayOpacity
|
||||||
case keepAwake
|
case keepAwake
|
||||||
case respectFocusModes
|
case respectFocusModes
|
||||||
|
case liveActivitiesEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
@ -138,6 +140,7 @@ class ClockStyle: Codable, Equatable {
|
|||||||
self.overlayOpacity = try container.decodeIfPresent(Double.self, forKey: .overlayOpacity) ?? self.overlayOpacity
|
self.overlayOpacity = try container.decodeIfPresent(Double.self, forKey: .overlayOpacity) ?? self.overlayOpacity
|
||||||
self.keepAwake = try container.decodeIfPresent(Bool.self, forKey: .keepAwake) ?? self.keepAwake
|
self.keepAwake = try container.decodeIfPresent(Bool.self, forKey: .keepAwake) ?? self.keepAwake
|
||||||
self.respectFocusModes = try container.decodeIfPresent(Bool.self, forKey: .respectFocusModes) ?? self.respectFocusModes
|
self.respectFocusModes = try container.decodeIfPresent(Bool.self, forKey: .respectFocusModes) ?? self.respectFocusModes
|
||||||
|
self.liveActivitiesEnabled = try container.decodeIfPresent(Bool.self, forKey: .liveActivitiesEnabled) ?? self.liveActivitiesEnabled
|
||||||
|
|
||||||
clearColorCache()
|
clearColorCache()
|
||||||
}
|
}
|
||||||
@ -171,6 +174,7 @@ class ClockStyle: Codable, Equatable {
|
|||||||
try container.encode(overlayOpacity, forKey: .overlayOpacity)
|
try container.encode(overlayOpacity, forKey: .overlayOpacity)
|
||||||
try container.encode(keepAwake, forKey: .keepAwake)
|
try container.encode(keepAwake, forKey: .keepAwake)
|
||||||
try container.encode(respectFocusModes, forKey: .respectFocusModes)
|
try container.encode(respectFocusModes, forKey: .respectFocusModes)
|
||||||
|
try container.encode(liveActivitiesEnabled, forKey: .liveActivitiesEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
// MARK: - Computed Properties
|
||||||
@ -341,19 +345,19 @@ class ClockStyle: Codable, Equatable {
|
|||||||
/// Get the effective brightness considering color theme and night mode
|
/// Get the effective brightness considering color theme and night mode
|
||||||
var effectiveBrightness: Double {
|
var effectiveBrightness: Double {
|
||||||
if !autoBrightness {
|
if !autoBrightness {
|
||||||
Design.debugLog("[brightness] effectiveBrightness: Auto-brightness disabled, returning 1.0")
|
//Design.debugLog("[brightness] effectiveBrightness: Auto-brightness disabled, returning 1.0")
|
||||||
return 1.0 // Full brightness when auto-brightness is disabled
|
return 1.0 // Full brightness when auto-brightness is disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
if isNightModeActive {
|
if isNightModeActive {
|
||||||
Design.debugLog("[brightness] effectiveBrightness: Night mode active, returning 0.3")
|
//Design.debugLog("[brightness] effectiveBrightness: Night mode active, returning 0.3")
|
||||||
// Dim the display to 30% brightness in night mode
|
// Dim the display to 30% brightness in night mode
|
||||||
return 0.3
|
return 0.3
|
||||||
}
|
}
|
||||||
|
|
||||||
// Color-aware brightness adaptation
|
// Color-aware brightness adaptation
|
||||||
let colorAwareBrightness = getColorAwareBrightness()
|
let colorAwareBrightness = getColorAwareBrightness()
|
||||||
Design.debugLog("[brightness] effectiveBrightness: Color-aware brightness = \(String(format: "%.2f", colorAwareBrightness))")
|
//Design.debugLog("[brightness] effectiveBrightness: Color-aware brightness = \(String(format: "%.2f", colorAwareBrightness))")
|
||||||
return colorAwareBrightness
|
return colorAwareBrightness
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -463,7 +467,8 @@ class ClockStyle: Codable, Equatable {
|
|||||||
lhs.clockOpacity == rhs.clockOpacity &&
|
lhs.clockOpacity == rhs.clockOpacity &&
|
||||||
lhs.overlayOpacity == rhs.overlayOpacity &&
|
lhs.overlayOpacity == rhs.overlayOpacity &&
|
||||||
lhs.keepAwake == rhs.keepAwake &&
|
lhs.keepAwake == rhs.keepAwake &&
|
||||||
lhs.respectFocusModes == rhs.respectFocusModes
|
lhs.respectFocusModes == rhs.respectFocusModes &&
|
||||||
|
lhs.liveActivitiesEnabled == rhs.liveActivitiesEnabled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -60,16 +60,16 @@ class AmbientLightService {
|
|||||||
let clampedBrightness = max(0.0, min(1.0, brightness))
|
let clampedBrightness = max(0.0, min(1.0, brightness))
|
||||||
let previousBrightness = UIScreen.main.brightness
|
let previousBrightness = UIScreen.main.brightness
|
||||||
|
|
||||||
Design.debugLog("[ambient] AmbientLightService.setBrightness:")
|
// Design.debugLog("[ambient] AmbientLightService.setBrightness:")
|
||||||
Design.debugLog("[ambient] - Requested brightness: \(String(format: "%.2f", brightness))")
|
// Design.debugLog("[ambient] - Requested brightness: \(String(format: "%.2f", brightness))")
|
||||||
Design.debugLog("[ambient] - Clamped brightness: \(String(format: "%.2f", clampedBrightness))")
|
// Design.debugLog("[ambient] - Clamped brightness: \(String(format: "%.2f", clampedBrightness))")
|
||||||
Design.debugLog("[ambient] - Previous screen brightness: \(String(format: "%.2f", previousBrightness))")
|
// Design.debugLog("[ambient] - Previous screen brightness: \(String(format: "%.2f", previousBrightness))")
|
||||||
|
|
||||||
UIScreen.main.brightness = clampedBrightness
|
UIScreen.main.brightness = clampedBrightness
|
||||||
currentBrightness = clampedBrightness
|
currentBrightness = clampedBrightness
|
||||||
|
|
||||||
Design.debugLog("[ambient] - New screen brightness: \(String(format: "%.2f", UIScreen.main.brightness))")
|
// Design.debugLog("[ambient] - New screen brightness: \(String(format: "%.2f", UIScreen.main.brightness))")
|
||||||
Design.debugLog("[ambient] - Service currentBrightness: \(String(format: "%.2f", currentBrightness))")
|
// Design.debugLog("[ambient] - Service currentBrightness: \(String(format: "%.2f", currentBrightness))")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current screen brightness
|
/// Get current screen brightness
|
||||||
@ -90,7 +90,7 @@ class AmbientLightService {
|
|||||||
let previousBrightness = currentBrightness
|
let previousBrightness = currentBrightness
|
||||||
currentBrightness = newBrightness
|
currentBrightness = newBrightness
|
||||||
|
|
||||||
Design.debugLog("[ambient] AmbientLightService: Brightness changed from \(String(format: "%.2f", previousBrightness)) to \(String(format: "%.2f", newBrightness))")
|
//Design.debugLog("[ambient] AmbientLightService: Brightness changed from \(String(format: "%.2f", previousBrightness)) to \(String(format: "%.2f", newBrightness))")
|
||||||
|
|
||||||
// Notify that brightness changed
|
// Notify that brightness changed
|
||||||
onBrightnessChange?()
|
onBrightnessChange?()
|
||||||
|
|||||||
@ -32,6 +32,7 @@ class ClockViewModel {
|
|||||||
private var minuteTimer: Timer.TimerPublisher?
|
private var minuteTimer: Timer.TimerPublisher?
|
||||||
private var secondCancellable: AnyCancellable?
|
private var secondCancellable: AnyCancellable?
|
||||||
private var minuteCancellable: AnyCancellable?
|
private var minuteCancellable: AnyCancellable?
|
||||||
|
private var styleObserver: NSObjectProtocol?
|
||||||
|
|
||||||
// Persistence
|
// Persistence
|
||||||
private var persistenceWorkItem: DispatchWorkItem?
|
private var persistenceWorkItem: DispatchWorkItem?
|
||||||
@ -52,29 +53,45 @@ 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 { return }
|
guard isDisplayMode != enabled else {
|
||||||
|
Design.debugLog("[ClockViewModel] setDisplayMode(\(enabled)) - already at this value, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Design.debugLog("[ClockViewModel] setDisplayMode: \(isDisplayMode) -> \(enabled)")
|
||||||
withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) {
|
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) {
|
||||||
@ -108,6 +125,7 @@ class ClockViewModel {
|
|||||||
style.digitAnimationStyle = newStyle.digitAnimationStyle
|
style.digitAnimationStyle = newStyle.digitAnimationStyle
|
||||||
style.dateFormat = newStyle.dateFormat
|
style.dateFormat = newStyle.dateFormat
|
||||||
style.respectFocusModes = newStyle.respectFocusModes
|
style.respectFocusModes = newStyle.respectFocusModes
|
||||||
|
style.liveActivitiesEnabled = newStyle.liveActivitiesEnabled
|
||||||
|
|
||||||
|
|
||||||
saveStyle()
|
saveStyle()
|
||||||
@ -116,6 +134,12 @@ 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) {
|
||||||
@ -126,6 +150,19 @@ class ClockViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func observeStyleUpdates() {
|
||||||
|
styleObserver = NotificationCenter.default.addObserver(
|
||||||
|
forName: .clockStyleDidUpdate,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.loadStyle()
|
||||||
|
self?.updateTimersIfNeeded()
|
||||||
|
self?.updateWakeLockState()
|
||||||
|
self?.updateBrightness()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func saveStyle() {
|
func saveStyle() {
|
||||||
persistenceWorkItem?.cancel()
|
persistenceWorkItem?.cancel()
|
||||||
|
|
||||||
@ -193,6 +230,11 @@ class ClockViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func requestKeepAwakePromptIfNeeded() {
|
||||||
|
guard !style.keepAwake else { return }
|
||||||
|
NotificationCenter.default.post(name: .keepAwakePromptRequested, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
/// Update wake lock state based on current settings
|
/// 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
|
||||||
@ -209,7 +251,7 @@ class ClockViewModel {
|
|||||||
|
|
||||||
// Set up callback to respond to brightness changes
|
// Set up callback to respond to brightness changes
|
||||||
ambientLightService.onBrightnessChange = { [weak self] in
|
ambientLightService.onBrightnessChange = { [weak self] in
|
||||||
Design.debugLog("[brightness] ClockViewModel: Received brightness change notification")
|
//Design.debugLog("[brightness] ClockViewModel: Received brightness change notification")
|
||||||
self?.updateBrightness()
|
self?.updateBrightness()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -226,21 +268,21 @@ class ClockViewModel {
|
|||||||
let currentScreenBrightness = UIScreen.main.brightness
|
let currentScreenBrightness = UIScreen.main.brightness
|
||||||
let isNightMode = style.isNightModeActive
|
let isNightMode = style.isNightModeActive
|
||||||
|
|
||||||
Design.debugLog("[brightness] Auto Brightness Debug:")
|
// Design.debugLog("[brightness] Auto Brightness Debug:")
|
||||||
Design.debugLog("[brightness] - Auto brightness enabled: \(style.autoBrightness)")
|
// Design.debugLog("[brightness] - Auto brightness enabled: \(style.autoBrightness)")
|
||||||
Design.debugLog("[brightness] - Current screen brightness: \(String(format: "%.2f", currentScreenBrightness))")
|
// Design.debugLog("[brightness] - Current screen brightness: \(String(format: "%.2f", currentScreenBrightness))")
|
||||||
Design.debugLog("[brightness] - Target brightness: \(String(format: "%.2f", targetBrightness))")
|
// Design.debugLog("[brightness] - Target brightness: \(String(format: "%.2f", targetBrightness))")
|
||||||
Design.debugLog("[brightness] - Night mode active: \(isNightMode)")
|
// Design.debugLog("[brightness] - Night mode active: \(isNightMode)")
|
||||||
Design.debugLog("[brightness] - Color theme: \(style.selectedColorTheme)")
|
// Design.debugLog("[brightness] - Color theme: \(style.selectedColorTheme)")
|
||||||
Design.debugLog("[brightness] - Ambient light threshold: \(String(format: "%.2f", style.ambientLightThreshold))")
|
// Design.debugLog("[brightness] - Ambient light threshold: \(String(format: "%.2f", style.ambientLightThreshold))")
|
||||||
|
|
||||||
ambientLightService.setBrightness(targetBrightness)
|
ambientLightService.setBrightness(targetBrightness)
|
||||||
|
|
||||||
Design.debugLog("[brightness] - Brightness set to: \(String(format: "%.2f", targetBrightness))")
|
// Design.debugLog("[brightness] - Brightness set to: \(String(format: "%.2f", targetBrightness))")
|
||||||
Design.debugLog("[brightness] - Actual screen brightness now: \(String(format: "%.2f", UIScreen.main.brightness))")
|
// Design.debugLog("[brightness] - Actual screen brightness now: \(String(format: "%.2f", UIScreen.main.brightness))")
|
||||||
Design.debugLog("[brightness] ---")
|
// Design.debugLog("[brightness] ---")
|
||||||
} else {
|
// } else {
|
||||||
Design.debugLog("[brightness] Auto Brightness: DISABLED")
|
// Design.debugLog("[brightness] Auto Brightness: DISABLED")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,15 +14,20 @@ 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(style: ClockStyle, onCommit: @escaping (ClockStyle) -> Void) {
|
init(
|
||||||
|
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
|
||||||
@ -36,34 +41,17 @@ struct ClockSettingsView: View {
|
|||||||
backgroundColor: $backgroundColor
|
backgroundColor: $backgroundColor
|
||||||
)
|
)
|
||||||
|
|
||||||
|
FontSection(style: $style)
|
||||||
|
|
||||||
|
AdvancedAppearanceSection(style: $style)
|
||||||
|
|
||||||
BasicDisplaySection(style: $style)
|
BasicDisplaySection(style: $style)
|
||||||
|
|
||||||
if showAdvancedSettings {
|
AdvancedDisplaySection(style: $style)
|
||||||
AdvancedAppearanceSection(style: $style)
|
|
||||||
|
|
||||||
FontSection(style: $style)
|
NightModeSection(style: $style)
|
||||||
|
|
||||||
NightModeSection(style: $style)
|
OverlaySection(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(
|
||||||
@ -92,6 +80,32 @@ 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,8 +16,18 @@ 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 {
|
||||||
@ -65,22 +75,24 @@ 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)
|
||||||
.overlay {
|
// Tab bar visibility controlled here but decision includes isOnClockTab from parent
|
||||||
// Tab bar management overlay
|
// This prevents race conditions: when tab changes, isOnClockTab becomes false immediately
|
||||||
ClockTabBarManager(isDisplayMode: viewModel.isDisplayMode)
|
.toolbar(shouldHideTabBar ? .hidden : .visible, for: .tabBar)
|
||||||
|
.onChange(of: shouldHideTabBar) { oldValue, newValue in
|
||||||
|
Design.debugLog("[ClockView] shouldHideTabBar changed: \(oldValue) -> \(newValue) (isOnClockTab=\(isOnClockTab), isDisplayMode=\(viewModel.isDisplayMode))")
|
||||||
}
|
}
|
||||||
.simultaneousGesture(
|
.simultaneousGesture(
|
||||||
DragGesture(minimumDistance: 0)
|
DragGesture(minimumDistance: 0)
|
||||||
@ -94,9 +106,13 @@ 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
|
||||||
}
|
}
|
||||||
@ -121,7 +137,16 @@ struct ClockView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func enterDisplayModeFromIdle() {
|
private func enterDisplayModeFromIdle() {
|
||||||
guard !viewModel.isDisplayMode else { return }
|
// Guard against entering display mode if we're no longer on the clock tab
|
||||||
|
guard isViewActive else {
|
||||||
|
Design.debugLog("[ClockView] enterDisplayModeFromIdle - BLOCKED: view is not active (user switched tabs)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard !viewModel.isDisplayMode else {
|
||||||
|
Design.debugLog("[ClockView] enterDisplayModeFromIdle - BLOCKED: already in display mode")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Design.debugLog("[ClockView] enterDisplayModeFromIdle - entering display mode")
|
||||||
viewModel.toggleDisplayMode()
|
viewModel.toggleDisplayMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -181,7 +206,7 @@ struct ClockView: View {
|
|||||||
// MARK: - Preview
|
// MARK: - Preview
|
||||||
#Preview {
|
#Preview {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ClockView(viewModel: ClockViewModel())
|
ClockView(viewModel: ClockViewModel(), isOnClockTab: true)
|
||||||
}
|
}
|
||||||
.frame(width: 400, height: 600)
|
.frame(width: 400, height: 600)
|
||||||
.background(Color.black)
|
.background(Color.black)
|
||||||
|
|||||||
@ -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.overlayOpacity,
|
opacity: style.clockOpacity,
|
||||||
dateFormat: style.dateFormat
|
dateFormat: style.dateFormat
|
||||||
)
|
)
|
||||||
.padding(.top, Design.Spacing.small)
|
.padding(.top, Design.Spacing.small)
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
//
|
|
||||||
// ClockTabBarManager.swift
|
|
||||||
// TheNoiseClock
|
|
||||||
//
|
|
||||||
// Created by Matt Bruce on 9/8/25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
/// Component that manages tab bar visibility for display mode
|
|
||||||
/// Uses SwiftUI's native toolbar hiding for proper iPad compatibility
|
|
||||||
struct ClockTabBarManager: View {
|
|
||||||
|
|
||||||
// MARK: - Properties
|
|
||||||
let isDisplayMode: Bool
|
|
||||||
|
|
||||||
// MARK: - Body
|
|
||||||
var body: some View {
|
|
||||||
EmptyView()
|
|
||||||
.toolbar(isDisplayMode ? .hidden : .automatic, for: .tabBar)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Preview
|
|
||||||
#Preview {
|
|
||||||
ClockTabBarManager(isDisplayMode: false)
|
|
||||||
}
|
|
||||||
@ -27,6 +27,13 @@ struct AdvancedDisplaySection: View {
|
|||||||
accentColor: AppAccent.primary
|
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")
|
||||||
@ -40,7 +47,7 @@ struct AdvancedDisplaySection: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text("Advanced display and system integration settings.")
|
Text("Advanced display and system integration settings. Keep Awake helps alarms stay active while the app remains open.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(AppTextColors.tertiary)
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
|
|
||||||
|
|||||||
@ -22,16 +22,6 @@ 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,11 +138,13 @@ struct TimeDisplayView: View {
|
|||||||
design: fontDesign,
|
design: fontDesign,
|
||||||
fontSize: fontSize
|
fontSize: fontSize
|
||||||
)
|
)
|
||||||
let segmentWidth = fixedDigitWidth * 2 // Each segment has 2 digits
|
let segmentWidth = fixedDigitWidth * 2 // Minutes/seconds always 2 digits
|
||||||
|
// Hour width is dynamic based on actual digit count (1 or 2 digits)
|
||||||
|
let hourSegmentWidth = fixedDigitWidth * CGFloat(hour.count)
|
||||||
|
|
||||||
HStack(alignment: .center, spacing: 0) {
|
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: segmentWidth)
|
.frame(width: hourSegmentWidth)
|
||||||
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)
|
||||||
@ -263,9 +265,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",
|
||||||
@ -308,17 +310,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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,62 @@
|
|||||||
|
//
|
||||||
|
// OnboardingPageView.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Reusable onboarding page component with icon, title, and description.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
/// A single onboarding page with icon, title, and description
|
||||||
|
struct OnboardingPageView: View {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
let icon: String
|
||||||
|
let iconColor: Color
|
||||||
|
let title: String
|
||||||
|
let description: String
|
||||||
|
|
||||||
|
// MARK: - Body
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Design.Spacing.xxLarge) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Icon
|
||||||
|
SymbolIcon(icon, size: .hero, color: iconColor, weight: .medium)
|
||||||
|
.padding(.bottom, Design.Spacing.medium)
|
||||||
|
|
||||||
|
// Title
|
||||||
|
Text(title)
|
||||||
|
.typography(.heroBold)
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
// Description
|
||||||
|
Text(description)
|
||||||
|
.typography(.body)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
OnboardingPageView(
|
||||||
|
icon: "clock.fill",
|
||||||
|
iconColor: AppAccent.primary,
|
||||||
|
title: "Beautiful Clock Display",
|
||||||
|
description: "A stunning full-screen digital clock with customizable fonts, colors, and animations."
|
||||||
|
)
|
||||||
|
.background(AppSurface.primary)
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
481
TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift
Normal file
481
TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift
Normal file
@ -0,0 +1,481 @@
|
|||||||
|
//
|
||||||
|
// OnboardingView.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Streamlined onboarding flow optimized for time-to-value.
|
||||||
|
// Shows real clock immediately, requests AlarmKit permission,
|
||||||
|
// and gets users to their "aha moment" fast.
|
||||||
|
//
|
||||||
|
// Updated for AlarmKit (iOS 26+) - alarms now cut through
|
||||||
|
// Focus modes and silent mode automatically.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Streamlined onboarding optimized for activation
|
||||||
|
struct OnboardingView: View {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
let onComplete: () -> Void
|
||||||
|
|
||||||
|
@State private var currentPage = 0
|
||||||
|
@State private var alarmKitPermissionGranted = false
|
||||||
|
@State private var keepAwakeEnabled = false
|
||||||
|
@State private var showCelebration = false
|
||||||
|
|
||||||
|
private let totalPages = 3
|
||||||
|
|
||||||
|
// MARK: - Body
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
// Background
|
||||||
|
AppSurface.primary
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Page content
|
||||||
|
TabView(selection: $currentPage) {
|
||||||
|
welcomeWithClockPage
|
||||||
|
.tag(0)
|
||||||
|
|
||||||
|
permissionsPage
|
||||||
|
.tag(1)
|
||||||
|
|
||||||
|
getStartedPage
|
||||||
|
.tag(2)
|
||||||
|
}
|
||||||
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||||
|
.animation(.easeInOut(duration: 0.3), value: currentPage)
|
||||||
|
|
||||||
|
// Bottom controls
|
||||||
|
bottomControls
|
||||||
|
.padding(.horizontal, Design.Spacing.xLarge)
|
||||||
|
.padding(.bottom, Design.Spacing.xxLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Celebration overlay
|
||||||
|
if showCelebration {
|
||||||
|
celebrationOverlay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Page 1: Welcome with Live Clock Preview
|
||||||
|
|
||||||
|
private var welcomeWithClockPage: some View {
|
||||||
|
VStack(spacing: Design.Spacing.large) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Live clock preview - immediate value using TimelineView
|
||||||
|
liveClockPreview
|
||||||
|
.padding(.bottom, Design.Spacing.medium)
|
||||||
|
|
||||||
|
Text("The Noise Clock")
|
||||||
|
.typography(.heroBold)
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
|
||||||
|
Text("Your beautiful bedside companion")
|
||||||
|
.typography(.title3)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
|
||||||
|
// Quick feature highlights - benefit focused
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
featureHighlight(
|
||||||
|
icon: "moon.stars.fill",
|
||||||
|
text: "Fall asleep to soothing sounds"
|
||||||
|
)
|
||||||
|
featureHighlight(
|
||||||
|
icon: "alarm.fill",
|
||||||
|
text: "Wake up gently, on your terms"
|
||||||
|
)
|
||||||
|
featureHighlight(
|
||||||
|
icon: "hand.tap.fill",
|
||||||
|
text: "Long-press for immersive mode"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(.top, Design.Spacing.large)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var liveClockPreview: some View {
|
||||||
|
TimelineView(.periodic(from: .now, by: 1.0)) { context in
|
||||||
|
OnboardingClockText(date: context.date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func featureHighlight(icon: String, text: String) -> some View {
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundStyle(AppAccent.primary)
|
||||||
|
.frame(width: 28)
|
||||||
|
|
||||||
|
Text(text)
|
||||||
|
.typography(.body)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Page 2: AlarmKit Permissions
|
||||||
|
|
||||||
|
private var permissionsPage: some View {
|
||||||
|
VStack(spacing: Design.Spacing.xxLarge) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Alarm icon with animated waves
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(AppAccent.primary.opacity(0.15))
|
||||||
|
.frame(width: 120, height: 120)
|
||||||
|
|
||||||
|
Image(systemName: "alarm.waves.left.and.right.fill")
|
||||||
|
.font(.system(size: 50, weight: .medium))
|
||||||
|
.foregroundStyle(AppAccent.primary)
|
||||||
|
.symbolEffect(.pulse, options: .repeating)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Alarms that actually work")
|
||||||
|
.typography(.heroBold)
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Text("Works in silent mode, Focus mode, and even when your phone is locked.")
|
||||||
|
.typography(.body)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||||
|
|
||||||
|
// Feature bullets
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
|
alarmFeatureRow(icon: "moon.zzz.fill", text: "Cuts through Do Not Disturb")
|
||||||
|
alarmFeatureRow(icon: "lock.iphone", text: "Shows countdown on Lock Screen")
|
||||||
|
alarmFeatureRow(icon: "iphone.badge.play", text: "Works when app is closed")
|
||||||
|
}
|
||||||
|
.padding(.top, Design.Spacing.medium)
|
||||||
|
|
||||||
|
// Permission button or success state
|
||||||
|
permissionButton
|
||||||
|
.padding(.top, Design.Spacing.large)
|
||||||
|
|
||||||
|
// Optional: Keep Awake for bedside clock mode
|
||||||
|
keepAwakeSection
|
||||||
|
.padding(.top, Design.Spacing.medium)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.onAppear {
|
||||||
|
keepAwakeEnabled = isKeepAwakeEnabled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var keepAwakeSection: some View {
|
||||||
|
VStack(spacing: Design.Spacing.small) {
|
||||||
|
Text("Want the clock always visible?")
|
||||||
|
.typography(.callout)
|
||||||
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
enableKeepAwake()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: Design.Spacing.small) {
|
||||||
|
Image(systemName: keepAwakeEnabled ? "checkmark.circle.fill" : "bolt.fill")
|
||||||
|
Text(keepAwakeEnabled ? "Keep Awake Enabled" : "Enable Keep Awake")
|
||||||
|
}
|
||||||
|
.typography(.callout)
|
||||||
|
.foregroundStyle(keepAwakeEnabled ? AppStatus.success : AppTextColors.secondary)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
.padding(.vertical, Design.Spacing.small)
|
||||||
|
.background(keepAwakeEnabled ? AppStatus.success.opacity(0.15) : AppSurface.secondary)
|
||||||
|
.cornerRadius(Design.CornerRadius.medium)
|
||||||
|
}
|
||||||
|
.disabled(keepAwakeEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func alarmFeatureRow(icon: String, text: String) -> some View {
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.foregroundStyle(AppAccent.primary)
|
||||||
|
.frame(width: 28)
|
||||||
|
|
||||||
|
Text(text)
|
||||||
|
.typography(.body)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var permissionButton: some View {
|
||||||
|
Group {
|
||||||
|
if alarmKitPermissionGranted {
|
||||||
|
// Success state
|
||||||
|
HStack(spacing: Design.Spacing.small) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 24))
|
||||||
|
Text("Alarms enabled!")
|
||||||
|
}
|
||||||
|
.foregroundStyle(AppStatus.success)
|
||||||
|
.typography(.bodyEmphasis)
|
||||||
|
.padding(Design.Spacing.medium)
|
||||||
|
.background(AppStatus.success.opacity(0.15))
|
||||||
|
.cornerRadius(Design.CornerRadius.medium)
|
||||||
|
} else {
|
||||||
|
// Request AlarmKit authorization
|
||||||
|
Button {
|
||||||
|
requestAlarmKitPermission()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "alarm.fill")
|
||||||
|
Text("Enable Alarms")
|
||||||
|
}
|
||||||
|
.typography(.bodyEmphasis)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(maxWidth: 280)
|
||||||
|
.padding(Design.Spacing.medium)
|
||||||
|
.background(AppAccent.primary)
|
||||||
|
.cornerRadius(Design.CornerRadius.medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Page 3: Get Started (Quick Win)
|
||||||
|
|
||||||
|
private var getStartedPage: some View {
|
||||||
|
VStack(spacing: Design.Spacing.xxLarge) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Celebration icon
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(AppStatus.success.opacity(0.15))
|
||||||
|
.frame(width: 120, height: 120)
|
||||||
|
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 60, weight: .medium))
|
||||||
|
.foregroundStyle(AppStatus.success)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("You're ready!")
|
||||||
|
.typography(.heroBold)
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
|
||||||
|
Text("Your alarms will work even in silent mode and Focus mode. Try long-pressing the clock for immersive mode!")
|
||||||
|
.typography(.body)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||||
|
|
||||||
|
// Quick tips
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
|
tipRow(icon: "alarm.fill", text: "Create your first alarm")
|
||||||
|
tipRow(icon: "hand.tap", text: "Long-press clock for full screen")
|
||||||
|
tipRow(icon: "speaker.wave.2", text: "Tap Noise to play sounds")
|
||||||
|
}
|
||||||
|
.padding(.top, Design.Spacing.medium)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tipRow(icon: String, text: String) -> some View {
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundStyle(AppAccent.primary)
|
||||||
|
.frame(width: 24)
|
||||||
|
|
||||||
|
Text(text)
|
||||||
|
.typography(.callout)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bottom Controls
|
||||||
|
|
||||||
|
private var bottomControls: some View {
|
||||||
|
VStack(spacing: Design.Spacing.large) {
|
||||||
|
// Page indicators
|
||||||
|
HStack(spacing: Design.Spacing.small) {
|
||||||
|
ForEach(0..<totalPages, id: \.self) { index in
|
||||||
|
Capsule()
|
||||||
|
.fill(index == currentPage ? AppAccent.primary : AppTextColors.tertiary)
|
||||||
|
.frame(width: index == currentPage ? 24 : 8, height: 8)
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: currentPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation buttons
|
||||||
|
HStack(spacing: Design.Spacing.large) {
|
||||||
|
// Back / Skip button
|
||||||
|
Button {
|
||||||
|
if currentPage > 0 {
|
||||||
|
withAnimation {
|
||||||
|
currentPage -= 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onComplete()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(currentPage == 0 ? "Skip" : "Back")
|
||||||
|
.typography(.bodyEmphasis)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(Design.Spacing.medium)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next / Get Started button
|
||||||
|
Button {
|
||||||
|
if currentPage < totalPages - 1 {
|
||||||
|
withAnimation {
|
||||||
|
currentPage += 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
triggerCelebration()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(currentPage == totalPages - 1 ? "Get Started" : "Next")
|
||||||
|
.typography(.bodyEmphasis)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(Design.Spacing.medium)
|
||||||
|
.background(AppAccent.primary)
|
||||||
|
.cornerRadius(Design.CornerRadius.medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Celebration
|
||||||
|
|
||||||
|
private var celebrationOverlay: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.black.opacity(0.3)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: Design.Spacing.large) {
|
||||||
|
Image(systemName: "party.popper.fill")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundStyle(AppAccent.primary)
|
||||||
|
|
||||||
|
Text("Let's go!")
|
||||||
|
.typography(.heroBold)
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.xxxLarge)
|
||||||
|
.background(AppSurface.overlay)
|
||||||
|
.cornerRadius(Design.CornerRadius.xxLarge)
|
||||||
|
.shadow(radius: 20)
|
||||||
|
}
|
||||||
|
.transition(.opacity.combined(with: .scale))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func requestAlarmKitPermission() {
|
||||||
|
Task {
|
||||||
|
// Request AlarmKit authorization (iOS 26+)
|
||||||
|
let granted = await AlarmKitService.shared.requestAuthorization()
|
||||||
|
withAnimation(.spring(duration: 0.3)) {
|
||||||
|
alarmKitPermissionGranted = granted
|
||||||
|
}
|
||||||
|
// Auto-advance after permission granted
|
||||||
|
if granted {
|
||||||
|
try? await Task.sleep(for: .milliseconds(800))
|
||||||
|
withAnimation {
|
||||||
|
currentPage = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func enableKeepAwake() {
|
||||||
|
var style = loadClockStyle()
|
||||||
|
style.keepAwake = true
|
||||||
|
saveClockStyle(style)
|
||||||
|
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
|
||||||
|
withAnimation(.spring(duration: 0.3)) {
|
||||||
|
keepAwakeEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isKeepAwakeEnabled() -> Bool {
|
||||||
|
loadClockStyle().keepAwake
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadClockStyle() -> ClockStyle {
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey),
|
||||||
|
let decoded = try? JSONDecoder().decode(ClockStyle.self, from: data) else {
|
||||||
|
return ClockStyle()
|
||||||
|
}
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveClockStyle(_ style: ClockStyle) {
|
||||||
|
if let data = try? JSONEncoder().encode(style) {
|
||||||
|
UserDefaults.standard.set(data, forKey: ClockStyle.appStorageKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func triggerCelebration() {
|
||||||
|
withAnimation(.spring(duration: 0.4)) {
|
||||||
|
showCelebration = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismiss after short celebration
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(for: .milliseconds(1200))
|
||||||
|
onComplete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Onboarding Clock Text
|
||||||
|
|
||||||
|
/// Separate view for TimelineView content to avoid view builder issues
|
||||||
|
private struct OnboardingClockText: View {
|
||||||
|
let date: Date
|
||||||
|
|
||||||
|
private var timeString: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "h:mm"
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(timeString)
|
||||||
|
.font(.system(size: 80, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(AppAccent.primary)
|
||||||
|
.contentTransition(.numericText())
|
||||||
|
.animation(.snappy(duration: 0.3), value: timeString)
|
||||||
|
.shadow(color: AppAccent.primary.opacity(0.5), radius: 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
OnboardingView {
|
||||||
|
print("Onboarding complete")
|
||||||
|
}
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
@ -12,5 +12,9 @@
|
|||||||
<string>$(CLOUDKIT_CONTAINER_IDENTIFIER)</string>
|
<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.caf",
|
"fileName": "digital-alarm.mp3",
|
||||||
"description": "Classic digital alarm sound",
|
"description": "Classic digital alarm sound",
|
||||||
"category": "alarm",
|
"category": "alarm",
|
||||||
"bundleName": null,
|
"bundleName": "AlarmSounds",
|
||||||
"isDefault": true
|
"isDefault": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "buzzing-alarm",
|
"id": "buzzing-alarm",
|
||||||
"name": "Buzzing Alarm",
|
"name": "Buzzing Alarm",
|
||||||
"fileName": "buzzing-alarm.caf",
|
"fileName": "buzzing-alarm.mp3",
|
||||||
"description": "Buzzing sound for gentle wake-up",
|
"description": "Buzzing sound for gentle wake-up",
|
||||||
"category": "alarm",
|
"category": "alarm",
|
||||||
"bundleName": null
|
"bundleName": "AlarmSounds"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "classic-alarm",
|
"id": "classic-alarm",
|
||||||
"name": "Classic Alarm",
|
"name": "Classic Alarm",
|
||||||
"fileName": "classic-alarm.caf",
|
"fileName": "classic-alarm.mp3",
|
||||||
"description": "Traditional alarm sound",
|
"description": "Traditional alarm sound",
|
||||||
"category": "alarm",
|
"category": "alarm",
|
||||||
"bundleName": null
|
"bundleName": "AlarmSounds"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "beep-alarm",
|
"id": "beep-alarm",
|
||||||
"name": "Beep Alarm",
|
"name": "Beep Alarm",
|
||||||
"fileName": "beep-alarm.caf",
|
"fileName": "beep-alarm.mp3",
|
||||||
"description": "Short beep alarm sound",
|
"description": "Short beep alarm sound",
|
||||||
"category": "alarm",
|
"category": "alarm",
|
||||||
"bundleName": null
|
"bundleName": "AlarmSounds"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "siren-alarm",
|
"id": "siren-alarm",
|
||||||
"name": "Siren Alarm",
|
"name": "Siren Alarm",
|
||||||
"fileName": "siren-alarm.caf",
|
"fileName": "siren-alarm.mp3",
|
||||||
"description": "Emergency siren alarm for heavy sleepers",
|
"description": "Emergency siren alarm for heavy sleepers",
|
||||||
"category": "alarm",
|
"category": "alarm",
|
||||||
"bundleName": null
|
"bundleName": "AlarmSounds"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
BIN
TheNoiseClock/Resources/AlarmSounds/beep-alarm.mp3
Normal file
BIN
TheNoiseClock/Resources/AlarmSounds/beep-alarm.mp3
Normal file
Binary file not shown.
Binary file not shown.
BIN
TheNoiseClock/Resources/AlarmSounds/buzzing-alarm.mp3
Normal file
BIN
TheNoiseClock/Resources/AlarmSounds/buzzing-alarm.mp3
Normal file
Binary file not shown.
Binary file not shown.
BIN
TheNoiseClock/Resources/AlarmSounds/classic-alarm.mp3
Normal file
BIN
TheNoiseClock/Resources/AlarmSounds/classic-alarm.mp3
Normal file
Binary file not shown.
Binary file not shown.
BIN
TheNoiseClock/Resources/AlarmSounds/digital-alarm.mp3
Normal file
BIN
TheNoiseClock/Resources/AlarmSounds/digital-alarm.mp3
Normal file
Binary file not shown.
Binary file not shown.
BIN
TheNoiseClock/Resources/AlarmSounds/siren-alarm.mp3
Normal file
BIN
TheNoiseClock/Resources/AlarmSounds/siren-alarm.mp3
Normal file
Binary file not shown.
@ -12,7 +12,7 @@ enum AppConstants {
|
|||||||
|
|
||||||
// MARK: - App Information
|
// MARK: - App Information
|
||||||
static let appName = "TheNoiseClock"
|
static let appName = "TheNoiseClock"
|
||||||
static let minimumIOSVersion = "18.0"
|
static let minimumIOSVersion = "26.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.caf"
|
static let defaultSound = "digital-alarm.mp3"
|
||||||
static let availableSounds = ["default", "bell", "chimes", "ding", "glass", "silence"]
|
static let availableSounds = ["default", "bell", "chimes", "ding", "glass", "silence"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,31 @@
|
|||||||
|
//
|
||||||
|
// NoiseClockAlarmMetadata.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 2/2/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AlarmKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Metadata for alarm Live Activities, shared between app and widget extension.
|
||||||
|
/// Must conform to AlarmMetadata and be nonisolated for cross-actor use.
|
||||||
|
nonisolated struct NoiseClockAlarmMetadata: AlarmMetadata {
|
||||||
|
/// The unique identifier for the alarm
|
||||||
|
var alarmId: String
|
||||||
|
|
||||||
|
/// The sound file name to play when the alarm fires
|
||||||
|
var soundName: String
|
||||||
|
|
||||||
|
/// The snooze duration in minutes
|
||||||
|
var snoozeDuration: Int
|
||||||
|
|
||||||
|
/// The alarm label to display
|
||||||
|
var label: String
|
||||||
|
|
||||||
|
/// The custom notification message
|
||||||
|
var message: String
|
||||||
|
|
||||||
|
/// Volume level (0.0 to 1.0)
|
||||||
|
var volume: Float
|
||||||
|
}
|
||||||
37
TheNoiseClock/Shared/Utilities/AlarmNotifications.swift
Normal file
37
TheNoiseClock/Shared/Utilities/AlarmNotifications.swift
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
//
|
||||||
|
// AlarmNotifications.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 2/2/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum AlarmNotificationConstants {
|
||||||
|
static let categoryIdentifier = "ALARM_CATEGORY"
|
||||||
|
static let snoozeActionIdentifier = "SNOOZE_ACTION"
|
||||||
|
static let stopActionIdentifier = "STOP_ACTION"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AlarmNotificationKeys {
|
||||||
|
static let alarmId = "alarmId"
|
||||||
|
static let soundName = "soundName"
|
||||||
|
static let repeats = "repeats"
|
||||||
|
static let isSnooze = "isSnooze"
|
||||||
|
static let originalAlarmId = "originalAlarmId"
|
||||||
|
static let label = "label"
|
||||||
|
static let notificationMessage = "notificationMessage"
|
||||||
|
static let snoozeDuration = "snoozeDuration"
|
||||||
|
static let isVibrationEnabled = "isVibrationEnabled"
|
||||||
|
static let volume = "volume"
|
||||||
|
static let title = "title"
|
||||||
|
static let body = "body"
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static let alarmDidFire = Notification.Name("alarmDidFire")
|
||||||
|
static let alarmDidStop = Notification.Name("alarmDidStop")
|
||||||
|
static let alarmDidSnooze = Notification.Name("alarmDidSnooze")
|
||||||
|
static let keepAwakePromptRequested = Notification.Name("keepAwakePromptRequested")
|
||||||
|
static let clockStyleDidUpdate = Notification.Name("clockStyleDidUpdate")
|
||||||
|
}
|
||||||
56
TheNoiseClock/Shared/Utilities/KeepAwakePrompt.swift
Normal file
56
TheNoiseClock/Shared/Utilities/KeepAwakePrompt.swift
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
//
|
||||||
|
// KeepAwakePrompt.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 2/2/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
struct KeepAwakePrompt: View {
|
||||||
|
let onEnable: () -> Void
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Design.Spacing.large) {
|
||||||
|
Image(systemName: "bolt.fill")
|
||||||
|
.font(.system(size: 36, weight: .semibold))
|
||||||
|
.foregroundStyle(AppAccent.primary)
|
||||||
|
|
||||||
|
VStack(spacing: Design.Spacing.small) {
|
||||||
|
Text("Keep Awake for Alarms")
|
||||||
|
.typography(.title2)
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
|
||||||
|
Text("Enable Keep Awake so your alarm can play loudly and show the full screen while TheNoiseClock stays open.")
|
||||||
|
.typography(.body)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: Design.Spacing.small) {
|
||||||
|
Button(action: onEnable) {
|
||||||
|
Text("Enable Keep Awake")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(color: AppAccent.primary)
|
||||||
|
|
||||||
|
Button(action: onDismiss) {
|
||||||
|
Text("Not Now")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.xLarge)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(AppSurface.primary)
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
KeepAwakePrompt(onEnable: {}, onDismiss: {})
|
||||||
|
}
|
||||||
30
TheNoiseClock/Shared/Utilities/KeepAwakePromptState.swift
Normal file
30
TheNoiseClock/Shared/Utilities/KeepAwakePromptState.swift
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
//
|
||||||
|
// KeepAwakePromptState.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 2/2/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
class KeepAwakePromptState {
|
||||||
|
|
||||||
|
var isPresented = false
|
||||||
|
private var hasShownThisSession = false
|
||||||
|
|
||||||
|
func showIfNeeded(isKeepAwakeEnabled: Bool) {
|
||||||
|
guard !isKeepAwakeEnabled, !hasShownThisSession else { return }
|
||||||
|
isPresented = true
|
||||||
|
hasShownThisSession = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismiss() {
|
||||||
|
isPresented = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetSessionFlag() {
|
||||||
|
hasShownThisSession = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -36,15 +36,19 @@ enum NotificationUtils {
|
|||||||
let content = UNMutableNotificationContent()
|
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 {
|
} else if Bundle.main.url(forResource: soundName, withExtension: nil) != nil {
|
||||||
// Use the sound name directly since sounds.json now references CAF files
|
// Use the sound name directly since sounds.json now references CAF files
|
||||||
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: soundName))
|
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: soundName))
|
||||||
Design.debugLog("[settings] Using custom alarm sound: \(soundName)")
|
Design.debugLog("[settings] Using custom alarm sound: \(soundName)")
|
||||||
Design.debugLog("[settings] Sound file should be in main bundle: \(soundName)")
|
Design.debugLog("[settings] Sound file should be in main bundle: \(soundName)")
|
||||||
|
} else {
|
||||||
|
content.sound = UNNotificationSound.default
|
||||||
|
Design.debugLog("[settings] Alarm sound not found in main bundle, falling back to default: \(soundName)")
|
||||||
}
|
}
|
||||||
|
|
||||||
return content
|
return content
|
||||||
|
|||||||
119
TheNoiseClockWidget/AlarmIntents.swift
Normal file
119
TheNoiseClockWidget/AlarmIntents.swift
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
//
|
||||||
|
// AlarmIntents.swift
|
||||||
|
// TheNoiseClockWidget
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 2/2/26.
|
||||||
|
//
|
||||||
|
// App Intents for alarm actions from Live Activity and widget buttons.
|
||||||
|
// These intents are duplicated in the widget target for compilation.
|
||||||
|
// Note: Must be kept in sync with TheNoiseClock/Features/Alarms/Intents/AlarmIntents.swift
|
||||||
|
//
|
||||||
|
|
||||||
|
import AlarmKit
|
||||||
|
import AppIntents
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Stop Alarm Intent
|
||||||
|
|
||||||
|
/// Intent to stop an active alarm from the Live Activity or notification.
|
||||||
|
struct StopAlarmIntent: LiveActivityIntent {
|
||||||
|
|
||||||
|
static var title: LocalizedStringResource = "Stop Alarm"
|
||||||
|
static var description = IntentDescription("Stops the currently ringing alarm")
|
||||||
|
|
||||||
|
@Parameter(title: "Alarm ID")
|
||||||
|
var alarmId: String
|
||||||
|
|
||||||
|
static var supportedModes: IntentModes { .background }
|
||||||
|
|
||||||
|
init() {
|
||||||
|
self.alarmId = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
init(alarmId: String) {
|
||||||
|
self.alarmId = alarmId
|
||||||
|
}
|
||||||
|
|
||||||
|
func perform() throws -> some IntentResult {
|
||||||
|
guard let uuid = UUID(uuidString: alarmId) else {
|
||||||
|
throw AlarmIntentError.invalidAlarmID
|
||||||
|
}
|
||||||
|
|
||||||
|
try AlarmManager.shared.stop(id: uuid)
|
||||||
|
return .result()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Snooze Alarm Intent
|
||||||
|
|
||||||
|
/// Intent to snooze an active alarm from the Live Activity or notification.
|
||||||
|
struct SnoozeAlarmIntent: LiveActivityIntent {
|
||||||
|
|
||||||
|
static var title: LocalizedStringResource = "Snooze Alarm"
|
||||||
|
static var description = IntentDescription("Snoozes the currently ringing alarm")
|
||||||
|
|
||||||
|
@Parameter(title: "Alarm ID")
|
||||||
|
var alarmId: String
|
||||||
|
|
||||||
|
static var supportedModes: IntentModes { .background }
|
||||||
|
|
||||||
|
init() {
|
||||||
|
self.alarmId = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
init(alarmId: String) {
|
||||||
|
self.alarmId = alarmId
|
||||||
|
}
|
||||||
|
|
||||||
|
func perform() throws -> some IntentResult {
|
||||||
|
guard let uuid = UUID(uuidString: alarmId) else {
|
||||||
|
throw AlarmIntentError.invalidAlarmID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use countdown to postpone the alarm by its configured snooze duration
|
||||||
|
try AlarmManager.shared.countdown(id: uuid)
|
||||||
|
return .result()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Open App Intent
|
||||||
|
|
||||||
|
/// Intent to open the app when the user taps the Live Activity.
|
||||||
|
struct OpenAlarmAppIntent: LiveActivityIntent {
|
||||||
|
|
||||||
|
static var title: LocalizedStringResource = "Open TheNoiseClock"
|
||||||
|
static var description = IntentDescription("Opens the app to the alarm screen")
|
||||||
|
static var openAppWhenRun = true
|
||||||
|
|
||||||
|
@Parameter(title: "Alarm ID")
|
||||||
|
var alarmId: String
|
||||||
|
|
||||||
|
init() {
|
||||||
|
self.alarmId = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
init(alarmId: String) {
|
||||||
|
self.alarmId = alarmId
|
||||||
|
}
|
||||||
|
|
||||||
|
func perform() throws -> some IntentResult {
|
||||||
|
// The app will be opened due to openAppWhenRun = true
|
||||||
|
return .result()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Errors
|
||||||
|
|
||||||
|
enum AlarmIntentError: Error, LocalizedError {
|
||||||
|
case invalidAlarmID
|
||||||
|
case alarmNotFound
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidAlarmID:
|
||||||
|
return "Invalid alarm ID"
|
||||||
|
case .alarmNotFound:
|
||||||
|
return "Alarm not found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
238
TheNoiseClockWidget/AlarmLiveActivityWidget.swift
Normal file
238
TheNoiseClockWidget/AlarmLiveActivityWidget.swift
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
//
|
||||||
|
// AlarmLiveActivityWidget.swift
|
||||||
|
// TheNoiseClockWidget
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 2/2/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AlarmKit
|
||||||
|
import AppIntents
|
||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
/// Live Activity widget for alarm countdown and alerting states.
|
||||||
|
/// Uses AlarmKit's AlarmAttributes for automatic countdown management.
|
||||||
|
struct AlarmLiveActivityWidget: Widget {
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
ActivityConfiguration(for: AlarmAttributes<NoiseClockAlarmMetadata>.self) { context in
|
||||||
|
// Lock Screen presentation
|
||||||
|
LockScreenAlarmView(
|
||||||
|
attributes: context.attributes,
|
||||||
|
state: context.state
|
||||||
|
)
|
||||||
|
} dynamicIsland: { context in
|
||||||
|
DynamicIsland {
|
||||||
|
// Expanded regions - shown when long-pressed or alerting
|
||||||
|
DynamicIslandExpandedRegion(.leading) {
|
||||||
|
if let metadata = context.attributes.metadata {
|
||||||
|
AlarmTitleView(metadata: metadata)
|
||||||
|
} else {
|
||||||
|
Text("Alarm")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DynamicIslandExpandedRegion(.trailing) {
|
||||||
|
AlarmProgressView(state: context.state)
|
||||||
|
}
|
||||||
|
DynamicIslandExpandedRegion(.bottom) {
|
||||||
|
ExpandedAlarmView(
|
||||||
|
attributes: context.attributes,
|
||||||
|
state: context.state
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} compactLeading: {
|
||||||
|
// Compact leading - alarm icon during countdown
|
||||||
|
Image(systemName: "alarm.fill")
|
||||||
|
.foregroundStyle(context.attributes.tintColor)
|
||||||
|
} compactTrailing: {
|
||||||
|
// Compact trailing - countdown text
|
||||||
|
CountdownTextView(state: context.state)
|
||||||
|
.font(.caption2.monospacedDigit())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} minimal: {
|
||||||
|
// Minimal - just an alarm icon
|
||||||
|
Image(systemName: "alarm.fill")
|
||||||
|
.foregroundStyle(context.attributes.tintColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lock Screen View
|
||||||
|
|
||||||
|
struct LockScreenAlarmView: View {
|
||||||
|
let attributes: AlarmAttributes<NoiseClockAlarmMetadata>
|
||||||
|
let state: AlarmPresentationState
|
||||||
|
|
||||||
|
private var alarmLabel: String {
|
||||||
|
attributes.metadata?.label ?? "Alarm"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var alarmId: String {
|
||||||
|
attributes.metadata?.alarmId ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
// Alarm label
|
||||||
|
Text(alarmLabel)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
|
||||||
|
// Content based on state
|
||||||
|
if case .countdown(let countdown) = state.mode {
|
||||||
|
// Countdown state - show timer
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text("Alarm in")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(timerInterval: Date.now...countdown.fireDate, countsDown: true)
|
||||||
|
.font(.system(size: 48, weight: .bold, design: .rounded))
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundStyle(attributes.tintColor)
|
||||||
|
}
|
||||||
|
} else if case .paused = state.mode {
|
||||||
|
Text("Paused")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
// Alerting state - show ringing UI with action buttons
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "alarm.waves.left.and.right.fill")
|
||||||
|
.font(.system(size: 40))
|
||||||
|
.foregroundStyle(attributes.tintColor)
|
||||||
|
.symbolEffect(.bounce.byLayer, options: .repeating)
|
||||||
|
|
||||||
|
Text("Alarm Ringing")
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
// Snooze button
|
||||||
|
Button(intent: SnoozeAlarmIntent(alarmId: alarmId)) {
|
||||||
|
Label("Snooze", systemImage: "zzz")
|
||||||
|
.font(.callout.weight(.medium))
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.blue)
|
||||||
|
|
||||||
|
// Stop button
|
||||||
|
Button(intent: StopAlarmIntent(alarmId: alarmId)) {
|
||||||
|
Label("Stop", systemImage: "stop.fill")
|
||||||
|
.font(.callout.weight(.medium))
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Expanded Dynamic Island View
|
||||||
|
|
||||||
|
struct ExpandedAlarmView: View {
|
||||||
|
let attributes: AlarmAttributes<NoiseClockAlarmMetadata>
|
||||||
|
let state: AlarmPresentationState
|
||||||
|
|
||||||
|
private var alarmId: String {
|
||||||
|
attributes.metadata?.alarmId ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isAlerting: Bool {
|
||||||
|
if case .countdown = state.mode { return false }
|
||||||
|
if case .paused = state.mode { return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if isAlerting {
|
||||||
|
// Alerting state - show action buttons
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Button(intent: SnoozeAlarmIntent(alarmId: alarmId)) {
|
||||||
|
Text("Snooze")
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.blue)
|
||||||
|
|
||||||
|
Button(intent: StopAlarmIntent(alarmId: alarmId)) {
|
||||||
|
Text("Stop")
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.red)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Countdown state - show countdown info
|
||||||
|
HStack {
|
||||||
|
CountdownTextView(state: state)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
AlarmProgressView(state: state)
|
||||||
|
.frame(maxHeight: 30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Countdown Text View
|
||||||
|
|
||||||
|
struct CountdownTextView: View {
|
||||||
|
let state: AlarmPresentationState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if case .countdown(let countdown) = state.mode {
|
||||||
|
Text(timerInterval: Date.now...countdown.fireDate, countsDown: true)
|
||||||
|
.monospacedDigit()
|
||||||
|
.lineLimit(1)
|
||||||
|
} else if case .paused = state.mode {
|
||||||
|
Text("Paused")
|
||||||
|
} else {
|
||||||
|
Text("Now!")
|
||||||
|
.fontWeight(.bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Progress View
|
||||||
|
|
||||||
|
struct AlarmProgressView: View {
|
||||||
|
let state: AlarmPresentationState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if case .countdown(let countdown) = state.mode {
|
||||||
|
ProgressView(
|
||||||
|
timerInterval: Date.now...countdown.fireDate,
|
||||||
|
label: { EmptyView() },
|
||||||
|
currentValueLabel: { Text("") }
|
||||||
|
)
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
} else if case .paused = state.mode {
|
||||||
|
Image(systemName: "pause.fill")
|
||||||
|
} else {
|
||||||
|
Image(systemName: "alarm.waves.left.and.right.fill")
|
||||||
|
.symbolEffect(.pulse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Title View
|
||||||
|
|
||||||
|
struct AlarmTitleView: View {
|
||||||
|
let metadata: NoiseClockAlarmMetadata
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(metadata.label)
|
||||||
|
.font(.caption)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
16
TheNoiseClockWidget/Info.plist
Normal file
16
TheNoiseClockWidget/Info.plist
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionAttributes</key>
|
||||||
|
<dict>
|
||||||
|
<key>SupportsLiveActivities</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.widgetkit-extension</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
35
TheNoiseClockWidget/NoiseClockAlarmMetadata.swift
Normal file
35
TheNoiseClockWidget/NoiseClockAlarmMetadata.swift
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
//
|
||||||
|
// NoiseClockAlarmMetadata.swift
|
||||||
|
// TheNoiseClockWidget
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 2/2/26.
|
||||||
|
//
|
||||||
|
// NOTE: This file must be kept in sync with the main app's version.
|
||||||
|
// In Xcode, add the main app's NoiseClockAlarmMetadata.swift to both targets
|
||||||
|
// and remove this file, or use a shared Swift package.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AlarmKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Metadata for alarm Live Activities, shared between app and widget extension.
|
||||||
|
/// Must conform to AlarmMetadata and be nonisolated for cross-actor use.
|
||||||
|
nonisolated struct NoiseClockAlarmMetadata: AlarmMetadata {
|
||||||
|
/// The unique identifier for the alarm
|
||||||
|
var alarmId: String
|
||||||
|
|
||||||
|
/// The sound file name to play when the alarm fires
|
||||||
|
var soundName: String
|
||||||
|
|
||||||
|
/// The snooze duration in minutes
|
||||||
|
var snoozeDuration: Int
|
||||||
|
|
||||||
|
/// The alarm label to display
|
||||||
|
var label: String
|
||||||
|
|
||||||
|
/// The custom notification message
|
||||||
|
var message: String
|
||||||
|
|
||||||
|
/// Volume level (0.0 to 1.0)
|
||||||
|
var volume: Float
|
||||||
|
}
|
||||||
16
TheNoiseClockWidget/TheNoiseClockWidgetBundle.swift
Normal file
16
TheNoiseClockWidget/TheNoiseClockWidgetBundle.swift
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
//
|
||||||
|
// TheNoiseClockWidgetBundle.swift
|
||||||
|
// TheNoiseClockWidget
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 2/2/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct TheNoiseClockWidgetBundle: WidgetBundle {
|
||||||
|
var body: some Widget {
|
||||||
|
AlarmLiveActivityWidget()
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user