more refactoring by codex

Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
Matt Bruce 2026-02-08 10:48:39 -06:00
parent 86e4382cc2
commit d2c6a09e47
15 changed files with 254 additions and 115 deletions

37
PRD.md
View File

@ -484,8 +484,7 @@ TheNoiseClock/
│ │ └── OnboardingPageView.swift │ │ └── OnboardingPageView.swift
│ └── Resources/ │ └── Resources/
│ ├── LaunchScreen.storyboard # Branded native launch screen │ ├── LaunchScreen.storyboard # Branded native launch screen
│ ├── sounds.json # Ambient sound configuration and definitions │ ├── SoundsSettings.json # Shared audio settings
│ ├── alarm-sounds.json # Alarm sound configuration and definitions
│ ├── Ambient.bundle/ # Ambient sound category │ ├── Ambient.bundle/ # Ambient sound category
│ │ └── white-noise.mp3 │ │ └── white-noise.mp3
│ ├── Nature.bundle/ # Nature sound category │ ├── Nature.bundle/ # Nature sound category
@ -493,11 +492,12 @@ TheNoiseClock/
│ ├── Mechanical.bundle/ # Mechanical sound category │ ├── Mechanical.bundle/ # Mechanical sound category
│ │ └── fan-white-noise-heater.mp3 │ │ └── fan-white-noise-heater.mp3
│ ├── AlarmSounds.bundle/ # Alarm sound category │ ├── AlarmSounds.bundle/ # Alarm sound category
│ │ ├── digital-alarm.caf │ │ ├── sounds.json # Alarm sound metadata
│ │ ├── classic-alarm.caf │ │ ├── digital-alarm.mp3
│ │ ├── beep-alarm.caf │ │ ├── classic-alarm.mp3
│ │ ├── siren-alarm.caf │ │ ├── beep-alarm.mp3
│ │ └── buzzing-alarm.caf │ │ ├── siren-alarm.mp3
│ │ └── buzzing-alarm.mp3
│ └── Assets.xcassets/ │ └── Assets.xcassets/
│ └── [Asset catalogs] │ └── [Asset catalogs]
└── TheNoiseClock.xcodeproj/ # Xcode project with AudioPlaybackKit dependency └── TheNoiseClock.xcodeproj/ # Xcode project with AudioPlaybackKit dependency
@ -727,8 +727,8 @@ The following terminal commands are used for building and testing the project. T
# Navigate to project directory # Navigate to project directory
cd /Users/mattbruce/Documents/Projects/TheNoiseClock cd /Users/mattbruce/Documents/Projects/TheNoiseClock
# Build for iOS Simulator (iPad mini) # Build for iOS Simulator (iPhone 17 Pro Max)
xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPad mini (A17 Pro),OS=18.1' build xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2' build
# Build for iOS Simulator (any device) # Build for iOS Simulator (any device)
xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=Any iOS Simulator Device' build xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=Any iOS Simulator Device' build
@ -740,7 +740,7 @@ xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock build
#### Error Checking Commands #### Error Checking Commands
```bash ```bash
# Check for build errors only (filtered output) # Check for build errors only (filtered output)
xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPad mini (A17 Pro),OS=18.1' build 2>&1 | grep -E "(error:|warning:|failed)" | head -10 xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2' build 2>&1 | grep -E "(error:|warning:|failed)" | head -10
# Quick syntax check for specific files # Quick syntax check for specific files
swift -frontend -parse TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift swift -frontend -parse TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift
@ -749,15 +749,7 @@ swift -frontend -parse TheNoiseClock/Views/Clock/Components/DigitView.swift
``` ```
#### Available Simulators #### Available Simulators
The following simulators are available for testing: Use **iPhone 17 Pro Max (iOS 26.2)** as the primary simulator for build and test validation.
- **iPad mini (A17 Pro)** - Primary testing device
- **iPad (10th generation)**
- **iPad Air 11-inch (M2)**
- **iPad Air 13-inch (M2)**
- **iPad Pro 11-inch (M4)**
- **iPad Pro 13-inch (M4)**
- **iPhone 16, 16 Plus, 16 Pro, 16 Pro Max**
- **iPhone SE (3rd generation)**
#### Build Troubleshooting #### Build Troubleshooting
1. **Provisioning Profile Errors**: Use iOS Simulator builds instead of device builds 1. **Provisioning Profile Errors**: Use iOS Simulator builds instead of device builds
@ -772,6 +764,13 @@ The following simulators are available for testing:
4. **Test on simulator** using Xcode or terminal build 4. **Test on simulator** using Xcode or terminal build
5. **Update PRD** if architectural changes are made 5. **Update PRD** if architectural changes are made
#### Recent Engineering Quality Updates
- Alarm creation now defaults to `digital-alarm.mp3` (matches bundled assets and AlarmKit expectations).
- `AlarmSoundService` now caches decoded configuration/settings and falls back gracefully if bundled JSON is missing or malformed.
- Alarm-change notifications are now separated from clock-style notifications using `Notification.Name.alarmsDidUpdate`.
- Date overlay formatting now uses a per-thread `DateFormatter` cache to reduce formatter churn.
- Shared `TheNoiseClock` scheme now includes both unit and UI test targets for consistent `xcodebuild test` execution.
## Development Notes ## Development Notes
### Project Information ### Project Information

