Compare commits

..

9 Commits

Author SHA1 Message Date
3b45fe2114 Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2026-02-08 12:25:47 -06:00
f700411058 Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2026-02-08 12:19:51 -06:00
8f79836481 added features.
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2026-02-08 12:10:38 -06:00
263b2fffcc Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2026-02-08 11:53:56 -06:00
c3dad57700 Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2026-02-08 11:04:08 -06:00
fbd0377348 Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2026-02-08 10:54:11 -06:00
d2c6a09e47 more refactoring by codex
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2026-02-08 10:48:39 -06:00
86e4382cc2 updated tests
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2026-02-08 09:33:51 -06:00
7b3a8903a8 Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2026-02-07 16:47:22 -06:00
43 changed files with 1385 additions and 232 deletions

46
PRD.md
View File

@ -99,6 +99,7 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di
- **Sound preview**: Play/stop functionality for testing alarm sounds before selection
- **Custom labels**: User-defined alarm names and descriptions
- **Repeat schedules**: Set alarms to repeat on specific weekdays or daily
- **Locale-aware repeat picker**: Weekday ordering follows the users locale-first weekday
- **Sound selection**: Choose from extensive alarm sounds with live preview
- **Volume control**: Adjustable alarm volume (0-100%)
- **Vibration settings**: Enable/disable vibration for each alarm
@ -110,6 +111,8 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di
- **AlarmKit authorization**: Requires user permission via NSAlarmKitUsageDescription
- **Persistent storage**: Alarms saved to UserDefaults with backward compatibility
- **Alarm management**: Add, edit, delete, and duplicate alarms
- **Sorted alarm list**: Enabled alarms appear first and are ordered by nearest upcoming trigger
- **Resilient save flow**: Add/Edit sheets stay open on scheduling failures and preserve current edits
- **Next trigger preview**: Shows when the next alarm will fire
- **Responsive time picker**: Font sizes adapt to available space and orientation
- **AlarmKitService**: Centralized service for AlarmKit integration
@ -117,6 +120,9 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di
- **In-app alarm screen**: Full-screen alarm UI with Snooze/Stop when the app is active
- **App Intents**: StopAlarmIntent and SnoozeAlarmIntent for Live Activity button actions
### 7. Onboarding & Activation
- **Feature discoverability copy**: Onboarding explicitly calls out repeat schedules and per-alarm vibration/flash/volume controls.
## Advanced Clock Display Features
### Fixed-Width Digit Rendering
@ -484,8 +490,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 +498,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 +733,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 +746,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 +755,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 +770,16 @@ 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.
- Onboarding Keep Awake setup now routes through `ClockViewModel` to keep clock-style state management centralized.
- Alarm add/update/toggle now rollback on AlarmKit scheduling failures and expose user-facing error alerts.
- Key views and controls now include accessibility identifiers to stabilize UI automation and improve assistive technology support.
## 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,24 @@ 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`.
- Onboarding now updates Keep Awake through `ClockViewModel` (single source of truth) instead of direct `UserDefaults` writes.
- Alarm scheduling failures now surface user-visible errors and rollback failed add/update/toggle operations.
- Added accessibility identifiers for critical controls and updated UI tests to use stable identifiers instead of coordinate heuristics.
- Alarms now support weekday repeat schedules end-to-end, including editor UI, next-trigger calculation, and AlarmKit weekly relative schedules.
- Add/Edit alarm sheets now stay open if scheduling fails, preserving user inputs and showing an in-sheet error alert.
- Alarm list ordering now prioritizes enabled alarms and sorts by the next trigger time for faster at-a-glance relevance.
- Repeat-day controls now follow locale weekday order and include accessibility identifiers for repeat/alert options controls.
- Onboarding now explicitly highlights repeat schedules plus per-alarm vibration/flash/volume customization for better feature discovery.
---
## Architecture
TheNoiseClock follows a clean, modular structure:

View File

