Compare commits

...

9 Commits

57 changed files with 2542 additions and 813 deletions

View File

@ -1,6 +1,7 @@
Use /ios-18-role
read the PRD.md
read the README.md
read the Bedrock README.md as well
Always update the PRD.md and README.md when there are code changes that might cause these files to require those changes documented.

View File

@ -95,7 +95,8 @@ public class SoundConfigurationService {
/// Load sound configuration from multiple category-specific JSON files
public func loadConfigurationFromBundles(from bundle: Bundle = .main) -> SoundConfiguration {
let bundleNames = ["Colored", "Nature", "Mechanical", "Ambient"]
// Include AlarmSounds bundle for alarm sound preview functionality
let bundleNames = ["Colored", "Nature", "Mechanical", "Ambient", "AlarmSounds"]
var allSounds: [Sound] = []
for bundleName in bundleNames {

View File

@ -41,6 +41,14 @@ public class SoundPlayer {
}
public func playSound(_ sound: Sound) {
playSound(sound, volumeOverride: nil)
}
public func playSound(_ sound: Sound, volume: Float) {
playSound(sound, volumeOverride: volume)
}
private func playSound(_ sound: Sound, volumeOverride: Float?) {
print("🎵 Attempting to play: \(sound.name)")
// Stop current sound if playing
@ -63,7 +71,7 @@ public class SoundPlayer {
do {
let newPlayer = try AVAudioPlayer(contentsOf: fileUrl)
newPlayer.numberOfLoops = AudioConstants.Playback.numberOfLoops
newPlayer.volume = AudioConstants.Volume.default
newPlayer.volume = volumeOverride ?? AudioConstants.Volume.default
newPlayer.prepareToPlay()
players[sound.fileName] = newPlayer
currentPlayer = newPlayer
@ -77,6 +85,9 @@ public class SoundPlayer {
}
currentPlayer = player
if let volumeOverride {
player.volume = volumeOverride
}
let success = player.play()
print("🎵 Play result: \(success ? "SUCCESS" : "FAILED")")
print("🔊 Player isPlaying: \(player.isPlaying)")
@ -119,7 +130,11 @@ public class SoundPlayer {
return Bundle.main.url(forResource: fileName, withExtension: nil, subdirectory: subfolder)
} else {
// Direct file path (fallback)
return Bundle.main.url(forResource: sound.fileName, withExtension: nil)
if let url = Bundle.main.url(forResource: sound.fileName, withExtension: nil) {
return url
}
// Alarm sounds live in a subdirectory; try that next
return Bundle.main.url(forResource: sound.fileName, withExtension: nil, subdirectory: "AlarmSounds")
}
}

68
PRD.md
View File

@ -89,27 +89,32 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di
- **Responsive layout**: Optimized for both portrait and landscape orientations
- **AudioPlaybackKit integration**: Powered by reusable Swift package for audio functionality
### 6. Advanced Alarm System
### 6. Advanced Alarm System (Powered by AlarmKit)
- **AlarmKit integration**: iOS 26+ AlarmKit framework for reliable alarms that cut through Focus modes and silent mode
- **Multiple alarms**: Create and manage unlimited alarms
- **Rich alarm editor**: Full-featured alarm creation and editing interface
- **Time selection**: Wheel-style date picker with optimized font sizing for maximum readability
- **Dynamic alarm sounds**: Configurable alarm sounds loaded from dedicated alarm-sounds.json configuration
- **Dynamic alarm sounds**: MP3 alarm sounds loaded from AlarmSounds folder
- **Sound preview**: Play/stop functionality for testing alarm sounds before selection
- **Sound organization**: Alarm sounds organized in dedicated AlarmSounds.bundle with categories
- **Custom labels**: User-defined alarm names and descriptions
- **Repeat schedules**: Set alarms to repeat on specific weekdays or daily
- **Sound selection**: Choose from extensive alarm sounds with live preview
- **Volume control**: Adjustable alarm volume (0-100%)
- **Vibration settings**: Enable/disable vibration for each alarm
- **Snooze functionality**: Configurable snooze duration (5, 7, 8, 9, 10, 15, 20 minutes)
- **Smart notifications**: Automatic scheduling for one-time and repeating alarms
- **Snooze functionality**: AlarmKit countdown feature for snooze support
- **Live Activity countdown**: Shows 5 minutes before alarm fires on Lock Screen and Dynamic Island
- **Dynamic Island**: Compact and expanded views with countdown timer
- **Lock Screen widget**: Full countdown display with alarm label
- **Enable/disable toggles**: Individual alarm control with instant feedback
- **Notification integration**: Uses iOS UserNotifications framework with proper scheduling
- **AlarmKit authorization**: Requires user permission via NSAlarmKitUsageDescription
- **Persistent storage**: Alarms saved to UserDefaults with backward compatibility
- **Alarm management**: Add, edit, delete, and duplicate alarms
- **Next trigger preview**: Shows when the next alarm will fire
- **Responsive time picker**: Font sizes adapt to available space and orientation
- **AlarmKitService**: Centralized service for AlarmKit integration
- **AlarmSoundService integration**: Dedicated service for alarm-specific sound management
- **In-app alarm screen**: Full-screen alarm UI with Snooze/Stop when the app is active
- **App Intents**: StopAlarmIntent and SnoozeAlarmIntent for Live Activity button actions
## Advanced Clock Display Features
@ -351,6 +356,7 @@ These principles are fundamental to the project's long-term success and must be
- **Real-time updates**: Changes apply immediately with live preview
- **Sheet presentation**: Full-screen settings sheet for uninterrupted editing
- **Enum-based architecture**: Type-safe picker selections eliminate string-based errors
- **Always-visible settings**: Advanced sections are always shown for clarity
## File Structure and Organization
@ -399,9 +405,12 @@ TheNoiseClock/
│ │ │ └── View+Extensions.swift # Common view modifiers and responsive utilities
│ │ ├── Models/
│ │ │ └── SoundCategory.swift # Shared sound category definitions
│ │ ├── LiveActivity/
│ │ │ └── NoiseClockAlarmMetadata.swift # AlarmKit metadata shared with widget
│ │ └── Utilities/
│ │ ├── ColorUtils.swift # Color manipulation utilities
│ │ └── NotificationUtils.swift # Notification helper functions
│ │ ├── NotificationUtils.swift # Notification helper functions
│ │ └── AlarmNotifications.swift # Alarm notification constants and events
│ ├── Features/
│ │ ├── Clock/
│ │ │ ├── Models/
@ -426,7 +435,6 @@ TheNoiseClock/
│ │ │ ├── ClockDisplayContainer.swift
│ │ │ ├── ClockOverlayContainer.swift
│ │ │ ├── ClockGestureHandler.swift
│ │ │ ├── ClockTabBarManager.swift
│ │ │ ├── ClockToolbar.swift
│ │ │ ├── FullScreenHintView.swift
│ │ │ └── Settings/
@ -444,11 +452,11 @@ TheNoiseClock/
│ │ │ ├── State/
│ │ │ │ └── AlarmViewModel.swift
│ │ │ ├── Services/
│ │ │ │ ├── AlarmService.swift
│ │ │ │ ├── AlarmSoundService.swift
│ │ │ │ ├── FocusModeService.swift
│ │ │ │ ├── NotificationService.swift
│ │ │ │ └── NotificationDelegate.swift
│ │ │ │ ├── AlarmService.swift # Alarm persistence
│ │ │ │ ├── AlarmSoundService.swift # Alarm sound metadata
│ │ │ │ └── AlarmKitService.swift # AlarmKit integration (iOS 26+)
│ │ │ ├── Intents/
│ │ │ │ └── AlarmIntents.swift # App Intents for Stop/Snooze
│ │ │ └── Views/
│ │ │ ├── AlarmView.swift
│ │ │ ├── AddAlarmView.swift
@ -462,12 +470,17 @@ TheNoiseClock/
│ │ │ ├── SoundSelectionView.swift
│ │ │ ├── TimePickerSection.swift
│ │ │ └── TimeUntilAlarmSection.swift
│ │ └── Noise/
│ │ ├── Noise/
│ │ │ └── Views/
│ │ │ ├── NoiseView.swift
│ │ │ └── Components/
│ │ │ ├── SoundCategoryView.swift
│ │ │ └── SoundControlView.swift
│ │ └── Onboarding/
│ │ └── Views/
│ │ ├── NoiseView.swift
│ │ ├── OnboardingView.swift
│ │ └── Components/
│ │ ├── SoundCategoryView.swift
│ │ └── SoundControlView.swift
│ │ └── OnboardingPageView.swift
│ └── Resources/
│ ├── LaunchScreen.storyboard # Branded native launch screen
│ ├── sounds.json # Ambient sound configuration and definitions
@ -488,6 +501,10 @@ TheNoiseClock/
│ └── [Asset catalogs]
└── TheNoiseClock.xcodeproj/ # Xcode project with AudioPlaybackKit dependency
└── project.pbxproj # Project configuration with local package reference
TheNoiseClockWidget/ # Widget extension (Live Activity)
├── AlarmLiveActivityWidget.swift # Live Activity UI for Dynamic Island/Lock Screen
├── TheNoiseClockWidgetBundle.swift # Widget bundle entry point
└── Info.plist # Widget extension Info.plist
```
### File Naming Conventions
@ -535,6 +552,16 @@ The following changes **automatically require** PRD updates:
- **Version consistency**: Code and documentation must always be in sync
- **No manual requests**: Users should not need to ask for PRD updates
### 7. Onboarding Experience
- **First-launch detection**: Uses OnboardingState to detect first-time users
- **Welcome screen**: Introduces the app with branded visuals
- **Feature highlights**: Dedicated pages for Clock, Alarms, and Noise features
- **Permission request**: Notification permission request with contextual explanation
- **Skip option**: Users can skip onboarding at any time
- **Persistent state**: Onboarding completion saved to UserDefaults
- **Smooth transitions**: Animated page transitions with page indicators
- **Get Started flow**: Clear call-to-action to complete onboarding
## Key User Interactions
### Clock Tab
@ -547,9 +574,10 @@ The following changes **automatically require** PRD updates:
1. **Time format**: Toggle 24-hour, seconds, AM/PM display
2. **Appearance**: Adjust colors, glow, size, opacity
3. **Display**: Control keep awake functionality for display mode
4. **Focus Modes**: Control how app behaves with Focus modes (Do Not Disturb)
5. **Overlays**: Control battery and date display
6. **Background**: Set background color and use presets
4. **Keep Awake prompt**: Auto-prompt when needed (alarms tab, enabling alarms, display mode)
5. **Focus Modes**: Control how app behaves with Focus modes (Do Not Disturb)
6. **Overlays**: Control battery and date display
7. **Background**: Set background color and use presets
### Alarms Tab
1. **View alarms**: List of all created alarms with labels and repeat schedules

View File

@ -1,6 +1,6 @@
# TheNoiseClock
TheNoiseClock is a SwiftUI iOS app that blends a bold, full-screen digital clock with white noise playback and a rich alarm system. It is optimized for iOS 18+ with Swift 6, built on a modular architecture, and styled with the Bedrock design system.
TheNoiseClock is a SwiftUI iOS app that blends a bold, full-screen digital clock with white noise playback and a rich alarm system. It is optimized for iOS 26+ with Swift 6, built on a modular architecture, and styled with the Bedrock design system. Alarms use AlarmKit for reliable wake-up alerts that cut through Focus modes and silent mode.
---
@ -36,11 +36,16 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and
- Seamless looping with background audio support
- Quick preview on long-press, instant play/stop controls
**Alarms**
**Alarms (Powered by AlarmKit)**
- Unlimited alarms with labels, repeat schedules, and snooze options
- Alarm sound library with preview
- Alarm sound library with preview (MP3 format)
- Vibration and volume controls per alarm
- Focus-mode aware scheduling
- AlarmKit integration: alarms cut through Focus modes and silent mode
- Live Activity countdown shows 5 minutes before alarm fires
- Dynamic Island displays countdown and alarm status
- Lock Screen shows alarm countdown with custom UI
- Full-screen in-app alarm screen with Snooze/Stop when active
- Snooze support via AlarmKit's countdown feature
**Display Mode**
- Long-press to enter immersive display mode
@ -48,6 +53,7 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and
- Optional wake-lock to keep the screen on
### What's New
- First-launch onboarding with feature highlights and notification setup
- Branded launch experience with Bedrock theming
- Redesigned settings interface with cards, toggles, and sliders
- Centralized build identifiers via xcconfig
@ -60,15 +66,17 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and
- Full-screen display mode and Dynamic Island awareness
- White noise playback with categories and previews
- Rich alarm editor with scheduling and snooze controls
- Full-screen in-app alarm screen with Snooze/Stop controls
- Bedrock-based theming and branded launch
- iPhone and iPad support with adaptive layouts
- First-launch onboarding with feature highlights and permission setup
---
## Requirements
- iOS 18.0+
- Xcode 16+
- iOS 26.0+
- Xcode 26+
- Swift 6
---

View File

@ -9,6 +9,7 @@
/* Begin PBXBuildFile section */
EA384E832E6F806200CA7D50 /* AudioPlaybackKit in Frameworks */ = {isa = PBXBuildFile; productRef = EA384D3D2E6F554D00CA7D50 /* AudioPlaybackKit */; };
EAC051B12F2E64AB007F87EA /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EAC051B02F2E64AB007F87EA /* Bedrock */; };
EAF1C0DE2F3A4B5C0011223E /* TheNoiseClockWidget.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = EAF1C0DE2F3A4B5C00112234 /* TheNoiseClockWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -26,14 +27,36 @@
remoteGlobalIDString = EA384AFA2E6E6B6000CA7D50;
remoteInfo = TheNoiseClock;
};
EAF1C0DE2F3A4B5C00112240 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = EA384AF32E6E6B6000CA7D50 /* Project object */;
proxyType = 1;
remoteGlobalIDString = EAF1C0DE2F3A4B5C00112233;
remoteInfo = TheNoiseClockWidget;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
EAF1C0DE2F3A4B5C0011223D /* Embed App Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
EAF1C0DE2F3A4B5C0011223E /* TheNoiseClockWidget.appex in Embed App Extensions */,
);
name = "Embed App Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
EA384AFB2E6E6B6000CA7D50 /* TheNoiseClock.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TheNoiseClock.app; sourceTree = BUILT_PRODUCTS_DIR; };
EA384B082E6E6B6100CA7D50 /* TheNoiseClockTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TheNoiseClockTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
EA384B122E6E6B6100CA7D50 /* TheNoiseClockUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TheNoiseClockUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
EAD6E3AF5A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = TheNoiseClock/Configuration/Debug.xcconfig; sourceTree = SOURCE_ROOT; };
EAD6E3B05A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = TheNoiseClock/Configuration/Release.xcconfig; sourceTree = SOURCE_ROOT; };
EAF1C0DE2F3A4B5C00112234 /* TheNoiseClockWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TheNoiseClockWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@ -44,6 +67,13 @@
);
target = EA384AFA2E6E6B6000CA7D50 /* TheNoiseClock */;
};
EAF1C0DE2F3A4B5C0011223C /* Exceptions for "TheNoiseClockWidget" folder in "TheNoiseClockWidget" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = EAF1C0DE2F3A4B5C00112233 /* TheNoiseClockWidget */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
@ -65,6 +95,14 @@
path = TheNoiseClockUITests;
sourceTree = "<group>";
};
EAF1C0DE2F3A4B5C0011223B /* TheNoiseClockWidget */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
EAF1C0DE2F3A4B5C0011223C /* Exceptions for "TheNoiseClockWidget" folder in "TheNoiseClockWidget" target */,
);
path = TheNoiseClockWidget;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@ -91,6 +129,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
EAF1C0DE2F3A4B5C0011223A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@ -100,6 +145,7 @@
EA384AFD2E6E6B6000CA7D50 /* TheNoiseClock */,
EA384B0B2E6E6B6100CA7D50 /* TheNoiseClockTests */,
EA384B152E6E6B6100CA7D50 /* TheNoiseClockUITests */,
EAF1C0DE2F3A4B5C0011223B /* TheNoiseClockWidget */,
EA384AFC2E6E6B6000CA7D50 /* Products */,
EAC057642F2E69E8007F87EA /* Recovered References */,
);
@ -111,6 +157,7 @@
EA384AFB2E6E6B6000CA7D50 /* TheNoiseClock.app */,
EA384B082E6E6B6100CA7D50 /* TheNoiseClockTests.xctest */,
EA384B122E6E6B6100CA7D50 /* TheNoiseClockUITests.xctest */,
EAF1C0DE2F3A4B5C00112234 /* TheNoiseClockWidget.appex */,
);
name = Products;
sourceTree = "<group>";
@ -134,10 +181,12 @@
EA384AF72E6E6B6000CA7D50 /* Sources */,
EA384AF82E6E6B6000CA7D50 /* Frameworks */,
EA384AF92E6E6B6000CA7D50 /* Resources */,
EAF1C0DE2F3A4B5C0011223D /* Embed App Extensions */,
);
buildRules = (
);
dependencies = (
EAF1C0DE2F3A4B5C0011223F /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
EA384AFD2E6E6B6000CA7D50 /* TheNoiseClock */,
@ -197,6 +246,26 @@
productReference = EA384B122E6E6B6100CA7D50 /* TheNoiseClockUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
EAF1C0DE2F3A4B5C00112233 /* TheNoiseClockWidget */ = {
isa = PBXNativeTarget;
buildConfigurationList = EAF1C0DE2F3A4B5C00112235 /* Build configuration list for PBXNativeTarget "TheNoiseClockWidget" */;
buildPhases = (
EAF1C0DE2F3A4B5C00112238 /* Sources */,
EAF1C0DE2F3A4B5C0011223A /* Frameworks */,
EAF1C0DE2F3A4B5C00112239 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
EAF1C0DE2F3A4B5C0011223B /* TheNoiseClockWidget */,
);
name = TheNoiseClockWidget;
productName = TheNoiseClockWidget;
productReference = EAF1C0DE2F3A4B5C00112234 /* TheNoiseClockWidget.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@ -218,6 +287,9 @@
CreatedOnToolsVersion = 26.0;
TestTargetID = EA384AFA2E6E6B6000CA7D50;
};
EAF1C0DE2F3A4B5C00112233 = {
CreatedOnToolsVersion = 26.0;
};
};
};
buildConfigurationList = EA384AF62E6E6B6000CA7D50 /* Build configuration list for PBXProject "TheNoiseClock" */;
@ -241,6 +313,7 @@
EA384AFA2E6E6B6000CA7D50 /* TheNoiseClock */,
EA384B072E6E6B6100CA7D50 /* TheNoiseClockTests */,
EA384B112E6E6B6100CA7D50 /* TheNoiseClockUITests */,
EAF1C0DE2F3A4B5C00112233 /* TheNoiseClockWidget */,
);
};
/* End PBXProject section */
@ -267,6 +340,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
EAF1C0DE2F3A4B5C00112239 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@ -291,6 +371,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
EAF1C0DE2F3A4B5C00112238 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@ -304,6 +391,11 @@
target = EA384AFA2E6E6B6000CA7D50 /* TheNoiseClock */;
targetProxy = EA384B132E6E6B6100CA7D50 /* PBXContainerItemProxy */;
};
EAF1C0DE2F3A4B5C0011223F /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = EAF1C0DE2F3A4B5C00112233 /* TheNoiseClockWidget */;
targetProxy = EAF1C0DE2F3A4B5C00112240 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
@ -446,7 +538,7 @@
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 18;
IPHONEOS_DEPLOYMENT_TARGET = 26;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -480,7 +572,7 @@
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 18;
IPHONEOS_DEPLOYMENT_TARGET = 26;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -586,6 +678,44 @@
};
name = Release;
};
EAF1C0DE2F3A4B5C00112236 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = EAD6E3AF5A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Debug.xcconfig */;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TheNoiseClockWidget/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 26;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(WIDGET_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
EAF1C0DE2F3A4B5C00112237 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = EAD6E3B05A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Release.xcconfig */;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TheNoiseClockWidget/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 26;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(WIDGET_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@ -625,6 +755,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
EAF1C0DE2F3A4B5C00112235 /* Build configuration list for PBXNativeTarget "TheNoiseClockWidget" */ = {
isa = XCConfigurationList;
buildConfigurations = (
EAF1C0DE2F3A4B5C00112236 /* Debug */,
EAF1C0DE2F3A4B5C00112237 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */

View File

@ -7,7 +7,12 @@
<key>TheNoiseClock.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
<integer>2</integer>
</dict>
<key>TheNoiseClockWidget.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>3</integer>
</dict>
</dict>
</dict>

View File

@ -11,60 +11,142 @@ import Bedrock
/// Main tab navigation coordinator
struct ContentView: View {
// MARK: - Body
private enum Tab: Hashable {
// MARK: - Properties
private enum Tab: Hashable, CustomStringConvertible {
case clock
case alarms
case noise
case settings
var description: String {
switch self {
case .clock: return "clock"
case .alarms: return "alarms"
case .noise: return "noise"
case .settings: return "settings"
}
}
}
@State private var selectedTab: Tab = .clock
@State private var clockViewModel = ClockViewModel()
@State private var alarmViewModel = AlarmViewModel()
@State private var onboardingState = OnboardingState(appIdentifier: "TheNoiseClock")
@State private var keepAwakePromptState = KeepAwakePromptState()
// MARK: - Computed Properties
/// Whether the clock tab is currently selected - passed to ClockView to prevent race conditions
private var isOnClockTab: Bool {
selectedTab == .clock
}
// MARK: - Body
var body: some View {
TabView(selection: $selectedTab) {
NavigationStack {
ClockView(viewModel: clockViewModel)
}
.tabItem {
Label("Clock", systemImage: "clock")
}
.tag(Tab.clock)
NavigationStack {
AlarmView()
}
.tabItem {
Label("Alarms", systemImage: "alarm")
}
.tag(Tab.alarms)
NavigationStack {
NoiseView()
}
.tabItem {
Label("Noise", systemImage: "waveform")
}
.tag(Tab.noise)
ZStack {
// Main tab content
TabView(selection: $selectedTab) {
NavigationStack {
// Pass isOnClockTab so ClockView can make the right tab bar decision
// Tab bar hides ONLY when: isOnClockTab && isDisplayMode
// This prevents race conditions on tab switch
ClockView(viewModel: clockViewModel, isOnClockTab: isOnClockTab)
}
.tabItem {
Label("Clock", systemImage: "clock")
}
.tag(Tab.clock)
NavigationStack {
AlarmView(viewModel: alarmViewModel)
}
.tabItem {
Label("Alarms", systemImage: "alarm")
}
.tag(Tab.alarms)
NavigationStack {
NoiseView()
}
.tabItem {
Label("Noise", systemImage: "waveform")
}
.tag(Tab.noise)
NavigationStack {
ClockSettingsView(style: clockViewModel.style) { newStyle in
clockViewModel.updateStyle(newStyle)
NavigationStack {
ClockSettingsView(
style: clockViewModel.style,
onCommit: { newStyle in
clockViewModel.updateStyle(newStyle)
},
onResetOnboarding: {
onboardingState.reset()
}
)
}
.tabItem {
Label("Settings", systemImage: "gearshape")
}
.tag(Tab.settings)
}
.onChange(of: selectedTab) { oldValue, newValue in
Design.debugLog("[ContentView] Tab changed: \(oldValue) -> \(newValue)")
if oldValue == .clock && newValue != .clock {
Design.debugLog("[ContentView] Leaving clock tab, setting displayMode to false")
// Safety net: also explicitly disable display mode when leaving clock tab
// The ClockView's toolbar modifier already responds to isOnClockTab changing
clockViewModel.setDisplayMode(false)
}
}
.tabItem {
Label("Settings", systemImage: "gearshape")
}
.tag(Tab.settings)
}
.onChange(of: selectedTab) { oldValue, newValue in
if oldValue == .clock && newValue != .clock {
clockViewModel.setDisplayMode(false)
.accentColor(AppAccent.primary)
.background(Color.Branding.primary.ignoresSafeArea())
// Note: AlarmKit handles the alarm UI via the system Lock Screen and Dynamic Island.
// No in-app alarm screen is needed - users interact with alarms via the system UI.
// Onboarding overlay for first-time users
if !onboardingState.hasCompletedWelcome {
OnboardingView {
onboardingState.completeWelcome()
}
.transition(.opacity)
}
}
.accentColor(AppAccent.primary)
.background(Color.Branding.primary.ignoresSafeArea())
.sheet(isPresented: $keepAwakePromptState.isPresented) {
KeepAwakePrompt(
onEnable: {
clockViewModel.setKeepAwakeEnabled(true)
keepAwakePromptState.dismiss()
},
onDismiss: {
keepAwakePromptState.dismiss()
}
)
}
.task {
Design.debugLog("[ContentView] App launched - initializing AlarmKit")
// Reschedule all enabled alarms with AlarmKit on app launch
await alarmViewModel.rescheduleAllAlarms()
Design.debugLog("[ContentView] AlarmKit initialization complete")
}
.onReceive(NotificationCenter.default.publisher(for: .keepAwakePromptRequested)) { _ in
guard onboardingState.hasCompletedWelcome else { return }
guard shouldShowKeepAwakePromptForTab() else { return }
keepAwakePromptState.showIfNeeded(isKeepAwakeEnabled: clockViewModel.style.keepAwake)
}
.animation(.easeInOut(duration: 0.3), value: onboardingState.hasCompletedWelcome)
}
private func shouldShowKeepAwakePromptForTab() -> Bool {
switch selectedTab {
case .clock, .alarms:
return true
case .noise, .settings:
return false
}
}
}

View File

@ -4,6 +4,9 @@
//
// Created by Matt Bruce on 9/7/25.
//
// AlarmKit handles all alarm UI and sound playback.
// No notification delegate is needed with AlarmKit (iOS 26+).
//
import SwiftUI
import Bedrock
@ -12,12 +15,6 @@ import Bedrock
@main
struct TheNoiseClockApp: App {
// MARK: - Initialization
init() {
// Initialize notification delegate to handle snooze actions
_ = NotificationDelegate.shared
}
// MARK: - Body
var body: some Scene {
WindowGroup {

View 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"
}
}
}

View 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)"
}
}
}

View File

@ -4,12 +4,17 @@
//
// Created by Matt Bruce on 9/7/25.
//
// NOTE: This service now only handles alarm persistence.
// Alarm scheduling is handled by AlarmKitService (iOS 26+).
// The old notification scheduling code has been removed.
//
import Foundation
import UserNotifications
import Observation
import Bedrock
/// Service for managing alarms and notifications
/// Service for managing alarm persistence.
/// Alarm scheduling is handled by AlarmKitService.
@Observable
class AlarmService {
@ -17,48 +22,48 @@ class AlarmService {
private(set) var alarms: [Alarm] = []
private var alarmLookup: [UUID: Int] = [:]
private var persistenceWorkItem: DispatchWorkItem?
private let focusModeService = FocusModeService.shared
// MARK: - Initialization
init() {
loadAlarms()
Task {
// Request permissions through FocusModeService for better compatibility
let granted = await focusModeService.requestNotificationPermissions()
if !granted {
// Fallback to original method
_ = await NotificationUtils.requestPermissions()
}
}
Design.debugLog("[alarms] AlarmService initialized with \(alarms.count) alarms")
}
// MARK: - Public Interface
/// Add an alarm to storage. Does NOT schedule - caller should use AlarmKitService.
func addAlarm(_ alarm: Alarm) {
Design.debugLog("[alarms] AlarmService.addAlarm: \(alarm.label) at \(alarm.time)")
alarms.append(alarm)
updateAlarmLookup()
scheduleNotification(for: alarm)
saveAlarms()
}
/// Update an alarm in storage. Does NOT reschedule - caller should use AlarmKitService.
func updateAlarm(_ alarm: Alarm) {
guard let index = alarmLookup[alarm.id] else { return }
guard let index = alarmLookup[alarm.id] else {
Design.debugLog("[alarms] AlarmService.updateAlarm: alarm not found \(alarm.id)")
return
}
Design.debugLog("[alarms] AlarmService.updateAlarm: \(alarm.label) enabled=\(alarm.isEnabled)")
alarms[index] = alarm
updateAlarmLookup()
scheduleNotification(for: alarm)
saveAlarms()
}
/// Delete an alarm from storage. Does NOT cancel - caller should use AlarmKitService.
func deleteAlarm(id: UUID) {
Design.debugLog("[alarms] AlarmService.deleteAlarm: \(id)")
alarms.removeAll { $0.id == id }
updateAlarmLookup()
NotificationUtils.removeNotification(identifier: id.uuidString)
saveAlarms()
}
/// Toggle an alarm's enabled state. Does NOT reschedule - caller should use AlarmKitService.
func toggleAlarm(id: UUID) {
guard let index = alarmLookup[id] else { return }
alarms[index].isEnabled.toggle()
scheduleNotification(for: alarms[index])
Design.debugLog("[alarms] AlarmService.toggleAlarm: \(id) now enabled=\(alarms[index].isEnabled)")
saveAlarms()
}
@ -74,36 +79,6 @@ class AlarmService {
}
}
private func scheduleNotification(for alarm: Alarm) {
// Remove existing notification
NotificationUtils.removeNotification(identifier: alarm.id.uuidString)
// Schedule new notification if enabled
if alarm.isEnabled {
Task {
let respectFocusModes = currentRespectFocusModes()
// Use FocusModeService for better Focus mode compatibility
focusModeService.scheduleAlarmNotification(
identifier: alarm.id.uuidString,
title: alarm.label,
body: alarm.notificationMessage,
date: alarm.time,
soundName: alarm.soundName,
repeats: false, // For now, set to false since Alarm model doesn't have repeatDays
respectFocusModes: respectFocusModes
)
}
}
}
private func currentRespectFocusModes() -> Bool {
guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey),
let style = try? JSONDecoder().decode(ClockStyle.self, from: data) else {
return ClockStyle().respectFocusModes
}
return style.respectFocusModes
}
private func saveAlarms() {
persistenceWorkItem?.cancel()
@ -124,13 +99,37 @@ class AlarmService {
private func loadAlarms() {
if let savedAlarms = UserDefaults.standard.data(forKey: AppConstants.StorageKeys.savedAlarms),
let decodedAlarms = try? JSONDecoder().decode([Alarm].self, from: savedAlarms) {
alarms = decodedAlarms
updateAlarmLookup()
// Reschedule all enabled alarms
for alarm in alarms where alarm.isEnabled {
scheduleNotification(for: alarm)
// Migrate sound file extensions from .caf to .mp3
alarms = decodedAlarms.map { alarm in
var migratedAlarm = alarm
migratedAlarm.soundName = migrateSoundName(alarm.soundName)
return migratedAlarm
}
updateAlarmLookup()
Design.debugLog("[alarms] Loaded \(alarms.count) alarms from storage")
// Save migrated alarms if any changes were made
let needsMigration = zip(decodedAlarms, alarms).contains { $0.soundName != $1.soundName }
if needsMigration {
Design.debugLog("[alarms] Sound file migration applied, saving...")
saveAlarms()
}
// Note: AlarmKit scheduling is handled by AlarmViewModel.rescheduleAllAlarms()
}
}
/// Migrate sound file names from .caf to .mp3
private func migrateSoundName(_ soundName: String) -> String {
if soundName.hasSuffix(".caf") {
let migrated = soundName.replacingOccurrences(of: ".caf", with: ".mp3")
Design.debugLog("[alarms] Migrating sound: \(soundName) -> \(migrated)")
return migrated
}
return soundName
}
/// Get all enabled alarms (for rescheduling with AlarmKit)
func getEnabledAlarms() -> [Alarm] {
return alarms.filter { $0.isEnabled }
}
}

View File

@ -58,7 +58,7 @@ class AlarmSoundService {
do {
let data = try Data(contentsOf: url)
let settings = try JSONDecoder().decode(AudioSettings.self, from: data)
Design.debugLog("[settings] Loaded audio settings for alarms from SoundsSettings.json")
//Design.debugLog("[settings] Loaded audio settings for alarms from SoundsSettings.json")
return settings
} catch {
Design.debugLog("[general] Warning: Error loading audio settings for alarms, using defaults: \(error)")
@ -114,4 +114,72 @@ class AlarmSoundService {
}
return fileName.replacingOccurrences(of: ".caf", with: "").capitalized
}
/// Get alarm sound by filename
func getAlarmSound(fileName: String) -> Sound? {
return getAlarmSounds().first { $0.fileName == fileName }
}
/// Get the file path URL for a sound by filename
/// - Parameter fileName: The sound filename (e.g., "classic-alarm.mp3")
/// - Returns: The file URL if found, nil otherwise
func getSoundPath(for fileName: String) -> URL? {
Design.debugLog("[audio] Looking for sound file: \(fileName)")
// Try AlarmSounds.bundle first
if let bundleURL = Bundle.main.url(forResource: "AlarmSounds", withExtension: "bundle"),
let alarmBundle = Bundle(url: bundleURL) {
// Try with full filename
let nameWithoutExtension = (fileName as NSString).deletingPathExtension
let ext = (fileName as NSString).pathExtension
if let url = alarmBundle.url(forResource: nameWithoutExtension, withExtension: ext) {
Design.debugLog("[audio] ✅ Found in AlarmSounds.bundle: \(url)")
return url
}
Design.debugLog("[audio] Not found in AlarmSounds.bundle with extension '\(ext)'")
// Try common extensions
for tryExt in ["mp3", "caf", "wav", "m4a"] {
if let url = alarmBundle.url(forResource: nameWithoutExtension, withExtension: tryExt) {
Design.debugLog("[audio] ✅ Found in AlarmSounds.bundle with .\(tryExt): \(url)")
return url
}
}
}
// Try AlarmSounds folder in main bundle
let nameWithoutExtension = (fileName as NSString).deletingPathExtension
let ext = (fileName as NSString).pathExtension
if let url = Bundle.main.url(forResource: "AlarmSounds/\(nameWithoutExtension)", withExtension: ext) {
Design.debugLog("[audio] ✅ Found in AlarmSounds folder: \(url)")
return url
}
// Try main bundle directly
if let url = Bundle.main.url(forResource: nameWithoutExtension, withExtension: ext) {
Design.debugLog("[audio] ✅ Found in main bundle: \(url)")
return url
}
Design.debugLog("[audio] ❌ Sound file not found: \(fileName)")
return nil
}
/// Log all available alarm sounds
func logAvailableSounds() {
Design.debugLog("[audio] ========== AVAILABLE ALARM SOUNDS ==========")
let sounds = getAlarmSounds()
Design.debugLog("[audio] Found \(sounds.count) alarm sound(s)")
for sound in sounds {
Design.debugLog("[audio] - \(sound.name): \(sound.fileName)")
if let path = getSoundPath(for: sound.fileName) {
Design.debugLog("[audio] Path: \(path.lastPathComponent)")
} else {
Design.debugLog("[audio] ⚠️ FILE NOT FOUND")
}
}
}
}

View File

@ -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.
"""
}
}

View File

@ -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)")
}
}
}

View File

@ -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()
}
}

View File

@ -5,16 +5,25 @@
// Created by Matt Bruce on 9/7/25.
//
import AlarmKit
import Bedrock
import Foundation
import Observation
/// ViewModel for alarm management
/// ViewModel for alarm management using AlarmKit (iOS 26+).
/// AlarmKit provides alarms that cut through Focus modes and silent mode,
/// with built-in Live Activity countdown and system alarm UI.
@Observable
class AlarmViewModel {
// MARK: - Properties
private let alarmService: AlarmService
private let notificationService: NotificationService
private let alarmKitService = AlarmKitService.shared
/// Whether AlarmKit is authorized
var isAlarmKitAuthorized: Bool {
alarmKitService.authorizationState == .authorized
}
var alarms: [Alarm] {
alarmService.alarms
@ -23,53 +32,54 @@ class AlarmViewModel {
var systemSounds: [String] {
AppConstants.SystemSounds.availableSounds
}
// MARK: - Initialization
init(alarmService: AlarmService = AlarmService(),
notificationService: NotificationService = NotificationService()) {
init(alarmService: AlarmService = AlarmService()) {
self.alarmService = alarmService
self.notificationService = notificationService
// Register alarm service with notification delegate for snooze handling
NotificationDelegate.shared.setAlarmService(alarmService)
}
// MARK: - Public Interface
// MARK: - Authorization
/// Request AlarmKit authorization. Should be called during onboarding.
func requestAlarmKitAuthorization() async -> Bool {
return await alarmKitService.requestAuthorization()
}
// MARK: - Alarm CRUD Operations
func addAlarm(_ alarm: Alarm) async {
alarmService.addAlarm(alarm)
// Schedule notification if alarm is enabled
// Schedule with AlarmKit if alarm is enabled
if alarm.isEnabled {
await notificationService.scheduleAlarmNotification(
id: alarm.id.uuidString,
title: alarm.label,
body: alarm.notificationMessage,
soundName: alarm.soundName,
date: alarm.time
)
Design.debugLog("[alarms] Scheduling AlarmKit alarm for \(alarm.label)")
do {
try await alarmKitService.scheduleAlarm(alarm)
} catch {
Design.debugLog("[alarms] AlarmKit scheduling failed: \(error)")
}
}
}
func updateAlarm(_ alarm: Alarm) async {
alarmService.updateAlarm(alarm)
// Reschedule notification
// Cancel existing and reschedule if enabled
alarmKitService.cancelAlarm(id: alarm.id)
if alarm.isEnabled {
await notificationService.scheduleAlarmNotification(
id: alarm.id.uuidString,
title: alarm.label,
body: alarm.notificationMessage,
soundName: alarm.soundName,
date: alarm.time
)
} else {
notificationService.cancelNotification(id: alarm.id.uuidString)
Design.debugLog("[alarms] Rescheduling AlarmKit alarm for \(alarm.label)")
do {
try await alarmKitService.scheduleAlarm(alarm)
} catch {
Design.debugLog("[alarms] AlarmKit rescheduling failed: \(error)")
}
}
}
func deleteAlarm(id: UUID) async {
// Cancel notification first
notificationService.cancelNotification(id: id.uuidString)
// Cancel AlarmKit alarm first
alarmKitService.cancelAlarm(id: id)
// Then delete from storage
alarmService.deleteAlarm(id: id)
@ -81,17 +91,16 @@ class AlarmViewModel {
alarm.isEnabled.toggle()
alarmService.updateAlarm(alarm)
// Schedule or cancel notification based on new state
// Schedule or cancel based on new state
if alarm.isEnabled {
await notificationService.scheduleAlarmNotification(
id: alarm.id.uuidString,
title: alarm.label,
body: alarm.notificationMessage,
soundName: alarm.soundName,
date: alarm.time
)
Design.debugLog("[alarms] Enabling AlarmKit alarm \(alarm.label)")
do {
try await alarmKitService.scheduleAlarm(alarm)
} catch {
Design.debugLog("[alarms] AlarmKit scheduling failed: \(error)")
}
} else {
notificationService.cancelNotification(id: id.uuidString)
alarmKitService.cancelAlarm(id: id)
}
}
@ -123,7 +132,26 @@ class AlarmViewModel {
)
}
func requestNotificationPermissions() async -> Bool {
return await notificationService.requestPermissions()
// MARK: - App Lifecycle
/// Reschedule all enabled alarms with AlarmKit.
/// Call this on app launch to ensure alarms are registered.
func rescheduleAllAlarms() async {
Design.debugLog("[alarmkit] ========== RESCHEDULING ALL ALARMS ==========")
let enabledAlarms = alarmService.getEnabledAlarms()
Design.debugLog("[alarmkit] Found \(enabledAlarms.count) enabled alarm(s)")
for alarm in enabledAlarms {
Design.debugLog("[alarmkit] Scheduling: \(alarm.label) at \(alarm.time)")
do {
try await alarmKitService.scheduleAlarm(alarm)
} catch {
Design.debugLog("[alarmkit] ❌ Failed to reschedule \(alarm.label): \(error)")
}
}
Design.debugLog("[alarmkit] ========== RESCHEDULING COMPLETE ==========")
alarmKitService.logCurrentAlarms()
}
}

View File

@ -7,6 +7,7 @@
import SwiftUI
import AudioPlaybackKit
import Foundation
/// View for creating new alarms with iOS-native style interface
struct AddAlarmView: View {
@ -14,6 +15,7 @@ struct AddAlarmView: View {
// MARK: - Properties
let viewModel: AlarmViewModel
@Binding var isPresented: Bool
@AppStorage(ClockStyle.appStorageKey) private var clockStyleData: Data = Data()
@State private var newAlarmTime = Calendar.current.date(bySettingHour: 6, minute: 0, second: 0, of: Date()) ?? Date()
@State private var selectedSoundName = "digital-alarm.caf"
@ -33,6 +35,15 @@ struct AddAlarmView: View {
// List for settings below
List {
if !isKeepAwakeEnabled {
Section {
AlarmLimitationsBanner()
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
// Label Section
NavigationLink(destination: LabelEditView(label: $alarmLabel)) {
HStack {
@ -127,4 +138,11 @@ struct AddAlarmView: View {
private func getSoundDisplayName(_ fileName: String) -> String {
return AlarmSoundService.shared.getSoundDisplayName(fileName)
}
private var isKeepAwakeEnabled: Bool {
guard let decoded = try? JSONDecoder().decode(ClockStyle.self, from: clockStyleData) else {
return ClockStyle().keepAwake
}
return decoded.keepAwake
}
}

View File

@ -7,31 +7,48 @@
import SwiftUI
import Bedrock
import Foundation
/// Main alarm management view
struct AlarmView: View {
// MARK: - Properties
@State private var viewModel = AlarmViewModel()
@Bindable var viewModel: AlarmViewModel
@State private var showAddAlarm = false
@State private var selectedAlarmForEdit: Alarm?
@AppStorage(ClockStyle.appStorageKey) private var clockStyleData: Data = Data()
// MARK: - Body
var body: some View {
let isPad = UIDevice.current.userInterfaceIdiom == .pad
Group {
if viewModel.alarms.isEmpty {
EmptyAlarmsView {
showAddAlarm = true
}
.contentShape(Rectangle())
.onTapGesture {
showAddAlarm = true
VStack(spacing: Design.Spacing.large) {
if !isKeepAwakeEnabled {
AlarmLimitationsBanner()
}
EmptyAlarmsView {
showAddAlarm = true
}
.contentShape(Rectangle())
.onTapGesture {
showAddAlarm = true
}
}
.frame(maxWidth: Design.Size.maxContentWidthPortrait)
.frame(maxWidth: .infinity, alignment: .center)
} else {
List {
if !isKeepAwakeEnabled {
Section {
AlarmLimitationsBanner()
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
ForEach(viewModel.alarms) { alarm in
AlarmRowView(
alarm: alarm,
@ -47,6 +64,7 @@ struct AlarmView: View {
}
.onDelete(perform: deleteAlarm)
}
.listStyle(.insetGrouped)
.frame(maxWidth: Design.Size.maxContentWidthPortrait)
.frame(maxWidth: .infinity, alignment: .center)
}
@ -65,7 +83,8 @@ struct AlarmView: View {
}
.onAppear {
Task {
await viewModel.requestNotificationPermissions()
// Request AlarmKit authorization when the alarms tab appears
await viewModel.requestAlarmKitAuthorization()
}
}
.sheet(isPresented: $showAddAlarm) {
@ -91,11 +110,18 @@ struct AlarmView: View {
}
}
}
private var isKeepAwakeEnabled: Bool {
guard let decoded = try? JSONDecoder().decode(ClockStyle.self, from: clockStyleData) else {
return ClockStyle().keepAwake
}
return decoded.keepAwake
}
}
// MARK: - Preview
#Preview {
NavigationStack {
AlarmView()
AlarmView(viewModel: AlarmViewModel())
}
}

View File

@ -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)
}

View File

@ -7,6 +7,7 @@
import SwiftUI
import Bedrock
import Foundation
/// Component for displaying individual alarm row
struct AlarmRowView: View {
@ -15,6 +16,7 @@ struct AlarmRowView: View {
let alarm: Alarm
let onToggle: () -> Void
let onEdit: () -> Void
@AppStorage(ClockStyle.appStorageKey) private var clockStyleData: Data = Data()
// MARK: - Body
var body: some View {
@ -31,6 +33,17 @@ struct AlarmRowView: View {
Text("\(AlarmSoundService.shared.getSoundDisplayName(alarm.soundName))")
.font(.caption)
.foregroundColor(AppTextColors.secondary)
if alarm.isEnabled && !isKeepAwakeEnabled {
HStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.caption2)
.foregroundStyle(AppStatus.warning)
Text("Foreground only for full alarm sound")
.font(.caption2)
.foregroundStyle(AppTextColors.tertiary)
}
}
}
Spacer()
@ -47,6 +60,13 @@ struct AlarmRowView: View {
}
}
private var isKeepAwakeEnabled: Bool {
guard let decoded = try? JSONDecoder().decode(ClockStyle.self, from: clockStyleData) else {
return ClockStyle().keepAwake
}
return decoded.keepAwake
}
}
// MARK: - Preview

View File

@ -7,6 +7,7 @@
import SwiftUI
import AudioPlaybackKit
import Foundation
/// View for editing existing alarms
struct EditAlarmView: View {
@ -15,6 +16,7 @@ struct EditAlarmView: View {
let viewModel: AlarmViewModel
let alarm: Alarm
@Environment(\.dismiss) private var dismiss
@AppStorage(ClockStyle.appStorageKey) private var clockStyleData: Data = Data()
@State private var alarmTime: Date
@State private var selectedSoundName: String
@ -51,6 +53,15 @@ struct EditAlarmView: View {
// List for settings below
List {
if !isKeepAwakeEnabled {
Section {
AlarmLimitationsBanner()
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
// Label Section
NavigationLink(destination: LabelEditView(label: $alarmLabel)) {
HStack {
@ -147,6 +158,13 @@ struct EditAlarmView: View {
private func getSoundDisplayName(_ fileName: String) -> String {
return AlarmSoundService.shared.getSoundDisplayName(fileName)
}
private var isKeepAwakeEnabled: Bool {
guard let decoded = try? JSONDecoder().decode(ClockStyle.self, from: clockStyleData) else {
return ClockStyle().keepAwake
}
return decoded.keepAwake
}
}
// MARK: - Preview

View File

@ -14,9 +14,9 @@ import Bedrock
class ClockStyle: Codable, Equatable {
// MARK: - Time Format Settings
var use24Hour: Bool = true
var use24Hour: Bool = false
var showSeconds: Bool = false
var showAmPm: Bool = true
var showAmPm: Bool = false
var forceHorizontalMode: Bool = false // Force horizontal layout even in portrait
// MARK: - Visual Settings
@ -53,6 +53,7 @@ class ClockStyle: Codable, Equatable {
// MARK: - Display Settings
var keepAwake: Bool = false // Keep screen awake in display mode
var respectFocusModes: Bool = true // Respect Focus mode settings for audio
var liveActivitiesEnabled: Bool = false // Show active alarm in Dynamic Island/Lock Screen
// MARK: - Cached Colors
private var _cachedDigitColor: Color?
@ -87,6 +88,7 @@ class ClockStyle: Codable, Equatable {
case overlayOpacity
case keepAwake
case respectFocusModes
case liveActivitiesEnabled
}
// MARK: - Initialization
@ -138,6 +140,7 @@ class ClockStyle: Codable, Equatable {
self.overlayOpacity = try container.decodeIfPresent(Double.self, forKey: .overlayOpacity) ?? self.overlayOpacity
self.keepAwake = try container.decodeIfPresent(Bool.self, forKey: .keepAwake) ?? self.keepAwake
self.respectFocusModes = try container.decodeIfPresent(Bool.self, forKey: .respectFocusModes) ?? self.respectFocusModes
self.liveActivitiesEnabled = try container.decodeIfPresent(Bool.self, forKey: .liveActivitiesEnabled) ?? self.liveActivitiesEnabled
clearColorCache()
}
@ -171,6 +174,7 @@ class ClockStyle: Codable, Equatable {
try container.encode(overlayOpacity, forKey: .overlayOpacity)
try container.encode(keepAwake, forKey: .keepAwake)
try container.encode(respectFocusModes, forKey: .respectFocusModes)
try container.encode(liveActivitiesEnabled, forKey: .liveActivitiesEnabled)
}
// MARK: - Computed Properties
@ -341,19 +345,19 @@ class ClockStyle: Codable, Equatable {
/// Get the effective brightness considering color theme and night mode
var effectiveBrightness: Double {
if !autoBrightness {
Design.debugLog("[brightness] effectiveBrightness: Auto-brightness disabled, returning 1.0")
//Design.debugLog("[brightness] effectiveBrightness: Auto-brightness disabled, returning 1.0")
return 1.0 // Full brightness when auto-brightness is disabled
}
if isNightModeActive {
Design.debugLog("[brightness] effectiveBrightness: Night mode active, returning 0.3")
//Design.debugLog("[brightness] effectiveBrightness: Night mode active, returning 0.3")
// Dim the display to 30% brightness in night mode
return 0.3
}
// Color-aware brightness adaptation
let colorAwareBrightness = getColorAwareBrightness()
Design.debugLog("[brightness] effectiveBrightness: Color-aware brightness = \(String(format: "%.2f", colorAwareBrightness))")
//Design.debugLog("[brightness] effectiveBrightness: Color-aware brightness = \(String(format: "%.2f", colorAwareBrightness))")
return colorAwareBrightness
}
@ -463,7 +467,8 @@ class ClockStyle: Codable, Equatable {
lhs.clockOpacity == rhs.clockOpacity &&
lhs.overlayOpacity == rhs.overlayOpacity &&
lhs.keepAwake == rhs.keepAwake &&
lhs.respectFocusModes == rhs.respectFocusModes
lhs.respectFocusModes == rhs.respectFocusModes &&
lhs.liveActivitiesEnabled == rhs.liveActivitiesEnabled
}
}

View File

@ -60,16 +60,16 @@ class AmbientLightService {
let clampedBrightness = max(0.0, min(1.0, brightness))
let previousBrightness = UIScreen.main.brightness
Design.debugLog("[ambient] AmbientLightService.setBrightness:")
Design.debugLog("[ambient] - Requested brightness: \(String(format: "%.2f", brightness))")
Design.debugLog("[ambient] - Clamped brightness: \(String(format: "%.2f", clampedBrightness))")
Design.debugLog("[ambient] - Previous screen brightness: \(String(format: "%.2f", previousBrightness))")
// Design.debugLog("[ambient] AmbientLightService.setBrightness:")
// Design.debugLog("[ambient] - Requested brightness: \(String(format: "%.2f", brightness))")
// Design.debugLog("[ambient] - Clamped brightness: \(String(format: "%.2f", clampedBrightness))")
// Design.debugLog("[ambient] - Previous screen brightness: \(String(format: "%.2f", previousBrightness))")
UIScreen.main.brightness = clampedBrightness
currentBrightness = clampedBrightness
Design.debugLog("[ambient] - New screen brightness: \(String(format: "%.2f", UIScreen.main.brightness))")
Design.debugLog("[ambient] - Service currentBrightness: \(String(format: "%.2f", currentBrightness))")
// Design.debugLog("[ambient] - New screen brightness: \(String(format: "%.2f", UIScreen.main.brightness))")
// Design.debugLog("[ambient] - Service currentBrightness: \(String(format: "%.2f", currentBrightness))")
}
/// Get current screen brightness
@ -90,7 +90,7 @@ class AmbientLightService {
let previousBrightness = currentBrightness
currentBrightness = newBrightness
Design.debugLog("[ambient] AmbientLightService: Brightness changed from \(String(format: "%.2f", previousBrightness)) to \(String(format: "%.2f", newBrightness))")
//Design.debugLog("[ambient] AmbientLightService: Brightness changed from \(String(format: "%.2f", previousBrightness)) to \(String(format: "%.2f", newBrightness))")
// Notify that brightness changed
onBrightnessChange?()

View File

@ -32,6 +32,7 @@ class ClockViewModel {
private var minuteTimer: Timer.TimerPublisher?
private var secondCancellable: AnyCancellable?
private var minuteCancellable: AnyCancellable?
private var styleObserver: NSObjectProtocol?
// Persistence
private var persistenceWorkItem: DispatchWorkItem?
@ -52,29 +53,45 @@ class ClockViewModel {
loadStyle()
setupTimers()
startAmbientLightMonitoring()
observeStyleUpdates()
}
deinit {
stopTimers()
stopAmbientLightMonitoring()
if let styleObserver {
NotificationCenter.default.removeObserver(styleObserver)
}
}
// MARK: - Public Interface
func toggleDisplayMode() {
let oldValue = isDisplayMode
withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) {
isDisplayMode.toggle()
}
Design.debugLog("[ClockViewModel] toggleDisplayMode: \(oldValue) -> \(isDisplayMode)")
// Manage wake lock based on display mode and keep awake setting
updateWakeLockState()
if isDisplayMode {
requestKeepAwakePromptIfNeeded()
}
}
func setDisplayMode(_ enabled: Bool) {
guard isDisplayMode != enabled else { return }
guard isDisplayMode != enabled else {
Design.debugLog("[ClockViewModel] setDisplayMode(\(enabled)) - already at this value, skipping")
return
}
Design.debugLog("[ClockViewModel] setDisplayMode: \(isDisplayMode) -> \(enabled)")
withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) {
isDisplayMode = enabled
}
updateWakeLockState()
if enabled {
requestKeepAwakePromptIfNeeded()
}
}
func updateStyle(_ newStyle: ClockStyle) {
@ -108,6 +125,7 @@ class ClockViewModel {
style.digitAnimationStyle = newStyle.digitAnimationStyle
style.dateFormat = newStyle.dateFormat
style.respectFocusModes = newStyle.respectFocusModes
style.liveActivitiesEnabled = newStyle.liveActivitiesEnabled
saveStyle()
@ -116,6 +134,12 @@ class ClockViewModel {
updateBrightness() // Update brightness when style changes
}
func setKeepAwakeEnabled(_ enabled: Bool) {
style.keepAwake = enabled
saveStyle()
updateWakeLockState()
}
// MARK: - Private Methods
private func loadStyle() {
if let decoded = try? JSONDecoder().decode(ClockStyle.self, from: styleJSON) {
@ -126,6 +150,19 @@ class ClockViewModel {
}
}
private func observeStyleUpdates() {
styleObserver = NotificationCenter.default.addObserver(
forName: .clockStyleDidUpdate,
object: nil,
queue: .main
) { [weak self] _ in
self?.loadStyle()
self?.updateTimersIfNeeded()
self?.updateWakeLockState()
self?.updateBrightness()
}
}
func saveStyle() {
persistenceWorkItem?.cancel()
@ -193,6 +230,11 @@ class ClockViewModel {
}
}
private func requestKeepAwakePromptIfNeeded() {
guard !style.keepAwake else { return }
NotificationCenter.default.post(name: .keepAwakePromptRequested, object: nil)
}
/// Update wake lock state based on current settings
private func updateWakeLockState() {
// Enable wake lock if in display mode and keep awake is enabled
@ -209,7 +251,7 @@ class ClockViewModel {
// Set up callback to respond to brightness changes
ambientLightService.onBrightnessChange = { [weak self] in
Design.debugLog("[brightness] ClockViewModel: Received brightness change notification")
//Design.debugLog("[brightness] ClockViewModel: Received brightness change notification")
self?.updateBrightness()
}
}
@ -226,21 +268,21 @@ class ClockViewModel {
let currentScreenBrightness = UIScreen.main.brightness
let isNightMode = style.isNightModeActive
Design.debugLog("[brightness] Auto Brightness Debug:")
Design.debugLog("[brightness] - Auto brightness enabled: \(style.autoBrightness)")
Design.debugLog("[brightness] - Current screen brightness: \(String(format: "%.2f", currentScreenBrightness))")
Design.debugLog("[brightness] - Target brightness: \(String(format: "%.2f", targetBrightness))")
Design.debugLog("[brightness] - Night mode active: \(isNightMode)")
Design.debugLog("[brightness] - Color theme: \(style.selectedColorTheme)")
Design.debugLog("[brightness] - Ambient light threshold: \(String(format: "%.2f", style.ambientLightThreshold))")
// Design.debugLog("[brightness] Auto Brightness Debug:")
// Design.debugLog("[brightness] - Auto brightness enabled: \(style.autoBrightness)")
// Design.debugLog("[brightness] - Current screen brightness: \(String(format: "%.2f", currentScreenBrightness))")
// Design.debugLog("[brightness] - Target brightness: \(String(format: "%.2f", targetBrightness))")
// Design.debugLog("[brightness] - Night mode active: \(isNightMode)")
// Design.debugLog("[brightness] - Color theme: \(style.selectedColorTheme)")
// Design.debugLog("[brightness] - Ambient light threshold: \(String(format: "%.2f", style.ambientLightThreshold))")
ambientLightService.setBrightness(targetBrightness)
Design.debugLog("[brightness] - Brightness set to: \(String(format: "%.2f", targetBrightness))")
Design.debugLog("[brightness] - Actual screen brightness now: \(String(format: "%.2f", UIScreen.main.brightness))")
Design.debugLog("[brightness] ---")
} else {
Design.debugLog("[brightness] Auto Brightness: DISABLED")
// Design.debugLog("[brightness] - Brightness set to: \(String(format: "%.2f", targetBrightness))")
// Design.debugLog("[brightness] - Actual screen brightness now: \(String(format: "%.2f", UIScreen.main.brightness))")
// Design.debugLog("[brightness] ---")
// } else {
// Design.debugLog("[brightness] Auto Brightness: DISABLED")
}
}
}

View File

@ -14,15 +14,20 @@ struct ClockSettingsView: View {
// MARK: - Properties
@State private var style: ClockStyle
let onCommit: (ClockStyle) -> Void
var onResetOnboarding: (() -> Void)?
@State private var digitColor: Color = .white
@State private var backgroundColor: Color = .black
@State private var showAdvancedSettings = false
// MARK: - Init
init(style: ClockStyle, onCommit: @escaping (ClockStyle) -> Void) {
init(
style: ClockStyle,
onCommit: @escaping (ClockStyle) -> Void,
onResetOnboarding: (() -> Void)? = nil
) {
self._style = State(initialValue: style)
self.onCommit = onCommit
self.onResetOnboarding = onResetOnboarding
}
// MARK: - Body
@ -36,34 +41,17 @@ struct ClockSettingsView: View {
backgroundColor: $backgroundColor
)
FontSection(style: $style)
AdvancedAppearanceSection(style: $style)
BasicDisplaySection(style: $style)
if showAdvancedSettings {
AdvancedAppearanceSection(style: $style)
AdvancedDisplaySection(style: $style)
FontSection(style: $style)
NightModeSection(style: $style)
NightModeSection(style: $style)
OverlaySection(style: $style)
AdvancedDisplaySection(style: $style)
}
SettingsSectionHeader(
title: "Advanced",
systemImage: "gearshape",
accentColor: AppAccent.primary
)
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
SettingsToggle(
title: "Show Advanced Settings",
subtitle: "Reveal additional customization options",
isOn: $showAdvancedSettings,
accentColor: AppAccent.primary
)
}
OverlaySection(style: $style)
#if DEBUG
SettingsSectionHeader(
@ -92,6 +80,32 @@ struct ClockSettingsView: View {
appName: "TheNoiseClock"
)
}
if let onResetOnboarding {
Divider()
.background(AppBorder.subtle)
Button {
onResetOnboarding()
} label: {
HStack {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text("Reset Onboarding")
.typography(.body)
.foregroundStyle(AppTextColors.primary)
Text("Show onboarding screens again on next launch")
.typography(.caption)
.foregroundStyle(AppTextColors.secondary)
}
Spacer()
Image(systemName: "arrow.counterclockwise")
.foregroundStyle(AppAccent.primary)
}
.padding(Design.Spacing.medium)
.background(AppSurface.primary)
}
.buttonStyle(.plain)
}
}
#endif
}

View File

@ -16,8 +16,18 @@ struct ClockView: View {
// MARK: - Properties
@Bindable var viewModel: ClockViewModel
/// Whether this view is currently the selected tab - prevents race conditions on tab switch
let isOnClockTab: Bool
@State private var idleTimer: Timer?
@State private var didHandleTouch = false
@State private var isViewActive = false
/// Tab bar should ONLY be hidden when BOTH conditions are true:
/// 1. We're on the clock tab (prevents hiding when user switches away)
/// 2. Display mode is active
private var shouldHideTabBar: Bool {
isOnClockTab && viewModel.isDisplayMode
}
// MARK: - Body
var body: some View {
@ -65,22 +75,24 @@ struct ClockView: View {
)
}
}
.onAppear {
logClockLayout(size: geometry.size, safeAreaInsets: safeInsets)
}
.onChange(of: geometry.size) { _, newSize in
logClockLayout(size: newSize, safeAreaInsets: safeInsets)
}
.onChange(of: safeInsets) { _, newInsets in
logClockLayout(size: geometry.size, safeAreaInsets: newInsets)
}
// .onAppear {
// logClockLayout(size: geometry.size, safeAreaInsets: safeInsets)
// }
// .onChange(of: geometry.size) { _, newSize in
// logClockLayout(size: newSize, safeAreaInsets: safeInsets)
// }
// .onChange(of: safeInsets) { _, newInsets in
// logClockLayout(size: geometry.size, safeAreaInsets: newInsets)
// }
}
.ignoresSafeArea() // Extend GeometryReader to full screen, we handle safe areas manually
.toolbar(.hidden, for: .navigationBar)
.statusBarHidden(true)
.overlay {
// Tab bar management overlay
ClockTabBarManager(isDisplayMode: viewModel.isDisplayMode)
// Tab bar visibility controlled here but decision includes isOnClockTab from parent
// This prevents race conditions: when tab changes, isOnClockTab becomes false immediately
.toolbar(shouldHideTabBar ? .hidden : .visible, for: .tabBar)
.onChange(of: shouldHideTabBar) { oldValue, newValue in
Design.debugLog("[ClockView] shouldHideTabBar changed: \(oldValue) -> \(newValue) (isOnClockTab=\(isOnClockTab), isDisplayMode=\(viewModel.isDisplayMode))")
}
.simultaneousGesture(
DragGesture(minimumDistance: 0)
@ -94,9 +106,13 @@ struct ClockView: View {
}
)
.onAppear {
Design.debugLog("[ClockView] onAppear - setting isViewActive = true")
isViewActive = true
resetIdleTimer()
}
.onDisappear {
Design.debugLog("[ClockView] onDisappear - setting isViewActive = false, invalidating timer")
isViewActive = false
idleTimer?.invalidate()
idleTimer = nil
}
@ -121,7 +137,16 @@ struct ClockView: View {
}
private func enterDisplayModeFromIdle() {
guard !viewModel.isDisplayMode else { return }
// Guard against entering display mode if we're no longer on the clock tab
guard isViewActive else {
Design.debugLog("[ClockView] enterDisplayModeFromIdle - BLOCKED: view is not active (user switched tabs)")
return
}
guard !viewModel.isDisplayMode else {
Design.debugLog("[ClockView] enterDisplayModeFromIdle - BLOCKED: already in display mode")
return
}
Design.debugLog("[ClockView] enterDisplayModeFromIdle - entering display mode")
viewModel.toggleDisplayMode()
}
@ -181,7 +206,7 @@ struct ClockView: View {
// MARK: - Preview
#Preview {
NavigationStack {
ClockView(viewModel: ClockViewModel())
ClockView(viewModel: ClockViewModel(), isOnClockTab: true)
}
.frame(width: 400, height: 600)
.background(Color.black)

View File

@ -22,7 +22,7 @@ struct ClockOverlayContainer: View {
showBattery: style.showBattery,
showDate: style.showDate,
color: style.effectiveDigitColor,
opacity: style.overlayOpacity,
opacity: style.clockOpacity,
dateFormat: style.dateFormat
)
.padding(.top, Design.Spacing.small)

View File

@ -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)
}

View File

@ -27,6 +27,13 @@ struct AdvancedDisplaySection: View {
accentColor: AppAccent.primary
)
SettingsToggle(
title: "Live Activities",
subtitle: "Show alarms on Lock Screen/Dynamic Island while ringing",
isOn: $style.liveActivitiesEnabled,
accentColor: AppAccent.primary
)
if style.autoBrightness {
HStack {
Text("Current Brightness")
@ -40,7 +47,7 @@ struct AdvancedDisplaySection: View {
}
}
Text("Advanced display and system integration settings.")
Text("Advanced display and system integration settings. Keep Awake helps alarms stay active while the app remains open.")
.font(.caption)
.foregroundStyle(AppTextColors.tertiary)

View File

@ -22,16 +22,6 @@ struct OverlaySection: View {
)
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
SettingsSlider(
title: "Overlay Opacity",
subtitle: "Adjust battery and date visibility",
value: $style.overlayOpacity,
in: 0.0...1.0,
step: 0.01,
format: SliderFormat.percentage,
accentColor: AppAccent.primary
)
SettingsToggle(
title: "Battery Level",
subtitle: "Show battery percentage",

View File

@ -138,11 +138,13 @@ struct TimeDisplayView: View {
design: fontDesign,
fontSize: fontSize
)
let segmentWidth = fixedDigitWidth * 2 // Each segment has 2 digits
let segmentWidth = fixedDigitWidth * 2 // Minutes/seconds always 2 digits
// Hour width is dynamic based on actual digit count (1 or 2 digits)
let hourSegmentWidth = fixedDigitWidth * CGFloat(hour.count)
HStack(alignment: .center, spacing: 0) {
TimeSegment(text: hour, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode, animationStyle: animationStyle)
.frame(width: segmentWidth)
.frame(width: hourSegmentWidth)
ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: false)
.frame(width: dotDiameter)
TimeSegment(text: minute, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode, animationStyle: animationStyle)
@ -263,9 +265,9 @@ struct TimeDisplayView: View {
height: max(1, availableHeight / digitRows)
)
Design.debugLog("[clockLayout] calcFont size=\(String(format: "%.1f", containerSize.width))x\(String(format: "%.1f", containerSize.height)) portrait=\(portrait) seconds=\(showSeconds)")
Design.debugLog("[clockLayout] calcFont available=\(String(format: "%.1f", availableWidth))x\(String(format: "%.1f", availableHeight)) columns=\(String(format: "%.1f", digitColumns)) rows=\(String(format: "%.1f", digitRows)) colonCount=\(String(format: "%.1f", colonCount))")
Design.debugLog("[clockLayout] calcFont digitSize=\(String(format: "%.1f", digitSize.width))x\(String(format: "%.1f", digitSize.height)) colonSize=\(String(format: "%.1f", colonSize))")
//Design.debugLog("[clockLayout] calcFont size=\(String(format: "%.1f", containerSize.width))x\(String(format: "%.1f", containerSize.height)) portrait=\(portrait) seconds=\(showSeconds)")
//Design.debugLog("[clockLayout] calcFont available=\(String(format: "%.1f", availableWidth))x\(String(format: "%.1f", availableHeight)) columns=\(String(format: "%.1f", digitColumns)) rows=\(String(format: "%.1f", digitRows)) colonCount=\(String(format: "%.1f", colonCount))")
//Design.debugLog("[clockLayout] calcFont digitSize=\(String(format: "%.1f", digitSize.width))x\(String(format: "%.1f", digitSize.height)) colonSize=\(String(format: "%.1f", colonSize))")
return FontUtils.calculateOptimalFontSize(
digit: "8",
@ -308,17 +310,17 @@ struct TimeDisplayView: View {
if totalWidth > containerSize.width {
let scaleFactor = containerSize.width / totalWidth
estimated *= scaleFactor * 0.98 // Add 2% margin
Design.debugLog("[clockLayout] width overflow: totalWidth=\(Int(totalWidth)) container=\(Int(containerSize.width)) scaling by \(String(format: "%.2f", scaleFactor))")
//Design.debugLog("[clockLayout] width overflow: totalWidth=\(Int(totalWidth)) container=\(Int(containerSize.width)) scaling by \(String(format: "%.2f", scaleFactor))")
}
}
Design.debugLog("[clockLayout] calcFont estimatedFontSize=\(String(format: "%.1f", estimated))")
//Design.debugLog("[clockLayout] calcFont estimatedFontSize=\(String(format: "%.1f", estimated))")
if abs(estimated - fontSize) > 1 {
fontSize = estimated
Design.debugLog("[clockLayout] calcFont updated fontSize \(String(format: "%.1f", previousFontSize)) -> \(String(format: "%.1f", fontSize))")
//Design.debugLog("[clockLayout] calcFont updated fontSize \(String(format: "%.1f", previousFontSize)) -> \(String(format: "%.1f", fontSize))")
} else {
Design.debugLog("[clockLayout] calcFont skipped update (current=\(String(format: "%.1f", previousFontSize)))")
// Design.debugLog("[clockLayout] calcFont skipped update (current=\(String(format: "%.1f", previousFontSize)))")
}
lastCalculatedContainerSize = containerSize
}

View File

@ -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)
}

View 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)
}

View File

@ -12,5 +12,9 @@
<string>$(CLOUDKIT_CONTAINER_IDENTIFIER)</string>
<key>AppClipDomain</key>
<string>$(APPCLIP_DOMAIN)</string>
<key>NSSupportsLiveActivities</key>
<true/>
<key>NSAlarmKitUsageDescription</key>
<string>TheNoiseClock uses alarms to wake you up at your scheduled time, even when your device is in silent mode or Focus mode.</string>
</dict>
</plist>

View File

@ -3,43 +3,43 @@
{
"id": "digital-alarm",
"name": "Digital Alarm",
"fileName": "digital-alarm.caf",
"fileName": "digital-alarm.mp3",
"description": "Classic digital alarm sound",
"category": "alarm",
"bundleName": null,
"bundleName": "AlarmSounds",
"isDefault": true
},
{
"id": "buzzing-alarm",
"name": "Buzzing Alarm",
"fileName": "buzzing-alarm.caf",
"fileName": "buzzing-alarm.mp3",
"description": "Buzzing sound for gentle wake-up",
"category": "alarm",
"bundleName": null
"bundleName": "AlarmSounds"
},
{
"id": "classic-alarm",
"name": "Classic Alarm",
"fileName": "classic-alarm.caf",
"fileName": "classic-alarm.mp3",
"description": "Traditional alarm sound",
"category": "alarm",
"bundleName": null
"bundleName": "AlarmSounds"
},
{
"id": "beep-alarm",
"name": "Beep Alarm",
"fileName": "beep-alarm.caf",
"fileName": "beep-alarm.mp3",
"description": "Short beep alarm sound",
"category": "alarm",
"bundleName": null
"bundleName": "AlarmSounds"
},
{
"id": "siren-alarm",
"name": "Siren Alarm",
"fileName": "siren-alarm.caf",
"fileName": "siren-alarm.mp3",
"description": "Emergency siren alarm for heavy sleepers",
"category": "alarm",
"bundleName": null
"bundleName": "AlarmSounds"
}
]
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -12,7 +12,7 @@ enum AppConstants {
// MARK: - App Information
static let appName = "TheNoiseClock"
static let minimumIOSVersion = "18.0"
static let minimumIOSVersion = "26.0"
// MARK: - Storage Keys
enum StorageKeys {
@ -59,7 +59,7 @@ enum AppConstants {
// MARK: - System Sounds
enum SystemSounds {
static let defaultSound = "digital-alarm.caf"
static let defaultSound = "digital-alarm.mp3"
static let availableSounds = ["default", "bell", "chimes", "ding", "glass", "silence"]
}
}

View File

@ -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
}

View 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")
}

View 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: {})
}

View 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
}
}

View File

@ -36,15 +36,19 @@ enum NotificationUtils {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.categoryIdentifier = AlarmNotificationConstants.categoryIdentifier
if soundName == "default" {
content.sound = UNNotificationSound.default
Design.debugLog("[settings] Using default notification sound")
} else {
} else if Bundle.main.url(forResource: soundName, withExtension: nil) != nil {
// Use the sound name directly since sounds.json now references CAF files
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: soundName))
Design.debugLog("[settings] Using custom alarm sound: \(soundName)")
Design.debugLog("[settings] Sound file should be in main bundle: \(soundName)")
} else {
content.sound = UNNotificationSound.default
Design.debugLog("[settings] Alarm sound not found in main bundle, falling back to default: \(soundName)")
}
return content

View 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"
}
}
}

View 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)
}
}

View 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>

View 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
}

View 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()
}
}