View File

@ -90,7 +90,13 @@ Open `TheNoiseClock.xcodeproj` and run the `TheNoiseClock` scheme.
### Terminal Build ### Terminal Build
```bash ```bash
cd /Users/mattbruce/Documents/Projects/iPhone/TheNoiseClock cd /Users/mattbruce/Documents/Projects/iPhone/TheNoiseClock
xcodebuild -project TheNoiseClock/TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPad mini (A17 Pro),OS=18.1' build xcodebuild -project TheNoiseClock/TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2' build
```
### Terminal Test
```bash
cd /Users/mattbruce/Documents/Projects/iPhone/TheNoiseClock
xcodebuild -project TheNoiseClock/TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2' test
``` ```
--- ---
@ -125,6 +131,16 @@ Swift access is provided via:
--- ---
## Recent Engineering Updates
- Alarm defaults now use `digital-alarm.mp3` for consistent AlarmKit scheduling.
- Alarm sound metadata loading now caches decoded data and falls back safely if configuration files are missing/corrupt.
- Alarm UI refresh uses a dedicated `alarmsDidUpdate` notification instead of reusing clock-style notifications.
- Date overlay formatting now uses cached formatters to reduce repeated allocation overhead.
- Shared scheme configuration includes both unit and UI tests under `xcodebuild ... -scheme TheNoiseClock test`.
---
## Architecture ## Architecture
TheNoiseClock follows a clean, modular structure: TheNoiseClock follows a clean, modular structure:

View File

@ -551,7 +551,7 @@
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
}; };
name = Debug; name = Debug;
@ -585,7 +585,7 @@
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
}; };
name = Release; name = Release;
@ -607,9 +607,9 @@
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TheNoiseClock.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TheNoiseClock"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/The Noise Clock.app/The Noise Clock";
}; };
name = Debug; name = Debug;
}; };
@ -630,9 +630,9 @@
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TheNoiseClock.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TheNoiseClock"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/The Noise Clock.app/The Noise Clock";
}; };
name = Release; name = Release;
}; };
@ -651,7 +651,7 @@
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = TheNoiseClock; TEST_TARGET_NAME = TheNoiseClock;
}; };
@ -672,7 +672,7 @@
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = TheNoiseClock; TEST_TARGET_NAME = TheNoiseClock;
}; };
@ -692,7 +692,7 @@
PRODUCT_BUNDLE_IDENTIFIER = "$(WIDGET_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(WIDGET_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
}; };
name = Debug; name = Debug;
@ -711,7 +711,7 @@
PRODUCT_BUNDLE_IDENTIFIER = "$(WIDGET_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(WIDGET_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
}; };
name = Release; name = Release;

View File

