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

This commit is contained in:
Matt Bruce 2025-09-08 13:18:46 -05:00
parent ebcc18db6a
commit bec3092001
8 changed files with 283 additions and 7 deletions

32
PRD.md
View File

@ -46,6 +46,8 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di
- **Safe area expansion**: Clock expands into tab bar area when hidden
- **Dynamic Island awareness**: Proper spacing to avoid Dynamic Island overlap
- **Orientation handling**: Full-screen mode works in both portrait and landscape
- **Keep awake functionality**: Optional screen wake lock to prevent device sleep in display mode
- **Battery optimization**: Wake lock automatically disabled when exiting display mode
### 4. Information Overlays
- **Battery level display**: Real-time battery percentage with icon
@ -64,6 +66,10 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di
- **JSON-based configuration**: Sound definitions loaded from external configuration
- **Bundle organization**: Sounds organized in category-based bundles
- **Shared audio player**: Singleton pattern prevents audio conflicts
- **Background audio support**: Continues playing when app is backgrounded
- **Audio interruption handling**: Automatically resumes after phone calls or route changes
- **Wake lock integration**: Prevents device sleep while audio is playing
- **Bluetooth audio support**: Works with AirPods and other Bluetooth audio devices
### 6. Advanced Alarm System
- **Multiple alarms**: Create and manage unlimited alarms
@ -200,9 +206,12 @@ These principles are fundamental to the project's long-term success and must be
- **AVFoundation**: AVAudioPlayer for noise playback
- **@Observable NoisePlayer**: Modern state management with preloading
- **Looping playback**: Infinite loop for ambient sounds
- **Audio session management**: Proper audio session configuration
- **Audio session management**: Proper audio session configuration with background support
- **Error handling**: Graceful handling of missing audio files
- **AlarmTonePlayer**: Dedicated player for alarm sound previews
- **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
### Notification System
- **UserNotifications**: iOS notification framework
@ -212,6 +221,14 @@ These principles are fundamental to the project's long-term success and must be
- **Sound customization**: System sound selection with volume control
- **Multiple notifications**: Support for repeating alarms with unique identifiers
### Wake Lock System
- **WakeLockService**: Singleton service for managing screen wake lock
- **Display mode integration**: Automatically enables wake lock in full-screen display mode
- **Audio integration**: Enables wake lock during audio playback to prevent device sleep
- **Battery optimization**: Automatic wake lock management with proper cleanup
- **Timer-based maintenance**: Periodic wake lock refresh to ensure continuous operation
- **State management**: Tracks wake lock status and provides toggle functionality
## User Interface Design
### Navigation
@ -292,9 +309,10 @@ TheNoiseClock/
│ ├── SoundPickerView.swift # Sound selection component
│ └── SoundControlView.swift # Playback controls component
├── Services/
│ ├── NoisePlayer.swift # Audio playback service
│ ├── NoisePlayer.swift # Audio playback service with background support
│ ├── AlarmService.swift # Alarm management service
│ └── NotificationService.swift # Notification handling service
│ ├── NotificationService.swift # Notification handling service
│ └── WakeLockService.swift # Screen wake lock management service
└── Resources/
├── sounds.json # Sound configuration and definitions
├── Ambient.bundle/ # Ambient sound category
@ -370,8 +388,9 @@ The following changes **automatically require** PRD updates:
### Settings
1. **Time format**: Toggle 24-hour, seconds, AM/PM display
2. **Appearance**: Adjust colors, glow, size, opacity
3. **Overlays**: Control battery and date display
4. **Background**: Set background color and use presets
3. **Display**: Control keep awake functionality for display mode
4. **Overlays**: Control battery and date display
5. **Background**: Set background color and use presets
### Alarms Tab
1. **View alarms**: List of all created alarms with labels and repeat schedules
@ -411,12 +430,13 @@ The following changes **automatically require** PRD updates:
### Dependencies
- **SwiftUI**: Native iOS UI framework with latest features
- **AVFoundation**: Audio playback with modern async patterns
- **AVFoundation**: Audio playback with modern async patterns and background support
- **UserNotifications**: Alarm notifications with rich content support
- **Combine**: Timer publishers and reactive programming
- **Observation**: Modern state management with @Observable
- **Foundation**: Core system frameworks and utilities
- **UIKit**: UIFont integration for precise text measurement and font customization
- **UIApplication**: Screen wake lock management and idle timer control
### Font and Typography Utilities
- **FontUtils.optimalFontSize()**: Calculates optimal font size for portrait orientation

View File

