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
│ └── 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

View File

@ -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:

View File

@ -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;

View File

@ -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

View File

@ -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)")

View File

@ -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? {

View File

@ -10,41 +10,64 @@ 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
}
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)
} catch {
fatalError("❌ Error loading alarm sound configuration: \(error)")
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)
configuration = SoundConfiguration(sounds: soundsOnly.sounds, settings: loadAudioSettings())
} 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
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,
@ -53,16 +76,19 @@ class AlarmSoundService {
audioSessionMode: "default",
audioSessionOptions: ["mixWithOthers"]
)
lock.lock()
cachedSettings = settings
lock.unlock()
return settings
}
do {
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")
return settings
} catch {
Design.debugLog("[general] Warning: Error loading audio settings for alarms, using defaults: \(error)")
return AudioSettings(
settings = AudioSettings(
defaultVolume: 1.0,
defaultLoopCount: -1,
preloadSounds: true,
@ -72,6 +98,57 @@ class AlarmSoundService {
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

View File

@ -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

View File

@ -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
for await _ in NotificationCenter.default.notifications(named: UIDevice.batteryLevelDidChangeNotification) {
self?.updateBatteryInfo()
}
}
// Monitor battery state changes
group.addTask { @MainActor [weak self] in
for await _ in NotificationCenter.default.notifications(named: UIDevice.batteryStateDidChangeNotification) {
self?.updateBatteryInfo()
}
}
levelNotificationTask?.cancel()
stateNotificationTask?.cancel()
levelNotificationTask = Task { [weak self] in
for await _ in NotificationCenter.default.notifications(named: UIDevice.batteryLevelDidChangeNotification) {
guard !Task.isCancelled else { break }
await self?.updateBatteryInfo()
}
}
stateNotificationTask = Task { [weak self] in
for await _ in NotificationCenter.default.notifications(named: UIDevice.batteryStateDidChangeNotification) {
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 _ = 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 {

View File

@ -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

View File

@ -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

View File

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

View File

@ -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)
}
}

View File

@ -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