From 46f5a5b586d677ee280983d6005ee30998aa77ba Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Mon, 8 Sep 2025 13:33:36 -0500 Subject: [PATCH] Signed-off-by: Matt Bruce --- PRD.md | 21 +- TheNoiseClock/Models/ClockStyle.swift | 7 +- TheNoiseClock/Services/AlarmService.swift | 22 +- TheNoiseClock/Services/FocusModeService.swift | 233 ++++++++++++++++++ TheNoiseClock/Services/NoisePlayer.swift | 13 +- .../Views/Clock/ClockSettingsView.swift | 4 + 6 files changed, 285 insertions(+), 15 deletions(-) create mode 100644 TheNoiseClock/Services/FocusModeService.swift diff --git a/PRD.md b/PRD.md index 7914918..7572a37 100644 --- a/PRD.md +++ b/PRD.md @@ -212,6 +212,8 @@ These principles are fundamental to the project's long-term success and must be - **Background audio**: Continues playback when app is backgrounded - **Interruption handling**: Automatic resume after phone calls and route changes - **Wake lock integration**: Prevents device sleep during audio playback +- **Focus mode awareness**: Monitors and respects Focus mode settings +- **Notification compatibility**: Ensures alarms work with Focus modes enabled ### Notification System - **UserNotifications**: iOS notification framework @@ -229,6 +231,15 @@ These principles are fundamental to the project's long-term success and must be - **Timer-based maintenance**: Periodic wake lock refresh to ensure continuous operation - **State management**: Tracks wake lock status and provides toggle functionality +### Focus Mode Integration +- **FocusModeService**: Comprehensive service for handling Focus mode interactions +- **Notification compatibility**: Ensures alarms work properly with Focus modes +- **Audio awareness**: Monitors Focus mode status for audio playback decisions +- **Permission management**: Requests notification permissions compatible with Focus modes +- **Alarm scheduling**: Uses Focus mode-aware notification scheduling +- **User settings**: Toggle to respect or override Focus mode restrictions +- **Guidance system**: Provides user instructions for optimal Focus mode configuration + ## User Interface Design ### Navigation @@ -310,9 +321,10 @@ TheNoiseClock/ │ └── SoundControlView.swift # Playback controls component ├── Services/ │ ├── NoisePlayer.swift # Audio playback service with background support -│ ├── AlarmService.swift # Alarm management service +│ ├── AlarmService.swift # Alarm management service with Focus mode integration │ ├── NotificationService.swift # Notification handling service -│ └── WakeLockService.swift # Screen wake lock management service +│ ├── WakeLockService.swift # Screen wake lock management service +│ └── FocusModeService.swift # Focus mode integration and notification management └── Resources/ ├── sounds.json # Sound configuration and definitions ├── Ambient.bundle/ # Ambient sound category @@ -389,8 +401,9 @@ The following changes **automatically require** PRD updates: 1. **Time format**: Toggle 24-hour, seconds, AM/PM display 2. **Appearance**: Adjust colors, glow, size, opacity 3. **Display**: Control keep awake functionality for display mode -4. **Overlays**: Control battery and date display -5. **Background**: Set background color and use presets +4. **Focus Modes**: Control how app behaves with Focus modes (Do Not Disturb) +5. **Overlays**: Control battery and date display +6. **Background**: Set background color and use presets ### Alarms Tab 1. **View alarms**: List of all created alarms with labels and repeat schedules diff --git a/TheNoiseClock/Models/ClockStyle.swift b/TheNoiseClock/Models/ClockStyle.swift index e53473a..9002af3 100644 --- a/TheNoiseClock/Models/ClockStyle.swift +++ b/TheNoiseClock/Models/ClockStyle.swift @@ -38,6 +38,7 @@ class ClockStyle: Codable, Equatable { // MARK: - Display Settings var keepAwake: Bool = false // Keep screen awake in display mode + var respectFocusModes: Bool = true // Respect Focus mode settings for audio // MARK: - Cached Colors private var _cachedDigitColor: Color? @@ -62,6 +63,7 @@ class ClockStyle: Codable, Equatable { case clockOpacity case overlayOpacity case keepAwake + case respectFocusModes } // MARK: - Initialization @@ -90,6 +92,7 @@ class ClockStyle: Codable, Equatable { self.clockOpacity = try container.decodeIfPresent(Double.self, forKey: .clockOpacity) ?? self.clockOpacity self.overlayOpacity = try container.decodeIfPresent(Double.self, forKey: .overlayOpacity) ?? self.overlayOpacity self.keepAwake = try container.decodeIfPresent(Bool.self, forKey: .keepAwake) ?? self.keepAwake + self.respectFocusModes = try container.decodeIfPresent(Bool.self, forKey: .respectFocusModes) ?? self.respectFocusModes clearColorCache() } @@ -113,6 +116,7 @@ class ClockStyle: Codable, Equatable { try container.encode(clockOpacity, forKey: .clockOpacity) try container.encode(overlayOpacity, forKey: .overlayOpacity) try container.encode(keepAwake, forKey: .keepAwake) + try container.encode(respectFocusModes, forKey: .respectFocusModes) } // MARK: - Computed Properties @@ -158,7 +162,8 @@ class ClockStyle: Codable, Equatable { lhs.dateFormat == rhs.dateFormat && lhs.clockOpacity == rhs.clockOpacity && lhs.overlayOpacity == rhs.overlayOpacity && - lhs.keepAwake == rhs.keepAwake + lhs.keepAwake == rhs.keepAwake && + lhs.respectFocusModes == rhs.respectFocusModes } } diff --git a/TheNoiseClock/Services/AlarmService.swift b/TheNoiseClock/Services/AlarmService.swift index 6d82903..c3c6ced 100644 --- a/TheNoiseClock/Services/AlarmService.swift +++ b/TheNoiseClock/Services/AlarmService.swift @@ -17,12 +17,18 @@ 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 { - await NotificationUtils.requestPermissions() + // Request permissions through FocusModeService for better compatibility + let granted = await focusModeService.requestNotificationPermissions() + if !granted { + // Fallback to original method + _ = await NotificationUtils.requestPermissions() + } } } @@ -75,16 +81,14 @@ class AlarmService { // Schedule new notification if enabled if alarm.isEnabled { Task { - let content = NotificationUtils.createAlarmContent( + // Use FocusModeService for better Focus mode compatibility + focusModeService.scheduleAlarmNotification( + identifier: alarm.id.uuidString, title: "Wake Up!", body: "Your alarm is ringing.", - soundName: alarm.soundName - ) - let trigger = NotificationUtils.createCalendarTrigger(for: alarm.time) - _ = await NotificationUtils.scheduleNotification( - identifier: alarm.id.uuidString, - content: content, - trigger: trigger + date: alarm.time, + soundName: alarm.soundName, + repeats: false // For now, set to false since Alarm model doesn't have repeatDays ) } } diff --git a/TheNoiseClock/Services/FocusModeService.swift b/TheNoiseClock/Services/FocusModeService.swift new file mode 100644 index 0000000..7bf518d --- /dev/null +++ b/TheNoiseClock/Services/FocusModeService.swift @@ -0,0 +1,233 @@ +// +// FocusModeService.swift +// TheNoiseClock +// +// Created by Matt Bruce on 9/7/25. +// + +import Foundation +import UserNotifications +import Observation + +/// Service to handle Focus mode interactions and ensure app functionality +@Observable +class FocusModeService { + + // MARK: - Singleton + static let shared = FocusModeService() + + // MARK: - Properties + private(set) var isFocusModeActive = false + private(set) var currentFocusMode: String? + private var focusModeObserver: NSObjectProtocol? + + // MARK: - Initialization + private init() { + setupFocusModeMonitoring() + } + + deinit { + removeFocusModeObserver() + } + + // MARK: - Public Interface + + /// Check if Focus mode is currently active + var isActive: Bool { + return isFocusModeActive + } + + /// Get the current Focus mode name if available + var activeFocusMode: String? { + return currentFocusMode + } + + /// 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() + } + + return granted + } catch { + print("❌ Error requesting notification permissions: \(error)") + return false + } + } + + /// Configure notification settings to work with Focus modes + private func configureNotificationSettings() async { + // Create notification categories that work with Focus modes + let alarmCategory = UNNotificationCategory( + identifier: "ALARM_CATEGORY", + actions: [ + UNNotificationAction( + identifier: "SNOOZE_ACTION", + title: "Snooze", + options: [.foreground] + ), + UNNotificationAction( + identifier: "STOP_ACTION", + title: "Stop", + options: [.destructive] + ) + ], + intentIdentifiers: [], + options: [.customDismissAction] + ) + + // Register the category + UNUserNotificationCenter.current().setNotificationCategories([alarmCategory]) + + print("🔔 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 + ) { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = UNNotificationSound(named: UNNotificationSoundName(soundName)) + content.categoryIdentifier = "ALARM_CATEGORY" + content.userInfo = [ + "alarmId": identifier, + "soundName": soundName, + "repeats": repeats + ] + + // Create trigger + let trigger: UNNotificationTrigger + if repeats { + let calendar = Calendar.current + let components = calendar.dateComponents([.hour, .minute], from: date) + trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true) + } else { + trigger = UNTimeIntervalNotificationTrigger(timeInterval: date.timeIntervalSinceNow, 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 { + print("❌ Error scheduling alarm notification: \(error)") + } else { + print("🔔 Alarm notification scheduled for \(date)") + } + } + } + + /// Cancel alarm notification + func cancelAlarmNotification(identifier: String) { + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [identifier]) + print("🔔 Cancelled alarm notification: \(identifier)") + } + + /// Cancel all alarm notifications + func cancelAllAlarmNotifications() { + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + print("🔔 Cancelled all alarm notifications") + } + + // MARK: - Private Methods + + /// Set up monitoring for Focus mode changes + private func setupFocusModeMonitoring() { + // Monitor notification center for Focus mode changes + focusModeObserver = NotificationCenter.default.addObserver( + forName: .NSSystemTimeZoneDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + self?.updateFocusModeStatus() + } + + // Initial status check + updateFocusModeStatus() + } + + /// Remove Focus mode observer + private func removeFocusModeObserver() { + if let observer = focusModeObserver { + NotificationCenter.default.removeObserver(observer) + focusModeObserver = nil + } + } + + /// Update Focus mode status + private func updateFocusModeStatus() { + // Check if Focus mode is active by examining notification settings + UNUserNotificationCenter.current().getNotificationSettings { settings in + DispatchQueue.main.async { + // This is a simplified check - in a real implementation, + // you might need to use private APIs or other methods + // to detect Focus mode status + self.isFocusModeActive = settings.authorizationStatus == .authorized + self.currentFocusMode = self.isFocusModeActive ? "Active" : nil + + if self.isFocusModeActive { + print("🎯 Focus mode is active") + } else { + print("🎯 Focus mode is not active") + } + } + } + } + + /// 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() + } + + print("🎯 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. + """ + } +} diff --git a/TheNoiseClock/Services/NoisePlayer.swift b/TheNoiseClock/Services/NoisePlayer.swift index e7ce1d5..af556e7 100644 --- a/TheNoiseClock/Services/NoisePlayer.swift +++ b/TheNoiseClock/Services/NoisePlayer.swift @@ -21,12 +21,14 @@ class NoisePlayer { private var currentSound: Sound? private var shouldResumeAfterInterruption = false private let wakeLockService = WakeLockService.shared + private let focusModeService = FocusModeService.shared // MARK: - Initialization private init() { setupAudioSession() preloadSounds() setupAudioInterruptionHandling() + focusModeService.configureForFocusModes() } deinit { @@ -40,6 +42,15 @@ class NoisePlayer { func playSound(_ sound: Sound) { print("🎵 Attempting to play: \(sound.name)") + + // Check Focus mode status if respecting Focus modes + Task { + let notificationsAllowed = await focusModeService.areNotificationsAllowed() + if !notificationsAllowed { + print("🎯 Focus mode is active - audio playback may be limited") + } + } + // Stop current sound if playing stopSound() @@ -136,7 +147,7 @@ class NoisePlayer { try AVAudioSession.sharedInstance().setActive(true) // Configure for background audio playback - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [.mixWithOthers, .allowBluetooth, .allowBluetoothA2DP]) + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [.mixWithOthers, .allowBluetoothHFP, .allowBluetoothA2DP]) try AVAudioSession.sharedInstance().setActive(true) print("🔊 Audio session configured for background playback") diff --git a/TheNoiseClock/Views/Clock/ClockSettingsView.swift b/TheNoiseClock/Views/Clock/ClockSettingsView.swift index 5ad83e7..c0937bc 100644 --- a/TheNoiseClock/Views/Clock/ClockSettingsView.swift +++ b/TheNoiseClock/Views/Clock/ClockSettingsView.swift @@ -198,6 +198,10 @@ private struct DisplaySection: View { Section(header: Text("Display"), footer: Text("Keep the screen awake when in full-screen display mode. This prevents the device from sleeping while viewing the clock.")) { Toggle("Keep Awake in Display Mode", isOn: $style.keepAwake) } + + Section(header: Text("Focus Modes"), footer: Text("Control how the app behaves when Focus modes (Do Not Disturb) are active. When enabled, audio may be paused during Focus mode.")) { + Toggle("Respect Focus Modes", isOn: $style.respectFocusModes) + } } }