more refactoring by codex
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
86e4382cc2
commit
d2c6a09e47
37
PRD.md
37
PRD.md
@ -484,8 +484,7 @@ TheNoiseClock/
|
||||
│ │ └── OnboardingPageView.swift
|
||||
│ └── Resources/
|
||||
│ ├── LaunchScreen.storyboard # Branded native launch screen
|
||||
│ ├── sounds.json # Ambient sound configuration and definitions
|
||||
│ ├── alarm-sounds.json # Alarm sound configuration and definitions
|
||||
│ ├── SoundsSettings.json # Shared audio settings
|
||||
│ ├── Ambient.bundle/ # Ambient sound category
|
||||
│ │ └── white-noise.mp3
|
||||
│ ├── Nature.bundle/ # Nature sound category
|
||||
@ -493,11 +492,12 @@ TheNoiseClock/
|
||||
│ ├── Mechanical.bundle/ # Mechanical sound category
|
||||
│ │ └── fan-white-noise-heater.mp3
|
||||
│ ├── AlarmSounds.bundle/ # Alarm sound category
|
||||
│ │ ├── digital-alarm.caf
|
||||
│ │ ├── classic-alarm.caf
|
||||
│ │ ├── beep-alarm.caf
|
||||
│ │ ├── siren-alarm.caf
|
||||
│ │ └── buzzing-alarm.caf
|
||||
│ │ ├── sounds.json # Alarm sound metadata
|
||||
│ │ ├── digital-alarm.mp3
|
||||
│ │ ├── classic-alarm.mp3
|
||||
│ │ ├── beep-alarm.mp3
|
||||
│ │ ├── siren-alarm.mp3
|
||||
│ │ └── buzzing-alarm.mp3
|
||||
│ └── Assets.xcassets/
|
||||
│ └── [Asset catalogs]
|
||||
└── 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
|
||||
cd /Users/mattbruce/Documents/Projects/TheNoiseClock
|
||||
|
||||
# Build for iOS Simulator (iPad mini)
|
||||
xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPad mini (A17 Pro),OS=18.1' build
|
||||
# Build for iOS Simulator (iPhone 17 Pro Max)
|
||||
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)
|
||||
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
|
||||
```bash
|
||||
# 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
|
||||
swift -frontend -parse TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift
|
||||
@ -749,15 +749,7 @@ swift -frontend -parse TheNoiseClock/Views/Clock/Components/DigitView.swift
|
||||
```
|
||||
|
||||
#### Available Simulators
|
||||
The following simulators are available for testing:
|
||||
- **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)**
|
||||
Use **iPhone 17 Pro Max (iOS 26.2)** as the primary simulator for build and test validation.
|
||||
|
||||
#### Build Troubleshooting
|
||||
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
|
||||
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
|
||||
|
||||
### Project Information
|
||||
|
||||
18
README.md
18
README.md
@ -90,7 +90,13 @@ Open `TheNoiseClock.xcodeproj` and run the `TheNoiseClock` scheme.
|
||||
### Terminal Build
|
||||
```bash
|
||||
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
|
||||
|
||||
TheNoiseClock follows a clean, modular structure:
|
||||
|
||||
@ -551,7 +551,7 @@
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_VERSION = 6.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
@ -585,7 +585,7 @@
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_VERSION = 6.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
@ -607,9 +607,9 @@
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_VERSION = 6.0;
|
||||
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;
|
||||
};
|
||||
@ -630,9 +630,9 @@
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_VERSION = 6.0;
|
||||
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;
|
||||
};
|
||||
@ -651,7 +651,7 @@
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_VERSION = 6.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_TARGET_NAME = TheNoiseClock;
|
||||
};
|
||||
@ -672,7 +672,7 @@
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_VERSION = 6.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_TARGET_NAME = TheNoiseClock;
|
||||
};
|
||||
@ -692,7 +692,7 @@
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(WIDGET_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_VERSION = 6.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
@ -711,7 +711,7 @@
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(WIDGET_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_VERSION = 6.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
|
||||
@ -17,8 +17,8 @@ import Foundation
|
||||
/// Intent to stop an active alarm from the Live Activity or notification.
|
||||
struct StopAlarmIntent: LiveActivityIntent {
|
||||
|
||||
static var title: LocalizedStringResource = "Stop Alarm"
|
||||
static var description = IntentDescription("Stops the currently ringing alarm")
|
||||
static let title: LocalizedStringResource = "Stop Alarm"
|
||||
static let description = IntentDescription("Stops the currently ringing alarm")
|
||||
|
||||
@Parameter(title: "Alarm ID")
|
||||
var alarmId: String
|
||||
@ -48,8 +48,8 @@ struct StopAlarmIntent: LiveActivityIntent {
|
||||
/// Intent to snooze an active alarm from the Live Activity or notification.
|
||||
struct SnoozeAlarmIntent: LiveActivityIntent {
|
||||
|
||||
static var title: LocalizedStringResource = "Snooze Alarm"
|
||||
static var description = IntentDescription("Snoozes the currently ringing alarm")
|
||||
static let title: LocalizedStringResource = "Snooze Alarm"
|
||||
static let description = IntentDescription("Snoozes the currently ringing alarm")
|
||||
|
||||
@Parameter(title: "Alarm ID")
|
||||
var alarmId: String
|
||||
@ -80,9 +80,9 @@ struct SnoozeAlarmIntent: LiveActivityIntent {
|
||||
/// Intent to open the app when the user taps the Live Activity.
|
||||
struct OpenAlarmAppIntent: LiveActivityIntent {
|
||||
|
||||
static var title: LocalizedStringResource = "Open TheNoiseClock"
|
||||
static var description = IntentDescription("Opens the app to the alarm screen")
|
||||
static var openAppWhenRun = true
|
||||
static let title: LocalizedStringResource = "Open TheNoiseClock"
|
||||
static let description = IntentDescription("Opens the app to the alarm screen")
|
||||
static let openAppWhenRun = true
|
||||
|
||||
@Parameter(title: "Alarm ID")
|
||||
var alarmId: String
|
||||
|
||||
@ -19,29 +19,28 @@ final class AlarmKitService {
|
||||
// MARK: - Singleton
|
||||
|
||||
static let shared = AlarmKitService()
|
||||
private let manager = AlarmManager.shared
|
||||
|
||||
private init() {
|
||||
Design.debugLog("[alarmkit] AlarmKitService initialized")
|
||||
Design.debugLog("[alarmkit] Authorization state: \(manager.authorizationState)")
|
||||
Design.debugLog("[alarmkit] Authorization state: \(AlarmManager.shared.authorizationState)")
|
||||
}
|
||||
|
||||
// MARK: - Authorization
|
||||
|
||||
/// The current authorization state for AlarmKit
|
||||
var authorizationState: AlarmManager.AuthorizationState {
|
||||
manager.authorizationState
|
||||
AlarmManager.shared.authorizationState
|
||||
}
|
||||
|
||||
/// Request authorization to schedule alarms.
|
||||
/// - Returns: `true` if authorized, `false` otherwise.
|
||||
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:
|
||||
do {
|
||||
let state = try await manager.requestAuthorization()
|
||||
let state = try await AlarmManager.shared.requestAuthorization()
|
||||
Design.debugLog("[alarmkit] Authorization result: \(state)")
|
||||
return state == .authorized
|
||||
} catch {
|
||||
@ -73,7 +72,7 @@ final class AlarmKitService {
|
||||
Design.debugLog("[alarmkit] ID: \(alarm.id)")
|
||||
|
||||
// Ensure we're authorized
|
||||
if manager.authorizationState != .authorized {
|
||||
if AlarmManager.shared.authorizationState != .authorized {
|
||||
Design.debugLog("[alarmkit] Not authorized, requesting...")
|
||||
let authorized = await requestAuthorization()
|
||||
guard authorized else {
|
||||
@ -160,7 +159,7 @@ final class AlarmKitService {
|
||||
|
||||
// Schedule the alarm
|
||||
do {
|
||||
let scheduledAlarm = try await manager.schedule(
|
||||
let scheduledAlarm = try await AlarmManager.shared.schedule(
|
||||
id: alarm.id,
|
||||
configuration: configuration
|
||||
)
|
||||
@ -299,7 +298,7 @@ final class AlarmKitService {
|
||||
func cancelAlarm(id: UUID) {
|
||||
Design.debugLog("[alarmkit] Cancelling alarm: \(id)")
|
||||
do {
|
||||
try manager.cancel(id: id)
|
||||
try AlarmManager.shared.cancel(id: id)
|
||||
Design.debugLog("[alarmkit] ✅ Alarm cancelled: \(id)")
|
||||
} catch {
|
||||
Design.debugLog("[alarmkit] ❌ Cancel error: \(error)")
|
||||
@ -311,7 +310,7 @@ final class AlarmKitService {
|
||||
func stopAlarm(id: UUID) {
|
||||
Design.debugLog("[alarmkit] Stopping alarm: \(id)")
|
||||
do {
|
||||
try manager.stop(id: id)
|
||||
try AlarmManager.shared.stop(id: id)
|
||||
Design.debugLog("[alarmkit] ✅ Alarm stopped: \(id)")
|
||||
} catch {
|
||||
Design.debugLog("[alarmkit] ❌ Stop error: \(error)")
|
||||
@ -323,7 +322,7 @@ final class AlarmKitService {
|
||||
func snoozeAlarm(id: UUID) {
|
||||
Design.debugLog("[alarmkit] Snoozing alarm: \(id)")
|
||||
do {
|
||||
try manager.countdown(id: id)
|
||||
try AlarmManager.shared.countdown(id: id)
|
||||
Design.debugLog("[alarmkit] ✅ Alarm snoozed: \(id)")
|
||||
} catch {
|
||||
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.
|
||||
var alarmUpdates: some AsyncSequence<[AlarmKit.Alarm], Never> {
|
||||
manager.alarmUpdates
|
||||
AlarmManager.shared.alarmUpdates
|
||||
}
|
||||
|
||||
/// Log current state of all scheduled alarms
|
||||
func logCurrentAlarms() {
|
||||
Design.debugLog("[alarmkit] ========== CURRENT ALARMS ==========")
|
||||
Task {
|
||||
for await alarms in manager.alarmUpdates {
|
||||
for await alarms in AlarmManager.shared.alarmUpdates {
|
||||
Design.debugLog("[alarmkit] Found \(alarms.count) alarm(s) in AlarmKit")
|
||||
for alarm in alarms {
|
||||
Design.debugLog("[alarmkit] - ID: \(alarm.id)")
|
||||
|
||||
@ -40,7 +40,7 @@ final class AlarmService {
|
||||
alarms.append(alarm)
|
||||
updateAlarmLookup()
|
||||
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.
|
||||
@ -53,7 +53,7 @@ final class AlarmService {
|
||||
alarms[index] = alarm
|
||||
updateAlarmLookup()
|
||||
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.
|
||||
@ -62,7 +62,7 @@ final class AlarmService {
|
||||
alarms.removeAll { $0.id == id }
|
||||
updateAlarmLookup()
|
||||
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.
|
||||
@ -71,7 +71,7 @@ final class AlarmService {
|
||||
alarms[index].isEnabled.toggle()
|
||||
Design.debugLog("[alarms] AlarmService.toggleAlarm: \(id) now enabled=\(alarms[index].isEnabled)")
|
||||
saveAlarms()
|
||||
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
|
||||
NotificationCenter.default.post(name: .alarmsDidUpdate, object: nil)
|
||||
}
|
||||
|
||||
func getAlarm(id: UUID) -> Alarm? {
|
||||
|
||||
@ -10,41 +10,85 @@ import AudioPlaybackKit
|
||||
import Bedrock
|
||||
|
||||
/// Extension service for alarm-specific sound functionality
|
||||
class AlarmSoundService {
|
||||
final class AlarmSoundService {
|
||||
static let shared = AlarmSoundService()
|
||||
|
||||
// MARK: - Constants
|
||||
/// The category ID for alarm sounds as defined in alarm-sounds.json
|
||||
static let alarmCategoryId = "alarm"
|
||||
|
||||
private let lock = NSLock()
|
||||
private var cachedConfiguration: SoundConfiguration?
|
||||
private var cachedSettings: AudioSettings?
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Load alarm sound configuration from AlarmSounds.bundle
|
||||
private func loadAlarmConfiguration() -> SoundConfiguration {
|
||||
guard let bundleURL = Bundle.main.url(forResource: "AlarmSounds", withExtension: "bundle"),
|
||||
let alarmBundle = Bundle(url: bundleURL),
|
||||
let url = alarmBundle.url(forResource: "sounds", withExtension: "json") else {
|
||||
fatalError("❌ sounds.json not found in AlarmSounds.bundle. Ensure the bundle and file exist.")
|
||||
lock.lock()
|
||||
if let cachedConfiguration {
|
||||
lock.unlock()
|
||||
return cachedConfiguration
|
||||
}
|
||||
lock.unlock()
|
||||
|
||||
let configuration: SoundConfiguration
|
||||
if let bundleURL = Bundle.main.url(forResource: "AlarmSounds", withExtension: "bundle"),
|
||||
let alarmBundle = Bundle(url: bundleURL),
|
||||
let url = alarmBundle.url(forResource: "sounds", withExtension: "json") {
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
let soundsOnly = try JSONDecoder().decode(SoundsOnly.self, from: data)
|
||||
|
||||
// Load settings from separate SoundsSettings.json file
|
||||
let settings = loadAudioSettings()
|
||||
|
||||
return SoundConfiguration(sounds: soundsOnly.sounds, settings: settings)
|
||||
configuration = SoundConfiguration(sounds: soundsOnly.sounds, settings: loadAudioSettings())
|
||||
} catch {
|
||||
fatalError("❌ Error loading alarm sound configuration: \(error)")
|
||||
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
|
||||
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 {
|
||||
Design.debugLog("[general] Warning: SoundsSettings.json not found, using default alarm settings")
|
||||
return AudioSettings(
|
||||
settings = AudioSettings(
|
||||
defaultVolume: 1.0,
|
||||
defaultLoopCount: -1,
|
||||
preloadSounds: true,
|
||||
preloadStrategy: "category",
|
||||
audioSessionCategory: "playback",
|
||||
audioSessionMode: "default",
|
||||
audioSessionOptions: ["mixWithOthers"]
|
||||
)
|
||||
lock.lock()
|
||||
cachedSettings = settings
|
||||
lock.unlock()
|
||||
return settings
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
settings = try JSONDecoder().decode(AudioSettings.self, from: data)
|
||||
//Design.debugLog("[settings] Loaded audio settings for alarms from SoundsSettings.json")
|
||||
} catch {
|
||||
Design.debugLog("[general] Warning: Error loading audio settings for alarms, using defaults: \(error)")
|
||||
settings = AudioSettings(
|
||||
defaultVolume: 1.0,
|
||||
defaultLoopCount: -1,
|
||||
preloadSounds: true,
|
||||
@ -55,23 +99,56 @@ class AlarmSoundService {
|
||||
)
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
let settings = try JSONDecoder().decode(AudioSettings.self, from: data)
|
||||
//Design.debugLog("[settings] Loaded audio settings for alarms from SoundsSettings.json")
|
||||
lock.lock()
|
||||
cachedSettings = settings
|
||||
lock.unlock()
|
||||
return settings
|
||||
} catch {
|
||||
Design.debugLog("[general] Warning: Error loading audio settings for alarms, using defaults: \(error)")
|
||||
return AudioSettings(
|
||||
defaultVolume: 1.0,
|
||||
defaultLoopCount: -1,
|
||||
preloadSounds: true,
|
||||
preloadStrategy: "category",
|
||||
audioSessionCategory: "playback",
|
||||
audioSessionMode: "default",
|
||||
audioSessionOptions: ["mixWithOthers"]
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@ -17,7 +17,7 @@ struct AddAlarmView: View {
|
||||
@Binding var isPresented: Bool
|
||||
|
||||
@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 notificationMessage = "Your alarm is ringing"
|
||||
@State private var snoozeDuration = 9 // minutes
|
||||
|
||||
@ -19,7 +19,8 @@ final class BatteryService {
|
||||
var batteryLevel: Int = 100
|
||||
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
|
||||
private init() {
|
||||
@ -42,21 +43,20 @@ final class BatteryService {
|
||||
|
||||
// MARK: - Private Methods
|
||||
private func startNotificationMonitoring() {
|
||||
monitoringTask = Task { [weak self] in
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
// Monitor battery level changes
|
||||
group.addTask { @MainActor [weak self] in
|
||||
levelNotificationTask?.cancel()
|
||||
stateNotificationTask?.cancel()
|
||||
|
||||
levelNotificationTask = Task { [weak self] in
|
||||
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) {
|
||||
self?.updateBatteryInfo()
|
||||
}
|
||||
}
|
||||
guard !Task.isCancelled else { break }
|
||||
await self?.updateBatteryInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,8 +32,9 @@ struct TopOverlayView: View {
|
||||
let _ = clockUpdateTrigger // Force re-render on style or alarm changes
|
||||
let _ = alarmService.alarms // Observe all alarms for changes
|
||||
let _ = soundPlayer.isPlaying // Observe player state
|
||||
|
||||
let _ = print("TopOverlayView: Rendering. Alarms count: \(alarmService.alarms.count), Enabled: \(alarmService.enabledAlarms.count)")
|
||||
let nextEnabledAlarm = alarmService.enabledAlarms
|
||||
.sorted(by: { $0.time.nextOccurrence() < $1.time.nextOccurrence() })
|
||||
.first
|
||||
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
// 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)
|
||||
HStack(alignment: .center) {
|
||||
if showNextAlarm, let nextAlarm = alarmService.enabledAlarms.sorted(by: { $0.time.nextOccurrence() < $1.time.nextOccurrence() }).first {
|
||||
if showNextAlarm, let nextAlarm = nextEnabledAlarm {
|
||||
NextAlarmOverlay(
|
||||
alarmTime: nextAlarm.time.nextOccurrence(),
|
||||
color: color,
|
||||
@ -88,8 +89,8 @@ struct TopOverlayView: View {
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
.padding(.vertical, Design.Spacing.small)
|
||||
.transition(.opacity)
|
||||
.id(clockUpdateTrigger) // Force re-render on style or alarm changes
|
||||
.onReceive(NotificationCenter.default.publisher(for: .clockStyleDidUpdate)) { _ in
|
||||
.id(clockUpdateTrigger) // Force re-render on alarm changes
|
||||
.onReceive(NotificationCenter.default.publisher(for: .alarmsDidUpdate)) { _ in
|
||||
clockUpdateTrigger.toggle()
|
||||
}
|
||||
.onAppear {
|
||||
|
||||
@ -10,7 +10,7 @@ import Bedrock
|
||||
|
||||
// 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 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)
|
||||
@ -22,7 +22,7 @@ public enum NoiseClockSurfaceColors: SurfaceColorProvider {
|
||||
|
||||
// MARK: - NoiseClock Text Colors
|
||||
|
||||
public enum NoiseClockTextColors: TextColorProvider {
|
||||
public enum NoiseClockTextColors: @MainActor TextColorProvider {
|
||||
public static let primary = Color.white
|
||||
public static let secondary = Color.white.opacity(Design.Opacity.accent)
|
||||
public static let tertiary = Color.white.opacity(Design.Opacity.medium)
|
||||
@ -33,7 +33,7 @@ public enum NoiseClockTextColors: TextColorProvider {
|
||||
|
||||
// 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 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)
|
||||
@ -42,7 +42,7 @@ public enum NoiseClockAccentColors: AccentColorProvider {
|
||||
|
||||
// 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 primaryDark = Color(red: 0.20, green: 0.50, blue: 0.85)
|
||||
public static let secondary = Color.white.opacity(Design.Opacity.subtle)
|
||||
@ -52,7 +52,7 @@ public enum NoiseClockButtonColors: ButtonColorProvider {
|
||||
|
||||
// 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 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)
|
||||
@ -61,7 +61,7 @@ public enum NoiseClockStatusColors: StatusColorProvider {
|
||||
|
||||
// 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 standard = Color.white.opacity(Design.Opacity.hint)
|
||||
public static let emphasized = Color.white.opacity(Design.Opacity.light)
|
||||
@ -70,7 +70,7 @@ public enum NoiseClockBorderColors: BorderColorProvider {
|
||||
|
||||
// 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 hover = Color.white.opacity(Design.Opacity.subtle)
|
||||
public static let pressed = Color.white.opacity(Design.Opacity.hint)
|
||||
@ -79,7 +79,7 @@ public enum NoiseClockInteractiveColors: InteractiveColorProvider {
|
||||
|
||||
// MARK: - NoiseClock Theme
|
||||
|
||||
public enum NoiseClockTheme: AppColorTheme {
|
||||
public enum NoiseClockTheme: @MainActor AppColorTheme {
|
||||
public typealias Surface = NoiseClockSurfaceColors
|
||||
public typealias Text = NoiseClockTextColors
|
||||
public typealias Accent = NoiseClockAccentColors
|
||||
|
||||
@ -8,14 +8,29 @@
|
||||
import Foundation
|
||||
|
||||
extension Date {
|
||||
private static let formatterCacheKeyPrefix = "TheNoiseClock.DateFormatter."
|
||||
|
||||
/// Format date for display in overlay with custom format
|
||||
/// - Parameter format: Date format string (e.g., "d MMM yyyy")
|
||||
/// - Returns: Formatted date 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()
|
||||
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
|
||||
|
||||
@ -10,4 +10,5 @@ import Foundation
|
||||
extension Notification.Name {
|
||||
static let keepAwakePromptRequested = Notification.Name("keepAwakePromptRequested")
|
||||
static let clockStyleDidUpdate = Notification.Name("clockStyleDidUpdate")
|
||||
static let alarmsDidUpdate = Notification.Name("alarmsDidUpdate")
|
||||
}
|
||||
|
||||
@ -6,12 +6,43 @@
|
||||
//
|
||||
|
||||
import Testing
|
||||
@testable import TheNoiseClock
|
||||
@testable import The_Noise_Clock
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
struct TheNoiseClockTests {
|
||||
|
||||
@Test func example() async throws {
|
||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||
@Test
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -18,8 +18,8 @@ import Foundation
|
||||
/// Intent to stop an active alarm from the Live Activity or notification.
|
||||
struct StopAlarmIntent: LiveActivityIntent {
|
||||
|
||||
static var title: LocalizedStringResource = "Stop Alarm"
|
||||
static var description = IntentDescription("Stops the currently ringing alarm")
|
||||
static let title: LocalizedStringResource = "Stop Alarm"
|
||||
static let description = IntentDescription("Stops the currently ringing alarm")
|
||||
|
||||
@Parameter(title: "Alarm ID")
|
||||
var alarmId: String
|
||||
@ -49,8 +49,8 @@ struct StopAlarmIntent: LiveActivityIntent {
|
||||
/// Intent to snooze an active alarm from the Live Activity or notification.
|
||||
struct SnoozeAlarmIntent: LiveActivityIntent {
|
||||
|
||||
static var title: LocalizedStringResource = "Snooze Alarm"
|
||||
static var description = IntentDescription("Snoozes the currently ringing alarm")
|
||||
static let title: LocalizedStringResource = "Snooze Alarm"
|
||||
static let description = IntentDescription("Snoozes the currently ringing alarm")
|
||||
|
||||
@Parameter(title: "Alarm ID")
|
||||
var alarmId: String
|
||||
@ -81,9 +81,9 @@ struct SnoozeAlarmIntent: LiveActivityIntent {
|
||||
/// Intent to open the app when the user taps the Live Activity.
|
||||
struct OpenAlarmAppIntent: LiveActivityIntent {
|
||||
|
||||
static var title: LocalizedStringResource = "Open TheNoiseClock"
|
||||
static var description = IntentDescription("Opens the app to the alarm screen")
|
||||
static var openAppWhenRun = true
|
||||
static let title: LocalizedStringResource = "Open TheNoiseClock"
|
||||
static let description = IntentDescription("Opens the app to the alarm screen")
|
||||
static let openAppWhenRun = true
|
||||
|
||||
@Parameter(title: "Alarm ID")
|
||||
var alarmId: String
|
||||
|
||||
Loading…
Reference in New Issue
Block a user