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

This commit is contained in:
Matt Bruce 2025-09-08 13:33:36 -05:00
parent bec3092001
commit 46f5a5b586
6 changed files with 285 additions and 15 deletions

21
PRD.md
View File

@ -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 - **Background audio**: Continues playback when app is backgrounded
- **Interruption handling**: Automatic resume after phone calls and route changes - **Interruption handling**: Automatic resume after phone calls and route changes
- **Wake lock integration**: Prevents device sleep during audio playback - **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 ### Notification System
- **UserNotifications**: iOS notification framework - **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 - **Timer-based maintenance**: Periodic wake lock refresh to ensure continuous operation
- **State management**: Tracks wake lock status and provides toggle functionality - **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 ## User Interface Design
### Navigation ### Navigation
@ -310,9 +321,10 @@ TheNoiseClock/
│ └── SoundControlView.swift # Playback controls component │ └── SoundControlView.swift # Playback controls component
├── Services/ ├── Services/
│ ├── NoisePlayer.swift # Audio playback service with background support │ ├── 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 │ ├── 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/ └── Resources/
├── sounds.json # Sound configuration and definitions ├── sounds.json # Sound configuration and definitions
├── Ambient.bundle/ # Ambient sound category ├── 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 1. **Time format**: Toggle 24-hour, seconds, AM/PM display
2. **Appearance**: Adjust colors, glow, size, opacity 2. **Appearance**: Adjust colors, glow, size, opacity
3. **Display**: Control keep awake functionality for display mode 3. **Display**: Control keep awake functionality for display mode
4. **Overlays**: Control battery and date display 4. **Focus Modes**: Control how app behaves with Focus modes (Do Not Disturb)
5. **Background**: Set background color and use presets 5. **Overlays**: Control battery and date display
6. **Background**: Set background color and use presets
### Alarms Tab ### Alarms Tab
1. **View alarms**: List of all created alarms with labels and repeat schedules 1. **View alarms**: List of all created alarms with labels and repeat schedules

View File

@ -38,6 +38,7 @@ class ClockStyle: Codable, Equatable {
// MARK: - Display Settings // MARK: - Display Settings
var keepAwake: Bool = false // Keep screen awake in display mode var keepAwake: Bool = false // Keep screen awake in display mode
var respectFocusModes: Bool = true // Respect Focus mode settings for audio
// MARK: - Cached Colors // MARK: - Cached Colors
private var _cachedDigitColor: Color? private var _cachedDigitColor: Color?
@ -62,6 +63,7 @@ class ClockStyle: Codable, Equatable {
case clockOpacity case clockOpacity
case overlayOpacity case overlayOpacity
case keepAwake case keepAwake
case respectFocusModes
} }
// MARK: - Initialization // MARK: - Initialization
@ -90,6 +92,7 @@ class ClockStyle: Codable, Equatable {
self.clockOpacity = try container.decodeIfPresent(Double.self, forKey: .clockOpacity) ?? self.clockOpacity self.clockOpacity = try container.decodeIfPresent(Double.self, forKey: .clockOpacity) ?? self.clockOpacity
self.overlayOpacity = try container.decodeIfPresent(Double.self, forKey: .overlayOpacity) ?? self.overlayOpacity self.overlayOpacity = try container.decodeIfPresent(Double.self, forKey: .overlayOpacity) ?? self.overlayOpacity
self.keepAwake = try container.decodeIfPresent(Bool.self, forKey: .keepAwake) ?? self.keepAwake self.keepAwake = try container.decodeIfPresent(Bool.self, forKey: .keepAwake) ?? self.keepAwake
self.respectFocusModes = try container.decodeIfPresent(Bool.self, forKey: .respectFocusModes) ?? self.respectFocusModes
clearColorCache() clearColorCache()
} }
@ -113,6 +116,7 @@ class ClockStyle: Codable, Equatable {
try container.encode(clockOpacity, forKey: .clockOpacity) try container.encode(clockOpacity, forKey: .clockOpacity)
try container.encode(overlayOpacity, forKey: .overlayOpacity) try container.encode(overlayOpacity, forKey: .overlayOpacity)
try container.encode(keepAwake, forKey: .keepAwake) try container.encode(keepAwake, forKey: .keepAwake)
try container.encode(respectFocusModes, forKey: .respectFocusModes)
} }
// MARK: - Computed Properties // MARK: - Computed Properties
@ -158,7 +162,8 @@ class ClockStyle: Codable, Equatable {
lhs.dateFormat == rhs.dateFormat && lhs.dateFormat == rhs.dateFormat &&
lhs.clockOpacity == rhs.clockOpacity && lhs.clockOpacity == rhs.clockOpacity &&
lhs.overlayOpacity == rhs.overlayOpacity && lhs.overlayOpacity == rhs.overlayOpacity &&
lhs.keepAwake == rhs.keepAwake lhs.keepAwake == rhs.keepAwake &&
lhs.respectFocusModes == rhs.respectFocusModes
} }
} }

View File

@ -17,12 +17,18 @@ class AlarmService {
private(set) var alarms: [Alarm] = [] private(set) var alarms: [Alarm] = []
private var alarmLookup: [UUID: Int] = [:] private var alarmLookup: [UUID: Int] = [:]
private var persistenceWorkItem: DispatchWorkItem? private var persistenceWorkItem: DispatchWorkItem?
private let focusModeService = FocusModeService.shared
// MARK: - Initialization // MARK: - Initialization
init() { init() {
loadAlarms() loadAlarms()
Task { 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 // Schedule new notification if enabled
if alarm.isEnabled { if alarm.isEnabled {
Task { Task {
let content = NotificationUtils.createAlarmContent( // Use FocusModeService for better Focus mode compatibility
focusModeService.scheduleAlarmNotification(
identifier: alarm.id.uuidString,
title: "Wake Up!", title: "Wake Up!",
body: "Your alarm is ringing.", body: "Your alarm is ringing.",
soundName: alarm.soundName date: alarm.time,
) soundName: alarm.soundName,
let trigger = NotificationUtils.createCalendarTrigger(for: alarm.time) repeats: false // For now, set to false since Alarm model doesn't have repeatDays
_ = await NotificationUtils.scheduleNotification(
identifier: alarm.id.uuidString,
content: content,
trigger: trigger
) )
} }
} }

View File

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

View File

@ -21,12 +21,14 @@ class NoisePlayer {
private var currentSound: Sound? private var currentSound: Sound?
private var shouldResumeAfterInterruption = false private var shouldResumeAfterInterruption = false
private let wakeLockService = WakeLockService.shared private let wakeLockService = WakeLockService.shared
private let focusModeService = FocusModeService.shared
// MARK: - Initialization // MARK: - Initialization
private init() { private init() {
setupAudioSession() setupAudioSession()
preloadSounds() preloadSounds()
setupAudioInterruptionHandling() setupAudioInterruptionHandling()
focusModeService.configureForFocusModes()
} }
deinit { deinit {
@ -40,6 +42,15 @@ class NoisePlayer {
func playSound(_ sound: Sound) { func playSound(_ sound: Sound) {
print("🎵 Attempting to play: \(sound.name)") 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 // Stop current sound if playing
stopSound() stopSound()
@ -136,7 +147,7 @@ class NoisePlayer {
try AVAudioSession.sharedInstance().setActive(true) try AVAudioSession.sharedInstance().setActive(true)
// Configure for background audio playback // 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) try AVAudioSession.sharedInstance().setActive(true)
print("🔊 Audio session configured for background playback") print("🔊 Audio session configured for background playback")

View File

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