@ -29,9 +29,22 @@
EA384B122E6E6B6100CA7D50 /* TheNoiseClockUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TheNoiseClockUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
EA384D3B2E6F554D00CA7D50 /* Exceptions for "TheNoiseClock" folder in "TheNoiseClock" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = EA384AFA2E6E6B6000CA7D50 /* TheNoiseClock */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
EA384AFD2E6E6B6000CA7D50 /* TheNoiseClock */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
EA384D3B2E6F554D00CA7D50 /* Exceptions for "TheNoiseClock" folder in "TheNoiseClock" target */,
);
path = TheNoiseClock;
sourceTree = "<group>";
};
@ -400,6 +413,7 @@
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TheNoiseClock/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@ -431,6 +445,7 @@
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TheNoiseClock/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;

10
TheNoiseClock/Info.plist Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
</dict>
</plist>

View File

@ -36,6 +36,9 @@ class ClockStyle: Codable, Equatable {
var clockOpacity: Double = AppConstants.Defaults.clockOpacity
var overlayOpacity: Double = AppConstants.Defaults.overlayOpacity
// MARK: - Display Settings
var keepAwake: Bool = false // Keep screen awake in display mode
// MARK: - Cached Colors
private var _cachedDigitColor: Color?
private var _cachedBackgroundColor: Color?
@ -58,6 +61,7 @@ class ClockStyle: Codable, Equatable {
case dateFormat
case clockOpacity
case overlayOpacity
case keepAwake
}
// MARK: - Initialization
@ -85,6 +89,7 @@ class ClockStyle: Codable, Equatable {
self.dateFormat = try container.decodeIfPresent(String.self, forKey: .dateFormat) ?? self.dateFormat
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
clearColorCache()
}
@ -107,6 +112,7 @@ class ClockStyle: Codable, Equatable {
try container.encode(dateFormat, forKey: .dateFormat)
try container.encode(clockOpacity, forKey: .clockOpacity)
try container.encode(overlayOpacity, forKey: .overlayOpacity)
try container.encode(keepAwake, forKey: .keepAwake)
}
// MARK: - Computed Properties
@ -151,7 +157,8 @@ class ClockStyle: Codable, Equatable {
lhs.showDate == rhs.showDate &&
lhs.dateFormat == rhs.dateFormat &&
lhs.clockOpacity == rhs.clockOpacity &&
lhs.overlayOpacity == rhs.overlayOpacity
lhs.overlayOpacity == rhs.overlayOpacity &&
lhs.keepAwake == rhs.keepAwake
}
}

View File

@ -18,11 +18,15 @@ class NoisePlayer {
// MARK: - Properties
private var players: [String: AVAudioPlayer] = [:]
private var currentPlayer: AVAudioPlayer?
private var currentSound: Sound?
private var shouldResumeAfterInterruption = false
private let wakeLockService = WakeLockService.shared
// MARK: - Initialization
private init() {
setupAudioSession()
preloadSounds()
setupAudioInterruptionHandling()
}
deinit {
@ -39,6 +43,9 @@ class NoisePlayer {
// Stop current sound if playing
stopSound()
// Store current sound for interruption handling
currentSound = sound
// Get or create player for this sound
guard let player = players[sound.fileName] else {
print("❌ Sound not preloaded: \(sound.fileName)")
@ -71,11 +78,21 @@ class NoisePlayer {
print("🎵 Play result: \(success ? "SUCCESS" : "FAILED")")
print("🔊 Player isPlaying: \(player.isPlaying)")
print("🔊 Player volume: \(player.volume)")
// Enable wake lock when playing audio to prevent device sleep
if success {
wakeLockService.enableWakeLock()
}
}
func stopSound() {
currentPlayer?.stop()
currentPlayer = nil
currentSound = nil
shouldResumeAfterInterruption = false
// Disable wake lock when stopping audio
wakeLockService.disableWakeLock()
}
// MARK: - Private Methods
@ -117,6 +134,12 @@ class NoisePlayer {
try AVAudioSession.sharedInstance().setCategory(category, mode: mode, options: options)
try AVAudioSession.sharedInstance().setActive(true)
// Configure for background audio playback
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [.mixWithOthers, .allowBluetooth, .allowBluetoothA2DP])
try AVAudioSession.sharedInstance().setActive(true)
print("🔊 Audio session configured for background playback")
} catch {
print("Error setting up audio session: \(error)")
}
@ -157,5 +180,100 @@ class NoisePlayer {
}
players.removeAll()
currentPlayer = nil
currentSound = nil
shouldResumeAfterInterruption = false
}
/// Set up audio interruption handling to maintain playback
private func setupAudioInterruptionHandling() {
NotificationCenter.default.addObserver(
self,
selector: #selector(handleAudioInterruption),
name: AVAudioSession.interruptionNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleRouteChange),
name: AVAudioSession.routeChangeNotification,
object: nil
)
}
@objc private func handleAudioInterruption(notification: Notification) {
guard let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
return
}
switch type {
case .began:
// Audio was interrupted (e.g., phone call)
shouldResumeAfterInterruption = isPlaying
if isPlaying {
currentPlayer?.pause()
print("🔇 Audio interrupted - will resume after interruption ends")
}
case .ended:
// Audio interruption ended
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) && shouldResumeAfterInterruption {
// Resume playback
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.resumePlayback()
}
}
}
@unknown default:
break
}
}
@objc private func handleRouteChange(notification: Notification) {
guard let userInfo = notification.userInfo,
let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else {
return
}
switch reason {
case .oldDeviceUnavailable:
// Headphones were unplugged, etc.
if isPlaying {
shouldResumeAfterInterruption = true
currentPlayer?.pause()
print("🔇 Audio route changed - will resume when new route available")
}
case .newDeviceAvailable:
// New audio device available
if shouldResumeAfterInterruption {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.resumePlayback()
}
}
default:
break
}
}
/// Resume playback after interruption
private func resumePlayback() {
guard shouldResumeAfterInterruption, let sound = currentSound else { return }
do {
try AVAudioSession.sharedInstance().setActive(true)
playSound(sound)
shouldResumeAfterInterruption = false
print("🔊 Audio playback resumed after interruption")
} catch {
print("❌ Error resuming audio playback: \(error)")
}
}
}

