Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-02-02 13:17:15 -06:00
parent a1cb0f4b1f
commit 5e45be0a2a
31 changed files with 982 additions and 537 deletions

30
PRD.md
View File

@ -89,38 +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
- **Background limitations**: Full alarm sound and screen require the app to be foregrounded; background alarms use notification sound
- **Inline alarm warnings**: Enabled alarms show a foreground-only warning when Keep Awake is off
- **Keep Awake prompt**: In-app popup enables Keep Awake without digging into settings
- **Keep Awake guidance**: Banner messaging explains why Keep Awake improves alarm reliability
- **Onboarding enablement**: Onboarding offers a one-tap Keep Awake enable action
- **Live Activity**: Dynamic Island/Lock Screen shows only while an alarm is ringing (user-enabled)
- **Live Activity availability**: Requires Live Activities permission in iOS Settings
- **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
- **Foreground alarm sound**: In-app playback of the selected alarm sound and volume
- **Notification tap routing**: Tapping an alarm notification opens the alarm screen
- **Foreground handling**: Alarm notifications surface as in-app UI instead of banners
- **App Intents**: StopAlarmIntent and SnoozeAlarmIntent for Live Activity button actions
## Advanced Clock Display Features
@ -412,7 +406,7 @@ TheNoiseClock/
│ │ ├── Models/
│ │ │ └── SoundCategory.swift # Shared sound category definitions
│ │ ├── LiveActivity/
│ │ │ └── AlarmActivityAttributes.swift # Live Activity attributes shared with widget
│ │ │ └── NoiseClockAlarmMetadata.swift # AlarmKit metadata shared with widget
│ │ └── Utilities/
│ │ ├── ColorUtils.swift # Color manipulation utilities
│ │ ├── NotificationUtils.swift # Notification helper functions
@ -460,7 +454,7 @@ TheNoiseClock/
│ │ │ ├── Services/
│ │ │ │ ├── AlarmService.swift
│ │ │ │ ├── AlarmSoundService.swift
│ │ │ │ ├── AlarmLiveActivityManager.swift
│ │ │ │ ├── AlarmKitService.swift # AlarmKit integration (iOS 26+)
│ │ │ │ ├── FocusModeService.swift
│ │ │ │ ├── NotificationService.swift
│ │ │ │ └── NotificationDelegate.swift

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,19 +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
- In-app alarm sound playback using the selected alarm sound
- Tapping alarm notifications opens the alarm screen
- Background limitations: full alarm sound/screen requires the app to be open in the foreground
- Keep Awake prompt enables staying on-screen for alarms
- Enabled alarm rows show a foreground-only warning when Keep Awake is off
- Live Activity shows only while an alarm is ringing (user-enabled)
- Live Activity requires Live Activities permission in iOS Settings
- Snooze support via AlarmKit's countdown feature
**Display Mode**
- Long-press to enter immersive display mode
@ -78,8 +75,8 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and
## Requirements
- iOS 18.0+
- Xcode 16+
- iOS 26.0+
- Xcode 26+
- Swift 6
---

View File

