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

This commit is contained in:
Matt Bruce 2026-02-02 13:35:37 -06:00
parent f3c98cedb9
commit b8428ca134
12 changed files with 309 additions and 817 deletions

View File

@ -102,18 +102,8 @@ struct ContentView: View {
}
.accentColor(AppAccent.primary)
.background(Color.Branding.primary.ignoresSafeArea())
.fullScreenCover(item: activeAlarmBinding) { alarm in
AlarmScreen(
alarm: alarm,
onSnooze: {
alarmViewModel.snoozeActiveAlarm()
},
onStop: {
alarmViewModel.stopActiveAlarm()
}
)
.interactiveDismissDisabled(true)
}
// 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 {
@ -134,18 +124,12 @@ struct ContentView: View {
}
)
}
// 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
@ -156,13 +140,6 @@ struct ContentView: View {
.animation(.easeInOut(duration: 0.3), value: onboardingState.hasCompletedWelcome)
}
private var activeAlarmBinding: Binding<Alarm?> {
Binding(
get: { alarmViewModel.activeAlarm },
set: { alarmViewModel.activeAlarm = $0 }
)
}
private func shouldShowKeepAwakePromptForTab() -> Bool {
switch selectedTab {
case .clock, .alarms:

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

@ -4,6 +4,9 @@
//
// 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
@ -18,20 +21,20 @@ struct StopAlarmIntent: LiveActivityIntent {
static var description = IntentDescription("Stops the currently ringing alarm")
@Parameter(title: "Alarm ID")
var alarmID: String
var alarmId: String
static var supportedModes: IntentModes { .background }
init() {
self.alarmID = ""
self.alarmId = ""
}
init(alarmID: String) {
self.alarmID = alarmID
init(alarmId: String) {
self.alarmId = alarmId
}
func perform() throws -> some IntentResult {
guard let uuid = UUID(uuidString: alarmID) else {
guard let uuid = UUID(uuidString: alarmId) else {
throw AlarmIntentError.invalidAlarmID
}
@ -49,20 +52,20 @@ struct SnoozeAlarmIntent: LiveActivityIntent {
static var description = IntentDescription("Snoozes the currently ringing alarm")
@Parameter(title: "Alarm ID")
var alarmID: String
var alarmId: String
static var supportedModes: IntentModes { .background }
init() {
self.alarmID = ""
self.alarmId = ""
}
init(alarmID: String) {
self.alarmID = alarmID
init(alarmId: String) {
self.alarmId = alarmId
}
func perform() throws -> some IntentResult {
guard let uuid = UUID(uuidString: alarmID) else {
guard let uuid = UUID(uuidString: alarmId) else {
throw AlarmIntentError.invalidAlarmID
}
@ -74,28 +77,26 @@ struct SnoozeAlarmIntent: LiveActivityIntent {
// MARK: - Open App Intent
/// Intent to open the app when the alarm fires.
/// 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
static var supportedModes: IntentModes { .foreground(.immediate) }
var alarmId: String
init() {
self.alarmID = ""
self.alarmId = ""
}
init(alarmID: String) {
self.alarmID = 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
// The app will be opened due to openAppWhenRun = true
return .result()
}
}

View File

@ -161,10 +161,54 @@ final class AlarmKitService {
private func getSoundNameForAlarmKit(_ soundName: String) -> String {
// AlarmKit expects the sound name without extension
let nameWithoutExtension = (soundName as NSString).deletingPathExtension
let ext = (soundName as NSString).pathExtension
Design.debugLog("[alarmkit] Sound name for AlarmKit: \(nameWithoutExtension) (from: \(soundName))")
// Verify the sound file exists in the bundle
if let _ = Bundle.main.url(forResource: nameWithoutExtension, withExtension: ext) {
Design.debugLog("[alarmkit] ✅ Sound file found in main bundle: \(soundName)")
} else if let _ = Bundle.main.url(forResource: "AlarmSounds/\(nameWithoutExtension)", withExtension: ext) {
Design.debugLog("[alarmkit] ✅ Sound file found in AlarmSounds folder: \(soundName)")
} else {
Design.debugLog("[alarmkit] ⚠️ Sound file NOT found in bundle: \(soundName)")
Design.debugLog("[alarmkit] ⚠️ AlarmKit may not be able to play this sound")
// Log bundle contents for debugging
logBundleSoundFiles()
}
return nameWithoutExtension
}
/// Log available sound files in the bundle for debugging
private func logBundleSoundFiles() {
Design.debugLog("[alarmkit] ========== BUNDLE SOUND FILES ==========")
// Check main bundle
if let resourcePath = Bundle.main.resourcePath {
let fileManager = FileManager.default
do {
let files = try fileManager.contentsOfDirectory(atPath: resourcePath)
let soundFiles = files.filter { $0.hasSuffix(".mp3") || $0.hasSuffix(".caf") || $0.hasSuffix(".wav") }
if soundFiles.isEmpty {
Design.debugLog("[alarmkit] No sound files in main bundle root")
} else {
Design.debugLog("[alarmkit] Sound files in main bundle: \(soundFiles)")
}
// Check AlarmSounds subdirectory
let alarmSoundsPath = (resourcePath as NSString).appendingPathComponent("AlarmSounds")
if fileManager.fileExists(atPath: alarmSoundsPath) {
let alarmFiles = try fileManager.contentsOfDirectory(atPath: alarmSoundsPath)
Design.debugLog("[alarmkit] Sound files in AlarmSounds: \(alarmFiles)")
}
} catch {
Design.debugLog("[alarmkit] Error listing bundle: \(error)")
}
}
}
/// Cancel a scheduled alarm.
/// - Parameter id: The UUID of the alarm to cancel.
func cancelAlarm(id: UUID) {
@ -227,17 +271,30 @@ final class AlarmKitService {
/// 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] Creating schedule for: \(triggerDate)")
Design.debugLog("[alarmkit] Current time: \(Date.now)")
Design.debugLog("[alarmkit] Time until alarm: \(triggerDate.timeIntervalSinceNow) seconds")
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 \(triggerDate)")
Design.debugLog("[alarmkit] Schedule created: fixed at \(formatter.string(from: triggerDate))")
return schedule
}
}

View File

@ -99,13 +99,35 @@ 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
// 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

@ -1,256 +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: AlarmNotificationConstants.categoryIdentifier,
actions: [
UNNotificationAction(
identifier: AlarmNotificationConstants.snoozeActionIdentifier,
title: "Snooze",
options: []
),
UNNotificationAction(
identifier: AlarmNotificationConstants.stopActionIdentifier,
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,
snoozeDuration: Int? = nil,
isVibrationEnabled: Bool? = nil,
volume: Float? = nil
) {
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 if Bundle.main.url(forResource: soundName, withExtension: nil) != nil {
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)")
}
content.categoryIdentifier = AlarmNotificationConstants.categoryIdentifier
if !respectFocusModes, timeSensitiveSetting == .enabled {
content.interruptionLevel = .timeSensitive
}
var userInfo: [AnyHashable: Any] = [
AlarmNotificationKeys.alarmId: identifier,
AlarmNotificationKeys.soundName: soundName,
AlarmNotificationKeys.repeats: repeats,
AlarmNotificationKeys.label: title,
AlarmNotificationKeys.notificationMessage: body
]
if let snoozeDuration {
userInfo[AlarmNotificationKeys.snoozeDuration] = snoozeDuration
}
if let isVibrationEnabled {
userInfo[AlarmNotificationKeys.isVibrationEnabled] = isVibrationEnabled
}
if let volume {
userInfo[AlarmNotificationKeys.volume] = volume
}
content.userInfo = userInfo
// 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,216 +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("[alarms] didReceive notification. category=\(notification.request.content.categoryIdentifier) action=\(actionIdentifier)")
Design.debugLog("[settings] Notification action received: \(actionIdentifier)")
switch actionIdentifier {
case AlarmNotificationConstants.snoozeActionIdentifier:
handleSnoozeAction(userInfo: userInfo)
postAlarmAction(name: .alarmDidSnooze, notification: notification)
case AlarmNotificationConstants.stopActionIdentifier:
handleStopAction(userInfo: userInfo)
postAlarmAction(name: .alarmDidStop, notification: notification)
case UNNotificationDefaultActionIdentifier:
// User tapped the notification itself
handleNotificationTap(userInfo: userInfo)
postAlarmDidFire(notification: notification)
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
) {
Design.debugLog("[alarms] willPresent notification. category=\(notification.request.content.categoryIdentifier)")
if notification.request.content.categoryIdentifier == AlarmNotificationConstants.categoryIdentifier {
postAlarmDidFire(notification: notification)
completionHandler([])
return
}
// Show non-alarm notifications even when app is in foreground
completionHandler([.banner, .sound, .badge])
}
// MARK: - Action Handlers
private func handleSnoozeAction(userInfo: [AnyHashable: Any]) {
guard let alarmIdString = userInfo[AlarmNotificationKeys.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[AlarmNotificationKeys.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[AlarmNotificationKeys.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
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: userInfo[AlarmNotificationKeys.alarmId] as? String ?? "",
AlarmNotificationKeys.label: snoozeAlarm.label,
AlarmNotificationKeys.notificationMessage: snoozeAlarm.notificationMessage,
AlarmNotificationKeys.snoozeDuration: snoozeAlarm.snoozeDuration,
AlarmNotificationKeys.isVibrationEnabled: snoozeAlarm.isVibrationEnabled,
AlarmNotificationKeys.volume: snoozeAlarm.volume
]
// 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)")
}
}
// MARK: - Notification Center Bridge
private func postAlarmDidFire(notification: UNNotification) {
var userInfo = notification.request.content.userInfo
userInfo[AlarmNotificationKeys.title] = notification.request.content.title
userInfo[AlarmNotificationKeys.body] = notification.request.content.body
NotificationCenter.default.post(name: .alarmDidFire, object: nil, userInfo: userInfo)
}
private func postAlarmAction(name: Notification.Name, notification: UNNotification) {
var userInfo = notification.request.content.userInfo
userInfo[AlarmNotificationKeys.title] = notification.request.content.title
userInfo[AlarmNotificationKeys.body] = notification.request.content.body
NotificationCenter.default.post(name: name, object: nil, userInfo: userInfo)
}
}

View File

@ -1,93 +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
)
content.userInfo = [
AlarmNotificationKeys.alarmId: id,
AlarmNotificationKeys.soundName: soundName,
AlarmNotificationKeys.label: title,
AlarmNotificationKeys.notificationMessage: body
]
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

@ -6,24 +6,19 @@
//
import AlarmKit
import AudioPlaybackKit
import Bedrock
import Foundation
import Observation
/// 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.
/// with built-in Live Activity countdown and system alarm UI.
@Observable
class AlarmViewModel {
// MARK: - Properties
private let alarmService: AlarmService
private let alarmKitService = AlarmKitService.shared
private let alarmSoundService = AlarmSoundService.shared
private let soundPlayer = SoundPlayer.shared
var activeAlarm: Alarm?
/// Whether AlarmKit is authorized
var isAlarmKitAuthorized: Bool {
@ -50,7 +45,8 @@ class AlarmViewModel {
return await alarmKitService.requestAuthorization()
}
// MARK: - Public Interface
// MARK: - Alarm CRUD Operations
func addAlarm(_ alarm: Alarm) async {
alarmService.addAlarm(alarm)
@ -136,113 +132,6 @@ class AlarmViewModel {
)
}
// MARK: - Active Alarm Handling
/// Stop an active alarm using AlarmKit.
/// - Parameter id: The UUID of the alarm to stop.
@MainActor
func stopAlarm(id: UUID) {
alarmKitService.stopAlarm(id: id)
// Also stop any in-app sound if playing
soundPlayer.stopSound()
// 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)
}
activeAlarm = nil
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 }
snoozeAlarm(id: alarm.id)
}
// MARK: - AlarmKit Updates
/// 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 {
for await alarms in alarmKitService.alarmUpdates {
await handleAlarmUpdates(alarms)
}
}
}
@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.

View File

@ -1,64 +0,0 @@
//
// AlarmScreen.swift
// TheNoiseClock
//
// Created by Matt Bruce on 2/2/26.
//
import SwiftUI
import Bedrock
/// Full-screen alarm UI for active alarms
struct AlarmScreen: View {
let alarm: Alarm
let onSnooze: () -> Void
let onStop: () -> Void
var body: some View {
ZStack {
Color.Branding.primary
.ignoresSafeArea()
VStack(spacing: Design.Spacing.large) {
Spacer()
Text(alarm.formattedTime())
.font(.system(size: 72, weight: .bold, design: .rounded))
.foregroundStyle(AppTextColors.primary)
Text(alarm.label)
.font(.title2.weight(.semibold))
.foregroundStyle(AppTextColors.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Design.Spacing.large)
Spacer()
HStack(spacing: Design.Spacing.large) {
Button(action: onSnooze) {
Text("Snooze")
.frame(maxWidth: .infinity)
}
.buttonStyle(color: AppAccent.primary)
Button(action: onStop) {
Text("Stop")
.frame(maxWidth: .infinity)
}
.buttonStyle(color: .red)
}
.padding(.horizontal, Design.Spacing.large)
.padding(.bottom, Design.Spacing.large)
}
}
}
}
#Preview {
AlarmScreen(
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

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

@ -6,6 +6,7 @@
//
import AlarmKit
import AppIntents
import SwiftUI
import WidgetKit
@ -21,7 +22,7 @@ struct AlarmLiveActivityWidget: Widget {
)
} dynamicIsland: { context in
DynamicIsland {
// Expanded regions
// Expanded regions - shown when long-pressed or alerting
DynamicIslandExpandedRegion(.leading) {
if let metadata = context.attributes.metadata {
AlarmTitleView(metadata: metadata)
@ -40,13 +41,14 @@ struct AlarmLiveActivityWidget: Widget {
)
}
} compactLeading: {
// Compact leading - countdown text
CountdownTextView(state: context.state)
// Compact leading - alarm icon during countdown
Image(systemName: "alarm.fill")
.foregroundStyle(context.attributes.tintColor)
} compactTrailing: {
// Compact trailing - progress ring
AlarmProgressView(state: context.state)
.frame(maxWidth: 32)
// Compact trailing - countdown text
CountdownTextView(state: context.state)
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
} minimal: {
// Minimal - just an alarm icon
Image(systemName: "alarm.fill")
@ -66,6 +68,10 @@ struct LockScreenAlarmView: View {
attributes.metadata?.label ?? "Alarm"
}
private var alarmId: String {
attributes.metadata?.alarmId ?? ""
}
var body: some View {
VStack(spacing: 12) {
// Alarm label
@ -73,8 +79,9 @@ struct LockScreenAlarmView: View {
.font(.headline)
.foregroundStyle(.primary)
// Countdown state
// Content based on state
if case .countdown(let countdown) = state.mode {
// Countdown state - show timer
VStack(spacing: 4) {
Text("Alarm in")
.font(.caption)
@ -89,14 +96,34 @@ struct LockScreenAlarmView: View {
.font(.title3)
.foregroundStyle(.secondary)
} else {
// Other states (alerting, etc.)
VStack(spacing: 4) {
// Alerting state - show ringing UI with action buttons
VStack(spacing: 16) {
Image(systemName: "alarm.waves.left.and.right.fill")
.font(.system(size: 32))
.font(.system(size: 40))
.foregroundStyle(attributes.tintColor)
.symbolEffect(.pulse)
.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)
}
}
}
}
@ -111,7 +138,38 @@ 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)
@ -123,6 +181,7 @@ struct ExpandedAlarmView: View {
}
}
}
}
// MARK: - Countdown Text View