View File

@ -0,0 +1,79 @@
//
// WakeLockService.swift
// TheNoiseClock
//
// Created by Matt Bruce on 9/7/25.
//
import UIKit
import Observation
/// Service to manage screen wake lock and prevent device from sleeping
@Observable
class WakeLockService {
// MARK: - Singleton
static let shared = WakeLockService()
// MARK: - Properties
private(set) var isWakeLockActive = false
private var wakeLockTimer: Timer?
// MARK: - Initialization
private init() {}
deinit {
disableWakeLock()
}
// MARK: - Public Interface
/// Enable wake lock to prevent device from sleeping
func enableWakeLock() {
guard !isWakeLockActive else { return }
// Prevent device from sleeping
UIApplication.shared.isIdleTimerDisabled = true
// Set up periodic timer to maintain wake lock
wakeLockTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { _ in
// Keep the app active by briefly enabling/disabling idle timer
UIApplication.shared.isIdleTimerDisabled = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
UIApplication.shared.isIdleTimerDisabled = true
}
}
isWakeLockActive = true
print("🔒 Wake lock enabled - device will not sleep")
}
/// Disable wake lock and allow device to sleep normally
func disableWakeLock() {
guard isWakeLockActive else { return }
// Allow device to sleep normally
UIApplication.shared.isIdleTimerDisabled = false
// Stop the maintenance timer
wakeLockTimer?.invalidate()
wakeLockTimer = nil
isWakeLockActive = false
print("🔓 Wake lock disabled - device can sleep normally")
}
/// Toggle wake lock state
func toggleWakeLock() {
if isWakeLockActive {
disableWakeLock()
} else {
enableWakeLock()
}
}
/// Check if wake lock is currently active
var isActive: Bool {
return isWakeLockActive
}
}

View File

@ -19,6 +19,9 @@ class ClockViewModel {
private(set) var style = ClockStyle()
private(set) var isDisplayMode = false
// Wake lock service
private let wakeLockService = WakeLockService.shared
// Timer management
private var secondTimer: Timer.TimerPublisher?
private var minuteTimer: Timer.TimerPublisher?
@ -54,12 +57,16 @@ class ClockViewModel {
withAnimation(UIConstants.AnimationCurves.bouncy) {
isDisplayMode.toggle()
}
// Manage wake lock based on display mode and keep awake setting
updateWakeLockState()
}
func updateStyle(_ newStyle: ClockStyle) {
style = newStyle
saveStyle()
updateTimersIfNeeded()
updateWakeLockState()
}
// MARK: - Private Methods
@ -130,4 +137,14 @@ class ClockViewModel {
secondTimer = nil
}
}
/// Update wake lock state based on current settings
private func updateWakeLockState() {
// Enable wake lock if in display mode and keep awake is enabled
if isDisplayMode && style.keepAwake {
wakeLockService.enableWakeLock()
} else {
wakeLockService.disableWakeLock()
}
}
}

View File

@ -35,6 +35,7 @@ struct ClockSettingsView: View {
backgroundColor: $backgroundColor,
onCommit: onCommit
)
DisplaySection(style: $style)
OverlaySection(style: $style)
}
.navigationTitle("Clock Settings")
@ -190,6 +191,15 @@ private struct OverlaySection: View {
}
}
private struct DisplaySection: View {
@Binding var style: ClockStyle
var body: some 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)
}
}
}
// MARK: - Preview
#Preview {