@ -10,7 +10,6 @@
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, ); }; };
EAF1C0DE2F3A4B5C00112242 /* TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF1C0DE2F3A4B5C00112241 /* TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -58,7 +57,6 @@
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; };
EAF1C0DE2F3A4B5C00112241 /* TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift; sourceTree = SOURCE_ROOT; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@ -169,7 +167,6 @@
children = (
EAD6E3AF5A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Debug.xcconfig */,
EAD6E3B05A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Release.xcconfig */,
EAF1C0DE2F3A4B5C00112241 /* TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift */,
);
name = "Recovered References";
sourceTree = "<group>";
@ -378,7 +375,6 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
EAF1C0DE2F3A4B5C00112242 /* TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -542,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",
@ -576,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",
@ -691,7 +687,7 @@
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TheNoiseClockWidget/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18;
IPHONEOS_DEPLOYMENT_TARGET = 26;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(WIDGET_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
@ -710,7 +706,7 @@
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TheNoiseClockWidget/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18;
IPHONEOS_DEPLOYMENT_TARGET = 26;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(WIDGET_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";

View File

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

View File

@ -134,14 +134,19 @@ struct ContentView: View {
}
)
}
.onReceive(NotificationCenter.default.publisher(for: .alarmDidFire)) { notification in
alarmViewModel.handleAlarmNotification(userInfo: notification.userInfo)
}
.onReceive(NotificationCenter.default.publisher(for: .alarmDidStop)) { _ in
alarmViewModel.stopActiveAlarm()
}
.onReceive(NotificationCenter.default.publisher(for: .alarmDidSnooze)) { _ in
alarmViewModel.stopActiveAlarm()
// Note: AlarmKit handles alarm alerts directly via the system.
// The in-app alarm screen is shown for alarms that are in the alerting state.
// AlarmKit's Live Activity provides the countdown and alerting UI on Lock Screen and Dynamic Island.
.task {
Design.debugLog("[ContentView] App launched - initializing AlarmKit")
// Reschedule all enabled alarms with AlarmKit on app launch
await alarmViewModel.rescheduleAllAlarms()
// Start observing AlarmKit alarm updates
alarmViewModel.startObservingAlarmUpdates()
Design.debugLog("[ContentView] AlarmKit initialization complete")
}
.onReceive(NotificationCenter.default.publisher(for: .keepAwakePromptRequested)) { _ in
guard onboardingState.hasCompletedWelcome else { return }

View File

@ -0,0 +1,117 @@
//
// AlarmIntents.swift
// TheNoiseClock
//
// Created by Matt Bruce on 2/2/26.
//
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 alarm fires.
struct OpenAlarmAppIntent: LiveActivityIntent {
static var title: LocalizedStringResource = "Open TheNoiseClock"
static var description = IntentDescription("Opens the app to the alarm screen")
@Parameter(title: "Alarm ID")
var alarmID: String
static var supportedModes: IntentModes { .foreground(.immediate) }
init() {
self.alarmID = ""
}
init(alarmID: String) {
self.alarmID = alarmID
}
func perform() throws -> some IntentResult {
// The app will be opened due to .foreground(.immediate)
// The alarm screen will be shown based on the active alarm state
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,285 @@
//
// AlarmKitService.swift
// TheNoiseClock
//
// Created by Matt Bruce on 2/2/26.
//
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 sound for the alarm
let alarmSound = createAlarmSound(for: alarm)
Design.debugLog("[alarmkit] Created alarm sound: \(alarmSound)")
// Create the alert presentation with stop button and sound
let stopButton = AlarmButton(
text: "Stop",
textColor: .red,
systemImageName: "stop.fill"
)
let snoozeButton = AlarmButton(
text: "Snooze",
textColor: .blue,
systemImageName: "zzz"
)
let alert = AlarmPresentation.Alert(
title: LocalizedStringResource(stringLiteral: alarm.label),
sound: alarmSound,
stopButton: stopButton,
snoozeButton: snoozeButton
)
Design.debugLog("[alarmkit] Created alert with sound and buttons")
// Create metadata for the alarm
let metadata = NoiseClockAlarmMetadata(
alarmId: alarm.id.uuidString,
soundName: alarm.soundName,
snoozeDuration: alarm.snoozeDuration,
label: alarm.label,
volume: alarm.volume
)
Design.debugLog("[alarmkit] Created metadata: \(metadata)")
// 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
let schedule = createSchedule(for: alarm)
Design.debugLog("[alarmkit] Created schedule: \(schedule)")
// Create countdown duration (5 min before alarm, 1 min after)
let countdownDuration = AlarmKit.Alarm.CountdownDuration(
preAlert: 300, // 5 minutes before
postAlert: 60 // 1 minute after
)
Design.debugLog("[alarmkit] Countdown duration: preAlert=300s, postAlert=60s")
// Create the alarm configuration
let configuration = AlarmManager.AlarmConfiguration<NoiseClockAlarmMetadata>(
countdownDuration: countdownDuration,
schedule: schedule,
attributes: attributes
)
Design.debugLog("[alarmkit] Created configuration")
// 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
/// Create an AlarmKit sound from the alarm's sound name.
private func createAlarmSound(for alarm: Alarm) -> AlarmKit.AlertSound {
let soundName = alarm.soundName
Design.debugLog("[alarmkit] Creating sound for: \(soundName)")
// Check if it's a bundled sound file (has extension)
if soundName.contains(".") {
// Extract filename without extension for named sound
let soundFileName = soundName
Design.debugLog("[alarmkit] Using named sound file: \(soundFileName)")
return .named(soundFileName)
} else {
// Assume it's a named sound resource
Design.debugLog("[alarmkit] Using named sound: \(soundName)")
return .named(soundName)
}
}
/// 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 {
// Extract time components
let calendar = Calendar.current
let components = calendar.dateComponents([.hour, .minute], from: alarm.time)
let hour = components.hour ?? 0
let minute = components.minute ?? 0
Design.debugLog("[alarmkit] Creating schedule for \(hour):\(String(format: "%02d", minute))")
let time = AlarmKit.Alarm.Schedule.Relative.Time(
hour: hour,
minute: minute
)
// For now, create a one-time alarm (non-repeating)
// Future: Support weekly repeating alarms based on alarm.repeatDays
let schedule = AlarmKit.Alarm.Schedule.relative(
AlarmKit.Alarm.Schedule.Relative(
time: time,
repeats: .never
)
)
Design.debugLog("[alarmkit] Schedule created: relative, repeats=never")
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

@ -1,86 +0,0 @@
//
// AlarmLiveActivityManager.swift
// TheNoiseClock
//
// Created by Matt Bruce on 2/2/26.
//
import ActivityKit
import Bedrock
import Foundation
@MainActor
final class AlarmLiveActivityManager {
private var currentActivity: Activity<AlarmActivityAttributes>?
func startOrUpdate(for alarm: Alarm) {
guard #available(iOS 16.1, *) else { return }
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
Design.debugLog("[alarms] Live Activities not authorized")
return
}
let attributes = AlarmActivityAttributes(id: alarm.id)
let alarmDate = alarm.nextTriggerTime()
let contentState = AlarmActivityAttributes.ContentState(
alarmDate: alarmDate,
label: alarm.label
)
if let activity = currentActivity {
if activity.attributes.id != alarm.id {
Task {
await activity.end(nil, dismissalPolicy: .immediate)
currentActivity = nil
startOrUpdate(for: alarm)
}
return
} else {
Task {
await activity.update(ActivityContent(state: contentState, staleDate: alarmDate))
}
Design.debugLog("[alarms] Live Activity updated for \(alarm.label) at \(alarmDate)")
return
}
}
do {
let activity = try Activity.request(
attributes: attributes,
content: ActivityContent(state: contentState, staleDate: alarmDate),
pushType: nil
)
currentActivity = activity
Design.debugLog("[alarms] Live Activity started for \(alarm.label) at \(alarmDate)")
} catch {
Design.debugLog("[alarms] Live Activity request failed: \(error)")
}
}
func endActivity() {
guard #available(iOS 16.1, *) else { return }
guard let activity = currentActivity else { return }
Task {
await activity.end(nil, dismissalPolicy: .immediate)
currentActivity = nil
Design.debugLog("[alarms] Live Activity ended")
}
}
func refresh(for alarms: [Alarm], isEnabled: Bool) {
guard isEnabled else {
Design.debugLog("[alarms] Live Activities disabled in app settings")
endActivity()
return
}
guard let nextAlarm = alarms
.filter({ $0.isEnabled })
.min(by: { $0.nextTriggerTime() < $1.nextTriggerTime() }) else {
Design.debugLog("[alarms] No enabled alarms; ending Live Activity")
endActivity()
return
}
Design.debugLog("[alarms] Live Activity refresh for \(nextAlarm.label)")
startOrUpdate(for: nextAlarm)
}
}

View File

@ -4,13 +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 {
@ -18,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()
}
@ -75,50 +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()
let liveActivitiesEnabled = isLiveActivitiesEnabled()
let body = liveActivitiesEnabled ? "" : alarm.notificationMessage
Design.debugLog("[alarms] AlarmService schedule \(alarm.label). LiveActivities=\(liveActivitiesEnabled) body=\(body.isEmpty ? "<empty>" : "present")")
// Use FocusModeService for better Focus mode compatibility
focusModeService.scheduleAlarmNotification(
identifier: alarm.id.uuidString,
title: alarm.label,
body: body,
date: alarm.time,
soundName: alarm.soundName,
repeats: false, // For now, set to false since Alarm model doesn't have repeatDays
respectFocusModes: respectFocusModes,
snoozeDuration: alarm.snoozeDuration,
isVibrationEnabled: alarm.isVibrationEnabled,
volume: alarm.volume
)
}
}
}
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 isLiveActivitiesEnabled() -> Bool {
guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey),
let style = try? JSONDecoder().decode(ClockStyle.self, from: data) else {
return ClockStyle().liveActivitiesEnabled
}
return style.liveActivitiesEnabled
}
private func saveAlarms() {
persistenceWorkItem?.cancel()
@ -141,11 +101,13 @@ class AlarmService {
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)
}
Design.debugLog("[alarms] Loaded \(alarms.count) alarms from storage")
// Note: AlarmKit scheduling is handled by AlarmViewModel.rescheduleAllAlarms()
}
}
/// Get all enabled alarms (for rescheduling with AlarmKit)
func getEnabledAlarms() -> [Alarm] {
return alarms.filter { $0.isEnabled }
}
}

View File

@ -119,4 +119,67 @@ class AlarmSoundService {
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

@ -5,26 +5,31 @@
// Created by Matt Bruce on 9/7/25.
//
import Foundation
import Observation
import UserNotifications
import AlarmKit
import AudioPlaybackKit
import Bedrock
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 support.
@Observable
class AlarmViewModel {
// MARK: - Properties
private let alarmService: AlarmService
private let notificationService: NotificationService
private let alarmKitService = AlarmKitService.shared
private let alarmSoundService = AlarmSoundService.shared
private let soundPlayer = SoundPlayer.shared
private let liveActivityManager = AlarmLiveActivityManager()
var activeAlarm: Alarm?
/// Whether AlarmKit is authorized
var isAlarmKitAuthorized: Bool {
alarmKitService.authorizationState == .authorized
}
var alarms: [Alarm] {
alarmService.alarms
}
@ -34,55 +39,51 @@ class AlarmViewModel {
}
// 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: - Authorization
/// Request AlarmKit authorization. Should be called during onboarding.
func requestAlarmKitAuthorization() async -> Bool {
return await alarmKitService.requestAuthorization()
}
// MARK: - Public Interface
func addAlarm(_ alarm: Alarm) async {
alarmService.addAlarm(alarm)
// Schedule notification if alarm is enabled
// Schedule with AlarmKit if alarm is enabled
if alarm.isEnabled {
Design.debugLog("[alarms] Scheduling alarm notification for \(alarm.label). LiveActivities=\(isLiveActivitiesEnabled())")
await notificationService.scheduleAlarmNotification(
id: alarm.id.uuidString,
title: alarm.label,
body: isLiveActivitiesEnabled() ? "" : alarm.notificationMessage,
soundName: alarm.soundName,
date: alarm.time
)
requestKeepAwakePromptIfNeeded()
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 {
Design.debugLog("[alarms] Rescheduling alarm notification for \(alarm.label). LiveActivities=\(isLiveActivitiesEnabled())")
await notificationService.scheduleAlarmNotification(
id: alarm.id.uuidString,
title: alarm.label,
body: isLiveActivitiesEnabled() ? "" : alarm.notificationMessage,
soundName: alarm.soundName,
date: alarm.time
)
requestKeepAwakePromptIfNeeded()
} 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)
@ -94,19 +95,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 {
Design.debugLog("[alarms] Enabling alarm \(alarm.label). LiveActivities=\(isLiveActivitiesEnabled())")
await notificationService.scheduleAlarmNotification(
id: alarm.id.uuidString,
title: alarm.label,
body: isLiveActivitiesEnabled() ? "" : alarm.notificationMessage,
soundName: alarm.soundName,
date: alarm.time
)
requestKeepAwakePromptIfNeeded()
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)
}
}
@ -137,182 +135,134 @@ class AlarmViewModel {
volume: volume
)
}
func requestNotificationPermissions() async -> Bool {
return await notificationService.requestPermissions()
}
func requestKeepAwakePromptIfNeeded() {
guard !isKeepAwakeEnabled() else { return }
NotificationCenter.default.post(name: .keepAwakePromptRequested, object: nil)
}
// MARK: - Active Alarm Handling
func handleAlarmNotification(userInfo: [AnyHashable: Any]?) {
guard let userInfo else { return }
Design.debugLog("[alarms] handleAlarmNotification userInfo keys: \(Array(userInfo.keys))")
if let alarm = resolveAlarm(from: userInfo) {
startActiveAlarm(alarm)
}
}
/// Stop an active alarm using AlarmKit.
/// - Parameter id: The UUID of the alarm to stop.
@MainActor
func stopActiveAlarm() {
func stopAlarm(id: UUID) {
alarmKitService.stopAlarm(id: id)
// Also stop any in-app sound if playing
soundPlayer.stopSound()
if let alarm = activeAlarm, let stored = alarmService.getAlarm(id: alarm.id) {
// Disable the alarm after it fires (one-time alarms)
if let stored = alarmService.getAlarm(id: id) {
var updated = stored
updated.isEnabled = false
alarmService.updateAlarm(updated)
notificationService.cancelNotification(id: updated.id.uuidString)
}
activeAlarm = nil
liveActivityManager.endActivity()
Design.debugLog("[alarms] Alarm stopped: \(id)")
}
/// Snooze an active alarm using AlarmKit's countdown feature.
/// - Parameter id: The UUID of the alarm to snooze.
@MainActor
func snoozeAlarm(id: UUID) {
alarmKitService.snoozeAlarm(id: id)
// Stop any in-app sound if playing
soundPlayer.stopSound()
activeAlarm = nil
Design.debugLog("[alarms] Alarm snoozed: \(id)")
}
/// Legacy method for backward compatibility with notification-based alarms.
@MainActor
func stopActiveAlarm() {
guard let alarm = activeAlarm else { return }
stopAlarm(id: alarm.id)
}
/// Legacy method for backward compatibility with notification-based alarms.
@MainActor
func snoozeActiveAlarm() {
guard let alarm = activeAlarm else { return }
scheduleSnoozeNotification(for: alarm)
stopActiveAlarm()
snoozeAlarm(id: alarm.id)
}
@MainActor
private func startActiveAlarm(_ alarm: Alarm) {
if activeAlarm?.id == alarm.id {
return
}
activeAlarm = alarm
playAlarmSound(for: alarm)
Design.debugLog("[alarms] Alarm fired: \(alarm.label). LiveActivities=\(isLiveActivitiesEnabled())")
if isLiveActivitiesEnabled() {
liveActivityManager.startOrUpdate(for: alarm)
}
}
// MARK: - AlarmKit Updates
private func playAlarmSound(for alarm: Alarm) {
let resolvedSound = alarmSoundService.getAlarmSound(fileName: alarm.soundName)
?? alarmSoundService.getDefaultAlarmSound()
guard let sound = resolvedSound else { return }
soundPlayer.playSound(sound, volume: alarm.volume)
}
private func isKeepAwakeEnabled() -> Bool {
guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey),
let style = try? JSONDecoder().decode(ClockStyle.self, from: data) else {
return ClockStyle().keepAwake
}
return style.keepAwake
}
private func isLiveActivitiesEnabled() -> Bool {
guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey),
let style = try? JSONDecoder().decode(ClockStyle.self, from: data) else {
return ClockStyle().liveActivitiesEnabled
}
return style.liveActivitiesEnabled
}
private func scheduleSnoozeNotification(for alarm: Alarm) {
let snoozeTime = Date().addingTimeInterval(TimeInterval(alarm.snoozeDuration * 60))
let snoozeAlarm = Alarm(
id: UUID(),
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
)
let content = UNMutableNotificationContent()
content.title = snoozeAlarm.label
content.body = snoozeAlarm.notificationMessage
if snoozeAlarm.soundName == "default" {
content.sound = .default
} else {
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: snoozeAlarm.soundName))
}
content.categoryIdentifier = AlarmNotificationConstants.categoryIdentifier
content.userInfo = [
AlarmNotificationKeys.alarmId: snoozeAlarm.id.uuidString,
AlarmNotificationKeys.soundName: snoozeAlarm.soundName,
AlarmNotificationKeys.isSnooze: true,
AlarmNotificationKeys.originalAlarmId: alarm.id.uuidString,
AlarmNotificationKeys.label: snoozeAlarm.label,
AlarmNotificationKeys.notificationMessage: snoozeAlarm.notificationMessage,
AlarmNotificationKeys.snoozeDuration: snoozeAlarm.snoozeDuration,
AlarmNotificationKeys.isVibrationEnabled: snoozeAlarm.isVibrationEnabled,
AlarmNotificationKeys.volume: snoozeAlarm.volume
]
let trigger = UNTimeIntervalNotificationTrigger(
timeInterval: snoozeAlarm.time.timeIntervalSinceNow,
repeats: false
)
let request = UNNotificationRequest(
identifier: snoozeAlarm.id.uuidString,
content: content,
trigger: trigger
)
/// Start observing AlarmKit alarm updates.
/// Call this when the app becomes active to sync with system alarm state.
func startObservingAlarmUpdates() {
Design.debugLog("[alarmkit] Starting to observe alarm updates")
Task {
do {
try await UNUserNotificationCenter.current().add(request)
} catch {
// Intentionally silent; notification system logs errors
for await alarms in alarmKitService.alarmUpdates {
await handleAlarmUpdates(alarms)
}
}
}
private func resolveAlarm(from userInfo: [AnyHashable: Any]) -> Alarm? {
if let alarmIdString = userInfo[AlarmNotificationKeys.alarmId] as? String,
let alarmId = UUID(uuidString: alarmIdString),
let alarm = alarmService.getAlarm(id: alarmId) {
return alarm
@MainActor
private func handleAlarmUpdates(_ alarms: [AlarmKit.Alarm]) {
Design.debugLog("[alarmkit] Received alarm update: \(alarms.count) alarm(s)")
for alarm in alarms {
Design.debugLog("[alarmkit] Alarm \(alarm.id): state=\(alarm.state)")
switch alarm.state {
case .alerting:
// Alarm is currently ringing - find the matching app alarm
if let appAlarm = alarmService.getAlarm(id: alarm.id) {
activeAlarm = appAlarm
Design.debugLog("[alarmkit] 🔔 ALARM ALERTING: \(appAlarm.label)")
// Play alarm sound in-app as backup
playAlarmSound(appAlarm)
} else {
Design.debugLog("[alarmkit] ⚠️ Alerting alarm not found in storage: \(alarm.id)")
}
case .countdown:
Design.debugLog("[alarmkit] ⏱️ Alarm counting down: \(alarm.id)")
default:
Design.debugLog("[alarmkit] Other state for alarm \(alarm.id): \(alarm.state)")
}
}
}
/// Play alarm sound in-app (backup for AlarmKit sound)
@MainActor
private func playAlarmSound(_ alarm: Alarm) {
Design.debugLog("[alarmkit] Playing in-app alarm sound: \(alarm.soundName)")
// Get the Sound object from AlarmSoundService
if let sound = alarmSoundService.getAlarmSound(fileName: alarm.soundName) {
Design.debugLog("[alarmkit] Sound found: \(sound.name)")
soundPlayer.playSound(sound, volume: alarm.volume)
} else {
Design.debugLog("[alarmkit] ⚠️ Sound not found for: \(alarm.soundName)")
// Try to find any default alarm sound
if let defaultSound = alarmSoundService.getDefaultAlarmSound() {
Design.debugLog("[alarmkit] Using default sound: \(defaultSound.name)")
soundPlayer.playSound(defaultSound, volume: alarm.volume)
}
}
}
// 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)")
}
}
let title = (userInfo[AlarmNotificationKeys.label] as? String) ?? (userInfo[AlarmNotificationKeys.title] as? String)
let body = (userInfo[AlarmNotificationKeys.notificationMessage] as? String) ?? (userInfo[AlarmNotificationKeys.body] as? String)
let soundName = (userInfo[AlarmNotificationKeys.soundName] as? String) ?? AppConstants.SystemSounds.defaultSound
let snoozeDuration = intValue(from: userInfo[AlarmNotificationKeys.snoozeDuration]) ?? 9
let isVibrationEnabled = boolValue(from: userInfo[AlarmNotificationKeys.isVibrationEnabled]) ?? true
let volume = floatValue(from: userInfo[AlarmNotificationKeys.volume]) ?? 1.0
return Alarm(
time: Date(),
isEnabled: true,
soundName: soundName,
label: title ?? "Alarm",
notificationMessage: body ?? "Your alarm is ringing",
snoozeDuration: snoozeDuration,
isVibrationEnabled: isVibrationEnabled,
isLightFlashEnabled: false,
volume: volume
)
}
private func intValue(from value: Any?) -> Int? {
if let intValue = value as? Int { return intValue }
if let number = value as? NSNumber { return number.intValue }
return nil
}
private func floatValue(from value: Any?) -> Float? {
if let floatValue = value as? Float { return floatValue }
if let doubleValue = value as? Double { return Float(doubleValue) }
if let number = value as? NSNumber { return number.floatValue }
return nil
}
private func boolValue(from value: Any?) -> Bool? {
if let boolValue = value as? Bool { return boolValue }
if let number = value as? NSNumber { return number.boolValue }
return nil
Design.debugLog("[alarmkit] ========== RESCHEDULING COMPLETE ==========")
alarmKitService.logCurrentAlarms()
}
}

View File

@ -57,7 +57,7 @@ struct AlarmScreen: View {
#Preview {
AlarmScreen(
alarm: Alarm(time: Date(), soundName: "digital-alarm.caf", label: "Wake Up", notificationMessage: "Alarm", snoozeDuration: 9, isVibrationEnabled: true, isLightFlashEnabled: false, volume: 1.0),
alarm: Alarm(time: Date(), soundName: "digital-alarm.mp3", label: "Wake Up", notificationMessage: "Alarm", snoozeDuration: 9, isVibrationEnabled: true, isLightFlashEnabled: false, volume: 1.0),
onSnooze: {},
onStop: {}
)

View File

@ -83,9 +83,9 @@ struct AlarmView: View {
}
.onAppear {
Task {
await viewModel.requestNotificationPermissions()
// Request AlarmKit authorization when the alarms tab appears
await viewModel.requestAlarmKitAuthorization()
}
viewModel.requestKeepAwakePromptIfNeeded()
}
.sheet(isPresented: $showAddAlarm) {
AddAlarmView(

View File

@ -3,9 +3,12 @@
// TheNoiseClock
//
// Streamlined onboarding flow optimized for time-to-value.
// Shows real clock immediately, requests permissions with context,
// 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
@ -19,10 +22,9 @@ struct OnboardingView: View {
let onComplete: () -> Void
@State private var currentPage = 0
@State private var notificationPermissionGranted = false
@State private var showCelebration = false
@State private var alarmKitPermissionGranted = false
@State private var keepAwakeEnabled = false
@State private var liveActivitiesEnabled = false
@State private var showCelebration = false
private let totalPages = 3
@ -124,84 +126,49 @@ struct OnboardingView: View {
.padding(.horizontal, Design.Spacing.xxLarge)
}
// MARK: - Page 2: Permissions (Contextual)
// MARK: - Page 2: AlarmKit Permissions
private var permissionsPage: some View {
VStack(spacing: Design.Spacing.xxLarge) {
Spacer()
// Alarm icon with context
// Alarm icon with animated waves
ZStack {
Circle()
.fill(AppAccent.primary.opacity(0.15))
.frame(width: 120, height: 120)
Image(systemName: "alarm.fill")
Image(systemName: "alarm.waves.left.and.right.fill")
.font(.system(size: 50, weight: .medium))
.foregroundStyle(AppAccent.primary)
.symbolEffect(.pulse, options: .repeating)
}
Text("Never miss an alarm")
Text("Alarms that actually work")
.typography(.heroBold)
.foregroundStyle(AppTextColors.primary)
.multilineTextAlignment(.center)
Text("Enable notifications so alarms work even when the app is closed or your phone is locked.")
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)
VStack(spacing: Design.Spacing.small) {
Text("For reliable alarms, keep TheNoiseClock on-screen.")
.typography(.body)
.foregroundStyle(AppTextColors.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Design.Spacing.xxLarge)
Button {
enableKeepAwake()
} label: {
HStack {
Image(systemName: keepAwakeEnabled ? "checkmark.circle.fill" : "bolt.fill")
Text(keepAwakeEnabled ? "Keep Awake Enabled" : "Enable Keep Awake")
}
.typography(.bodyEmphasis)
.foregroundStyle(keepAwakeEnabled ? AppStatus.success : .white)
.frame(maxWidth: 280)
.padding(Design.Spacing.medium)
.background(keepAwakeEnabled ? AppStatus.success.opacity(0.15) : AppAccent.primary)
.cornerRadius(Design.CornerRadius.medium)
}
.disabled(keepAwakeEnabled)
}
VStack(spacing: Design.Spacing.small) {
Text("Show alarms on the Dynamic Island and Lock Screen while they are ringing.")
.typography(.body)
.foregroundStyle(AppTextColors.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Design.Spacing.xxLarge)
Button {
enableLiveActivities()
} label: {
HStack {
Image(systemName: liveActivitiesEnabled ? "checkmark.circle.fill" : "sparkles")
Text(liveActivitiesEnabled ? "Live Activities Enabled" : "Enable Live Activities")
}
.typography(.bodyEmphasis)
.foregroundStyle(liveActivitiesEnabled ? AppStatus.success : .white)
.frame(maxWidth: 280)
.padding(Design.Spacing.medium)
.background(liveActivitiesEnabled ? AppStatus.success.opacity(0.15) : AppAccent.primary)
.cornerRadius(Design.CornerRadius.medium)
}
.disabled(liveActivitiesEnabled)
// 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()
@ -210,18 +177,58 @@ struct OnboardingView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear {
keepAwakeEnabled = isKeepAwakeEnabled()
liveActivitiesEnabled = isLiveActivitiesEnabled()
}
}
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 notificationPermissionGranted {
if alarmKitPermissionGranted {
// Success state
HStack(spacing: Design.Spacing.small) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 24))
Text("You're all set!")
Text("Alarms enabled!")
}
.foregroundStyle(AppStatus.success)
.typography(.bodyEmphasis)
@ -229,13 +236,13 @@ struct OnboardingView: View {
.background(AppStatus.success.opacity(0.15))
.cornerRadius(Design.CornerRadius.medium)
} else {
// Request button
// Request AlarmKit authorization
Button {
requestNotificationPermission()
requestAlarmKitPermission()
} label: {
HStack {
Image(systemName: "bell.badge.fill")
Text("Enable Notifications")
Image(systemName: "alarm.fill")
Text("Enable Alarms")
}
.typography(.bodyEmphasis)
.foregroundStyle(.white)
@ -269,7 +276,7 @@ struct OnboardingView: View {
.typography(.heroBold)
.foregroundStyle(AppTextColors.primary)
Text("Tap below to start using your new bedside clock. Try long-pressing the clock for immersive mode!")
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)
@ -277,9 +284,9 @@ struct OnboardingView: View {
// 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")
tipRow(icon: "plus", text: "Tap + on Alarms to add one")
}
.padding(.top, Design.Spacing.medium)
@ -384,11 +391,12 @@ struct OnboardingView: View {
// MARK: - Actions
private func requestNotificationPermission() {
private func requestAlarmKitPermission() {
Task {
let granted = await NotificationUtils.requestPermissions()
// Request AlarmKit authorization (iOS 26+)
let granted = await AlarmKitService.shared.requestAuthorization()
withAnimation(.spring(duration: 0.3)) {
notificationPermissionGranted = granted
alarmKitPermissionGranted = granted
}
// Auto-advance after permission granted
if granted {
@ -399,7 +407,7 @@ struct OnboardingView: View {
}
}
}
private func enableKeepAwake() {
var style = loadClockStyle()
style.keepAwake = true
@ -409,24 +417,10 @@ struct OnboardingView: View {
keepAwakeEnabled = true
}
}
private func enableLiveActivities() {
var style = loadClockStyle()
style.liveActivitiesEnabled = true
saveClockStyle(style)
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
withAnimation(.spring(duration: 0.3)) {
liveActivitiesEnabled = true
}
}
private func isKeepAwakeEnabled() -> Bool {
loadClockStyle().keepAwake
}
private func isLiveActivitiesEnabled() -> Bool {
loadClockStyle().liveActivitiesEnabled
}
private func loadClockStyle() -> ClockStyle {
guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey),

View File

@ -14,5 +14,7 @@
<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,7 +3,7 @@
{
"id": "digital-alarm",
"name": "Digital Alarm",
"fileName": "digital-alarm.caf",
"fileName": "digital-alarm.mp3",
"description": "Classic digital alarm sound",
"category": "alarm",
"bundleName": null,
@ -12,7 +12,7 @@
{
"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
@ -20,7 +20,7 @@
{
"id": "classic-alarm",
"name": "Classic Alarm",
"fileName": "classic-alarm.caf",
"fileName": "classic-alarm.mp3",
"description": "Traditional alarm sound",
"category": "alarm",
"bundleName": null
@ -28,7 +28,7 @@
{
"id": "beep-alarm",
"name": "Beep Alarm",
"fileName": "beep-alarm.caf",
"fileName": "beep-alarm.mp3",
"description": "Short beep alarm sound",
"category": "alarm",
"bundleName": null
@ -36,7 +36,7 @@
{
"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

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

@ -1,18 +0,0 @@
//
// AlarmActivityAttributes.swift
// TheNoiseClock
//
// Created by Matt Bruce on 2/2/26.
//
import ActivityKit
import Foundation
struct AlarmActivityAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
var alarmDate: Date
var label: String
}
var id: UUID
}

View File

@ -0,0 +1,28 @@
//
// 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
/// Volume level (0.0 to 1.0)
var volume: Float
}

View File

@ -5,51 +5,175 @@
// Created by Matt Bruce on 2/2/26.
//
import ActivityKit
import AlarmKit
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: AlarmActivityAttributes.self) { context in
VStack(spacing: 8) {
Text("Next Alarm")
.font(.caption)
.foregroundStyle(.secondary)
Text(context.state.label)
.font(.headline)
Text(context.state.alarmDate, style: .time)
.font(.title2.weight(.bold))
}
.padding()
ActivityConfiguration(for: AlarmAttributes<NoiseClockAlarmMetadata>.self) { context in
// Lock Screen presentation
LockScreenAlarmView(
attributes: context.attributes,
state: context.state
)
} dynamicIsland: { context in
DynamicIsland {
// Expanded regions
DynamicIslandExpandedRegion(.leading) {
Text("Alarm")
.font(.caption)
.foregroundStyle(.secondary)
if let metadata = context.attributes.metadata {
AlarmTitleView(metadata: metadata)
} else {
Text("Alarm")
.font(.caption)
}
}
DynamicIslandExpandedRegion(.trailing) {
Text(context.state.alarmDate, style: .time)
.font(.caption2)
}
DynamicIslandExpandedRegion(.center) {
Text(context.state.label)
.font(.headline)
AlarmProgressView(state: context.state)
}
DynamicIslandExpandedRegion(.bottom) {
Text("Alarm at \(context.state.alarmDate, style: .time)")
.font(.caption)
ExpandedAlarmView(
attributes: context.attributes,
state: context.state
)
}
} compactLeading: {
Image(systemName: "alarm")
// Compact leading - countdown text
CountdownTextView(state: context.state)
.foregroundStyle(context.attributes.tintColor)
} compactTrailing: {
Text(context.state.alarmDate, style: .time)
.font(.caption2)
// Compact trailing - progress ring
AlarmProgressView(state: context.state)
.frame(maxWidth: 32)
} minimal: {
Image(systemName: "alarm")
// 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"
}
var body: some View {
VStack(spacing: 12) {
// Alarm label
Text(alarmLabel)
.font(.headline)
.foregroundStyle(.primary)
// Countdown state
if case .countdown(let countdown) = state.mode {
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 {
// Other states (alerting, etc.)
VStack(spacing: 4) {
Image(systemName: "alarm.waves.left.and.right.fill")
.font(.system(size: 32))
.foregroundStyle(attributes.tintColor)
.symbolEffect(.pulse)
Text("Alarm Ringing")
.font(.title3.weight(.semibold))
}
}
}
.padding()
.frame(maxWidth: .infinity)
}
}
// MARK: - Expanded Dynamic Island View
struct ExpandedAlarmView: View {
let attributes: AlarmAttributes<NoiseClockAlarmMetadata>
let state: AlarmPresentationState
var body: some View {
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,32 @@
//
// 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
/// Volume level (0.0 to 1.0)
var volume: Float
}