Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
ebcc18db6a
commit
bec3092001
32
PRD.md
32
PRD.md
@ -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
|
||||
|
||||
@ -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
10
TheNoiseClock/Info.plist
Normal 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>
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
79
TheNoiseClock/Services/WakeLockService.swift
Normal file
79
TheNoiseClock/Services/WakeLockService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user