Compare commits
9 Commits
e03689f38c
...
3b45fe2114
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b45fe2114 | |||
| f700411058 | |||
| 8f79836481 | |||
| 263b2fffcc | |||
| c3dad57700 | |||
| fbd0377348 | |||
| d2c6a09e47 | |||
| 86e4382cc2 | |||
| 7b3a8903a8 |
46
PRD.md
46
PRD.md
@ -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 user’s 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
|
||||
|
||||
26
README.md
26
README.md
@ -90,7 +90,13 @@ Open `TheNoiseClock.xcodeproj` and run the `TheNoiseClock` scheme.
|
||||
### Terminal Build
|
||||
```bash
|
||||
cd /Users/mattbruce/Documents/Projects/iPhone/TheNoiseClock
|
||||
xcodebuild -project TheNoiseClock/TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPad mini (A17 Pro),OS=18.1' build
|
||||
xcodebuild -project TheNoiseClock/TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2' build
|
||||
```
|
||||
|
||||
### Terminal Test
|
||||
```bash
|
||||
cd /Users/mattbruce/Documents/Projects/iPhone/TheNoiseClock
|
||||
xcodebuild -project TheNoiseClock/TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2' test
|
||||
```
|
||||
|
||||
---
|
||||
@ -125,6 +131,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:
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -40,7 +40,7 @@ final class AlarmService {
|
||||
alarms.append(alarm)
|
||||
updateAlarmLookup()
|
||||
saveAlarms()
|
||||
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
|
||||
NotificationCenter.default.post(name: .alarmsDidUpdate, object: nil)
|
||||
}
|
||||
|
||||
/// Update an alarm in storage. Does NOT reschedule - caller should use AlarmKitService.
|
||||
@ -53,7 +53,7 @@ final class AlarmService {
|
||||
alarms[index] = alarm
|
||||
updateAlarmLookup()
|
||||
saveAlarms()
|
||||
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
|
||||
NotificationCenter.default.post(name: .alarmsDidUpdate, object: nil)
|
||||
}
|
||||
|
||||
/// Delete an alarm from storage. Does NOT cancel - caller should use AlarmKitService.
|
||||
@ -62,7 +62,7 @@ final class AlarmService {
|
||||
alarms.removeAll { $0.id == id }
|
||||
updateAlarmLookup()
|
||||
saveAlarms()
|
||||
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
|
||||
NotificationCenter.default.post(name: .alarmsDidUpdate, object: nil)
|
||||
}
|
||||
|
||||
/// Toggle an alarm's enabled state. Does NOT reschedule - caller should use AlarmKitService.
|
||||
@ -71,7 +71,7 @@ final class AlarmService {
|
||||
alarms[index].isEnabled.toggle()
|
||||
Design.debugLog("[alarms] AlarmService.toggleAlarm: \(id) now enabled=\(alarms[index].isEnabled)")
|
||||
saveAlarms()
|
||||
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
|
||||
NotificationCenter.default.post(name: .alarmsDidUpdate, object: nil)
|
||||
}
|
||||
|
||||
func getAlarm(id: UUID) -> Alarm? {
|
||||
|
||||
@ -10,41 +10,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
|
||||
|
||||
@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -70,6 +70,5 @@ struct EmptyAlarmsView: View {
|
||||
// MARK: - Preview
|
||||
#Preview {
|
||||
EmptyAlarmsView {
|
||||
print("Add alarm tapped")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -125,6 +125,7 @@ struct ClockSettingsView: View {
|
||||
.onDisappear {
|
||||
onCommit(style)
|
||||
}
|
||||
.accessibilityIdentifier("settings.screen")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.")
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -10,7 +10,7 @@ import Bedrock
|
||||
|
||||
// MARK: - NoiseClock Surface Colors
|
||||
|
||||
public enum NoiseClockSurfaceColors: SurfaceColorProvider {
|
||||
public enum NoiseClockSurfaceColors: @MainActor SurfaceColorProvider {
|
||||
public static let primary = Color(red: 0.06, green: 0.08, blue: 0.12)
|
||||
public static let secondary = Color(red: 0.09, green: 0.11, blue: 0.18)
|
||||
public static let tertiary = Color(red: 0.12, green: 0.15, blue: 0.22)
|
||||
@ -22,7 +22,7 @@ public enum NoiseClockSurfaceColors: SurfaceColorProvider {
|
||||
|
||||
// MARK: - NoiseClock Text Colors
|
||||
|
||||
public enum NoiseClockTextColors: TextColorProvider {
|
||||
public enum NoiseClockTextColors: @MainActor TextColorProvider {
|
||||
public static let primary = Color.white
|
||||
public static let secondary = Color.white.opacity(Design.Opacity.accent)
|
||||
public static let tertiary = Color.white.opacity(Design.Opacity.medium)
|
||||
@ -33,7 +33,7 @@ public enum NoiseClockTextColors: TextColorProvider {
|
||||
|
||||
// MARK: - NoiseClock Accent Colors
|
||||
|
||||
public enum NoiseClockAccentColors: AccentColorProvider {
|
||||
public enum NoiseClockAccentColors: @MainActor AccentColorProvider {
|
||||
public static let primary = Color(red: 0.45, green: 0.75, blue: 1.00)
|
||||
public static let light = Color(red: 0.65, green: 0.85, blue: 1.00)
|
||||
public static let dark = Color(red: 0.25, green: 0.55, blue: 0.90)
|
||||
@ -42,7 +42,7 @@ public enum NoiseClockAccentColors: AccentColorProvider {
|
||||
|
||||
// MARK: - NoiseClock Button Colors
|
||||
|
||||
public enum NoiseClockButtonColors: ButtonColorProvider {
|
||||
public enum NoiseClockButtonColors: @MainActor ButtonColorProvider {
|
||||
public static let primaryLight = Color(red: 0.55, green: 0.80, blue: 1.00)
|
||||
public static let primaryDark = Color(red: 0.20, green: 0.50, blue: 0.85)
|
||||
public static let secondary = Color.white.opacity(Design.Opacity.subtle)
|
||||
@ -52,7 +52,7 @@ public enum NoiseClockButtonColors: ButtonColorProvider {
|
||||
|
||||
// MARK: - NoiseClock Status Colors
|
||||
|
||||
public enum NoiseClockStatusColors: StatusColorProvider {
|
||||
public enum NoiseClockStatusColors: @MainActor StatusColorProvider {
|
||||
public static let success = Color(red: 0.20, green: 0.80, blue: 0.45)
|
||||
public static let warning = Color(red: 1.00, green: 0.75, blue: 0.20)
|
||||
public static let error = Color(red: 0.90, green: 0.30, blue: 0.30)
|
||||
@ -61,7 +61,7 @@ public enum NoiseClockStatusColors: StatusColorProvider {
|
||||
|
||||
// MARK: - NoiseClock Border Colors
|
||||
|
||||
public enum NoiseClockBorderColors: BorderColorProvider {
|
||||
public enum NoiseClockBorderColors: @MainActor BorderColorProvider {
|
||||
public static let subtle = Color.white.opacity(Design.Opacity.subtle)
|
||||
public static let standard = Color.white.opacity(Design.Opacity.hint)
|
||||
public static let emphasized = Color.white.opacity(Design.Opacity.light)
|
||||
@ -70,7 +70,7 @@ public enum NoiseClockBorderColors: BorderColorProvider {
|
||||
|
||||
// MARK: - NoiseClock Interactive Colors
|
||||
|
||||
public enum NoiseClockInteractiveColors: InteractiveColorProvider {
|
||||
public enum NoiseClockInteractiveColors: @MainActor InteractiveColorProvider {
|
||||
public static let selected = NoiseClockAccentColors.primary.opacity(Design.Opacity.selection)
|
||||
public static let hover = Color.white.opacity(Design.Opacity.subtle)
|
||||
public static let pressed = Color.white.opacity(Design.Opacity.hint)
|
||||
@ -79,7 +79,7 @@ public enum NoiseClockInteractiveColors: InteractiveColorProvider {
|
||||
|
||||
// MARK: - NoiseClock Theme
|
||||
|
||||
public enum NoiseClockTheme: AppColorTheme {
|
||||
public enum NoiseClockTheme: @MainActor AppColorTheme {
|
||||
public typealias Surface = NoiseClockSurfaceColors
|
||||
public typealias Text = NoiseClockTextColors
|
||||
public typealias Accent = NoiseClockAccentColors
|
||||
|
||||
@ -8,14 +8,29 @@
|
||||
import Foundation
|
||||
|
||||
extension Date {
|
||||
private static let formatterCacheKeyPrefix = "TheNoiseClock.DateFormatter."
|
||||
|
||||
/// Format date for display in overlay with custom format
|
||||
/// - Parameter format: Date format string (e.g., "d MMM yyyy")
|
||||
/// - Returns: Formatted date string
|
||||
func formattedForOverlay(format: String = "d MMM yyyy") -> String {
|
||||
let formatter = Self.cachedFormatter(for: format)
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
private static func cachedFormatter(for format: String) -> DateFormatter {
|
||||
let cacheKey = formatterCacheKeyPrefix + format
|
||||
let threadDictionary = Thread.current.threadDictionary
|
||||
if let cached = threadDictionary[cacheKey] as? DateFormatter {
|
||||
return cached
|
||||
}
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = format
|
||||
return formatter.string(from: self)
|
||||
formatter.locale = .autoupdatingCurrent
|
||||
formatter.calendar = .autoupdatingCurrent
|
||||
threadDictionary[cacheKey] = formatter
|
||||
return formatter
|
||||
}
|
||||
|
||||
/// Get available date format options with their display names
|
||||
|
||||
@ -10,4 +10,5 @@ import Foundation
|
||||
extension Notification.Name {
|
||||
static let keepAwakePromptRequested = Notification.Name("keepAwakePromptRequested")
|
||||
static let clockStyleDidUpdate = Notification.Name("clockStyleDidUpdate")
|
||||
static let alarmsDidUpdate = Notification.Name("alarmsDidUpdate")
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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 it’s 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 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,8 +18,8 @@ import Foundation
|
||||
/// Intent to stop an active alarm from the Live Activity or notification.
|
||||
struct StopAlarmIntent: LiveActivityIntent {
|
||||
|
||||
static var title: LocalizedStringResource = "Stop Alarm"
|
||||
static var description = IntentDescription("Stops the currently ringing alarm")
|
||||
static let title: LocalizedStringResource = "Stop Alarm"
|
||||
static let description = IntentDescription("Stops the currently ringing alarm")
|
||||
|
||||
@Parameter(title: "Alarm ID")
|
||||
var alarmId: String
|
||||
@ -49,8 +49,8 @@ struct StopAlarmIntent: LiveActivityIntent {
|
||||
/// Intent to snooze an active alarm from the Live Activity or notification.
|
||||
struct SnoozeAlarmIntent: LiveActivityIntent {
|
||||
|
||||
static var title: LocalizedStringResource = "Snooze Alarm"
|
||||
static var description = IntentDescription("Snoozes the currently ringing alarm")
|
||||
static let title: LocalizedStringResource = "Snooze Alarm"
|
||||
static let description = IntentDescription("Snoozes the currently ringing alarm")
|
||||
|
||||
@Parameter(title: "Alarm ID")
|
||||
var alarmId: String
|
||||
@ -81,9 +81,9 @@ struct SnoozeAlarmIntent: LiveActivityIntent {
|
||||
/// Intent to open the app when the user taps the Live Activity.
|
||||
struct OpenAlarmAppIntent: LiveActivityIntent {
|
||||
|
||||
static var title: LocalizedStringResource = "Open TheNoiseClock"
|
||||
static var description = IntentDescription("Opens the app to the alarm screen")
|
||||
static var openAppWhenRun = true
|
||||
static let title: LocalizedStringResource = "Open TheNoiseClock"
|
||||
static let description = IntentDescription("Opens the app to the alarm screen")
|
||||
static let openAppWhenRun = true
|
||||
|
||||
@Parameter(title: "Alarm ID")
|
||||
var alarmId: String
|
||||
|
||||
Loading…
Reference in New Issue
Block a user