@ -9,7 +9,7 @@
/* Begin PBXBuildFile section */
EA384E832E6F806200CA7D50 /* AudioPlaybackKit in Frameworks */ = {isa = PBXBuildFile; productRef = EA384D3D2E6F554D00CA7D50 /* AudioPlaybackKit */; };
EAC051B12F2E64AB007F87EA /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EAC051B02F2E64AB007F87EA /* Bedrock */; };
EAF1C0DE2F3A4B5C0011223E /* TheNoiseClockWidget.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = EAF1C0DE2F3A4B5C00112234 /* TheNoiseClockWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
EAF1C0DE2F3A4B5C0011223E /* TheNoiseClockWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = EAF1C0DE2F3A4B5C00112234 /* TheNoiseClockWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -37,15 +37,15 @@
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
EAF1C0DE2F3A4B5C0011223D /* Embed App Extensions */ = {
EAF1C0DE2F3A4B5C0011223D /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
EAF1C0DE2F3A4B5C0011223E /* TheNoiseClockWidget.appex in Embed App Extensions */,
EAF1C0DE2F3A4B5C0011223E /* TheNoiseClockWidget.appex in Embed Foundation Extensions */,
);
name = "Embed App Extensions";
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
@ -63,6 +63,9 @@
EA384D3B2E6F554D00CA7D50 /* Exceptions for "TheNoiseClock" folder in "TheNoiseClock" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Configuration/Base.xcconfig,
Configuration/Debug.xcconfig,
Configuration/Release.xcconfig,
Info.plist,
);
target = EA384AFA2E6E6B6000CA7D50 /* TheNoiseClock */;
@ -181,7 +184,7 @@
EA384AF72E6E6B6000CA7D50 /* Sources */,
EA384AF82E6E6B6000CA7D50 /* Frameworks */,
EA384AF92E6E6B6000CA7D50 /* Resources */,
EAF1C0DE2F3A4B5C0011223D /* Embed App Extensions */,
EAF1C0DE2F3A4B5C0011223D /* Embed Foundation Extensions */,
);
buildRules = (
);
@ -274,7 +277,7 @@
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2600;
LastUpgradeCheck = 2600;
LastUpgradeCheck = 2630;
TargetAttributes = {
EA384AFA2E6E6B6000CA7D50 = {
CreatedOnToolsVersion = 26.0;
@ -459,6 +462,7 @@
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
@ -517,6 +521,7 @@
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
@ -551,7 +556,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 +590,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 +612,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 +635,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 +656,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 +677,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 +697,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 +716,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

@ -0,0 +1,110 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2630"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EA384AFA2E6E6B6000CA7D50"
BuildableName = "The Noise Clock.app"
BlueprintName = "TheNoiseClock"
ReferencedContainer = "container:TheNoiseClock.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EA384AFA2E6E6B6000CA7D50"
BuildableName = "The Noise Clock.app"
BlueprintName = "TheNoiseClock"
ReferencedContainer = "container:TheNoiseClock.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EA384B072E6E6B6100CA7D50"
BuildableName = "TheNoiseClockTests.xctest"
BlueprintName = "TheNoiseClockTests"
ReferencedContainer = "container:TheNoiseClock.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EA384B112E6E6B6100CA7D50"
BuildableName = "TheNoiseClockUITests.xctest"
BlueprintName = "TheNoiseClockUITests"
ReferencedContainer = "container:TheNoiseClock.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EA384AFA2E6E6B6000CA7D50"
BuildableName = "The Noise Clock.app"
BlueprintName = "TheNoiseClock"
ReferencedContainer = "container:TheNoiseClock.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EA384AFA2E6E6B6000CA7D50"
BuildableName = "The Noise Clock.app"
BlueprintName = "TheNoiseClock"
ReferencedContainer = "container:TheNoiseClock.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -7,12 +7,12 @@
<key>TheNoiseClock.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
<integer>2</integer>
</dict>
<key>TheNoiseClockWidget.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
<integer>3</integer>
</dict>
</dict>
</dict>

View File

@ -50,9 +50,20 @@ struct ContentView: View {
// Show ONLY the onboarding no heavy app views behind it.
// This prevents ClockView, NoiseView, etc. from initializing
// and competing for the main thread during page transitions.
OnboardingView {
onboardingState.completeWelcome()
}
OnboardingView(
onComplete: {
onboardingState.completeWelcome()
},
requestAlarmPermission: {
await alarmViewModel.requestAlarmKitAuthorization()
},
isKeepAwakeEnabled: {
clockViewModel.style.keepAwake
},
onEnableKeepAwake: {
clockViewModel.setKeepAwakeEnabled(true)
}
)
.transition(.asymmetric(
insertion: .opacity,
removal: .opacity.combined(with: .move(edge: .bottom)).combined(with: .scale(scale: 0.9))

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

@ -12,6 +12,8 @@ struct Alarm: Identifiable, Codable, Equatable {
let id: UUID
var time: Date
var isEnabled: Bool
/// Calendar weekday values (1=Sunday...7=Saturday). Empty means one-time alarm.
var repeatWeekdays: [Int]
var soundName: String
var label: String
var notificationMessage: String // Custom notification message
@ -20,11 +22,26 @@ struct Alarm: Identifiable, Codable, Equatable {
var isLightFlashEnabled: Bool
var volume: Float
private enum CodingKeys: String, CodingKey {
case id
case time
case isEnabled
case repeatWeekdays
case soundName
case label
case notificationMessage
case snoozeDuration
case isVibrationEnabled
case isLightFlashEnabled
case volume
}
// MARK: - Initialization
init(
id: UUID = UUID(),
time: Date,
isEnabled: Bool = true,
repeatWeekdays: [Int] = [],
soundName: String = AppConstants.SystemSounds.defaultSound,
label: String = "Alarm",
notificationMessage: String = "Your alarm is ringing",
@ -36,6 +53,7 @@ struct Alarm: Identifiable, Codable, Equatable {
self.id = id
self.time = time
self.isEnabled = isEnabled
self.repeatWeekdays = Self.sanitizedWeekdays(repeatWeekdays)
self.soundName = soundName
self.label = label
self.notificationMessage = notificationMessage
@ -45,6 +63,23 @@ struct Alarm: Identifiable, Codable, Equatable {
self.volume = volume
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(UUID.self, forKey: .id)
self.time = try container.decode(Date.self, forKey: .time)
self.isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true
self.repeatWeekdays = Self.sanitizedWeekdays(
try container.decodeIfPresent([Int].self, forKey: .repeatWeekdays) ?? []
)
self.soundName = try container.decodeIfPresent(String.self, forKey: .soundName) ?? AppConstants.SystemSounds.defaultSound
self.label = try container.decodeIfPresent(String.self, forKey: .label) ?? "Alarm"
self.notificationMessage = try container.decodeIfPresent(String.self, forKey: .notificationMessage) ?? "Your alarm is ringing"
self.snoozeDuration = try container.decodeIfPresent(Int.self, forKey: .snoozeDuration) ?? 9
self.isVibrationEnabled = try container.decodeIfPresent(Bool.self, forKey: .isVibrationEnabled) ?? true
self.isLightFlashEnabled = try container.decodeIfPresent(Bool.self, forKey: .isLightFlashEnabled) ?? false
self.volume = try container.decodeIfPresent(Float.self, forKey: .volume) ?? 1.0
}
// MARK: - Equatable
static func ==(lhs: Alarm, rhs: Alarm) -> Bool {
lhs.id == rhs.id
@ -52,10 +87,77 @@ struct Alarm: Identifiable, Codable, Equatable {
// MARK: - Helper Methods
func nextTriggerTime() -> Date {
let calendar = Calendar.current
let now = Date()
let timeComponents = calendar.dateComponents([.hour, .minute], from: time)
guard let hour = timeComponents.hour, let minute = timeComponents.minute else {
return time.nextOccurrence()
}
let weekdays = Self.sanitizedWeekdays(repeatWeekdays)
guard !weekdays.isEmpty else {
return time.nextOccurrence()
}
let today = calendar.startOfDay(for: now)
for offset in 0...7 {
guard let candidateDay = calendar.date(byAdding: .day, value: offset, to: today),
let candidateDate = calendar.date(bySettingHour: hour, minute: minute, second: 0, of: candidateDay)
else {
continue
}
let weekday = calendar.component(.weekday, from: candidateDay)
if weekdays.contains(weekday), candidateDate > now {
return candidateDate
}
}
return time.nextOccurrence()
}
func formattedTime() -> String {
time.formatted(date: .omitted, time: .shortened)
}
var repeatSummary: String {
Self.repeatSummary(for: repeatWeekdays)
}
static func repeatSummary(for weekdays: [Int]) -> String {
let normalized = sanitizedWeekdays(weekdays)
guard !normalized.isEmpty else { return "Once" }
let weekdaySet = Set(normalized)
if weekdaySet.count == 7 {
return "Every day"
}
if weekdaySet == Set([2, 3, 4, 5, 6]) {
return "Weekdays"
}
if weekdaySet == Set([1, 7]) {
return "Weekends"
}
let symbols = Calendar.current.shortWeekdaySymbols
let orderedWeekdays = displayOrderedWeekdays(for: Calendar.current)
let labels = orderedWeekdays.compactMap { weekday -> String? in
guard weekdaySet.contains(weekday) else { return nil }
guard (1...7).contains(weekday) else { return nil }
return symbols[weekday - 1]
}
return labels.joined(separator: ", ")
}
static func sanitizedWeekdays(_ weekdays: [Int]) -> [Int] {
Array(Set(weekdays.filter { (1...7).contains($0) })).sorted()
}
private static func displayOrderedWeekdays(for calendar: Calendar) -> [Int] {
let first = max(1, min(calendar.firstWeekday, 7))
return Array(first...7) + Array(1..<first)
}
}

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)")
@ -360,6 +359,24 @@ final class AlarmKitService {
let debugFormat = Date.FormatStyle.dateTime.year().month().day().hour().minute().second().timeZone()
Design.debugLog("[alarmkit] Raw alarm.time: \(alarm.time.formatted(debugFormat))")
let normalizedWeekdays = Alarm.sanitizedWeekdays(alarm.repeatWeekdays)
if !normalizedWeekdays.isEmpty {
let calendar = Calendar.current
let components = calendar.dateComponents([.hour, .minute], from: alarm.time)
let hour = components.hour ?? 0
let minute = components.minute ?? 0
let localeWeekdays = normalizedWeekdays.compactMap(localeWeekday(from:))
Design.debugLog("[alarmkit] Repeat weekdays: \(normalizedWeekdays)")
let relative = AlarmKit.Alarm.Schedule.Relative(
time: .init(hour: hour, minute: minute),
repeats: .weekly(localeWeekdays)
)
let schedule = AlarmKit.Alarm.Schedule.relative(relative)
Design.debugLog("[alarmkit] Schedule created: relative weekly at \(hour):\(String(format: "%02d", minute))")
return schedule
}
// Calculate the next trigger time
let triggerDate = alarm.nextTriggerTime()
@ -381,6 +398,19 @@ final class AlarmKitService {
Design.debugLog("[alarmkit] Schedule created: fixed at \(triggerDate.formatted(debugFormat))")
return schedule
}
private func localeWeekday(from calendarWeekday: Int) -> Locale.Weekday? {
switch calendarWeekday {
case 1: return .sunday
case 2: return .monday
case 3: return .tuesday
case 4: return .wednesday
case 5: return .thursday
case 6: return .friday
case 7: return .saturday
default: return nil
}
}
}
// MARK: - Errors

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

@ -14,7 +14,12 @@ import Observation
/// AlarmKit provides alarms that cut through Focus modes and silent mode,
/// with built-in Live Activity countdown and system alarm UI.
@Observable
@MainActor
final class AlarmViewModel {
enum AlarmOperationResult {
case success
case failure(String)
}
// MARK: - Properties
private let alarmService: AlarmService
@ -32,6 +37,9 @@ final class AlarmViewModel {
var systemSounds: [String] {
AppConstants.SystemSounds.availableSounds
}
var isShowingErrorAlert = false
var errorAlertMessage = ""
// MARK: - Initialization
init(alarmService: AlarmService = AlarmService.shared) {
@ -47,7 +55,8 @@ final class AlarmViewModel {
// MARK: - Alarm CRUD Operations
func addAlarm(_ alarm: Alarm) async {
@discardableResult
func addAlarm(_ alarm: Alarm, presentErrorAlert: Bool = true) async -> AlarmOperationResult {
alarmService.addAlarm(alarm)
// Schedule with AlarmKit if alarm is enabled
@ -57,11 +66,22 @@ final class AlarmViewModel {
try await alarmKitService.scheduleAlarm(alarm)
} catch {
Design.debugLog("[alarms] AlarmKit scheduling failed: \(error)")
// Roll back add so enabled alarms always represent scheduled alarms.
alarmService.deleteAlarm(id: alarm.id)
let message = alarmOperationErrorMessage(action: "add", error: error)
if presentErrorAlert {
presentAlarmOperationError(message: message)
}
return .failure(message)
}
}
return .success
}
func updateAlarm(_ alarm: Alarm) async {
@discardableResult
func updateAlarm(_ alarm: Alarm, presentErrorAlert: Bool = true) async -> AlarmOperationResult {
let previousAlarm = alarmService.getAlarm(id: alarm.id)
alarmService.updateAlarm(alarm)
// Cancel existing and reschedule if enabled
@ -73,8 +93,24 @@ final class AlarmViewModel {
try await alarmKitService.scheduleAlarm(alarm)
} catch {
Design.debugLog("[alarms] AlarmKit rescheduling failed: \(error)")
if let previousAlarm {
// Restore prior state when the new schedule cannot be committed.
alarmService.updateAlarm(previousAlarm)
if previousAlarm.isEnabled {
try? await alarmKitService.scheduleAlarm(previousAlarm)
} else {
alarmKitService.cancelAlarm(id: previousAlarm.id)
}
}
let message = alarmOperationErrorMessage(action: "update", error: error)
if presentErrorAlert {
presentAlarmOperationError(message: message)
}
return .failure(message)
}
}
return .success
}
func deleteAlarm(id: UUID) async {
@ -87,6 +123,7 @@ final class AlarmViewModel {
func toggleAlarm(id: UUID) async {
guard var alarm = alarmService.getAlarm(id: id) else { return }
let previousAlarm = alarm
alarm.isEnabled.toggle()
alarmService.updateAlarm(alarm)
@ -98,6 +135,9 @@ final class AlarmViewModel {
try await alarmKitService.scheduleAlarm(alarm)
} catch {
Design.debugLog("[alarms] AlarmKit scheduling failed: \(error)")
// Restore previous enabled state if scheduling fails.
alarmService.updateAlarm(previousAlarm)
presentAlarmOperationError(action: "toggle", error: error)
}
} else {
alarmKitService.cancelAlarm(id: id)
@ -110,6 +150,7 @@ final class AlarmViewModel {
func createNewAlarm(
time: Date,
repeatWeekdays: [Int] = [],
soundName: String = AppConstants.SystemSounds.defaultSound,
label: String = "Alarm",
notificationMessage: String = "Your alarm is ringing",
@ -122,6 +163,7 @@ final class AlarmViewModel {
id: UUID(),
time: time,
isEnabled: true,
repeatWeekdays: repeatWeekdays,
soundName: soundName,
label: label,
notificationMessage: notificationMessage,
@ -154,4 +196,29 @@ final class AlarmViewModel {
Design.debugLog("[alarmkit] ========== RESCHEDULING COMPLETE ==========")
alarmKitService.logCurrentAlarms()
}
func dismissErrorAlert() {
isShowingErrorAlert = false
errorAlertMessage = ""
}
private func presentAlarmOperationError(action: String, error: Error) {
let message = alarmOperationErrorMessage(action: action, error: error)
presentAlarmOperationError(message: message)
}
private func presentAlarmOperationError(message: String) {
errorAlertMessage = message
isShowingErrorAlert = true
}
private func alarmOperationErrorMessage(action: String, error: Error) -> String {
let detail = if let localized = error as? LocalizedError,
let description = localized.errorDescription {
description
} else {
error.localizedDescription
}
return "Unable to \(action) alarm. \(detail)"
}
}

View File

@ -17,20 +17,24 @@ 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 repeatWeekdays: [Int] = []
@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
@State private var isVibrationEnabled = true
@State private var isLightFlashEnabled = false
@State private var volume: Float = 1.0
@State private var isSaving = false
@State private var isShowingSaveErrorAlert = false
@State private var saveErrorMessage = ""
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Time picker section at top
TimePickerSection(selectedTime: $newAlarmTime)
TimeUntilAlarmSection(alarmTime: newAlarmTime)
TimeUntilAlarmSection(alarmTime: newAlarmTime, repeatWeekdays: repeatWeekdays)
// List for settings below
List {
@ -74,6 +78,20 @@ struct AddAlarmView: View {
}
}
// Repeat Section
NavigationLink(destination: RepeatSelectionView(repeatWeekdays: $repeatWeekdays)) {
HStack {
Image(systemName: "repeat")
.foregroundStyle(AppAccent.primary)
.frame(width: 24)
Text("Repeat")
Spacer()
Text(Alarm.repeatSummary(for: repeatWeekdays))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
// Snooze Section
NavigationLink(destination: SnoozeSelectionView(snoozeDuration: $snoozeDuration)) {
HStack {
@ -86,6 +104,12 @@ struct AddAlarmView: View {
.foregroundStyle(.secondary)
}
}
AlertOptionsSection(
isVibrationEnabled: $isVibrationEnabled,
isLightFlashEnabled: $isLightFlashEnabled,
volume: $volume
)
}
.listStyle(.insetGrouped)
}
@ -98,34 +122,58 @@ struct AddAlarmView: View {
isPresented = false
}
.foregroundStyle(AppAccent.primary)
.accessibilityIdentifier("alarms.add.cancelButton")
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
Button(isSaving ? "Saving..." : "Save") {
Task {
let newAlarm = viewModel.createNewAlarm(
time: newAlarmTime,
soundName: selectedSoundName,
label: alarmLabel,
notificationMessage: notificationMessage,
snoozeDuration: snoozeDuration,
isVibrationEnabled: isVibrationEnabled,
isLightFlashEnabled: isLightFlashEnabled,
volume: volume
)
await viewModel.addAlarm(newAlarm)
isPresented = false
await saveAlarm()
}
}
.disabled(isSaving)
.foregroundStyle(AppAccent.primary)
.fontWeight(.semibold)
.accessibilityIdentifier("alarms.add.saveButton")
}
}
}
.alert("Alarm Error", isPresented: $isShowingSaveErrorAlert) {
Button("OK", role: .cancel) { }
} message: {
Text(saveErrorMessage)
}
}
// MARK: - Helper Methods
private func getSoundDisplayName(_ fileName: String) -> String {
return AlarmSoundService.shared.getSoundDisplayName(fileName)
}
private func saveAlarm() async {
guard !isSaving else { return }
isSaving = true
defer { isSaving = false }
let newAlarm = viewModel.createNewAlarm(
time: newAlarmTime,
repeatWeekdays: repeatWeekdays,
soundName: selectedSoundName,
label: alarmLabel,
notificationMessage: notificationMessage,
snoozeDuration: snoozeDuration,
isVibrationEnabled: isVibrationEnabled,
isLightFlashEnabled: isLightFlashEnabled,
volume: volume
)
let result = await viewModel.addAlarm(newAlarm, presentErrorAlert: false)
switch result {
case .success:
isPresented = false
case .failure(let message):
saveErrorMessage = message
isShowingSaveErrorAlert = true
}
}
}

View File

@ -40,7 +40,7 @@ struct AlarmView: View {
}
} else {
List {
ForEach(viewModel.alarms) { alarm in
ForEach(sortedAlarms) { alarm in
AlarmRowView(
alarm: alarm,
onToggle: {
@ -82,6 +82,7 @@ struct AlarmView: View {
.font(.title2)
.symbolEffect(.bounce, value: showAddAlarm)
}
.accessibilityIdentifier("alarms.addButton")
}
}
.onAppear {
@ -105,9 +106,37 @@ struct AlarmView: View {
.presentationCornerRadius(Design.CornerRadius.xxLarge)
}
.sensoryFeedback(.impact(flexibility: .soft), trigger: showAddAlarm)
.accessibilityIdentifier("alarms.screen")
.alert("Alarm Error", isPresented: $viewModel.isShowingErrorAlert) {
Button("OK", role: .cancel) {
viewModel.dismissErrorAlert()
}
} message: {
Text(viewModel.errorAlertMessage)
}
}
// MARK: - Private Methods
private var sortedAlarms: [Alarm] {
viewModel.alarms.sorted { lhs, rhs in
if lhs.isEnabled != rhs.isEnabled {
return lhs.isEnabled && !rhs.isEnabled
}
let lhsTrigger = lhs.nextTriggerTime()
let rhsTrigger = rhs.nextTriggerTime()
if lhsTrigger != rhsTrigger {
return lhsTrigger < rhsTrigger
}
if lhs.time != rhs.time {
return lhs.time < rhs.time
}
return lhs.label.localizedStandardCompare(rhs.label) == .orderedAscending
}
}
private func deleteAlarm(at offsets: IndexSet) {
Task {
for index in offsets {

View File

@ -32,6 +32,10 @@ struct AlarmRowView: View {
.font(.subheadline)
.foregroundStyle(AppTextColors.secondary)
Text(alarm.repeatSummary)
.font(.caption)
.foregroundStyle(AppTextColors.tertiary)
Text("\(AlarmSoundService.shared.getSoundDisplayName(alarm.soundName))")
.font(.caption)
.foregroundStyle(AppTextColors.secondary)
@ -61,11 +65,16 @@ struct AlarmRowView: View {
accentColor: AppAccent.primary
)
.labelsHidden()
.accessibilityIdentifier("alarms.toggle.\(alarm.id.uuidString)")
}
.contentShape(Rectangle())
.onTapGesture {
onEdit()
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("alarms.row.\(alarm.id.uuidString)")
.accessibilityLabel("\(alarm.label), \(alarm.formattedTime())")
.accessibilityValue(alarm.isEnabled ? "Enabled" : "Disabled")
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {

View File

@ -0,0 +1,48 @@
//
// AlertOptionsSection.swift
// TheNoiseClock
//
// Created by Matt Bruce on 2/8/26.
//
import SwiftUI
import Bedrock
/// Reusable alarm alert options section for vibration, flash, and volume.
struct AlertOptionsSection: View {
@Binding var isVibrationEnabled: Bool
@Binding var isLightFlashEnabled: Bool
@Binding var volume: Float
var body: some View {
Section("Alert Options") {
Toggle("Vibration", isOn: $isVibrationEnabled)
.tint(AppAccent.primary)
.accessibilityIdentifier("alarms.alertOptions.vibrationToggle")
Toggle("Flash Screen", isOn: $isLightFlashEnabled)
.tint(AppAccent.primary)
.accessibilityIdentifier("alarms.alertOptions.flashToggle")
VStack(alignment: .leading, spacing: Design.Spacing.small) {
HStack {
Text("Volume")
Spacer()
Text("\(Int((volume * 100).rounded()))%")
.foregroundStyle(.secondary)
}
Slider(
value: Binding(
get: { Double(volume) },
set: { volume = Float(min(max($0, 0), 1)) }
),
in: 0...1
)
.tint(AppAccent.primary)
.accessibilityIdentifier("alarms.alertOptions.volumeSlider")
}
.padding(.vertical, Design.Spacing.xxSmall)
}
}
}

View File

@ -70,6 +70,5 @@ struct EmptyAlarmsView: View {
// MARK: - Preview
#Preview {
EmptyAlarmsView {
print("Add alarm tapped")
}
}

View File

@ -0,0 +1,96 @@
//
// RepeatSelectionView.swift
// TheNoiseClock
//
// Created by Matt Bruce on 2/8/26.
//
import SwiftUI
import Bedrock
/// View for selecting repeat days for an alarm.
struct RepeatSelectionView: View {
@Binding var repeatWeekdays: [Int]
private let allWeekdays = [1, 2, 3, 4, 5, 6, 7]
var body: some View {
List {
Section("Quick Picks") {
quickPickRow(id: "once", title: "Once", weekdays: [])
quickPickRow(id: "everyday", title: "Every Day", weekdays: allWeekdays)
quickPickRow(id: "weekdays", title: "Weekdays", weekdays: [2, 3, 4, 5, 6])
quickPickRow(id: "weekends", title: "Weekends", weekdays: [1, 7])
}
Section("Repeat On") {
ForEach(orderedWeekdays, id: \.self) { weekday in
HStack {
Text(dayName(for: weekday))
.foregroundStyle(AppTextColors.primary)
Spacer()
if normalizedWeekdays.contains(weekday) {
Image(systemName: "checkmark")
.foregroundStyle(AppAccent.primary)
}
}
.contentShape(Rectangle())
.listRowBackground(AppSurface.card)
.onTapGesture {
toggleDay(weekday)
}
.accessibilityIdentifier("alarms.repeat.day.\(weekday)")
}
}
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
.background(AppSurface.primary.ignoresSafeArea())
.navigationTitle("Repeat")
.navigationBarTitleDisplayMode(.inline)
}
private var normalizedWeekdays: [Int] {
Alarm.sanitizedWeekdays(repeatWeekdays)
}
private var orderedWeekdays: [Int] {
let first = max(1, min(Calendar.current.firstWeekday, 7))
return Array(first...7) + Array(1..<first)
}
@ViewBuilder
private func quickPickRow(id: String, title: String, weekdays: [Int]) -> some View {
HStack {
Text(title)
.foregroundStyle(AppTextColors.primary)
Spacer()
if Set(normalizedWeekdays) == Set(Alarm.sanitizedWeekdays(weekdays)) {
Image(systemName: "checkmark")
.foregroundStyle(AppAccent.primary)
}
}
.contentShape(Rectangle())
.listRowBackground(AppSurface.card)
.onTapGesture {
repeatWeekdays = Alarm.sanitizedWeekdays(weekdays)
}
.accessibilityIdentifier("alarms.repeat.quick.\(id)")
}
private func dayName(for weekday: Int) -> String {
let symbols = Calendar.current.weekdaySymbols
guard (1...7).contains(weekday) else { return "" }
return symbols[weekday - 1]
}
private func toggleDay(_ weekday: Int) {
var updated = Set(normalizedWeekdays)
if updated.contains(weekday) {
updated.remove(weekday)
} else {
updated.insert(weekday)
}
repeatWeekdays = Alarm.sanitizedWeekdays(Array(updated))
}
}

View File

@ -10,6 +10,12 @@ import SwiftUI
/// Component showing time until alarm and day information
struct TimeUntilAlarmSection: View {
let alarmTime: Date
let repeatWeekdays: [Int]
init(alarmTime: Date, repeatWeekdays: [Int] = []) {
self.alarmTime = alarmTime
self.repeatWeekdays = repeatWeekdays
}
var body: some View {
VStack(spacing: 4) {
@ -30,21 +36,13 @@ struct TimeUntilAlarmSection: View {
.background(AppSurface.primary)
}
private var nextAlarmTime: Date {
Alarm(time: alarmTime, repeatWeekdays: repeatWeekdays).nextTriggerTime()
}
private var timeUntilAlarm: String {
let now = Date()
let calendar = Calendar.current
// Calculate the next occurrence of the alarm time
let nextAlarmTime: Date
if calendar.isDateInToday(alarmTime) && alarmTime < now {
// If alarm time has passed today, calculate for tomorrow
nextAlarmTime = calendar.date(byAdding: .day, value: 1, to: alarmTime) ?? alarmTime
} else {
// Use the alarm time as-is (either future today or already tomorrow+)
nextAlarmTime = alarmTime
}
// Calculate time difference from now to next alarm
let components = calendar.dateComponents([.hour, .minute], from: now, to: nextAlarmTime)
if let hours = components.hour, let minutes = components.minute {
if hours > 0 {
@ -61,17 +59,12 @@ struct TimeUntilAlarmSection: View {
private var dayText: String {
let calendar = Calendar.current
let now = Date()
// If alarm time is in the past today, show tomorrow
if calendar.isDateInToday(alarmTime) && alarmTime < now {
return "Tomorrow"
} else if calendar.isDateInToday(alarmTime) {
if calendar.isDateInToday(nextAlarmTime) {
return "Today"
} else if calendar.isDateInTomorrow(alarmTime) {
} else if calendar.isDateInTomorrow(nextAlarmTime) {
return "Tomorrow"
} else {
return alarmTime.formatted(.dateTime.weekday(.wide))
return nextAlarmTime.formatted(.dateTime.weekday(.wide))
}
}
}

View File

@ -19,6 +19,7 @@ struct EditAlarmView: View {
@Environment(\.dismiss) private var dismiss
@State private var alarmTime: Date
@State private var repeatWeekdays: [Int]
@State private var selectedSoundName: String
@State private var alarmLabel: String
@State private var notificationMessage: String
@ -26,6 +27,9 @@ struct EditAlarmView: View {
@State private var isVibrationEnabled: Bool
@State private var isLightFlashEnabled: Bool
@State private var volume: Float
@State private var isSaving = false
@State private var isShowingSaveErrorAlert = false
@State private var saveErrorMessage = ""
// MARK: - Initialization
init(viewModel: AlarmViewModel, alarm: Alarm) {
@ -34,6 +38,7 @@ struct EditAlarmView: View {
// Initialize state with current alarm values
self._alarmTime = State(initialValue: alarm.time)
self._repeatWeekdays = State(initialValue: alarm.repeatWeekdays)
self._selectedSoundName = State(initialValue: alarm.soundName)
self._alarmLabel = State(initialValue: alarm.label)
self._notificationMessage = State(initialValue: alarm.notificationMessage)
@ -49,7 +54,7 @@ struct EditAlarmView: View {
VStack(spacing: 0) {
// Time picker section at top
TimePickerSection(selectedTime: $alarmTime)
TimeUntilAlarmSection(alarmTime: alarmTime)
TimeUntilAlarmSection(alarmTime: alarmTime, repeatWeekdays: repeatWeekdays)
// List for settings below
List {
@ -99,6 +104,22 @@ struct EditAlarmView: View {
}
.listRowBackground(AppSurface.card)
// Repeat Section
NavigationLink(destination: RepeatSelectionView(repeatWeekdays: $repeatWeekdays)) {
HStack {
Image(systemName: "repeat")
.foregroundStyle(AppAccent.primary)
.frame(width: 24)
Text("Repeat")
.foregroundStyle(AppTextColors.primary)
Spacer()
Text(Alarm.repeatSummary(for: repeatWeekdays))
.foregroundStyle(AppTextColors.secondary)
.lineLimit(1)
}
}
.listRowBackground(AppSurface.card)
// Snooze Section
NavigationLink(destination: SnoozeSelectionView(snoozeDuration: $snoozeDuration)) {
HStack {
@ -113,6 +134,13 @@ struct EditAlarmView: View {
}
}
.listRowBackground(AppSurface.card)
AlertOptionsSection(
isVibrationEnabled: $isVibrationEnabled,
isLightFlashEnabled: $isLightFlashEnabled,
volume: $volume
)
.listRowBackground(AppSurface.card)
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
@ -127,38 +155,62 @@ struct EditAlarmView: View {
dismiss()
}
.foregroundStyle(AppAccent.primary)
.accessibilityIdentifier("alarms.edit.cancelButton")
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
Button(isSaving ? "Saving..." : "Save") {
Task {
let updatedAlarm = Alarm(
id: alarm.id, // Keep the same ID
time: alarmTime,
isEnabled: alarm.isEnabled, // Keep the same enabled state
soundName: selectedSoundName,
label: alarmLabel,
notificationMessage: notificationMessage,
snoozeDuration: snoozeDuration,
isVibrationEnabled: isVibrationEnabled,
isLightFlashEnabled: isLightFlashEnabled,
volume: volume
)
await viewModel.updateAlarm(updatedAlarm)
dismiss()
await saveAlarm()
}
}
.disabled(isSaving)
.foregroundStyle(AppAccent.primary)
.fontWeight(.semibold)
.accessibilityIdentifier("alarms.edit.saveButton")
}
}
}
.alert("Alarm Error", isPresented: $isShowingSaveErrorAlert) {
Button("OK", role: .cancel) { }
} message: {
Text(saveErrorMessage)
}
}
// MARK: - Helper Methods
private func getSoundDisplayName(_ fileName: String) -> String {
return AlarmSoundService.shared.getSoundDisplayName(fileName)
}
private func saveAlarm() async {
guard !isSaving else { return }
isSaving = true
defer { isSaving = false }
let updatedAlarm = Alarm(
id: alarm.id,
time: alarmTime,
isEnabled: alarm.isEnabled,
repeatWeekdays: repeatWeekdays,
soundName: selectedSoundName,
label: alarmLabel,
notificationMessage: notificationMessage,
snoozeDuration: snoozeDuration,
isVibrationEnabled: isVibrationEnabled,
isLightFlashEnabled: isLightFlashEnabled,
volume: volume
)
let result = await viewModel.updateAlarm(updatedAlarm, presentErrorAlert: false)
switch result {
case .success:
dismiss()
case .failure(let message):
saveErrorMessage = message
isShowingSaveErrorAlert = true
}
}
}
// MARK: - Preview

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 }
self?.updateBatteryInfo()
}
}
stateNotificationTask = Task { [weak self] in
for await _ in NotificationCenter.default.notifications(named: UIDevice.batteryStateDidChangeNotification) {
guard !Task.isCancelled else { break }
self?.updateBatteryInfo()
}
}
}

View File

@ -125,6 +125,7 @@ struct ClockSettingsView: View {
.onDisappear {
onCommit(style)
}
.accessibilityIdentifier("settings.screen")
}
}

View File

@ -123,6 +123,7 @@ struct ClockView: View {
resetIdleTimer()
}
}
.accessibilityIdentifier("clock.screen")
}
// MARK: - Idle Timer
@ -131,7 +132,9 @@ struct ClockView: View {
idleTimer = nil
guard !viewModel.isFullScreenMode else { return }
idleTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { _ in
enterFullScreenFromIdle()
Task { @MainActor in
enterFullScreenFromIdle()
}
}
}

View File

@ -43,8 +43,25 @@ struct ClockDisplayContainer: View {
.frame(width: geometry.size.width, height: geometry.size.height)
.transition(.opacity)
.animation(.smooth(duration: Design.Animation.standard), value: isFullScreenMode)
.accessibilityElement(children: .ignore)
.accessibilityIdentifier("clock.timeDisplay")
.accessibilityLabel("Current time")
.accessibilityValue(accessibilityTimeValue)
}
}
private var accessibilityTimeValue: String {
let format: String
if style.use24Hour {
format = style.showSeconds ? "HH:mm:ss" : "HH:mm"
} else if style.showAmPm {
format = style.showSeconds ? "h:mm:ss a" : "h:mm a"
} else {
format = style.showSeconds ? "h:mm:ss" : "h:mm"
}
return currentTime.formattedForOverlay(format: format)
}
}
// MARK: - Preview

View File

@ -27,6 +27,7 @@ struct AdvancedDisplaySection: View {
isOn: $style.keepAwake,
accentColor: AppAccent.primary
)
.accessibilityIdentifier("settings.keepAwake.toggle")
if style.autoBrightness {
Rectangle()
@ -68,6 +69,7 @@ struct AdvancedDisplaySection: View {
isOn: $style.respectFocusModes,
accentColor: AppAccent.primary
)
.accessibilityIdentifier("settings.respectFocus.toggle")
}
Text("Control how the app behaves when Focus modes are active.")

View File

@ -32,11 +32,13 @@ 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) {
HStack {
// Row 1: Date and Battery (Standard Status Bar positions)
HStack(alignment: .center) {
if showDate {
DateOverlayView(color: color, opacity: opacity, dateFormat: dateFormat)
}
@ -52,9 +54,11 @@ struct TopOverlayView: View {
)
}
}
.padding(.top, Design.Spacing.xxSmall) // Minimal top padding for status bar alignment
HStack(alignment: .top) {
if showNextAlarm, let nextAlarm = alarmService.enabledAlarms.sorted(by: { $0.time.nextOccurrence() < $1.time.nextOccurrence() }).first {
// Row 2: Alarms and Noise Controls (Below Dynamic Island)
HStack(alignment: .center) {
if showNextAlarm, let nextAlarm = nextEnabledAlarm {
NextAlarmOverlay(
alarmTime: nextAlarm.time.nextOccurrence(),
color: color,
@ -80,12 +84,13 @@ struct TopOverlayView: View {
)
}
}
.padding(.top, Design.Spacing.xSmall) // Extra spacing to clear Dynamic Island in portrait
}
.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

@ -78,6 +78,8 @@ struct SoundControlView: View {
.opacity(selectedSound == nil ? 0.6 : 1.0)
.animation(.easeInOut(duration: 0.2), value: isPlaying)
.animation(.easeInOut(duration: 0.2), value: selectedSound)
.accessibilityIdentifier("noise.playStopButton")
.accessibilityLabel(isPlaying ? "Stop Sound" : "Play Sound")
}
.frame(maxWidth: 400) // Reasonable max width for iPad
.padding(Design.Spacing.medium)

View File

@ -47,6 +47,7 @@ struct NoiseView: View {
TextField("Search sounds", text: $searchText)
.textFieldStyle(.plain)
.foregroundStyle(AppTextColors.primary)
.accessibilityIdentifier("noise.searchField")
if !searchText.isEmpty {
Button(action: { searchText = "" }) {
@ -81,6 +82,7 @@ struct NoiseView: View {
.navigationTitle("Noise")
.navigationBarTitleDisplayMode(.inline)
.animation(.easeInOut(duration: 0.3), value: selectedSound)
.accessibilityIdentifier("noise.screen")
}
// MARK: - Computed Properties

View File

@ -43,6 +43,7 @@ struct OnboardingBottomControls: View {
.frame(maxWidth: .infinity)
.padding(Design.Spacing.medium)
}
.accessibilityIdentifier("onboarding.secondaryButton")
Button {
if currentPage < totalPages - 1 {
@ -59,6 +60,7 @@ struct OnboardingBottomControls: View {
.background(AppAccent.primary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
.accessibilityIdentifier("onboarding.primaryButton")
}
}
}

View File

@ -25,6 +25,10 @@ struct OnboardingFeatureRow: View {
Text(text)
.typography(.body)
.foregroundStyle(AppTextColors.secondary)
.lineLimit(nil)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
.layoutPriority(1)
}
.frame(maxWidth: 320, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .center)

View File

@ -37,6 +37,8 @@ struct OnboardingGetStartedPage: View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
OnboardingFeatureRow(icon: "alarm.fill", text: "Create your first alarm")
OnboardingFeatureRow(icon: "repeat", text: "Set repeat days (weekdays/weekends)")
OnboardingFeatureRow(icon: "slider.horizontal.3", text: "Customize vibration, flash, and volume")
OnboardingFeatureRow(icon: "clock.fill", text: "Wait 5s for full screen")
OnboardingFeatureRow(icon: "speaker.wave.2", text: "Tap Noise to play sounds")
}

View File

@ -13,6 +13,9 @@ struct OnboardingPermissionsPage: View {
@Binding var alarmKitPermissionGranted: Bool
@Binding var keepAwakeEnabled: Bool
let requestAlarmPermission: () async -> Bool
let isKeepAwakeEnabled: () -> Bool
let onEnableKeepAwake: () -> Void
let onAdvanceToFinal: () -> Void
var body: some View {
@ -34,8 +37,10 @@ struct OnboardingPermissionsPage: View {
.typography(.heroBold)
.foregroundStyle(AppTextColors.primary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, Design.Spacing.large)
Text("Works in silent mode, Focus mode, and even when your phone is locked.")
Text("Works in silent mode, Focus mode, and even when your phone is locked. You can then set repeat days and customize alert behavior.")
.typography(.body)
.foregroundStyle(AppTextColors.secondary)
.multilineTextAlignment(.center)
@ -93,6 +98,7 @@ struct OnboardingPermissionsPage: View {
.background(AppAccent.primary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
.accessibilityIdentifier("onboarding.enableAlarmsButton")
}
}
@ -120,6 +126,7 @@ struct OnboardingPermissionsPage: View {
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
.disabled(keepAwakeEnabled)
.accessibilityIdentifier("onboarding.enableKeepAwakeButton")
}
}
@ -127,7 +134,7 @@ struct OnboardingPermissionsPage: View {
private func requestAlarmKitPermission() {
Task {
let granted = await AlarmKitService.shared.requestAuthorization()
let granted = await requestAlarmPermission()
withAnimation(.spring(duration: 0.3)) {
alarmKitPermissionGranted = granted
}
@ -139,32 +146,11 @@ struct OnboardingPermissionsPage: View {
}
private func enableKeepAwake() {
let style = loadClockStyle()
style.keepAwake = true
saveClockStyle(style)
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
onEnableKeepAwake()
withAnimation(.spring(duration: 0.3)) {
keepAwakeEnabled = true
}
}
private func isKeepAwakeEnabled() -> Bool {
loadClockStyle().keepAwake
}
private func loadClockStyle() -> ClockStyle {
guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey),
let decoded = try? JSONDecoder().decode(ClockStyle.self, from: data) else {
return ClockStyle()
}
return decoded
}
private func saveClockStyle(_ style: ClockStyle) {
if let data = try? JSONEncoder().encode(style) {
UserDefaults.standard.set(data, forKey: ClockStyle.appStorageKey)
}
}
}
// MARK: - Preview
@ -173,6 +159,9 @@ struct OnboardingPermissionsPage: View {
OnboardingPermissionsPage(
alarmKitPermissionGranted: .constant(false),
keepAwakeEnabled: .constant(false),
requestAlarmPermission: { true },
isKeepAwakeEnabled: { false },
onEnableKeepAwake: {},
onAdvanceToFinal: {}
)
.preferredColorScheme(.dark)

View File

@ -35,7 +35,7 @@ struct OnboardingWelcomePage: View {
)
OnboardingFeatureRow(
icon: "alarm.fill",
text: "Wake up gently, on your terms"
text: "Wake up your way with custom alarms"
)
OnboardingFeatureRow(
icon: "clock.fill",

View File

@ -20,6 +20,9 @@ struct OnboardingView: View {
// MARK: - Properties
let onComplete: () -> Void
let requestAlarmPermission: () async -> Bool
let isKeepAwakeEnabled: () -> Bool
let onEnableKeepAwake: () -> Void
@State private var currentPage = 0
@State private var alarmKitPermissionGranted = false
@ -50,6 +53,9 @@ struct OnboardingView: View {
OnboardingPermissionsPage(
alarmKitPermissionGranted: $alarmKitPermissionGranted,
keepAwakeEnabled: $keepAwakeEnabled,
requestAlarmPermission: requestAlarmPermission,
isKeepAwakeEnabled: isKeepAwakeEnabled,
onEnableKeepAwake: onEnableKeepAwake,
onAdvanceToFinal: {
withAnimation { currentPage = 3 }
}
@ -84,7 +90,11 @@ struct OnboardingView: View {
#Preview {
OnboardingView {
print("Onboarding complete")
} requestAlarmPermission: {
true
} isKeepAwakeEnabled: {
false
} onEnableKeepAwake: {
}
.preferredColorScheme(.dark)
}

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

@ -21,35 +21,43 @@ struct KeepAwakePrompt: View {
VStack(spacing: Design.Spacing.small) {
Text("Keep Awake for Alarms")
.typography(.title2)
.typography(.title2Bold)
.foregroundStyle(AppTextColors.primary)
.multilineTextAlignment(.center)
Text("Enable Keep Awake so your alarm can play loudly and show the full screen while TheNoiseClock stays open.")
.typography(.body)
.foregroundStyle(AppTextColors.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Design.Spacing.small)
}
VStack(spacing: Design.Spacing.small) {
VStack(spacing: Design.Spacing.medium) {
Button(action: onEnable) {
Text("Enable Keep Awake")
.font(Typography.headingEmphasis.font)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(AppAccent.primary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
.buttonStyle(color: AppAccent.primary)
Button(action: onDismiss) {
Text("Not Now")
.font(Typography.bodyEmphasis.font)
.foregroundStyle(AppTextColors.secondary)
.frame(maxWidth: .infinity)
.frame(height: 44)
}
.buttonStyle(.plain)
.foregroundStyle(AppTextColors.secondary)
}
}
.padding(Design.Spacing.xLarge)
.frame(maxWidth: .infinity)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppSurface.primary)
.presentationDetents([.medium])
.presentationDetents([.height(340)])
.presentationCornerRadius(Design.CornerRadius.xxLarge)
.presentationBackground(AppSurface.primary)
}
}

View File

@ -6,12 +6,69 @@
//
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)
}
@Test
func repeatingAlarmComputesNextMatchingWeekday() {
let calendar = Calendar.current
let now = Date()
let targetDate = calendar.date(byAdding: .day, value: 2, to: now) ?? now
let targetWeekday = calendar.component(.weekday, from: targetDate)
let templateTime = calendar.date(bySettingHour: 9, minute: 30, second: 0, of: now) ?? now
let alarm = Alarm(time: templateTime, repeatWeekdays: [targetWeekday])
let next = alarm.nextTriggerTime()
let components = calendar.dateComponents([.hour, .minute, .weekday], from: next)
#expect(next > now)
#expect(components.weekday == targetWeekday)
#expect(components.hour == 9)
#expect(components.minute == 30)
}
@Test
func repeatSummaryRecognizesCommonPatterns() {
#expect(Alarm.repeatSummary(for: []) == "Once")
#expect(Alarm.repeatSummary(for: [1, 2, 3, 4, 5, 6, 7]) == "Every day")
#expect(Alarm.repeatSummary(for: [2, 3, 4, 5, 6]) == "Weekdays")
#expect(Alarm.repeatSummary(for: [1, 7]) == "Weekends")
}
}

View File

@ -10,32 +10,357 @@ import XCTest
final class TheNoiseClockUITests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
// In UI tests it is usually best to stop immediately when a failure occurs.
continueAfterFailure = false
// In UI tests its important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
@MainActor
func testExample() throws {
// UI tests must launch the application that they test.
func testAppStoreScreenshots_iPhone69() throws {
let app = XCUIApplication()
app.launchArguments += [
"-onboarding.TheNoiseClock.hasCompletedWelcome", "YES",
"-onboarding.TheNoiseClock.hasLaunched", "YES"
]
app.launch()
// Use XCTAssert and related functions to verify your tests produce the correct results.
dismissOnboardingIfNeeded(app)
ensureKeepAwakeEnabled(app)
ensureAlarmExistsAndEnabled(app)
captureNoiseViewPortraitAndLandscape(app)
captureClockPortraitAndLandscape(app)
captureAlarmsViewPortraitAndLandscape(app)
captureSettingsViewPortraitAndLandscape(app)
XCUIDevice.shared.orientation = .portrait
}
// MARK: - Capture Steps
@MainActor
private func captureClockPortraitAndLandscape(_ app: XCUIApplication) {
XCUIDevice.shared.orientation = .portrait
sleep(1)
openTab(named: "Clock", in: app)
waitForClockFullscreenTransition()
saveScreenshot(named: "01-clock-view-portrait.png")
XCUIDevice.shared.orientation = .landscapeLeft
sleep(2)
openTab(named: "Clock", in: app)
waitForClockFullscreenTransition()
saveScreenshot(named: "02-clock-view-landscape.png")
}
@MainActor
func testLaunchPerformance() throws {
// This measures how long it takes to launch your application.
measure(metrics: [XCTApplicationLaunchMetric()]) {
XCUIApplication().launch()
private func captureAlarmsViewPortraitAndLandscape(_ app: XCUIApplication) {
XCUIDevice.shared.orientation = .portrait
sleep(1)
openTab(named: "Alarms", in: app)
sleep(1)
saveScreenshot(named: "03-alarms-view-portrait.png")
XCUIDevice.shared.orientation = .landscapeLeft
sleep(2)
openTab(named: "Alarms", in: app)
sleep(1)
saveScreenshot(named: "04-alarms-view-landscape.png")
}
@MainActor
private func captureNoiseViewPortraitAndLandscape(_ app: XCUIApplication) {
XCUIDevice.shared.orientation = .portrait
sleep(1)
openTab(named: "Noise", in: app)
ensureNoiseSoundSelectedAndPlaying(app)
sleep(1)
saveScreenshot(named: "05-noise-view-portrait.png")
XCUIDevice.shared.orientation = .landscapeLeft
sleep(2)
openTab(named: "Noise", in: app)
ensureNoiseSoundSelectedAndPlaying(app)
sleep(1)
saveScreenshot(named: "06-noise-view-landscape.png")
}
@MainActor
private func captureSettingsViewPortraitAndLandscape(_ app: XCUIApplication) {
XCUIDevice.shared.orientation = .portrait
sleep(1)
openTab(named: "Settings", in: app)
sleep(1)
saveScreenshot(named: "07-clock-settings-portrait.png")
XCUIDevice.shared.orientation = .landscapeLeft
sleep(2)
openTab(named: "Settings", in: app)
sleep(1)
saveScreenshot(named: "08-clock-settings-landscape.png")
}
@MainActor
private func ensureNoiseSoundSelectedAndPlaying(_ app: XCUIApplication) {
// Select a known sound so controls become visible.
let soundNames = [
"White Noise",
"Brown Noise",
"Heavy Rain",
"Atmospheric Pad",
"Fan Heater"
]
var didSelectSound = false
for _ in 0..<8 {
for soundName in soundNames {
let sound = app.staticTexts[soundName]
if sound.exists && sound.isHittable {
sound.tap()
didSelectSound = true
break
}
}
if didSelectSound {
break
}
app.swipeUp()
usleep(250_000)
}
let playButton = app.buttons["Play Sound"]
if playButton.waitForExistence(timeout: 4) {
playButton.tap()
}
}
// MARK: - State Setup
@MainActor
private func dismissOnboardingIfNeeded(_ app: XCUIApplication) {
let secondaryButton = app.buttons["onboarding.secondaryButton"]
if secondaryButton.waitForExistence(timeout: 3), secondaryButton.label == "Skip" {
secondaryButton.tap()
waitForMainTabs(app)
return
}
if app.buttons["Skip"].waitForExistence(timeout: 3) {
app.buttons["Skip"].tap()
waitForMainTabs(app)
return
}
// Fallback: walk onboarding pages if skip is not available.
for _ in 0..<6 {
if hasMainTabs(app) { return }
let next = app.buttons["Next"]
let getStarted = app.buttons["Get Started"]
if getStarted.exists {
getStarted.tap()
waitForMainTabs(app)
return
}
if next.exists {
next.tap()
usleep(300_000)
}
}
}
@MainActor
private func ensureKeepAwakeEnabled(_ app: XCUIApplication) {
openTab(named: "Settings", in: app)
let keepAwakeSwitch = app.switches["settings.keepAwake.toggle"]
for _ in 0..<8 {
if keepAwakeSwitch.exists { break }
app.swipeUp()
usleep(200_000)
}
guard keepAwakeSwitch.waitForExistence(timeout: 3) else {
XCTFail("Could not find Keep Awake toggle by accessibility identifier.")
return
}
if !isSwitchOn(keepAwakeSwitch) {
keepAwakeSwitch.tap()
sleep(1)
}
}
@MainActor
private func ensureAlarmExistsAndEnabled(_ app: XCUIApplication) {
openTab(named: "Alarms", in: app)
// If no alarms exist, create one with defaults.
if app.staticTexts["No Alarms Set"].exists || app.buttons["Add Your First Alarm"].exists {
let addFirstAlarm = app.buttons["Add Your First Alarm"]
if addFirstAlarm.exists {
addFirstAlarm.tap()
} else {
tapNavigationPlusButton(in: app)
}
let saveButton = app.buttons["Save"]
XCTAssertTrue(saveButton.waitForExistence(timeout: 5), "Add Alarm sheet did not appear.")
saveButton.tap()
sleep(1)
}
// Make sure at least one alarm is enabled.
let firstSwitch = app.switches.firstMatch
if firstSwitch.waitForExistence(timeout: 3), !isSwitchOn(firstSwitch) {
firstSwitch.tap()
sleep(1)
}
}
// MARK: - Helpers
@MainActor
private func openTab(named tabName: String, in app: XCUIApplication) {
let expectedIndex: Int? = switch tabName {
case "Clock": 0
case "Alarms": 1
case "Noise": 2
case "Settings": 3
default: nil
}
func tapByCoordinates(_ element: XCUIElement) {
let coordinate = app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
.withOffset(CGVector(dx: element.frame.midX, dy: element.frame.midY))
coordinate.tap()
}
func tapLabeledButtonAnywhere() -> Bool {
let matches = app.buttons.matching(NSPredicate(format: "label == %@", tabName))
.allElementsBoundByIndex
.filter(\.exists)
guard !matches.isEmpty else { return false }
if let hittable = matches.first(where: \.isHittable) {
hittable.tap()
return true
}
tapByCoordinates(matches[0])
return true
}
func tapLabeledButton() -> Bool {
let query = app.tabBars.buttons.matching(NSPredicate(format: "label == %@", tabName))
let matches = query.allElementsBoundByIndex.filter(\.exists)
guard !matches.isEmpty else { return false }
if let hittable = matches.first(where: \.isHittable) {
hittable.tap()
return true
}
tapByCoordinates(matches[0])
return true
}
func tapIndexedButton() -> Bool {
guard let expectedIndex else { return false }
let tabBar = app.tabBars.firstMatch
guard tabBar.exists else { return false }
let buttons = tabBar.buttons
guard buttons.count > expectedIndex else { return false }
let button = buttons.element(boundBy: expectedIndex)
guard button.exists else { return false }
button.tap()
return true
}
if tapLabeledButton() || tapIndexedButton() {
return
}
// Fallback for cases where tab buttons are not exposed under tabBars.
if tapLabeledButtonAnywhere() {
return
}
// Reveal tab bar when clock is in auto full-screen mode.
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
usleep(300_000)
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.98)).tap()
usleep(300_000)
if tapLabeledButton() || tapIndexedButton() || tapLabeledButtonAnywhere() {
return
}
let buttonLabels = app.buttons.allElementsBoundByIndex
.prefix(16)
.map(\.label)
.joined(separator: ", ")
XCTFail("Could not open tab '\(tabName)' by label or index. tabBars=\(app.tabBars.count), buttons=[\(buttonLabels)]")
}
@MainActor
private func tapNavigationPlusButton(in app: XCUIApplication) {
let plusCandidates = [
app.navigationBars.buttons["plus"],
app.navigationBars.buttons["Add"],
app.buttons["plus"]
]
for candidate in plusCandidates where candidate.exists {
candidate.tap()
return
}
XCTFail("Could not find add (+) button.")
}
private func isSwitchOn(_ toggle: XCUIElement) -> Bool {
guard let value = toggle.value as? String else { return false }
return value == "1" || value.lowercased() == "on"
}
private func waitForClockFullscreenTransition() {
// Clock auto-hides chrome after 5 seconds of inactivity.
sleep(6)
}
private func saveScreenshot(named fileName: String) {
let screenshot = XCUIScreen.main.screenshot()
let data = screenshot.pngRepresentation
let destination = screenshotDirectory().appendingPathComponent(fileName)
do {
try data.write(to: destination)
} catch {
XCTFail("Failed to write screenshot to \(destination.path): \(error)")
}
let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.png")
attachment.name = fileName
attachment.lifetime = .keepAlways
add(attachment)
}
private func screenshotDirectory() -> URL {
let env = ProcessInfo.processInfo.environment["SCREENSHOT_OUTPUT_DIR"]
let path = (env?.isEmpty == false) ? env! : NSTemporaryDirectory()
let url = URL(fileURLWithPath: path, isDirectory: true)
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
private func waitForMainTabs(_ app: XCUIApplication) {
for _ in 0..<20 {
if hasMainTabs(app) { return }
usleep(200_000)
}
}
private func hasMainTabs(_ app: XCUIApplication) -> Bool {
let labels = ["Clock", "Alarms", "Noise", "Settings"]
if app.tabBars.count > 0 { return true }
return labels.contains(where: { app.buttons[$0].exists })
}
}

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