Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
f3c98cedb9
commit
b8428ca134
@ -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:
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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.
|
||||
"""
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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: {}
|
||||
)
|
||||
}
|
||||
119
TheNoiseClockWidget/AlarmIntents.swift
Normal file
119
TheNoiseClockWidget/AlarmIntents.swift
Normal file
@ -0,0 +1,119 @@
|
||||
//
|
||||
// AlarmIntents.swift
|
||||
// TheNoiseClockWidget
|
||||
//
|
||||
// Created by Matt Bruce on 2/2/26.
|
||||
//
|
||||
// App Intents for alarm actions from Live Activity and widget buttons.
|
||||
// These intents are duplicated in the widget target for compilation.
|
||||
// Note: Must be kept in sync with TheNoiseClock/Features/Alarms/Intents/AlarmIntents.swift
|
||||
//
|
||||
|
||||
import AlarmKit
|
||||
import AppIntents
|
||||
import Foundation
|
||||
|
||||
// MARK: - Stop Alarm Intent
|
||||
|
||||
/// Intent to stop an active alarm from the Live Activity or notification.
|
||||
struct StopAlarmIntent: LiveActivityIntent {
|
||||
|
||||
static var title: LocalizedStringResource = "Stop Alarm"
|
||||
static var description = IntentDescription("Stops the currently ringing alarm")
|
||||
|
||||
@Parameter(title: "Alarm ID")
|
||||
var alarmId: String
|
||||
|
||||
static var supportedModes: IntentModes { .background }
|
||||
|
||||
init() {
|
||||
self.alarmId = ""
|
||||
}
|
||||
|
||||
init(alarmId: String) {
|
||||
self.alarmId = alarmId
|
||||
}
|
||||
|
||||
func perform() throws -> some IntentResult {
|
||||
guard let uuid = UUID(uuidString: alarmId) else {
|
||||
throw AlarmIntentError.invalidAlarmID
|
||||
}
|
||||
|
||||
try AlarmManager.shared.stop(id: uuid)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Snooze Alarm Intent
|
||||
|
||||
/// Intent to snooze an active alarm from the Live Activity or notification.
|
||||
struct SnoozeAlarmIntent: LiveActivityIntent {
|
||||
|
||||
static var title: LocalizedStringResource = "Snooze Alarm"
|
||||
static var description = IntentDescription("Snoozes the currently ringing alarm")
|
||||
|
||||
@Parameter(title: "Alarm ID")
|
||||
var alarmId: String
|
||||
|
||||
static var supportedModes: IntentModes { .background }
|
||||
|
||||
init() {
|
||||
self.alarmId = ""
|
||||
}
|
||||
|
||||
init(alarmId: String) {
|
||||
self.alarmId = alarmId
|
||||
}
|
||||
|
||||
func perform() throws -> some IntentResult {
|
||||
guard let uuid = UUID(uuidString: alarmId) else {
|
||||
throw AlarmIntentError.invalidAlarmID
|
||||
}
|
||||
|
||||
// Use countdown to postpone the alarm by its configured snooze duration
|
||||
try AlarmManager.shared.countdown(id: uuid)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Open App Intent
|
||||
|
||||
/// Intent to open the app when the user taps the Live Activity.
|
||||
struct OpenAlarmAppIntent: LiveActivityIntent {
|
||||
|
||||
static var title: LocalizedStringResource = "Open TheNoiseClock"
|
||||
static var description = IntentDescription("Opens the app to the alarm screen")
|
||||
static var openAppWhenRun = true
|
||||
|
||||
@Parameter(title: "Alarm ID")
|
||||
var alarmId: String
|
||||
|
||||
init() {
|
||||
self.alarmId = ""
|
||||
}
|
||||
|
||||
init(alarmId: String) {
|
||||
self.alarmId = alarmId
|
||||
}
|
||||
|
||||
func perform() throws -> some IntentResult {
|
||||
// The app will be opened due to openAppWhenRun = true
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum AlarmIntentError: Error, LocalizedError {
|
||||
case invalidAlarmID
|
||||
case alarmNotFound
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidAlarmID:
|
||||
return "Invalid alarm ID"
|
||||
case .alarmNotFound:
|
||||
return "Alarm not found"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
@ -122,6 +180,7 @@ struct ExpandedAlarmView: View {
|
||||
.frame(maxHeight: 30)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Countdown Text View
|
||||
|
||||
Loading…
Reference in New Issue
Block a user