@ -17,8 +17,8 @@ import Foundation
/// Intent to stop an active alarm from the Live Activity or notification. /// Intent to stop an active alarm from the Live Activity or notification.
struct StopAlarmIntent: LiveActivityIntent { struct StopAlarmIntent: LiveActivityIntent {
static var title: LocalizedStringResource = "Stop Alarm" static let title: LocalizedStringResource = "Stop Alarm"
static var description = IntentDescription("Stops the currently ringing alarm") static let description = IntentDescription("Stops the currently ringing alarm")
@Parameter(title: "Alarm ID") @Parameter(title: "Alarm ID")
var alarmId: String var alarmId: String
@ -48,8 +48,8 @@ struct StopAlarmIntent: LiveActivityIntent {
/// Intent to snooze an active alarm from the Live Activity or notification. /// Intent to snooze an active alarm from the Live Activity or notification.
struct SnoozeAlarmIntent: LiveActivityIntent { struct SnoozeAlarmIntent: LiveActivityIntent {
static var title: LocalizedStringResource = "Snooze Alarm" static let title: LocalizedStringResource = "Snooze Alarm"
static var description = IntentDescription("Snoozes the currently ringing alarm") static let description = IntentDescription("Snoozes the currently ringing alarm")
@Parameter(title: "Alarm ID") @Parameter(title: "Alarm ID")
var alarmId: String var alarmId: String
@ -80,9 +80,9 @@ struct SnoozeAlarmIntent: LiveActivityIntent {
/// Intent to open the app when the user taps the Live Activity. /// Intent to open the app when the user taps the Live Activity.
struct OpenAlarmAppIntent: LiveActivityIntent { struct OpenAlarmAppIntent: LiveActivityIntent {
static var title: LocalizedStringResource = "Open TheNoiseClock" static let title: LocalizedStringResource = "Open TheNoiseClock"
static var description = IntentDescription("Opens the app to the alarm screen") static let description = IntentDescription("Opens the app to the alarm screen")
static var openAppWhenRun = true static let openAppWhenRun = true
@Parameter(title: "Alarm ID") @Parameter(title: "Alarm ID")
var alarmId: String var alarmId: String

View File

@ -19,29 +19,28 @@ final class AlarmKitService {
// MARK: - Singleton // MARK: - Singleton
static let shared = AlarmKitService() static let shared = AlarmKitService()
private let manager = AlarmManager.shared
private init() { private init() {
Design.debugLog("[alarmkit] AlarmKitService initialized") Design.debugLog("[alarmkit] AlarmKitService initialized")
Design.debugLog("[alarmkit] Authorization state: \(manager.authorizationState)") Design.debugLog("[alarmkit] Authorization state: \(AlarmManager.shared.authorizationState)")
} }
// MARK: - Authorization // MARK: - Authorization
/// The current authorization state for AlarmKit /// The current authorization state for AlarmKit
var authorizationState: AlarmManager.AuthorizationState { var authorizationState: AlarmManager.AuthorizationState {
manager.authorizationState AlarmManager.shared.authorizationState
} }
/// Request authorization to schedule alarms. /// Request authorization to schedule alarms.
/// - Returns: `true` if authorized, `false` otherwise. /// - Returns: `true` if authorized, `false` otherwise.
func requestAuthorization() async -> Bool { func requestAuthorization() async -> Bool {
Design.debugLog("[alarmkit] Requesting authorization, current state: \(manager.authorizationState)") Design.debugLog("[alarmkit] Requesting authorization, current state: \(AlarmManager.shared.authorizationState)")
switch manager.authorizationState { switch AlarmManager.shared.authorizationState {
case .notDetermined: case .notDetermined:
do { do {
let state = try await manager.requestAuthorization() let state = try await AlarmManager.shared.requestAuthorization()
Design.debugLog("[alarmkit] Authorization result: \(state)") Design.debugLog("[alarmkit] Authorization result: \(state)")
return state == .authorized return state == .authorized
} catch { } catch {
@ -73,7 +72,7 @@ final class AlarmKitService {
Design.debugLog("[alarmkit] ID: \(alarm.id)") Design.debugLog("[alarmkit] ID: \(alarm.id)")
// Ensure we're authorized // Ensure we're authorized
if manager.authorizationState != .authorized { if AlarmManager.shared.authorizationState != .authorized {
Design.debugLog("[alarmkit] Not authorized, requesting...") Design.debugLog("[alarmkit] Not authorized, requesting...")
let authorized = await requestAuthorization() let authorized = await requestAuthorization()
guard authorized else { guard authorized else {
@ -160,7 +159,7 @@ final class AlarmKitService {
// Schedule the alarm // Schedule the alarm
do { do {
let scheduledAlarm = try await manager.schedule( let scheduledAlarm = try await AlarmManager.shared.schedule(
id: alarm.id, id: alarm.id,
configuration: configuration configuration: configuration
) )
@ -299,7 +298,7 @@ final class AlarmKitService {
func cancelAlarm(id: UUID) { func cancelAlarm(id: UUID) {
Design.debugLog("[alarmkit] Cancelling alarm: \(id)") Design.debugLog("[alarmkit] Cancelling alarm: \(id)")
do { do {
try manager.cancel(id: id) try AlarmManager.shared.cancel(id: id)
Design.debugLog("[alarmkit] ✅ Alarm cancelled: \(id)") Design.debugLog("[alarmkit] ✅ Alarm cancelled: \(id)")
} catch { } catch {
Design.debugLog("[alarmkit] ❌ Cancel error: \(error)") Design.debugLog("[alarmkit] ❌ Cancel error: \(error)")
@ -311,7 +310,7 @@ final class AlarmKitService {
func stopAlarm(id: UUID) { func stopAlarm(id: UUID) {
Design.debugLog("[alarmkit] Stopping alarm: \(id)") Design.debugLog("[alarmkit] Stopping alarm: \(id)")
do { do {
try manager.stop(id: id) try AlarmManager.shared.stop(id: id)
Design.debugLog("[alarmkit] ✅ Alarm stopped: \(id)") Design.debugLog("[alarmkit] ✅ Alarm stopped: \(id)")
} catch { } catch {
Design.debugLog("[alarmkit] ❌ Stop error: \(error)") Design.debugLog("[alarmkit] ❌ Stop error: \(error)")
@ -323,7 +322,7 @@ final class AlarmKitService {
func snoozeAlarm(id: UUID) { func snoozeAlarm(id: UUID) {
Design.debugLog("[alarmkit] Snoozing alarm: \(id)") Design.debugLog("[alarmkit] Snoozing alarm: \(id)")
do { do {
try manager.countdown(id: id) try AlarmManager.shared.countdown(id: id)
Design.debugLog("[alarmkit] ✅ Alarm snoozed: \(id)") Design.debugLog("[alarmkit] ✅ Alarm snoozed: \(id)")
} catch { } catch {
Design.debugLog("[alarmkit] ❌ Snooze error: \(error)") Design.debugLog("[alarmkit] ❌ Snooze error: \(error)")
@ -334,14 +333,14 @@ final class AlarmKitService {
/// Async sequence that emits the current set of alarms whenever changes occur. /// Async sequence that emits the current set of alarms whenever changes occur.
var alarmUpdates: some AsyncSequence<[AlarmKit.Alarm], Never> { var alarmUpdates: some AsyncSequence<[AlarmKit.Alarm], Never> {
manager.alarmUpdates AlarmManager.shared.alarmUpdates
} }
/// Log current state of all scheduled alarms /// Log current state of all scheduled alarms
func logCurrentAlarms() { func logCurrentAlarms() {
Design.debugLog("[alarmkit] ========== CURRENT ALARMS ==========") Design.debugLog("[alarmkit] ========== CURRENT ALARMS ==========")
Task { Task {
for await alarms in manager.alarmUpdates { for await alarms in AlarmManager.shared.alarmUpdates {
Design.debugLog("[alarmkit] Found \(alarms.count) alarm(s) in AlarmKit") Design.debugLog("[alarmkit] Found \(alarms.count) alarm(s) in AlarmKit")
for alarm in alarms { for alarm in alarms {
Design.debugLog("[alarmkit] - ID: \(alarm.id)") Design.debugLog("[alarmkit] - ID: \(alarm.id)")

View File

@ -40,7 +40,7 @@ final class AlarmService {
alarms.append(alarm) alarms.append(alarm)
updateAlarmLookup() updateAlarmLookup()
saveAlarms() saveAlarms()
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil) NotificationCenter.default.post(name: .alarmsDidUpdate, object: nil)
} }
/// Update an alarm in storage. Does NOT reschedule - caller should use AlarmKitService. /// Update an alarm in storage. Does NOT reschedule - caller should use AlarmKitService.
@ -53,7 +53,7 @@ final class AlarmService {
alarms[index] = alarm alarms[index] = alarm
updateAlarmLookup() updateAlarmLookup()
saveAlarms() saveAlarms()
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil) NotificationCenter.default.post(name: .alarmsDidUpdate, object: nil)
} }
/// Delete an alarm from storage. Does NOT cancel - caller should use AlarmKitService. /// Delete an alarm from storage. Does NOT cancel - caller should use AlarmKitService.
@ -62,7 +62,7 @@ final class AlarmService {
alarms.removeAll { $0.id == id } alarms.removeAll { $0.id == id }
updateAlarmLookup() updateAlarmLookup()
saveAlarms() saveAlarms()
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil) NotificationCenter.default.post(name: .alarmsDidUpdate, object: nil)
} }
/// Toggle an alarm's enabled state. Does NOT reschedule - caller should use AlarmKitService. /// Toggle an alarm's enabled state. Does NOT reschedule - caller should use AlarmKitService.
@ -71,7 +71,7 @@ final class AlarmService {
alarms[index].isEnabled.toggle() alarms[index].isEnabled.toggle()
Design.debugLog("[alarms] AlarmService.toggleAlarm: \(id) now enabled=\(alarms[index].isEnabled)") Design.debugLog("[alarms] AlarmService.toggleAlarm: \(id) now enabled=\(alarms[index].isEnabled)")
saveAlarms() saveAlarms()
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil) NotificationCenter.default.post(name: .alarmsDidUpdate, object: nil)
} }
func getAlarm(id: UUID) -> Alarm? { func getAlarm(id: UUID) -> Alarm? {

View File

@ -10,41 +10,64 @@ import AudioPlaybackKit
import Bedrock import Bedrock
/// Extension service for alarm-specific sound functionality /// Extension service for alarm-specific sound functionality
class AlarmSoundService { final class AlarmSoundService {
static let shared = AlarmSoundService() static let shared = AlarmSoundService()
// MARK: - Constants // MARK: - Constants
/// The category ID for alarm sounds as defined in alarm-sounds.json /// The category ID for alarm sounds as defined in alarm-sounds.json
static let alarmCategoryId = "alarm" static let alarmCategoryId = "alarm"
private let lock = NSLock()
private var cachedConfiguration: SoundConfiguration?
private var cachedSettings: AudioSettings?
private init() {} private init() {}
/// Load alarm sound configuration from AlarmSounds.bundle /// Load alarm sound configuration from AlarmSounds.bundle
private func loadAlarmConfiguration() -> SoundConfiguration { private func loadAlarmConfiguration() -> SoundConfiguration {
guard let bundleURL = Bundle.main.url(forResource: "AlarmSounds", withExtension: "bundle"), lock.lock()
let alarmBundle = Bundle(url: bundleURL), if let cachedConfiguration {
let url = alarmBundle.url(forResource: "sounds", withExtension: "json") else { lock.unlock()
fatalError("❌ sounds.json not found in AlarmSounds.bundle. Ensure the bundle and file exist.") return cachedConfiguration
} }
lock.unlock()
do {
let data = try Data(contentsOf: url) let configuration: SoundConfiguration
let soundsOnly = try JSONDecoder().decode(SoundsOnly.self, from: data) if let bundleURL = Bundle.main.url(forResource: "AlarmSounds", withExtension: "bundle"),
let alarmBundle = Bundle(url: bundleURL),
// Load settings from separate SoundsSettings.json file let url = alarmBundle.url(forResource: "sounds", withExtension: "json") {
let settings = loadAudioSettings() do {
let data = try Data(contentsOf: url)
return SoundConfiguration(sounds: soundsOnly.sounds, settings: settings) let soundsOnly = try JSONDecoder().decode(SoundsOnly.self, from: data)
} catch { configuration = SoundConfiguration(sounds: soundsOnly.sounds, settings: loadAudioSettings())
fatalError("❌ Error loading alarm sound configuration: \(error)") } catch {
Design.debugLog("[audio] Warning: Failed to decode alarm sound config, using fallback sounds: \(error)")
configuration = SoundConfiguration(sounds: fallbackAlarmSounds(), settings: loadAudioSettings())
}
} else {
Design.debugLog("[audio] Warning: AlarmSounds.bundle/sounds.json missing, using fallback sounds")
configuration = SoundConfiguration(sounds: fallbackAlarmSounds(), settings: loadAudioSettings())
} }
lock.lock()
cachedConfiguration = configuration
lock.unlock()
return configuration
} }
/// Load audio settings from SoundsSettings.json /// Load audio settings from SoundsSettings.json
private func loadAudioSettings() -> AudioSettings { private func loadAudioSettings() -> AudioSettings {
lock.lock()
if let cachedSettings {
lock.unlock()
return cachedSettings
}
lock.unlock()
let settings: AudioSettings
guard let url = Bundle.main.url(forResource: "SoundsSettings", withExtension: "json") else { guard let url = Bundle.main.url(forResource: "SoundsSettings", withExtension: "json") else {
Design.debugLog("[general] Warning: SoundsSettings.json not found, using default alarm settings") Design.debugLog("[general] Warning: SoundsSettings.json not found, using default alarm settings")
return AudioSettings( settings = AudioSettings(
defaultVolume: 1.0, defaultVolume: 1.0,
defaultLoopCount: -1, defaultLoopCount: -1,
preloadSounds: true, preloadSounds: true,
@ -53,16 +76,19 @@ class AlarmSoundService {
audioSessionMode: "default", audioSessionMode: "default",
audioSessionOptions: ["mixWithOthers"] audioSessionOptions: ["mixWithOthers"]
) )
lock.lock()
cachedSettings = settings
lock.unlock()
return settings
} }
do { do {
let data = try Data(contentsOf: url) let data = try Data(contentsOf: url)
let settings = try JSONDecoder().decode(AudioSettings.self, from: data) settings = try JSONDecoder().decode(AudioSettings.self, from: data)
//Design.debugLog("[settings] Loaded audio settings for alarms from SoundsSettings.json") //Design.debugLog("[settings] Loaded audio settings for alarms from SoundsSettings.json")
return settings
} catch { } catch {
Design.debugLog("[general] Warning: Error loading audio settings for alarms, using defaults: \(error)") Design.debugLog("[general] Warning: Error loading audio settings for alarms, using defaults: \(error)")
return AudioSettings( settings = AudioSettings(
defaultVolume: 1.0, defaultVolume: 1.0,
defaultLoopCount: -1, defaultLoopCount: -1,
preloadSounds: true, preloadSounds: true,
@ -72,6 +98,57 @@ class AlarmSoundService {
audioSessionOptions: ["mixWithOthers"] audioSessionOptions: ["mixWithOthers"]
) )
} }
lock.lock()
cachedSettings = settings
lock.unlock()
return settings
}
private func fallbackAlarmSounds() -> [Sound] {
[
Sound(
id: "digital-alarm",
name: "Digital Alarm",
fileName: "digital-alarm.mp3",
category: Self.alarmCategoryId,
description: "Classic digital alarm sound",
bundleName: "AlarmSounds",
isDefault: true
),
Sound(
id: "buzzing-alarm",
name: "Buzzing Alarm",
fileName: "buzzing-alarm.mp3",
category: Self.alarmCategoryId,
description: "Buzzing sound for gentle wake-up",
bundleName: "AlarmSounds"
),
Sound(
id: "classic-alarm",
name: "Classic Alarm",
fileName: "classic-alarm.mp3",
category: Self.alarmCategoryId,
description: "Traditional alarm sound",
bundleName: "AlarmSounds"
),
Sound(
id: "beep-alarm",
name: "Beep Alarm",
fileName: "beep-alarm.mp3",
category: Self.alarmCategoryId,
description: "Short beep alarm sound",
bundleName: "AlarmSounds"
),
Sound(
id: "siren-alarm",
name: "Siren Alarm",
fileName: "siren-alarm.mp3",
category: Self.alarmCategoryId,
description: "Emergency siren alarm for heavy sleepers",
bundleName: "AlarmSounds"
)
]
} }
/// Get all available alarm sounds /// Get all available alarm sounds

View File

@ -17,7 +17,7 @@ struct AddAlarmView: View {
@Binding var isPresented: Bool @Binding var isPresented: Bool
@State private var newAlarmTime = Calendar.current.date(bySettingHour: 6, minute: 0, second: 0, of: Date()) ?? Date() @State private var newAlarmTime = Calendar.current.date(bySettingHour: 6, minute: 0, second: 0, of: Date()) ?? Date()
@State private var selectedSoundName = "digital-alarm.caf" @State private var selectedSoundName = AppConstants.SystemSounds.defaultSound
@State private var alarmLabel = "Alarm" @State private var alarmLabel = "Alarm"
@State private var notificationMessage = "Your alarm is ringing" @State private var notificationMessage = "Your alarm is ringing"
@State private var snoozeDuration = 9 // minutes @State private var snoozeDuration = 9 // minutes

View File

@ -19,7 +19,8 @@ final class BatteryService {
var batteryLevel: Int = 100 var batteryLevel: Int = 100
var isCharging: Bool = false var isCharging: Bool = false
@ObservationIgnored private var monitoringTask: Task<Void, Never>? @ObservationIgnored private var levelNotificationTask: Task<Void, Never>?
@ObservationIgnored private var stateNotificationTask: Task<Void, Never>?
// MARK: - Initialization // MARK: - Initialization
private init() { private init() {
@ -42,21 +43,20 @@ final class BatteryService {
// MARK: - Private Methods // MARK: - Private Methods
private func startNotificationMonitoring() { private func startNotificationMonitoring() {
monitoringTask = Task { [weak self] in levelNotificationTask?.cancel()
await withTaskGroup(of: Void.self) { group in stateNotificationTask?.cancel()
// Monitor battery level changes
group.addTask { @MainActor [weak self] in levelNotificationTask = Task { [weak self] in
for await _ in NotificationCenter.default.notifications(named: UIDevice.batteryLevelDidChangeNotification) { for await _ in NotificationCenter.default.notifications(named: UIDevice.batteryLevelDidChangeNotification) {
self?.updateBatteryInfo() guard !Task.isCancelled else { break }
} await self?.updateBatteryInfo()
} }
}
// Monitor battery state changes
group.addTask { @MainActor [weak self] in stateNotificationTask = Task { [weak self] in
for await _ in NotificationCenter.default.notifications(named: UIDevice.batteryStateDidChangeNotification) { for await _ in NotificationCenter.default.notifications(named: UIDevice.batteryStateDidChangeNotification) {
self?.updateBatteryInfo() guard !Task.isCancelled else { break }
} await self?.updateBatteryInfo()
}
} }
} }
} }

View File

@ -32,8 +32,9 @@ struct TopOverlayView: View {
let _ = clockUpdateTrigger // Force re-render on style or alarm changes let _ = clockUpdateTrigger // Force re-render on style or alarm changes
let _ = alarmService.alarms // Observe all alarms for changes let _ = alarmService.alarms // Observe all alarms for changes
let _ = soundPlayer.isPlaying // Observe player state let _ = soundPlayer.isPlaying // Observe player state
let nextEnabledAlarm = alarmService.enabledAlarms
let _ = print("TopOverlayView: Rendering. Alarms count: \(alarmService.alarms.count), Enabled: \(alarmService.enabledAlarms.count)") .sorted(by: { $0.time.nextOccurrence() < $1.time.nextOccurrence() })
.first
VStack(spacing: Design.Spacing.small) { VStack(spacing: Design.Spacing.small) {
// Row 1: Date and Battery (Standard Status Bar positions) // Row 1: Date and Battery (Standard Status Bar positions)
@ -57,7 +58,7 @@ struct TopOverlayView: View {
// Row 2: Alarms and Noise Controls (Below Dynamic Island) // Row 2: Alarms and Noise Controls (Below Dynamic Island)
HStack(alignment: .center) { HStack(alignment: .center) {
if showNextAlarm, let nextAlarm = alarmService.enabledAlarms.sorted(by: { $0.time.nextOccurrence() < $1.time.nextOccurrence() }).first { if showNextAlarm, let nextAlarm = nextEnabledAlarm {
NextAlarmOverlay( NextAlarmOverlay(
alarmTime: nextAlarm.time.nextOccurrence(), alarmTime: nextAlarm.time.nextOccurrence(),
color: color, color: color,
@ -88,8 +89,8 @@ struct TopOverlayView: View {
.padding(.horizontal, Design.Spacing.medium) .padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.small) .padding(.vertical, Design.Spacing.small)
.transition(.opacity) .transition(.opacity)
.id(clockUpdateTrigger) // Force re-render on style or alarm changes .id(clockUpdateTrigger) // Force re-render on alarm changes
.onReceive(NotificationCenter.default.publisher(for: .clockStyleDidUpdate)) { _ in .onReceive(NotificationCenter.default.publisher(for: .alarmsDidUpdate)) { _ in
clockUpdateTrigger.toggle() clockUpdateTrigger.toggle()
} }
.onAppear { .onAppear {

View File

@ -10,7 +10,7 @@ import Bedrock
// MARK: - NoiseClock Surface Colors // MARK: - NoiseClock Surface Colors
public enum NoiseClockSurfaceColors: SurfaceColorProvider { public enum NoiseClockSurfaceColors: @MainActor SurfaceColorProvider {
public static let primary = Color(red: 0.06, green: 0.08, blue: 0.12) public static let primary = Color(red: 0.06, green: 0.08, blue: 0.12)
public static let secondary = Color(red: 0.09, green: 0.11, blue: 0.18) public static let secondary = Color(red: 0.09, green: 0.11, blue: 0.18)
public static let tertiary = Color(red: 0.12, green: 0.15, blue: 0.22) public static let tertiary = Color(red: 0.12, green: 0.15, blue: 0.22)
@ -22,7 +22,7 @@ public enum NoiseClockSurfaceColors: SurfaceColorProvider {
// MARK: - NoiseClock Text Colors // MARK: - NoiseClock Text Colors
public enum NoiseClockTextColors: TextColorProvider { public enum NoiseClockTextColors: @MainActor TextColorProvider {
public static let primary = Color.white public static let primary = Color.white
public static let secondary = Color.white.opacity(Design.Opacity.accent) public static let secondary = Color.white.opacity(Design.Opacity.accent)
public static let tertiary = Color.white.opacity(Design.Opacity.medium) public static let tertiary = Color.white.opacity(Design.Opacity.medium)
@ -33,7 +33,7 @@ public enum NoiseClockTextColors: TextColorProvider {
// MARK: - NoiseClock Accent Colors // MARK: - NoiseClock Accent Colors
public enum NoiseClockAccentColors: AccentColorProvider { public enum NoiseClockAccentColors: @MainActor AccentColorProvider {
public static let primary = Color(red: 0.45, green: 0.75, blue: 1.00) public static let primary = Color(red: 0.45, green: 0.75, blue: 1.00)
public static let light = Color(red: 0.65, green: 0.85, blue: 1.00) public static let light = Color(red: 0.65, green: 0.85, blue: 1.00)
public static let dark = Color(red: 0.25, green: 0.55, blue: 0.90) public static let dark = Color(red: 0.25, green: 0.55, blue: 0.90)
@ -42,7 +42,7 @@ public enum NoiseClockAccentColors: AccentColorProvider {
// MARK: - NoiseClock Button Colors // MARK: - NoiseClock Button Colors
public enum NoiseClockButtonColors: ButtonColorProvider { public enum NoiseClockButtonColors: @MainActor ButtonColorProvider {
public static let primaryLight = Color(red: 0.55, green: 0.80, blue: 1.00) public static let primaryLight = Color(red: 0.55, green: 0.80, blue: 1.00)
public static let primaryDark = Color(red: 0.20, green: 0.50, blue: 0.85) public static let primaryDark = Color(red: 0.20, green: 0.50, blue: 0.85)
public static let secondary = Color.white.opacity(Design.Opacity.subtle) public static let secondary = Color.white.opacity(Design.Opacity.subtle)
@ -52,7 +52,7 @@ public enum NoiseClockButtonColors: ButtonColorProvider {
// MARK: - NoiseClock Status Colors // MARK: - NoiseClock Status Colors
public enum NoiseClockStatusColors: StatusColorProvider { public enum NoiseClockStatusColors: @MainActor StatusColorProvider {
public static let success = Color(red: 0.20, green: 0.80, blue: 0.45) public static let success = Color(red: 0.20, green: 0.80, blue: 0.45)
public static let warning = Color(red: 1.00, green: 0.75, blue: 0.20) public static let warning = Color(red: 1.00, green: 0.75, blue: 0.20)
public static let error = Color(red: 0.90, green: 0.30, blue: 0.30) public static let error = Color(red: 0.90, green: 0.30, blue: 0.30)
@ -61,7 +61,7 @@ public enum NoiseClockStatusColors: StatusColorProvider {
// MARK: - NoiseClock Border Colors // MARK: - NoiseClock Border Colors
public enum NoiseClockBorderColors: BorderColorProvider { public enum NoiseClockBorderColors: @MainActor BorderColorProvider {
public static let subtle = Color.white.opacity(Design.Opacity.subtle) public static let subtle = Color.white.opacity(Design.Opacity.subtle)
public static let standard = Color.white.opacity(Design.Opacity.hint) public static let standard = Color.white.opacity(Design.Opacity.hint)
public static let emphasized = Color.white.opacity(Design.Opacity.light) public static let emphasized = Color.white.opacity(Design.Opacity.light)
@ -70,7 +70,7 @@ public enum NoiseClockBorderColors: BorderColorProvider {
// MARK: - NoiseClock Interactive Colors // MARK: - NoiseClock Interactive Colors
public enum NoiseClockInteractiveColors: InteractiveColorProvider { public enum NoiseClockInteractiveColors: @MainActor InteractiveColorProvider {
public static let selected = NoiseClockAccentColors.primary.opacity(Design.Opacity.selection) public static let selected = NoiseClockAccentColors.primary.opacity(Design.Opacity.selection)
public static let hover = Color.white.opacity(Design.Opacity.subtle) public static let hover = Color.white.opacity(Design.Opacity.subtle)
public static let pressed = Color.white.opacity(Design.Opacity.hint) public static let pressed = Color.white.opacity(Design.Opacity.hint)
@ -79,7 +79,7 @@ public enum NoiseClockInteractiveColors: InteractiveColorProvider {
// MARK: - NoiseClock Theme // MARK: - NoiseClock Theme
public enum NoiseClockTheme: AppColorTheme { public enum NoiseClockTheme: @MainActor AppColorTheme {
public typealias Surface = NoiseClockSurfaceColors public typealias Surface = NoiseClockSurfaceColors
public typealias Text = NoiseClockTextColors public typealias Text = NoiseClockTextColors
public typealias Accent = NoiseClockAccentColors public typealias Accent = NoiseClockAccentColors

View File

@ -8,14 +8,29 @@
import Foundation import Foundation
extension Date { extension Date {
private static let formatterCacheKeyPrefix = "TheNoiseClock.DateFormatter."
/// Format date for display in overlay with custom format /// Format date for display in overlay with custom format
/// - Parameter format: Date format string (e.g., "d MMM yyyy") /// - Parameter format: Date format string (e.g., "d MMM yyyy")
/// - Returns: Formatted date string /// - Returns: Formatted date string
func formattedForOverlay(format: String = "d MMM yyyy") -> String { func formattedForOverlay(format: String = "d MMM yyyy") -> String {
let formatter = Self.cachedFormatter(for: format)
return formatter.string(from: self)
}
private static func cachedFormatter(for format: String) -> DateFormatter {
let cacheKey = formatterCacheKeyPrefix + format
let threadDictionary = Thread.current.threadDictionary
if let cached = threadDictionary[cacheKey] as? DateFormatter {
return cached
}
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateFormat = format formatter.dateFormat = format
return formatter.string(from: self) formatter.locale = .autoupdatingCurrent
formatter.calendar = .autoupdatingCurrent
threadDictionary[cacheKey] = formatter
return formatter
} }
/// Get available date format options with their display names /// Get available date format options with their display names

View File

@ -10,4 +10,5 @@ import Foundation
extension Notification.Name { extension Notification.Name {
static let keepAwakePromptRequested = Notification.Name("keepAwakePromptRequested") static let keepAwakePromptRequested = Notification.Name("keepAwakePromptRequested")
static let clockStyleDidUpdate = Notification.Name("clockStyleDidUpdate") static let clockStyleDidUpdate = Notification.Name("clockStyleDidUpdate")
static let alarmsDidUpdate = Notification.Name("alarmsDidUpdate")
} }

View File

@ -6,12 +6,43 @@
// //
import Testing import Testing
@testable import TheNoiseClock @testable import The_Noise_Clock
import Foundation
@MainActor
struct TheNoiseClockTests { struct TheNoiseClockTests {
@Test func example() async throws { @Test
// Write your test here and use APIs like `#expect(...)` to check expected conditions. func defaultAlarmSoundIsMP3() {
#expect(AppConstants.SystemSounds.defaultSound.hasSuffix(".mp3"))
}
@Test
func nextOccurrenceIsInFuture() {
let oneMinuteAgo = Calendar.current.date(byAdding: .minute, value: -1, to: Date()) ?? Date()
let next = oneMinuteAgo.nextOccurrence()
#expect(next > Date())
#expect(next.timeIntervalSinceNow < 26 * 60 * 60)
}
@Test
func clockStyleCodableRoundTripPreservesSettings() throws {
let style = ClockStyle()
style.showSeconds = true
style.showDate = false
style.keepAwake = true
style.fontFamily = .avenir
style.digitAnimationStyle = .glitch
let data = try JSONEncoder().encode(style)
let decoded = try JSONDecoder().decode(ClockStyle.self, from: data)
#expect(decoded.showSeconds)
#expect(decoded.showDate == false)
#expect(decoded.keepAwake)
#expect(decoded.fontFamily == .avenir)
#expect(decoded.digitAnimationStyle == .glitch)
} }
} }

View File

@ -18,8 +18,8 @@ import Foundation
/// Intent to stop an active alarm from the Live Activity or notification. /// Intent to stop an active alarm from the Live Activity or notification.
struct StopAlarmIntent: LiveActivityIntent { struct StopAlarmIntent: LiveActivityIntent {
static var title: LocalizedStringResource = "Stop Alarm" static let title: LocalizedStringResource = "Stop Alarm"
static var description = IntentDescription("Stops the currently ringing alarm") static let description = IntentDescription("Stops the currently ringing alarm")
@Parameter(title: "Alarm ID") @Parameter(title: "Alarm ID")
var alarmId: String var alarmId: String
@ -49,8 +49,8 @@ struct StopAlarmIntent: LiveActivityIntent {
/// Intent to snooze an active alarm from the Live Activity or notification. /// Intent to snooze an active alarm from the Live Activity or notification.
struct SnoozeAlarmIntent: LiveActivityIntent { struct SnoozeAlarmIntent: LiveActivityIntent {
static var title: LocalizedStringResource = "Snooze Alarm" static let title: LocalizedStringResource = "Snooze Alarm"
static var description = IntentDescription("Snoozes the currently ringing alarm") static let description = IntentDescription("Snoozes the currently ringing alarm")
@Parameter(title: "Alarm ID") @Parameter(title: "Alarm ID")
var alarmId: String var alarmId: String
@ -81,9 +81,9 @@ struct SnoozeAlarmIntent: LiveActivityIntent {
/// Intent to open the app when the user taps the Live Activity. /// Intent to open the app when the user taps the Live Activity.
struct OpenAlarmAppIntent: LiveActivityIntent { struct OpenAlarmAppIntent: LiveActivityIntent {
static var title: LocalizedStringResource = "Open TheNoiseClock" static let title: LocalizedStringResource = "Open TheNoiseClock"
static var description = IntentDescription("Opens the app to the alarm screen") static let description = IntentDescription("Opens the app to the alarm screen")
static var openAppWhenRun = true static let openAppWhenRun = true
@Parameter(title: "Alarm ID") @Parameter(title: "Alarm ID")
var alarmId: String var alarmId: String