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
|
- **Sound preview**: Play/stop functionality for testing alarm sounds before selection
|
||||||
- **Custom labels**: User-defined alarm names and descriptions
|
- **Custom labels**: User-defined alarm names and descriptions
|
||||||
- **Repeat schedules**: Set alarms to repeat on specific weekdays or daily
|
- **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
|
- **Sound selection**: Choose from extensive alarm sounds with live preview
|
||||||
- **Volume control**: Adjustable alarm volume (0-100%)
|
- **Volume control**: Adjustable alarm volume (0-100%)
|
||||||
- **Vibration settings**: Enable/disable vibration for each alarm
|
- **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
|
- **AlarmKit authorization**: Requires user permission via NSAlarmKitUsageDescription
|
||||||
- **Persistent storage**: Alarms saved to UserDefaults with backward compatibility
|
- **Persistent storage**: Alarms saved to UserDefaults with backward compatibility
|
||||||
- **Alarm management**: Add, edit, delete, and duplicate alarms
|
- **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
|
- **Next trigger preview**: Shows when the next alarm will fire
|
||||||
- **Responsive time picker**: Font sizes adapt to available space and orientation
|
- **Responsive time picker**: Font sizes adapt to available space and orientation
|
||||||
- **AlarmKitService**: Centralized service for AlarmKit integration
|
- **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
|
- **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
|
- **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
|
## Advanced Clock Display Features
|
||||||
|
|
||||||
### Fixed-Width Digit Rendering
|
### Fixed-Width Digit Rendering
|
||||||
@ -484,8 +490,7 @@ TheNoiseClock/
|
|||||||
│ │ └── OnboardingPageView.swift
|
│ │ └── OnboardingPageView.swift
|
||||||
│ └── Resources/
|
│ └── Resources/
|
||||||
│ ├── LaunchScreen.storyboard # Branded native launch screen
|
│ ├── LaunchScreen.storyboard # Branded native launch screen
|
||||||
│ ├── sounds.json # Ambient sound configuration and definitions
|
│ ├── SoundsSettings.json # Shared audio settings
|
||||||
│ ├── alarm-sounds.json # Alarm sound configuration and definitions
|
|
||||||
│ ├── Ambient.bundle/ # Ambient sound category
|
│ ├── Ambient.bundle/ # Ambient sound category
|
||||||
│ │ └── white-noise.mp3
|
│ │ └── white-noise.mp3
|
||||||
│ ├── Nature.bundle/ # Nature sound category
|
│ ├── Nature.bundle/ # Nature sound category
|
||||||
@ -493,11 +498,12 @@ TheNoiseClock/
|
|||||||
│ ├── Mechanical.bundle/ # Mechanical sound category
|
│ ├── Mechanical.bundle/ # Mechanical sound category
|
||||||
│ │ └── fan-white-noise-heater.mp3
|
│ │ └── fan-white-noise-heater.mp3
|
||||||
│ ├── AlarmSounds.bundle/ # Alarm sound category
|
│ ├── AlarmSounds.bundle/ # Alarm sound category
|
||||||
│ │ ├── digital-alarm.caf
|
│ │ ├── sounds.json # Alarm sound metadata
|
||||||
│ │ ├── classic-alarm.caf
|
│ │ ├── digital-alarm.mp3
|
||||||
│ │ ├── beep-alarm.caf
|
│ │ ├── classic-alarm.mp3
|
||||||
│ │ ├── siren-alarm.caf
|
│ │ ├── beep-alarm.mp3
|
||||||
│ │ └── buzzing-alarm.caf
|
│ │ ├── siren-alarm.mp3
|
||||||
|
│ │ └── buzzing-alarm.mp3
|
||||||
│ └── Assets.xcassets/
|
│ └── Assets.xcassets/
|
||||||
│ └── [Asset catalogs]
|
│ └── [Asset catalogs]
|
||||||
└── TheNoiseClock.xcodeproj/ # Xcode project with AudioPlaybackKit dependency
|
└── TheNoiseClock.xcodeproj/ # Xcode project with AudioPlaybackKit dependency
|
||||||
@ -727,8 +733,8 @@ The following terminal commands are used for building and testing the project. T
|
|||||||
# Navigate to project directory
|
# Navigate to project directory
|
||||||
cd /Users/mattbruce/Documents/Projects/TheNoiseClock
|
cd /Users/mattbruce/Documents/Projects/TheNoiseClock
|
||||||
|
|
||||||
# Build for iOS Simulator (iPad mini)
|
# Build for iOS Simulator (iPhone 17 Pro Max)
|
||||||
xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPad mini (A17 Pro),OS=18.1' build
|
xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2' build
|
||||||
|
|
||||||
# Build for iOS Simulator (any device)
|
# Build for iOS Simulator (any device)
|
||||||
xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=Any iOS Simulator Device' build
|
xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=Any iOS Simulator Device' build
|
||||||
@ -740,7 +746,7 @@ xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock build
|
|||||||
#### Error Checking Commands
|
#### Error Checking Commands
|
||||||
```bash
|
```bash
|
||||||
# Check for build errors only (filtered output)
|
# Check for build errors only (filtered output)
|
||||||
xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPad mini (A17 Pro),OS=18.1' build 2>&1 | grep -E "(error:|warning:|failed)" | head -10
|
xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2' build 2>&1 | grep -E "(error:|warning:|failed)" | head -10
|
||||||
|
|
||||||
# Quick syntax check for specific files
|
# Quick syntax check for specific files
|
||||||
swift -frontend -parse TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift
|
swift -frontend -parse TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift
|
||||||
@ -749,15 +755,7 @@ swift -frontend -parse TheNoiseClock/Views/Clock/Components/DigitView.swift
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Available Simulators
|
#### Available Simulators
|
||||||
The following simulators are available for testing:
|
Use **iPhone 17 Pro Max (iOS 26.2)** as the primary simulator for build and test validation.
|
||||||
- **iPad mini (A17 Pro)** - Primary testing device
|
|
||||||
- **iPad (10th generation)**
|
|
||||||
- **iPad Air 11-inch (M2)**
|
|
||||||
- **iPad Air 13-inch (M2)**
|
|
||||||
- **iPad Pro 11-inch (M4)**
|
|
||||||
- **iPad Pro 13-inch (M4)**
|
|
||||||
- **iPhone 16, 16 Plus, 16 Pro, 16 Pro Max**
|
|
||||||
- **iPhone SE (3rd generation)**
|
|
||||||
|
|
||||||
#### Build Troubleshooting
|
#### Build Troubleshooting
|
||||||
1. **Provisioning Profile Errors**: Use iOS Simulator builds instead of device builds
|
1. **Provisioning Profile Errors**: Use iOS Simulator builds instead of device builds
|
||||||
@ -772,6 +770,16 @@ The following simulators are available for testing:
|
|||||||
4. **Test on simulator** using Xcode or terminal build
|
4. **Test on simulator** using Xcode or terminal build
|
||||||
5. **Update PRD** if architectural changes are made
|
5. **Update PRD** if architectural changes are made
|
||||||
|
|
||||||
|
#### Recent Engineering Quality Updates
|
||||||
|
- Alarm creation now defaults to `digital-alarm.mp3` (matches bundled assets and AlarmKit expectations).
|
||||||
|
- `AlarmSoundService` now caches decoded configuration/settings and falls back gracefully if bundled JSON is missing or malformed.
|
||||||
|
- Alarm-change notifications are now separated from clock-style notifications using `Notification.Name.alarmsDidUpdate`.
|
||||||
|
- Date overlay formatting now uses a per-thread `DateFormatter` cache to reduce formatter churn.
|
||||||
|
- Shared `TheNoiseClock` scheme now includes both unit and UI test targets for consistent `xcodebuild test` execution.
|
||||||
|
- 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
|
## Development Notes
|
||||||
|
|
||||||
### Project Information
|
### Project Information
|
||||||
|
|||||||
26
README.md
26
README.md
@ -90,7 +90,13 @@ Open `TheNoiseClock.xcodeproj` and run the `TheNoiseClock` scheme.
|
|||||||
### Terminal Build
|
### Terminal Build
|
||||||
```bash
|
```bash
|
||||||
cd /Users/mattbruce/Documents/Projects/iPhone/TheNoiseClock
|
cd /Users/mattbruce/Documents/Projects/iPhone/TheNoiseClock
|
||||||
xcodebuild -project TheNoiseClock/TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPad mini (A17 Pro),OS=18.1' build
|
xcodebuild -project TheNoiseClock/TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2' build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Terminal Test
|
||||||
|
```bash
|
||||||
|
cd /Users/mattbruce/Documents/Projects/iPhone/TheNoiseClock
|
||||||
|
xcodebuild -project TheNoiseClock/TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2' test
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -125,6 +131,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
|
## Architecture
|
||||||
|
|
||||||
TheNoiseClock follows a clean, modular structure:
|
TheNoiseClock follows a clean, modular structure:
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
EA384E832E6F806200CA7D50 /* AudioPlaybackKit in Frameworks */ = {isa = PBXBuildFile; productRef = EA384D3D2E6F554D00CA7D50 /* AudioPlaybackKit */; };
|
EA384E832E6F806200CA7D50 /* AudioPlaybackKit in Frameworks */ = {isa = PBXBuildFile; productRef = EA384D3D2E6F554D00CA7D50 /* AudioPlaybackKit */; };
|
||||||
EAC051B12F2E64AB007F87EA /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EAC051B02F2E64AB007F87EA /* Bedrock */; };
|
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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@ -37,15 +37,15 @@
|
|||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
EAF1C0DE2F3A4B5C0011223D /* Embed App Extensions */ = {
|
EAF1C0DE2F3A4B5C0011223D /* Embed Foundation Extensions */ = {
|
||||||
isa = PBXCopyFilesBuildPhase;
|
isa = PBXCopyFilesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
dstPath = "";
|
dstPath = "";
|
||||||
dstSubfolderSpec = 13;
|
dstSubfolderSpec = 13;
|
||||||
files = (
|
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;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
@ -63,6 +63,9 @@
|
|||||||
EA384D3B2E6F554D00CA7D50 /* Exceptions for "TheNoiseClock" folder in "TheNoiseClock" target */ = {
|
EA384D3B2E6F554D00CA7D50 /* Exceptions for "TheNoiseClock" folder in "TheNoiseClock" target */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
|
Configuration/Base.xcconfig,
|
||||||
|
Configuration/Debug.xcconfig,
|
||||||
|
Configuration/Release.xcconfig,
|
||||||
Info.plist,
|
Info.plist,
|
||||||
);
|
);
|
||||||
target = EA384AFA2E6E6B6000CA7D50 /* TheNoiseClock */;
|
target = EA384AFA2E6E6B6000CA7D50 /* TheNoiseClock */;
|
||||||
@ -181,7 +184,7 @@
|
|||||||
EA384AF72E6E6B6000CA7D50 /* Sources */,
|
EA384AF72E6E6B6000CA7D50 /* Sources */,
|
||||||
EA384AF82E6E6B6000CA7D50 /* Frameworks */,
|
EA384AF82E6E6B6000CA7D50 /* Frameworks */,
|
||||||
EA384AF92E6E6B6000CA7D50 /* Resources */,
|
EA384AF92E6E6B6000CA7D50 /* Resources */,
|
||||||
EAF1C0DE2F3A4B5C0011223D /* Embed App Extensions */,
|
EAF1C0DE2F3A4B5C0011223D /* Embed Foundation Extensions */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@ -274,7 +277,7 @@
|
|||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = 1;
|
BuildIndependentTargetsInParallel = 1;
|
||||||
LastSwiftUpdateCheck = 2600;
|
LastSwiftUpdateCheck = 2600;
|
||||||
LastUpgradeCheck = 2600;
|
LastUpgradeCheck = 2630;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
EA384AFA2E6E6B6000CA7D50 = {
|
EA384AFA2E6E6B6000CA7D50 = {
|
||||||
CreatedOnToolsVersion = 26.0;
|
CreatedOnToolsVersion = 26.0;
|
||||||
@ -459,6 +462,7 @@
|
|||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
};
|
};
|
||||||
@ -517,6 +521,7 @@
|
|||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
VALIDATE_PRODUCT = YES;
|
VALIDATE_PRODUCT = YES;
|
||||||
};
|
};
|
||||||
@ -551,7 +556,7 @@
|
|||||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 6.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
@ -585,7 +590,7 @@
|
|||||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 6.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
@ -607,9 +612,9 @@
|
|||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 6.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TheNoiseClock.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TheNoiseClock";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/The Noise Clock.app/The Noise Clock";
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
@ -630,9 +635,9 @@
|
|||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 6.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TheNoiseClock.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TheNoiseClock";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/The Noise Clock.app/The Noise Clock";
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
@ -651,7 +656,7 @@
|
|||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 6.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
TEST_TARGET_NAME = TheNoiseClock;
|
TEST_TARGET_NAME = TheNoiseClock;
|
||||||
};
|
};
|
||||||
@ -672,7 +677,7 @@
|
|||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 6.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
TEST_TARGET_NAME = TheNoiseClock;
|
TEST_TARGET_NAME = TheNoiseClock;
|
||||||
};
|
};
|
||||||
@ -692,7 +697,7 @@
|
|||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(WIDGET_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(WIDGET_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 6.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
@ -711,7 +716,7 @@
|
|||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(WIDGET_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(WIDGET_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 6.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
|
|||||||
@ -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>
|
<key>TheNoiseClock.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>1</integer>
|
<integer>2</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>TheNoiseClockWidget.xcscheme_^#shared#^_</key>
|
<key>TheNoiseClockWidget.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>0</integer>
|
<integer>3</integer>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@ -50,9 +50,20 @@ struct ContentView: View {
|
|||||||
// Show ONLY the onboarding — no heavy app views behind it.
|
// Show ONLY the onboarding — no heavy app views behind it.
|
||||||
// This prevents ClockView, NoiseView, etc. from initializing
|
// This prevents ClockView, NoiseView, etc. from initializing
|
||||||
// and competing for the main thread during page transitions.
|
// and competing for the main thread during page transitions.
|
||||||
OnboardingView {
|
OnboardingView(
|
||||||
onboardingState.completeWelcome()
|
onComplete: {
|
||||||
}
|
onboardingState.completeWelcome()
|
||||||
|
},
|
||||||
|
requestAlarmPermission: {
|
||||||
|
await alarmViewModel.requestAlarmKitAuthorization()
|
||||||
|
},
|
||||||
|
isKeepAwakeEnabled: {
|
||||||
|
clockViewModel.style.keepAwake
|
||||||
|
},
|
||||||
|
onEnableKeepAwake: {
|
||||||
|
clockViewModel.setKeepAwakeEnabled(true)
|
||||||
|
}
|
||||||
|
)
|
||||||
.transition(.asymmetric(
|
.transition(.asymmetric(
|
||||||
insertion: .opacity,
|
insertion: .opacity,
|
||||||
removal: .opacity.combined(with: .move(edge: .bottom)).combined(with: .scale(scale: 0.9))
|
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.
|
/// Intent to stop an active alarm from the Live Activity or notification.
|
||||||
struct StopAlarmIntent: LiveActivityIntent {
|
struct StopAlarmIntent: LiveActivityIntent {
|
||||||
|
|
||||||
static var title: LocalizedStringResource = "Stop Alarm"
|
static let title: LocalizedStringResource = "Stop Alarm"
|
||||||
static var description = IntentDescription("Stops the currently ringing alarm")
|
static let description = IntentDescription("Stops the currently ringing alarm")
|
||||||
|
|
||||||
@Parameter(title: "Alarm ID")
|
@Parameter(title: "Alarm ID")
|
||||||
var alarmId: String
|
var alarmId: String
|
||||||
@ -48,8 +48,8 @@ struct StopAlarmIntent: LiveActivityIntent {
|
|||||||
/// Intent to snooze an active alarm from the Live Activity or notification.
|
/// Intent to snooze an active alarm from the Live Activity or notification.
|
||||||
struct SnoozeAlarmIntent: LiveActivityIntent {
|
struct SnoozeAlarmIntent: LiveActivityIntent {
|
||||||
|
|
||||||
static var title: LocalizedStringResource = "Snooze Alarm"
|
static let title: LocalizedStringResource = "Snooze Alarm"
|
||||||
static var description = IntentDescription("Snoozes the currently ringing alarm")
|
static let description = IntentDescription("Snoozes the currently ringing alarm")
|
||||||
|
|
||||||
@Parameter(title: "Alarm ID")
|
@Parameter(title: "Alarm ID")
|
||||||
var alarmId: String
|
var alarmId: String
|
||||||
@ -80,9 +80,9 @@ struct SnoozeAlarmIntent: LiveActivityIntent {
|
|||||||
/// Intent to open the app when the user taps the Live Activity.
|
/// Intent to open the app when the user taps the Live Activity.
|
||||||
struct OpenAlarmAppIntent: LiveActivityIntent {
|
struct OpenAlarmAppIntent: LiveActivityIntent {
|
||||||
|
|
||||||
static var title: LocalizedStringResource = "Open TheNoiseClock"
|
static let title: LocalizedStringResource = "Open TheNoiseClock"
|
||||||
static var description = IntentDescription("Opens the app to the alarm screen")
|
static let description = IntentDescription("Opens the app to the alarm screen")
|
||||||
static var openAppWhenRun = true
|
static let openAppWhenRun = true
|
||||||
|
|
||||||
@Parameter(title: "Alarm ID")
|
@Parameter(title: "Alarm ID")
|
||||||
var alarmId: String
|
var alarmId: String
|
||||||
|
|||||||
@ -12,6 +12,8 @@ struct Alarm: Identifiable, Codable, Equatable {
|
|||||||
let id: UUID
|
let id: UUID
|
||||||
var time: Date
|
var time: Date
|
||||||
var isEnabled: Bool
|
var isEnabled: Bool
|
||||||
|
/// Calendar weekday values (1=Sunday...7=Saturday). Empty means one-time alarm.
|
||||||
|
var repeatWeekdays: [Int]
|
||||||
var soundName: String
|
var soundName: String
|
||||||
var label: String
|
var label: String
|
||||||
var notificationMessage: String // Custom notification message
|
var notificationMessage: String // Custom notification message
|
||||||
@ -20,11 +22,26 @@ struct Alarm: Identifiable, Codable, Equatable {
|
|||||||
var isLightFlashEnabled: Bool
|
var isLightFlashEnabled: Bool
|
||||||
var volume: Float
|
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
|
// MARK: - Initialization
|
||||||
init(
|
init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
time: Date,
|
time: Date,
|
||||||
isEnabled: Bool = true,
|
isEnabled: Bool = true,
|
||||||
|
repeatWeekdays: [Int] = [],
|
||||||
soundName: String = AppConstants.SystemSounds.defaultSound,
|
soundName: String = AppConstants.SystemSounds.defaultSound,
|
||||||
label: String = "Alarm",
|
label: String = "Alarm",
|
||||||
notificationMessage: String = "Your alarm is ringing",
|
notificationMessage: String = "Your alarm is ringing",
|
||||||
@ -36,6 +53,7 @@ struct Alarm: Identifiable, Codable, Equatable {
|
|||||||
self.id = id
|
self.id = id
|
||||||
self.time = time
|
self.time = time
|
||||||
self.isEnabled = isEnabled
|
self.isEnabled = isEnabled
|
||||||
|
self.repeatWeekdays = Self.sanitizedWeekdays(repeatWeekdays)
|
||||||
self.soundName = soundName
|
self.soundName = soundName
|
||||||
self.label = label
|
self.label = label
|
||||||
self.notificationMessage = notificationMessage
|
self.notificationMessage = notificationMessage
|
||||||
@ -45,6 +63,23 @@ struct Alarm: Identifiable, Codable, Equatable {
|
|||||||
self.volume = volume
|
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
|
// MARK: - Equatable
|
||||||
static func ==(lhs: Alarm, rhs: Alarm) -> Bool {
|
static func ==(lhs: Alarm, rhs: Alarm) -> Bool {
|
||||||
lhs.id == rhs.id
|
lhs.id == rhs.id
|
||||||
@ -52,10 +87,77 @@ struct Alarm: Identifiable, Codable, Equatable {
|
|||||||
|
|
||||||
// MARK: - Helper Methods
|
// MARK: - Helper Methods
|
||||||
func nextTriggerTime() -> Date {
|
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()
|
return time.nextOccurrence()
|
||||||
}
|
}
|
||||||
|
|
||||||
func formattedTime() -> String {
|
func formattedTime() -> String {
|
||||||
time.formatted(date: .omitted, time: .shortened)
|
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
|
// MARK: - Singleton
|
||||||
|
|
||||||
static let shared = AlarmKitService()
|
static let shared = AlarmKitService()
|
||||||
private let manager = AlarmManager.shared
|
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
Design.debugLog("[alarmkit] AlarmKitService initialized")
|
Design.debugLog("[alarmkit] AlarmKitService initialized")
|
||||||
Design.debugLog("[alarmkit] Authorization state: \(manager.authorizationState)")
|
Design.debugLog("[alarmkit] Authorization state: \(AlarmManager.shared.authorizationState)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Authorization
|
// MARK: - Authorization
|
||||||
|
|
||||||
/// The current authorization state for AlarmKit
|
/// The current authorization state for AlarmKit
|
||||||
var authorizationState: AlarmManager.AuthorizationState {
|
var authorizationState: AlarmManager.AuthorizationState {
|
||||||
manager.authorizationState
|
AlarmManager.shared.authorizationState
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request authorization to schedule alarms.
|
/// Request authorization to schedule alarms.
|
||||||
/// - Returns: `true` if authorized, `false` otherwise.
|
/// - Returns: `true` if authorized, `false` otherwise.
|
||||||
func requestAuthorization() async -> Bool {
|
func requestAuthorization() async -> Bool {
|
||||||
Design.debugLog("[alarmkit] Requesting authorization, current state: \(manager.authorizationState)")
|
Design.debugLog("[alarmkit] Requesting authorization, current state: \(AlarmManager.shared.authorizationState)")
|
||||||
|
|
||||||
switch manager.authorizationState {
|
switch AlarmManager.shared.authorizationState {
|
||||||
case .notDetermined:
|
case .notDetermined:
|
||||||
do {
|
do {
|
||||||
let state = try await manager.requestAuthorization()
|
let state = try await AlarmManager.shared.requestAuthorization()
|
||||||
Design.debugLog("[alarmkit] Authorization result: \(state)")
|
Design.debugLog("[alarmkit] Authorization result: \(state)")
|
||||||
return state == .authorized
|
return state == .authorized
|
||||||
} catch {
|
} catch {
|
||||||
@ -73,7 +72,7 @@ final class AlarmKitService {
|
|||||||
Design.debugLog("[alarmkit] ID: \(alarm.id)")
|
Design.debugLog("[alarmkit] ID: \(alarm.id)")
|
||||||
|
|
||||||
// Ensure we're authorized
|
// Ensure we're authorized
|
||||||
if manager.authorizationState != .authorized {
|
if AlarmManager.shared.authorizationState != .authorized {
|
||||||
Design.debugLog("[alarmkit] Not authorized, requesting...")
|
Design.debugLog("[alarmkit] Not authorized, requesting...")
|
||||||
let authorized = await requestAuthorization()
|
let authorized = await requestAuthorization()
|
||||||
guard authorized else {
|
guard authorized else {
|
||||||
@ -160,7 +159,7 @@ final class AlarmKitService {
|
|||||||
|
|
||||||
// Schedule the alarm
|
// Schedule the alarm
|
||||||
do {
|
do {
|
||||||
let scheduledAlarm = try await manager.schedule(
|
let scheduledAlarm = try await AlarmManager.shared.schedule(
|
||||||
id: alarm.id,
|
id: alarm.id,
|
||||||
configuration: configuration
|
configuration: configuration
|
||||||
)
|
)
|
||||||
@ -299,7 +298,7 @@ final class AlarmKitService {
|
|||||||
func cancelAlarm(id: UUID) {
|
func cancelAlarm(id: UUID) {
|
||||||
Design.debugLog("[alarmkit] Cancelling alarm: \(id)")
|
Design.debugLog("[alarmkit] Cancelling alarm: \(id)")
|
||||||
do {
|
do {
|
||||||
try manager.cancel(id: id)
|
try AlarmManager.shared.cancel(id: id)
|
||||||
Design.debugLog("[alarmkit] ✅ Alarm cancelled: \(id)")
|
Design.debugLog("[alarmkit] ✅ Alarm cancelled: \(id)")
|
||||||
} catch {
|
} catch {
|
||||||
Design.debugLog("[alarmkit] ❌ Cancel error: \(error)")
|
Design.debugLog("[alarmkit] ❌ Cancel error: \(error)")
|
||||||
@ -311,7 +310,7 @@ final class AlarmKitService {
|
|||||||
func stopAlarm(id: UUID) {
|
func stopAlarm(id: UUID) {
|
||||||
Design.debugLog("[alarmkit] Stopping alarm: \(id)")
|
Design.debugLog("[alarmkit] Stopping alarm: \(id)")
|
||||||
do {
|
do {
|
||||||
try manager.stop(id: id)
|
try AlarmManager.shared.stop(id: id)
|
||||||
Design.debugLog("[alarmkit] ✅ Alarm stopped: \(id)")
|
Design.debugLog("[alarmkit] ✅ Alarm stopped: \(id)")
|
||||||
} catch {
|
} catch {
|
||||||
Design.debugLog("[alarmkit] ❌ Stop error: \(error)")
|
Design.debugLog("[alarmkit] ❌ Stop error: \(error)")
|
||||||
@ -323,7 +322,7 @@ final class AlarmKitService {
|
|||||||
func snoozeAlarm(id: UUID) {
|
func snoozeAlarm(id: UUID) {
|
||||||
Design.debugLog("[alarmkit] Snoozing alarm: \(id)")
|
Design.debugLog("[alarmkit] Snoozing alarm: \(id)")
|
||||||
do {
|
do {
|
||||||
try manager.countdown(id: id)
|
try AlarmManager.shared.countdown(id: id)
|
||||||
Design.debugLog("[alarmkit] ✅ Alarm snoozed: \(id)")
|
Design.debugLog("[alarmkit] ✅ Alarm snoozed: \(id)")
|
||||||
} catch {
|
} catch {
|
||||||
Design.debugLog("[alarmkit] ❌ Snooze error: \(error)")
|
Design.debugLog("[alarmkit] ❌ Snooze error: \(error)")
|
||||||
@ -334,14 +333,14 @@ final class AlarmKitService {
|
|||||||
|
|
||||||
/// Async sequence that emits the current set of alarms whenever changes occur.
|
/// Async sequence that emits the current set of alarms whenever changes occur.
|
||||||
var alarmUpdates: some AsyncSequence<[AlarmKit.Alarm], Never> {
|
var alarmUpdates: some AsyncSequence<[AlarmKit.Alarm], Never> {
|
||||||
manager.alarmUpdates
|
AlarmManager.shared.alarmUpdates
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Log current state of all scheduled alarms
|
/// Log current state of all scheduled alarms
|
||||||
func logCurrentAlarms() {
|
func logCurrentAlarms() {
|
||||||
Design.debugLog("[alarmkit] ========== CURRENT ALARMS ==========")
|
Design.debugLog("[alarmkit] ========== CURRENT ALARMS ==========")
|
||||||
Task {
|
Task {
|
||||||
for await alarms in manager.alarmUpdates {
|
for await alarms in AlarmManager.shared.alarmUpdates {
|
||||||
Design.debugLog("[alarmkit] Found \(alarms.count) alarm(s) in AlarmKit")
|
Design.debugLog("[alarmkit] Found \(alarms.count) alarm(s) in AlarmKit")
|
||||||
for alarm in alarms {
|
for alarm in alarms {
|
||||||
Design.debugLog("[alarmkit] - ID: \(alarm.id)")
|
Design.debugLog("[alarmkit] - ID: \(alarm.id)")
|
||||||
@ -360,6 +359,24 @@ final class AlarmKitService {
|
|||||||
let debugFormat = Date.FormatStyle.dateTime.year().month().day().hour().minute().second().timeZone()
|
let debugFormat = Date.FormatStyle.dateTime.year().month().day().hour().minute().second().timeZone()
|
||||||
Design.debugLog("[alarmkit] Raw alarm.time: \(alarm.time.formatted(debugFormat))")
|
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
|
// Calculate the next trigger time
|
||||||
let triggerDate = alarm.nextTriggerTime()
|
let triggerDate = alarm.nextTriggerTime()
|
||||||
|
|
||||||
@ -381,6 +398,19 @@ final class AlarmKitService {
|
|||||||
Design.debugLog("[alarmkit] Schedule created: fixed at \(triggerDate.formatted(debugFormat))")
|
Design.debugLog("[alarmkit] Schedule created: fixed at \(triggerDate.formatted(debugFormat))")
|
||||||
return schedule
|
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
|
// MARK: - Errors
|
||||||
|
|||||||
@ -40,7 +40,7 @@ final class AlarmService {
|
|||||||
alarms.append(alarm)
|
alarms.append(alarm)
|
||||||
updateAlarmLookup()
|
updateAlarmLookup()
|
||||||
saveAlarms()
|
saveAlarms()
|
||||||
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
|
NotificationCenter.default.post(name: .alarmsDidUpdate, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update an alarm in storage. Does NOT reschedule - caller should use AlarmKitService.
|
/// Update an alarm in storage. Does NOT reschedule - caller should use AlarmKitService.
|
||||||
@ -53,7 +53,7 @@ final class AlarmService {
|
|||||||
alarms[index] = alarm
|
alarms[index] = alarm
|
||||||
updateAlarmLookup()
|
updateAlarmLookup()
|
||||||
saveAlarms()
|
saveAlarms()
|
||||||
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
|
NotificationCenter.default.post(name: .alarmsDidUpdate, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete an alarm from storage. Does NOT cancel - caller should use AlarmKitService.
|
/// Delete an alarm from storage. Does NOT cancel - caller should use AlarmKitService.
|
||||||
@ -62,7 +62,7 @@ final class AlarmService {
|
|||||||
alarms.removeAll { $0.id == id }
|
alarms.removeAll { $0.id == id }
|
||||||
updateAlarmLookup()
|
updateAlarmLookup()
|
||||||
saveAlarms()
|
saveAlarms()
|
||||||
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
|
NotificationCenter.default.post(name: .alarmsDidUpdate, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle an alarm's enabled state. Does NOT reschedule - caller should use AlarmKitService.
|
/// Toggle an alarm's enabled state. Does NOT reschedule - caller should use AlarmKitService.
|
||||||
@ -71,7 +71,7 @@ final class AlarmService {
|
|||||||
alarms[index].isEnabled.toggle()
|
alarms[index].isEnabled.toggle()
|
||||||
Design.debugLog("[alarms] AlarmService.toggleAlarm: \(id) now enabled=\(alarms[index].isEnabled)")
|
Design.debugLog("[alarms] AlarmService.toggleAlarm: \(id) now enabled=\(alarms[index].isEnabled)")
|
||||||
saveAlarms()
|
saveAlarms()
|
||||||
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
|
NotificationCenter.default.post(name: .alarmsDidUpdate, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAlarm(id: UUID) -> Alarm? {
|
func getAlarm(id: UUID) -> Alarm? {
|
||||||
|
|||||||
@ -10,41 +10,64 @@ import AudioPlaybackKit
|
|||||||
import Bedrock
|
import Bedrock
|
||||||
|
|
||||||
/// Extension service for alarm-specific sound functionality
|
/// Extension service for alarm-specific sound functionality
|
||||||
class AlarmSoundService {
|
final class AlarmSoundService {
|
||||||
static let shared = AlarmSoundService()
|
static let shared = AlarmSoundService()
|
||||||
|
|
||||||
// MARK: - Constants
|
// MARK: - Constants
|
||||||
/// The category ID for alarm sounds as defined in alarm-sounds.json
|
/// The category ID for alarm sounds as defined in alarm-sounds.json
|
||||||
static let alarmCategoryId = "alarm"
|
static let alarmCategoryId = "alarm"
|
||||||
|
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var cachedConfiguration: SoundConfiguration?
|
||||||
|
private var cachedSettings: AudioSettings?
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
/// Load alarm sound configuration from AlarmSounds.bundle
|
/// Load alarm sound configuration from AlarmSounds.bundle
|
||||||
private func loadAlarmConfiguration() -> SoundConfiguration {
|
private func loadAlarmConfiguration() -> SoundConfiguration {
|
||||||
guard let bundleURL = Bundle.main.url(forResource: "AlarmSounds", withExtension: "bundle"),
|
lock.lock()
|
||||||
let alarmBundle = Bundle(url: bundleURL),
|
if let cachedConfiguration {
|
||||||
let url = alarmBundle.url(forResource: "sounds", withExtension: "json") else {
|
lock.unlock()
|
||||||
fatalError("❌ sounds.json not found in AlarmSounds.bundle. Ensure the bundle and file exist.")
|
return cachedConfiguration
|
||||||
}
|
}
|
||||||
|
lock.unlock()
|
||||||
do {
|
|
||||||
let data = try Data(contentsOf: url)
|
let configuration: SoundConfiguration
|
||||||
let soundsOnly = try JSONDecoder().decode(SoundsOnly.self, from: data)
|
if let bundleURL = Bundle.main.url(forResource: "AlarmSounds", withExtension: "bundle"),
|
||||||
|
let alarmBundle = Bundle(url: bundleURL),
|
||||||
// Load settings from separate SoundsSettings.json file
|
let url = alarmBundle.url(forResource: "sounds", withExtension: "json") {
|
||||||
let settings = loadAudioSettings()
|
do {
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
return SoundConfiguration(sounds: soundsOnly.sounds, settings: settings)
|
let soundsOnly = try JSONDecoder().decode(SoundsOnly.self, from: data)
|
||||||
} catch {
|
configuration = SoundConfiguration(sounds: soundsOnly.sounds, settings: loadAudioSettings())
|
||||||
fatalError("❌ Error loading alarm sound configuration: \(error)")
|
} catch {
|
||||||
|
Design.debugLog("[audio] Warning: Failed to decode alarm sound config, using fallback sounds: \(error)")
|
||||||
|
configuration = SoundConfiguration(sounds: fallbackAlarmSounds(), settings: loadAudioSettings())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Design.debugLog("[audio] Warning: AlarmSounds.bundle/sounds.json missing, using fallback sounds")
|
||||||
|
configuration = SoundConfiguration(sounds: fallbackAlarmSounds(), settings: loadAudioSettings())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lock.lock()
|
||||||
|
cachedConfiguration = configuration
|
||||||
|
lock.unlock()
|
||||||
|
return configuration
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load audio settings from SoundsSettings.json
|
/// Load audio settings from SoundsSettings.json
|
||||||
private func loadAudioSettings() -> AudioSettings {
|
private func loadAudioSettings() -> AudioSettings {
|
||||||
|
lock.lock()
|
||||||
|
if let cachedSettings {
|
||||||
|
lock.unlock()
|
||||||
|
return cachedSettings
|
||||||
|
}
|
||||||
|
lock.unlock()
|
||||||
|
|
||||||
|
let settings: AudioSettings
|
||||||
guard let url = Bundle.main.url(forResource: "SoundsSettings", withExtension: "json") else {
|
guard let url = Bundle.main.url(forResource: "SoundsSettings", withExtension: "json") else {
|
||||||
Design.debugLog("[general] Warning: SoundsSettings.json not found, using default alarm settings")
|
Design.debugLog("[general] Warning: SoundsSettings.json not found, using default alarm settings")
|
||||||
return AudioSettings(
|
settings = AudioSettings(
|
||||||
defaultVolume: 1.0,
|
defaultVolume: 1.0,
|
||||||
defaultLoopCount: -1,
|
defaultLoopCount: -1,
|
||||||
preloadSounds: true,
|
preloadSounds: true,
|
||||||
@ -53,16 +76,19 @@ class AlarmSoundService {
|
|||||||
audioSessionMode: "default",
|
audioSessionMode: "default",
|
||||||
audioSessionOptions: ["mixWithOthers"]
|
audioSessionOptions: ["mixWithOthers"]
|
||||||
)
|
)
|
||||||
|
lock.lock()
|
||||||
|
cachedSettings = settings
|
||||||
|
lock.unlock()
|
||||||
|
return settings
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let data = try Data(contentsOf: url)
|
let data = try Data(contentsOf: url)
|
||||||
let settings = try JSONDecoder().decode(AudioSettings.self, from: data)
|
settings = try JSONDecoder().decode(AudioSettings.self, from: data)
|
||||||
//Design.debugLog("[settings] Loaded audio settings for alarms from SoundsSettings.json")
|
//Design.debugLog("[settings] Loaded audio settings for alarms from SoundsSettings.json")
|
||||||
return settings
|
|
||||||
} catch {
|
} catch {
|
||||||
Design.debugLog("[general] Warning: Error loading audio settings for alarms, using defaults: \(error)")
|
Design.debugLog("[general] Warning: Error loading audio settings for alarms, using defaults: \(error)")
|
||||||
return AudioSettings(
|
settings = AudioSettings(
|
||||||
defaultVolume: 1.0,
|
defaultVolume: 1.0,
|
||||||
defaultLoopCount: -1,
|
defaultLoopCount: -1,
|
||||||
preloadSounds: true,
|
preloadSounds: true,
|
||||||
@ -72,6 +98,57 @@ class AlarmSoundService {
|
|||||||
audioSessionOptions: ["mixWithOthers"]
|
audioSessionOptions: ["mixWithOthers"]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lock.lock()
|
||||||
|
cachedSettings = settings
|
||||||
|
lock.unlock()
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fallbackAlarmSounds() -> [Sound] {
|
||||||
|
[
|
||||||
|
Sound(
|
||||||
|
id: "digital-alarm",
|
||||||
|
name: "Digital Alarm",
|
||||||
|
fileName: "digital-alarm.mp3",
|
||||||
|
category: Self.alarmCategoryId,
|
||||||
|
description: "Classic digital alarm sound",
|
||||||
|
bundleName: "AlarmSounds",
|
||||||
|
isDefault: true
|
||||||
|
),
|
||||||
|
Sound(
|
||||||
|
id: "buzzing-alarm",
|
||||||
|
name: "Buzzing Alarm",
|
||||||
|
fileName: "buzzing-alarm.mp3",
|
||||||
|
category: Self.alarmCategoryId,
|
||||||
|
description: "Buzzing sound for gentle wake-up",
|
||||||
|
bundleName: "AlarmSounds"
|
||||||
|
),
|
||||||
|
Sound(
|
||||||
|
id: "classic-alarm",
|
||||||
|
name: "Classic Alarm",
|
||||||
|
fileName: "classic-alarm.mp3",
|
||||||
|
category: Self.alarmCategoryId,
|
||||||
|
description: "Traditional alarm sound",
|
||||||
|
bundleName: "AlarmSounds"
|
||||||
|
),
|
||||||
|
Sound(
|
||||||
|
id: "beep-alarm",
|
||||||
|
name: "Beep Alarm",
|
||||||
|
fileName: "beep-alarm.mp3",
|
||||||
|
category: Self.alarmCategoryId,
|
||||||
|
description: "Short beep alarm sound",
|
||||||
|
bundleName: "AlarmSounds"
|
||||||
|
),
|
||||||
|
Sound(
|
||||||
|
id: "siren-alarm",
|
||||||
|
name: "Siren Alarm",
|
||||||
|
fileName: "siren-alarm.mp3",
|
||||||
|
category: Self.alarmCategoryId,
|
||||||
|
description: "Emergency siren alarm for heavy sleepers",
|
||||||
|
bundleName: "AlarmSounds"
|
||||||
|
)
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all available alarm sounds
|
/// Get all available alarm sounds
|
||||||
|
|||||||
@ -14,7 +14,12 @@ import Observation
|
|||||||
/// AlarmKit provides alarms that cut through Focus modes and silent mode,
|
/// AlarmKit provides alarms that cut through Focus modes and silent mode,
|
||||||
/// with built-in Live Activity countdown and system alarm UI.
|
/// with built-in Live Activity countdown and system alarm UI.
|
||||||
@Observable
|
@Observable
|
||||||
|
@MainActor
|
||||||
final class AlarmViewModel {
|
final class AlarmViewModel {
|
||||||
|
enum AlarmOperationResult {
|
||||||
|
case success
|
||||||
|
case failure(String)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
private let alarmService: AlarmService
|
private let alarmService: AlarmService
|
||||||
@ -32,6 +37,9 @@ final class AlarmViewModel {
|
|||||||
var systemSounds: [String] {
|
var systemSounds: [String] {
|
||||||
AppConstants.SystemSounds.availableSounds
|
AppConstants.SystemSounds.availableSounds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isShowingErrorAlert = false
|
||||||
|
var errorAlertMessage = ""
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
init(alarmService: AlarmService = AlarmService.shared) {
|
init(alarmService: AlarmService = AlarmService.shared) {
|
||||||
@ -47,7 +55,8 @@ final class AlarmViewModel {
|
|||||||
|
|
||||||
// MARK: - Alarm CRUD Operations
|
// MARK: - Alarm CRUD Operations
|
||||||
|
|
||||||
func addAlarm(_ alarm: Alarm) async {
|
@discardableResult
|
||||||
|
func addAlarm(_ alarm: Alarm, presentErrorAlert: Bool = true) async -> AlarmOperationResult {
|
||||||
alarmService.addAlarm(alarm)
|
alarmService.addAlarm(alarm)
|
||||||
|
|
||||||
// Schedule with AlarmKit if alarm is enabled
|
// Schedule with AlarmKit if alarm is enabled
|
||||||
@ -57,11 +66,22 @@ final class AlarmViewModel {
|
|||||||
try await alarmKitService.scheduleAlarm(alarm)
|
try await alarmKitService.scheduleAlarm(alarm)
|
||||||
} catch {
|
} catch {
|
||||||
Design.debugLog("[alarms] AlarmKit scheduling failed: \(error)")
|
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)
|
alarmService.updateAlarm(alarm)
|
||||||
|
|
||||||
// Cancel existing and reschedule if enabled
|
// Cancel existing and reschedule if enabled
|
||||||
@ -73,8 +93,24 @@ final class AlarmViewModel {
|
|||||||
try await alarmKitService.scheduleAlarm(alarm)
|
try await alarmKitService.scheduleAlarm(alarm)
|
||||||
} catch {
|
} catch {
|
||||||
Design.debugLog("[alarms] AlarmKit rescheduling failed: \(error)")
|
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 {
|
func deleteAlarm(id: UUID) async {
|
||||||
@ -87,6 +123,7 @@ final class AlarmViewModel {
|
|||||||
|
|
||||||
func toggleAlarm(id: UUID) async {
|
func toggleAlarm(id: UUID) async {
|
||||||
guard var alarm = alarmService.getAlarm(id: id) else { return }
|
guard var alarm = alarmService.getAlarm(id: id) else { return }
|
||||||
|
let previousAlarm = alarm
|
||||||
|
|
||||||
alarm.isEnabled.toggle()
|
alarm.isEnabled.toggle()
|
||||||
alarmService.updateAlarm(alarm)
|
alarmService.updateAlarm(alarm)
|
||||||
@ -98,6 +135,9 @@ final class AlarmViewModel {
|
|||||||
try await alarmKitService.scheduleAlarm(alarm)
|
try await alarmKitService.scheduleAlarm(alarm)
|
||||||
} catch {
|
} catch {
|
||||||
Design.debugLog("[alarms] AlarmKit scheduling failed: \(error)")
|
Design.debugLog("[alarms] AlarmKit scheduling failed: \(error)")
|
||||||
|
// Restore previous enabled state if scheduling fails.
|
||||||
|
alarmService.updateAlarm(previousAlarm)
|
||||||
|
presentAlarmOperationError(action: "toggle", error: error)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
alarmKitService.cancelAlarm(id: id)
|
alarmKitService.cancelAlarm(id: id)
|
||||||
@ -110,6 +150,7 @@ final class AlarmViewModel {
|
|||||||
|
|
||||||
func createNewAlarm(
|
func createNewAlarm(
|
||||||
time: Date,
|
time: Date,
|
||||||
|
repeatWeekdays: [Int] = [],
|
||||||
soundName: String = AppConstants.SystemSounds.defaultSound,
|
soundName: String = AppConstants.SystemSounds.defaultSound,
|
||||||
label: String = "Alarm",
|
label: String = "Alarm",
|
||||||
notificationMessage: String = "Your alarm is ringing",
|
notificationMessage: String = "Your alarm is ringing",
|
||||||
@ -122,6 +163,7 @@ final class AlarmViewModel {
|
|||||||
id: UUID(),
|
id: UUID(),
|
||||||
time: time,
|
time: time,
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
|
repeatWeekdays: repeatWeekdays,
|
||||||
soundName: soundName,
|
soundName: soundName,
|
||||||
label: label,
|
label: label,
|
||||||
notificationMessage: notificationMessage,
|
notificationMessage: notificationMessage,
|
||||||
@ -154,4 +196,29 @@ final class AlarmViewModel {
|
|||||||
Design.debugLog("[alarmkit] ========== RESCHEDULING COMPLETE ==========")
|
Design.debugLog("[alarmkit] ========== RESCHEDULING COMPLETE ==========")
|
||||||
alarmKitService.logCurrentAlarms()
|
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
|
@Binding var isPresented: Bool
|
||||||
|
|
||||||
@State private var newAlarmTime = Calendar.current.date(bySettingHour: 6, minute: 0, second: 0, of: Date()) ?? Date()
|
@State private var newAlarmTime = Calendar.current.date(bySettingHour: 6, minute: 0, second: 0, of: Date()) ?? Date()
|
||||||
@State private var selectedSoundName = "digital-alarm.caf"
|
@State private var repeatWeekdays: [Int] = []
|
||||||
|
@State private var selectedSoundName = AppConstants.SystemSounds.defaultSound
|
||||||
@State private var alarmLabel = "Alarm"
|
@State private var alarmLabel = "Alarm"
|
||||||
@State private var notificationMessage = "Your alarm is ringing"
|
@State private var notificationMessage = "Your alarm is ringing"
|
||||||
@State private var snoozeDuration = 9 // minutes
|
@State private var snoozeDuration = 9 // minutes
|
||||||
@State private var isVibrationEnabled = true
|
@State private var isVibrationEnabled = true
|
||||||
@State private var isLightFlashEnabled = false
|
@State private var isLightFlashEnabled = false
|
||||||
@State private var volume: Float = 1.0
|
@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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Time picker section at top
|
// Time picker section at top
|
||||||
TimePickerSection(selectedTime: $newAlarmTime)
|
TimePickerSection(selectedTime: $newAlarmTime)
|
||||||
TimeUntilAlarmSection(alarmTime: newAlarmTime)
|
TimeUntilAlarmSection(alarmTime: newAlarmTime, repeatWeekdays: repeatWeekdays)
|
||||||
|
|
||||||
// List for settings below
|
// List for settings below
|
||||||
List {
|
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
|
// Snooze Section
|
||||||
NavigationLink(destination: SnoozeSelectionView(snoozeDuration: $snoozeDuration)) {
|
NavigationLink(destination: SnoozeSelectionView(snoozeDuration: $snoozeDuration)) {
|
||||||
HStack {
|
HStack {
|
||||||
@ -86,6 +104,12 @@ struct AddAlarmView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AlertOptionsSection(
|
||||||
|
isVibrationEnabled: $isVibrationEnabled,
|
||||||
|
isLightFlashEnabled: $isLightFlashEnabled,
|
||||||
|
volume: $volume
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.listStyle(.insetGrouped)
|
.listStyle(.insetGrouped)
|
||||||
}
|
}
|
||||||
@ -98,34 +122,58 @@ struct AddAlarmView: View {
|
|||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
.foregroundStyle(AppAccent.primary)
|
.foregroundStyle(AppAccent.primary)
|
||||||
|
.accessibilityIdentifier("alarms.add.cancelButton")
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button("Save") {
|
Button(isSaving ? "Saving..." : "Save") {
|
||||||
Task {
|
Task {
|
||||||
let newAlarm = viewModel.createNewAlarm(
|
await saveAlarm()
|
||||||
time: newAlarmTime,
|
|
||||||
soundName: selectedSoundName,
|
|
||||||
label: alarmLabel,
|
|
||||||
notificationMessage: notificationMessage,
|
|
||||||
snoozeDuration: snoozeDuration,
|
|
||||||
isVibrationEnabled: isVibrationEnabled,
|
|
||||||
isLightFlashEnabled: isLightFlashEnabled,
|
|
||||||
volume: volume
|
|
||||||
)
|
|
||||||
await viewModel.addAlarm(newAlarm)
|
|
||||||
isPresented = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.disabled(isSaving)
|
||||||
.foregroundStyle(AppAccent.primary)
|
.foregroundStyle(AppAccent.primary)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
|
.accessibilityIdentifier("alarms.add.saveButton")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.alert("Alarm Error", isPresented: $isShowingSaveErrorAlert) {
|
||||||
|
Button("OK", role: .cancel) { }
|
||||||
|
} message: {
|
||||||
|
Text(saveErrorMessage)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
// MARK: - Helper Methods
|
||||||
private func getSoundDisplayName(_ fileName: String) -> String {
|
private func getSoundDisplayName(_ fileName: String) -> String {
|
||||||
return AlarmSoundService.shared.getSoundDisplayName(fileName)
|
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 {
|
} else {
|
||||||
List {
|
List {
|
||||||
ForEach(viewModel.alarms) { alarm in
|
ForEach(sortedAlarms) { alarm in
|
||||||
AlarmRowView(
|
AlarmRowView(
|
||||||
alarm: alarm,
|
alarm: alarm,
|
||||||
onToggle: {
|
onToggle: {
|
||||||
@ -82,6 +82,7 @@ struct AlarmView: View {
|
|||||||
.font(.title2)
|
.font(.title2)
|
||||||
.symbolEffect(.bounce, value: showAddAlarm)
|
.symbolEffect(.bounce, value: showAddAlarm)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier("alarms.addButton")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@ -105,9 +106,37 @@ struct AlarmView: View {
|
|||||||
.presentationCornerRadius(Design.CornerRadius.xxLarge)
|
.presentationCornerRadius(Design.CornerRadius.xxLarge)
|
||||||
}
|
}
|
||||||
.sensoryFeedback(.impact(flexibility: .soft), trigger: showAddAlarm)
|
.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
|
// 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) {
|
private func deleteAlarm(at offsets: IndexSet) {
|
||||||
Task {
|
Task {
|
||||||
for index in offsets {
|
for index in offsets {
|
||||||
|
|||||||
@ -32,6 +32,10 @@ struct AlarmRowView: View {
|
|||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(AppTextColors.secondary)
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
|
||||||
|
Text(alarm.repeatSummary)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
|
|
||||||
Text("• \(AlarmSoundService.shared.getSoundDisplayName(alarm.soundName))")
|
Text("• \(AlarmSoundService.shared.getSoundDisplayName(alarm.soundName))")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(AppTextColors.secondary)
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
@ -61,11 +65,16 @@ struct AlarmRowView: View {
|
|||||||
accentColor: AppAccent.primary
|
accentColor: AppAccent.primary
|
||||||
)
|
)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
|
.accessibilityIdentifier("alarms.toggle.\(alarm.id.uuidString)")
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
onEdit()
|
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) {
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
Button(role: .destructive) {
|
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
|
// MARK: - Preview
|
||||||
#Preview {
|
#Preview {
|
||||||
EmptyAlarmsView {
|
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
|
/// Component showing time until alarm and day information
|
||||||
struct TimeUntilAlarmSection: View {
|
struct TimeUntilAlarmSection: View {
|
||||||
let alarmTime: Date
|
let alarmTime: Date
|
||||||
|
let repeatWeekdays: [Int]
|
||||||
|
|
||||||
|
init(alarmTime: Date, repeatWeekdays: [Int] = []) {
|
||||||
|
self.alarmTime = alarmTime
|
||||||
|
self.repeatWeekdays = repeatWeekdays
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
@ -30,21 +36,13 @@ struct TimeUntilAlarmSection: View {
|
|||||||
.background(AppSurface.primary)
|
.background(AppSurface.primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var nextAlarmTime: Date {
|
||||||
|
Alarm(time: alarmTime, repeatWeekdays: repeatWeekdays).nextTriggerTime()
|
||||||
|
}
|
||||||
|
|
||||||
private var timeUntilAlarm: String {
|
private var timeUntilAlarm: String {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let calendar = Calendar.current
|
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)
|
let components = calendar.dateComponents([.hour, .minute], from: now, to: nextAlarmTime)
|
||||||
if let hours = components.hour, let minutes = components.minute {
|
if let hours = components.hour, let minutes = components.minute {
|
||||||
if hours > 0 {
|
if hours > 0 {
|
||||||
@ -61,17 +59,12 @@ struct TimeUntilAlarmSection: View {
|
|||||||
|
|
||||||
private var dayText: String {
|
private var dayText: String {
|
||||||
let calendar = Calendar.current
|
let calendar = Calendar.current
|
||||||
let now = Date()
|
if calendar.isDateInToday(nextAlarmTime) {
|
||||||
|
|
||||||
// If alarm time is in the past today, show tomorrow
|
|
||||||
if calendar.isDateInToday(alarmTime) && alarmTime < now {
|
|
||||||
return "Tomorrow"
|
|
||||||
} else if calendar.isDateInToday(alarmTime) {
|
|
||||||
return "Today"
|
return "Today"
|
||||||
} else if calendar.isDateInTomorrow(alarmTime) {
|
} else if calendar.isDateInTomorrow(nextAlarmTime) {
|
||||||
return "Tomorrow"
|
return "Tomorrow"
|
||||||
} else {
|
} 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
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@State private var alarmTime: Date
|
@State private var alarmTime: Date
|
||||||
|
@State private var repeatWeekdays: [Int]
|
||||||
@State private var selectedSoundName: String
|
@State private var selectedSoundName: String
|
||||||
@State private var alarmLabel: String
|
@State private var alarmLabel: String
|
||||||
@State private var notificationMessage: String
|
@State private var notificationMessage: String
|
||||||
@ -26,6 +27,9 @@ struct EditAlarmView: View {
|
|||||||
@State private var isVibrationEnabled: Bool
|
@State private var isVibrationEnabled: Bool
|
||||||
@State private var isLightFlashEnabled: Bool
|
@State private var isLightFlashEnabled: Bool
|
||||||
@State private var volume: Float
|
@State private var volume: Float
|
||||||
|
@State private var isSaving = false
|
||||||
|
@State private var isShowingSaveErrorAlert = false
|
||||||
|
@State private var saveErrorMessage = ""
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
init(viewModel: AlarmViewModel, alarm: Alarm) {
|
init(viewModel: AlarmViewModel, alarm: Alarm) {
|
||||||
@ -34,6 +38,7 @@ struct EditAlarmView: View {
|
|||||||
|
|
||||||
// Initialize state with current alarm values
|
// Initialize state with current alarm values
|
||||||
self._alarmTime = State(initialValue: alarm.time)
|
self._alarmTime = State(initialValue: alarm.time)
|
||||||
|
self._repeatWeekdays = State(initialValue: alarm.repeatWeekdays)
|
||||||
self._selectedSoundName = State(initialValue: alarm.soundName)
|
self._selectedSoundName = State(initialValue: alarm.soundName)
|
||||||
self._alarmLabel = State(initialValue: alarm.label)
|
self._alarmLabel = State(initialValue: alarm.label)
|
||||||
self._notificationMessage = State(initialValue: alarm.notificationMessage)
|
self._notificationMessage = State(initialValue: alarm.notificationMessage)
|
||||||
@ -49,7 +54,7 @@ struct EditAlarmView: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Time picker section at top
|
// Time picker section at top
|
||||||
TimePickerSection(selectedTime: $alarmTime)
|
TimePickerSection(selectedTime: $alarmTime)
|
||||||
TimeUntilAlarmSection(alarmTime: alarmTime)
|
TimeUntilAlarmSection(alarmTime: alarmTime, repeatWeekdays: repeatWeekdays)
|
||||||
|
|
||||||
// List for settings below
|
// List for settings below
|
||||||
List {
|
List {
|
||||||
@ -99,6 +104,22 @@ struct EditAlarmView: View {
|
|||||||
}
|
}
|
||||||
.listRowBackground(AppSurface.card)
|
.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
|
// Snooze Section
|
||||||
NavigationLink(destination: SnoozeSelectionView(snoozeDuration: $snoozeDuration)) {
|
NavigationLink(destination: SnoozeSelectionView(snoozeDuration: $snoozeDuration)) {
|
||||||
HStack {
|
HStack {
|
||||||
@ -113,6 +134,13 @@ struct EditAlarmView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listRowBackground(AppSurface.card)
|
.listRowBackground(AppSurface.card)
|
||||||
|
|
||||||
|
AlertOptionsSection(
|
||||||
|
isVibrationEnabled: $isVibrationEnabled,
|
||||||
|
isLightFlashEnabled: $isLightFlashEnabled,
|
||||||
|
volume: $volume
|
||||||
|
)
|
||||||
|
.listRowBackground(AppSurface.card)
|
||||||
}
|
}
|
||||||
.listStyle(.insetGrouped)
|
.listStyle(.insetGrouped)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
@ -127,38 +155,62 @@ struct EditAlarmView: View {
|
|||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
.foregroundStyle(AppAccent.primary)
|
.foregroundStyle(AppAccent.primary)
|
||||||
|
.accessibilityIdentifier("alarms.edit.cancelButton")
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button("Save") {
|
Button(isSaving ? "Saving..." : "Save") {
|
||||||
Task {
|
Task {
|
||||||
let updatedAlarm = Alarm(
|
await saveAlarm()
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.disabled(isSaving)
|
||||||
.foregroundStyle(AppAccent.primary)
|
.foregroundStyle(AppAccent.primary)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
|
.accessibilityIdentifier("alarms.edit.saveButton")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.alert("Alarm Error", isPresented: $isShowingSaveErrorAlert) {
|
||||||
|
Button("OK", role: .cancel) { }
|
||||||
|
} message: {
|
||||||
|
Text(saveErrorMessage)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
// MARK: - Helper Methods
|
||||||
private func getSoundDisplayName(_ fileName: String) -> String {
|
private func getSoundDisplayName(_ fileName: String) -> String {
|
||||||
return AlarmSoundService.shared.getSoundDisplayName(fileName)
|
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
|
// MARK: - Preview
|
||||||
|
|||||||
@ -19,7 +19,8 @@ final class BatteryService {
|
|||||||
var batteryLevel: Int = 100
|
var batteryLevel: Int = 100
|
||||||
var isCharging: Bool = false
|
var isCharging: Bool = false
|
||||||
|
|
||||||
@ObservationIgnored private var monitoringTask: Task<Void, Never>?
|
@ObservationIgnored private var levelNotificationTask: Task<Void, Never>?
|
||||||
|
@ObservationIgnored private var stateNotificationTask: Task<Void, Never>?
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
private init() {
|
private init() {
|
||||||
@ -42,21 +43,20 @@ final class BatteryService {
|
|||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
private func startNotificationMonitoring() {
|
private func startNotificationMonitoring() {
|
||||||
monitoringTask = Task { [weak self] in
|
levelNotificationTask?.cancel()
|
||||||
await withTaskGroup(of: Void.self) { group in
|
stateNotificationTask?.cancel()
|
||||||
// Monitor battery level changes
|
|
||||||
group.addTask { @MainActor [weak self] in
|
levelNotificationTask = Task { [weak self] in
|
||||||
for await _ in NotificationCenter.default.notifications(named: UIDevice.batteryLevelDidChangeNotification) {
|
for await _ in NotificationCenter.default.notifications(named: UIDevice.batteryLevelDidChangeNotification) {
|
||||||
self?.updateBatteryInfo()
|
guard !Task.isCancelled else { break }
|
||||||
}
|
self?.updateBatteryInfo()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Monitor battery state changes
|
|
||||||
group.addTask { @MainActor [weak self] in
|
stateNotificationTask = Task { [weak self] in
|
||||||
for await _ in NotificationCenter.default.notifications(named: UIDevice.batteryStateDidChangeNotification) {
|
for await _ in NotificationCenter.default.notifications(named: UIDevice.batteryStateDidChangeNotification) {
|
||||||
self?.updateBatteryInfo()
|
guard !Task.isCancelled else { break }
|
||||||
}
|
self?.updateBatteryInfo()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -125,6 +125,7 @@ struct ClockSettingsView: View {
|
|||||||
.onDisappear {
|
.onDisappear {
|
||||||
onCommit(style)
|
onCommit(style)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier("settings.screen")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -123,6 +123,7 @@ struct ClockView: View {
|
|||||||
resetIdleTimer()
|
resetIdleTimer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier("clock.screen")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Idle Timer
|
// MARK: - Idle Timer
|
||||||
@ -131,7 +132,9 @@ struct ClockView: View {
|
|||||||
idleTimer = nil
|
idleTimer = nil
|
||||||
guard !viewModel.isFullScreenMode else { return }
|
guard !viewModel.isFullScreenMode else { return }
|
||||||
idleTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { _ in
|
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)
|
.frame(width: geometry.size.width, height: geometry.size.height)
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
.animation(.smooth(duration: Design.Animation.standard), value: isFullScreenMode)
|
.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
|
// MARK: - Preview
|
||||||
|
|||||||
@ -27,6 +27,7 @@ struct AdvancedDisplaySection: View {
|
|||||||
isOn: $style.keepAwake,
|
isOn: $style.keepAwake,
|
||||||
accentColor: AppAccent.primary
|
accentColor: AppAccent.primary
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier("settings.keepAwake.toggle")
|
||||||
|
|
||||||
if style.autoBrightness {
|
if style.autoBrightness {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
@ -68,6 +69,7 @@ struct AdvancedDisplaySection: View {
|
|||||||
isOn: $style.respectFocusModes,
|
isOn: $style.respectFocusModes,
|
||||||
accentColor: AppAccent.primary
|
accentColor: AppAccent.primary
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier("settings.respectFocus.toggle")
|
||||||
}
|
}
|
||||||
|
|
||||||
Text("Control how the app behaves when Focus modes are active.")
|
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 _ = clockUpdateTrigger // Force re-render on style or alarm changes
|
||||||
let _ = alarmService.alarms // Observe all alarms for changes
|
let _ = alarmService.alarms // Observe all alarms for changes
|
||||||
let _ = soundPlayer.isPlaying // Observe player state
|
let _ = soundPlayer.isPlaying // Observe player state
|
||||||
|
let nextEnabledAlarm = alarmService.enabledAlarms
|
||||||
let _ = print("TopOverlayView: Rendering. Alarms count: \(alarmService.alarms.count), Enabled: \(alarmService.enabledAlarms.count)")
|
.sorted(by: { $0.time.nextOccurrence() < $1.time.nextOccurrence() })
|
||||||
|
.first
|
||||||
|
|
||||||
VStack(spacing: Design.Spacing.small) {
|
VStack(spacing: Design.Spacing.small) {
|
||||||
HStack {
|
// Row 1: Date and Battery (Standard Status Bar positions)
|
||||||
|
HStack(alignment: .center) {
|
||||||
if showDate {
|
if showDate {
|
||||||
DateOverlayView(color: color, opacity: opacity, dateFormat: dateFormat)
|
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) {
|
// Row 2: Alarms and Noise Controls (Below Dynamic Island)
|
||||||
if showNextAlarm, let nextAlarm = alarmService.enabledAlarms.sorted(by: { $0.time.nextOccurrence() < $1.time.nextOccurrence() }).first {
|
HStack(alignment: .center) {
|
||||||
|
if showNextAlarm, let nextAlarm = nextEnabledAlarm {
|
||||||
NextAlarmOverlay(
|
NextAlarmOverlay(
|
||||||
alarmTime: nextAlarm.time.nextOccurrence(),
|
alarmTime: nextAlarm.time.nextOccurrence(),
|
||||||
color: color,
|
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(.horizontal, Design.Spacing.medium)
|
||||||
.padding(.vertical, Design.Spacing.small)
|
.padding(.vertical, Design.Spacing.small)
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
.id(clockUpdateTrigger) // Force re-render on style or alarm changes
|
.id(clockUpdateTrigger) // Force re-render on alarm changes
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .clockStyleDidUpdate)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .alarmsDidUpdate)) { _ in
|
||||||
clockUpdateTrigger.toggle()
|
clockUpdateTrigger.toggle()
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
|||||||
@ -78,6 +78,8 @@ struct SoundControlView: View {
|
|||||||
.opacity(selectedSound == nil ? 0.6 : 1.0)
|
.opacity(selectedSound == nil ? 0.6 : 1.0)
|
||||||
.animation(.easeInOut(duration: 0.2), value: isPlaying)
|
.animation(.easeInOut(duration: 0.2), value: isPlaying)
|
||||||
.animation(.easeInOut(duration: 0.2), value: selectedSound)
|
.animation(.easeInOut(duration: 0.2), value: selectedSound)
|
||||||
|
.accessibilityIdentifier("noise.playStopButton")
|
||||||
|
.accessibilityLabel(isPlaying ? "Stop Sound" : "Play Sound")
|
||||||
}
|
}
|
||||||
.frame(maxWidth: 400) // Reasonable max width for iPad
|
.frame(maxWidth: 400) // Reasonable max width for iPad
|
||||||
.padding(Design.Spacing.medium)
|
.padding(Design.Spacing.medium)
|
||||||
|
|||||||
@ -47,6 +47,7 @@ struct NoiseView: View {
|
|||||||
TextField("Search sounds", text: $searchText)
|
TextField("Search sounds", text: $searchText)
|
||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.foregroundStyle(AppTextColors.primary)
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
.accessibilityIdentifier("noise.searchField")
|
||||||
|
|
||||||
if !searchText.isEmpty {
|
if !searchText.isEmpty {
|
||||||
Button(action: { searchText = "" }) {
|
Button(action: { searchText = "" }) {
|
||||||
@ -81,6 +82,7 @@ struct NoiseView: View {
|
|||||||
.navigationTitle("Noise")
|
.navigationTitle("Noise")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.animation(.easeInOut(duration: 0.3), value: selectedSound)
|
.animation(.easeInOut(duration: 0.3), value: selectedSound)
|
||||||
|
.accessibilityIdentifier("noise.screen")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
// MARK: - Computed Properties
|
||||||
|
|||||||
@ -43,6 +43,7 @@ struct OnboardingBottomControls: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(Design.Spacing.medium)
|
.padding(Design.Spacing.medium)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier("onboarding.secondaryButton")
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
if currentPage < totalPages - 1 {
|
if currentPage < totalPages - 1 {
|
||||||
@ -59,6 +60,7 @@ struct OnboardingBottomControls: View {
|
|||||||
.background(AppAccent.primary)
|
.background(AppAccent.primary)
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier("onboarding.primaryButton")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,6 +25,10 @@ struct OnboardingFeatureRow: View {
|
|||||||
Text(text)
|
Text(text)
|
||||||
.typography(.body)
|
.typography(.body)
|
||||||
.foregroundStyle(AppTextColors.secondary)
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
.lineLimit(nil)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.layoutPriority(1)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: 320, alignment: .leading)
|
.frame(maxWidth: 320, alignment: .leading)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
|||||||
@ -37,6 +37,8 @@ struct OnboardingGetStartedPage: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
OnboardingFeatureRow(icon: "alarm.fill", text: "Create your first alarm")
|
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: "clock.fill", text: "Wait 5s for full screen")
|
||||||
OnboardingFeatureRow(icon: "speaker.wave.2", text: "Tap Noise to play sounds")
|
OnboardingFeatureRow(icon: "speaker.wave.2", text: "Tap Noise to play sounds")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,9 @@ struct OnboardingPermissionsPage: View {
|
|||||||
|
|
||||||
@Binding var alarmKitPermissionGranted: Bool
|
@Binding var alarmKitPermissionGranted: Bool
|
||||||
@Binding var keepAwakeEnabled: Bool
|
@Binding var keepAwakeEnabled: Bool
|
||||||
|
let requestAlarmPermission: () async -> Bool
|
||||||
|
let isKeepAwakeEnabled: () -> Bool
|
||||||
|
let onEnableKeepAwake: () -> Void
|
||||||
let onAdvanceToFinal: () -> Void
|
let onAdvanceToFinal: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -34,8 +37,10 @@ struct OnboardingPermissionsPage: View {
|
|||||||
.typography(.heroBold)
|
.typography(.heroBold)
|
||||||
.foregroundStyle(AppTextColors.primary)
|
.foregroundStyle(AppTextColors.primary)
|
||||||
.multilineTextAlignment(.center)
|
.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)
|
.typography(.body)
|
||||||
.foregroundStyle(AppTextColors.secondary)
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@ -93,6 +98,7 @@ struct OnboardingPermissionsPage: View {
|
|||||||
.background(AppAccent.primary)
|
.background(AppAccent.primary)
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier("onboarding.enableAlarmsButton")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -120,6 +126,7 @@ struct OnboardingPermissionsPage: View {
|
|||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
}
|
}
|
||||||
.disabled(keepAwakeEnabled)
|
.disabled(keepAwakeEnabled)
|
||||||
|
.accessibilityIdentifier("onboarding.enableKeepAwakeButton")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,7 +134,7 @@ struct OnboardingPermissionsPage: View {
|
|||||||
|
|
||||||
private func requestAlarmKitPermission() {
|
private func requestAlarmKitPermission() {
|
||||||
Task {
|
Task {
|
||||||
let granted = await AlarmKitService.shared.requestAuthorization()
|
let granted = await requestAlarmPermission()
|
||||||
withAnimation(.spring(duration: 0.3)) {
|
withAnimation(.spring(duration: 0.3)) {
|
||||||
alarmKitPermissionGranted = granted
|
alarmKitPermissionGranted = granted
|
||||||
}
|
}
|
||||||
@ -139,32 +146,11 @@ struct OnboardingPermissionsPage: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func enableKeepAwake() {
|
private func enableKeepAwake() {
|
||||||
let style = loadClockStyle()
|
onEnableKeepAwake()
|
||||||
style.keepAwake = true
|
|
||||||
saveClockStyle(style)
|
|
||||||
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
|
|
||||||
withAnimation(.spring(duration: 0.3)) {
|
withAnimation(.spring(duration: 0.3)) {
|
||||||
keepAwakeEnabled = true
|
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
|
// MARK: - Preview
|
||||||
@ -173,6 +159,9 @@ struct OnboardingPermissionsPage: View {
|
|||||||
OnboardingPermissionsPage(
|
OnboardingPermissionsPage(
|
||||||
alarmKitPermissionGranted: .constant(false),
|
alarmKitPermissionGranted: .constant(false),
|
||||||
keepAwakeEnabled: .constant(false),
|
keepAwakeEnabled: .constant(false),
|
||||||
|
requestAlarmPermission: { true },
|
||||||
|
isKeepAwakeEnabled: { false },
|
||||||
|
onEnableKeepAwake: {},
|
||||||
onAdvanceToFinal: {}
|
onAdvanceToFinal: {}
|
||||||
)
|
)
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
|
|||||||
@ -35,7 +35,7 @@ struct OnboardingWelcomePage: View {
|
|||||||
)
|
)
|
||||||
OnboardingFeatureRow(
|
OnboardingFeatureRow(
|
||||||
icon: "alarm.fill",
|
icon: "alarm.fill",
|
||||||
text: "Wake up gently, on your terms"
|
text: "Wake up your way with custom alarms"
|
||||||
)
|
)
|
||||||
OnboardingFeatureRow(
|
OnboardingFeatureRow(
|
||||||
icon: "clock.fill",
|
icon: "clock.fill",
|
||||||
|
|||||||
@ -20,6 +20,9 @@ struct OnboardingView: View {
|
|||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
|
|
||||||
let onComplete: () -> Void
|
let onComplete: () -> Void
|
||||||
|
let requestAlarmPermission: () async -> Bool
|
||||||
|
let isKeepAwakeEnabled: () -> Bool
|
||||||
|
let onEnableKeepAwake: () -> Void
|
||||||
|
|
||||||
@State private var currentPage = 0
|
@State private var currentPage = 0
|
||||||
@State private var alarmKitPermissionGranted = false
|
@State private var alarmKitPermissionGranted = false
|
||||||
@ -50,6 +53,9 @@ struct OnboardingView: View {
|
|||||||
OnboardingPermissionsPage(
|
OnboardingPermissionsPage(
|
||||||
alarmKitPermissionGranted: $alarmKitPermissionGranted,
|
alarmKitPermissionGranted: $alarmKitPermissionGranted,
|
||||||
keepAwakeEnabled: $keepAwakeEnabled,
|
keepAwakeEnabled: $keepAwakeEnabled,
|
||||||
|
requestAlarmPermission: requestAlarmPermission,
|
||||||
|
isKeepAwakeEnabled: isKeepAwakeEnabled,
|
||||||
|
onEnableKeepAwake: onEnableKeepAwake,
|
||||||
onAdvanceToFinal: {
|
onAdvanceToFinal: {
|
||||||
withAnimation { currentPage = 3 }
|
withAnimation { currentPage = 3 }
|
||||||
}
|
}
|
||||||
@ -84,7 +90,11 @@ struct OnboardingView: View {
|
|||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
OnboardingView {
|
OnboardingView {
|
||||||
print("Onboarding complete")
|
} requestAlarmPermission: {
|
||||||
|
true
|
||||||
|
} isKeepAwakeEnabled: {
|
||||||
|
false
|
||||||
|
} onEnableKeepAwake: {
|
||||||
}
|
}
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@ -10,7 +10,7 @@ import Bedrock
|
|||||||
|
|
||||||
// MARK: - NoiseClock Surface Colors
|
// MARK: - NoiseClock Surface Colors
|
||||||
|
|
||||||
public enum NoiseClockSurfaceColors: SurfaceColorProvider {
|
public enum NoiseClockSurfaceColors: @MainActor SurfaceColorProvider {
|
||||||
public static let primary = Color(red: 0.06, green: 0.08, blue: 0.12)
|
public static let primary = Color(red: 0.06, green: 0.08, blue: 0.12)
|
||||||
public static let secondary = Color(red: 0.09, green: 0.11, blue: 0.18)
|
public static let secondary = Color(red: 0.09, green: 0.11, blue: 0.18)
|
||||||
public static let tertiary = Color(red: 0.12, green: 0.15, blue: 0.22)
|
public static let tertiary = Color(red: 0.12, green: 0.15, blue: 0.22)
|
||||||
@ -22,7 +22,7 @@ public enum NoiseClockSurfaceColors: SurfaceColorProvider {
|
|||||||
|
|
||||||
// MARK: - NoiseClock Text Colors
|
// MARK: - NoiseClock Text Colors
|
||||||
|
|
||||||
public enum NoiseClockTextColors: TextColorProvider {
|
public enum NoiseClockTextColors: @MainActor TextColorProvider {
|
||||||
public static let primary = Color.white
|
public static let primary = Color.white
|
||||||
public static let secondary = Color.white.opacity(Design.Opacity.accent)
|
public static let secondary = Color.white.opacity(Design.Opacity.accent)
|
||||||
public static let tertiary = Color.white.opacity(Design.Opacity.medium)
|
public static let tertiary = Color.white.opacity(Design.Opacity.medium)
|
||||||
@ -33,7 +33,7 @@ public enum NoiseClockTextColors: TextColorProvider {
|
|||||||
|
|
||||||
// MARK: - NoiseClock Accent Colors
|
// MARK: - NoiseClock Accent Colors
|
||||||
|
|
||||||
public enum NoiseClockAccentColors: AccentColorProvider {
|
public enum NoiseClockAccentColors: @MainActor AccentColorProvider {
|
||||||
public static let primary = Color(red: 0.45, green: 0.75, blue: 1.00)
|
public static let primary = Color(red: 0.45, green: 0.75, blue: 1.00)
|
||||||
public static let light = Color(red: 0.65, green: 0.85, blue: 1.00)
|
public static let light = Color(red: 0.65, green: 0.85, blue: 1.00)
|
||||||
public static let dark = Color(red: 0.25, green: 0.55, blue: 0.90)
|
public static let dark = Color(red: 0.25, green: 0.55, blue: 0.90)
|
||||||
@ -42,7 +42,7 @@ public enum NoiseClockAccentColors: AccentColorProvider {
|
|||||||
|
|
||||||
// MARK: - NoiseClock Button Colors
|
// MARK: - NoiseClock Button Colors
|
||||||
|
|
||||||
public enum NoiseClockButtonColors: ButtonColorProvider {
|
public enum NoiseClockButtonColors: @MainActor ButtonColorProvider {
|
||||||
public static let primaryLight = Color(red: 0.55, green: 0.80, blue: 1.00)
|
public static let primaryLight = Color(red: 0.55, green: 0.80, blue: 1.00)
|
||||||
public static let primaryDark = Color(red: 0.20, green: 0.50, blue: 0.85)
|
public static let primaryDark = Color(red: 0.20, green: 0.50, blue: 0.85)
|
||||||
public static let secondary = Color.white.opacity(Design.Opacity.subtle)
|
public static let secondary = Color.white.opacity(Design.Opacity.subtle)
|
||||||
@ -52,7 +52,7 @@ public enum NoiseClockButtonColors: ButtonColorProvider {
|
|||||||
|
|
||||||
// MARK: - NoiseClock Status Colors
|
// MARK: - NoiseClock Status Colors
|
||||||
|
|
||||||
public enum NoiseClockStatusColors: StatusColorProvider {
|
public enum NoiseClockStatusColors: @MainActor StatusColorProvider {
|
||||||
public static let success = Color(red: 0.20, green: 0.80, blue: 0.45)
|
public static let success = Color(red: 0.20, green: 0.80, blue: 0.45)
|
||||||
public static let warning = Color(red: 1.00, green: 0.75, blue: 0.20)
|
public static let warning = Color(red: 1.00, green: 0.75, blue: 0.20)
|
||||||
public static let error = Color(red: 0.90, green: 0.30, blue: 0.30)
|
public static let error = Color(red: 0.90, green: 0.30, blue: 0.30)
|
||||||
@ -61,7 +61,7 @@ public enum NoiseClockStatusColors: StatusColorProvider {
|
|||||||
|
|
||||||
// MARK: - NoiseClock Border Colors
|
// MARK: - NoiseClock Border Colors
|
||||||
|
|
||||||
public enum NoiseClockBorderColors: BorderColorProvider {
|
public enum NoiseClockBorderColors: @MainActor BorderColorProvider {
|
||||||
public static let subtle = Color.white.opacity(Design.Opacity.subtle)
|
public static let subtle = Color.white.opacity(Design.Opacity.subtle)
|
||||||
public static let standard = Color.white.opacity(Design.Opacity.hint)
|
public static let standard = Color.white.opacity(Design.Opacity.hint)
|
||||||
public static let emphasized = Color.white.opacity(Design.Opacity.light)
|
public static let emphasized = Color.white.opacity(Design.Opacity.light)
|
||||||
@ -70,7 +70,7 @@ public enum NoiseClockBorderColors: BorderColorProvider {
|
|||||||
|
|
||||||
// MARK: - NoiseClock Interactive Colors
|
// MARK: - NoiseClock Interactive Colors
|
||||||
|
|
||||||
public enum NoiseClockInteractiveColors: InteractiveColorProvider {
|
public enum NoiseClockInteractiveColors: @MainActor InteractiveColorProvider {
|
||||||
public static let selected = NoiseClockAccentColors.primary.opacity(Design.Opacity.selection)
|
public static let selected = NoiseClockAccentColors.primary.opacity(Design.Opacity.selection)
|
||||||
public static let hover = Color.white.opacity(Design.Opacity.subtle)
|
public static let hover = Color.white.opacity(Design.Opacity.subtle)
|
||||||
public static let pressed = Color.white.opacity(Design.Opacity.hint)
|
public static let pressed = Color.white.opacity(Design.Opacity.hint)
|
||||||
@ -79,7 +79,7 @@ public enum NoiseClockInteractiveColors: InteractiveColorProvider {
|
|||||||
|
|
||||||
// MARK: - NoiseClock Theme
|
// MARK: - NoiseClock Theme
|
||||||
|
|
||||||
public enum NoiseClockTheme: AppColorTheme {
|
public enum NoiseClockTheme: @MainActor AppColorTheme {
|
||||||
public typealias Surface = NoiseClockSurfaceColors
|
public typealias Surface = NoiseClockSurfaceColors
|
||||||
public typealias Text = NoiseClockTextColors
|
public typealias Text = NoiseClockTextColors
|
||||||
public typealias Accent = NoiseClockAccentColors
|
public typealias Accent = NoiseClockAccentColors
|
||||||
|
|||||||
@ -8,14 +8,29 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
extension Date {
|
extension Date {
|
||||||
|
private static let formatterCacheKeyPrefix = "TheNoiseClock.DateFormatter."
|
||||||
|
|
||||||
/// Format date for display in overlay with custom format
|
/// Format date for display in overlay with custom format
|
||||||
/// - Parameter format: Date format string (e.g., "d MMM yyyy")
|
/// - Parameter format: Date format string (e.g., "d MMM yyyy")
|
||||||
/// - Returns: Formatted date string
|
/// - Returns: Formatted date string
|
||||||
func formattedForOverlay(format: String = "d MMM yyyy") -> String {
|
func formattedForOverlay(format: String = "d MMM yyyy") -> String {
|
||||||
|
let formatter = Self.cachedFormatter(for: format)
|
||||||
|
return formatter.string(from: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func cachedFormatter(for format: String) -> DateFormatter {
|
||||||
|
let cacheKey = formatterCacheKeyPrefix + format
|
||||||
|
let threadDictionary = Thread.current.threadDictionary
|
||||||
|
if let cached = threadDictionary[cacheKey] as? DateFormatter {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = format
|
formatter.dateFormat = format
|
||||||
return formatter.string(from: self)
|
formatter.locale = .autoupdatingCurrent
|
||||||
|
formatter.calendar = .autoupdatingCurrent
|
||||||
|
threadDictionary[cacheKey] = formatter
|
||||||
|
return formatter
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get available date format options with their display names
|
/// Get available date format options with their display names
|
||||||
|
|||||||
@ -10,4 +10,5 @@ import Foundation
|
|||||||
extension Notification.Name {
|
extension Notification.Name {
|
||||||
static let keepAwakePromptRequested = Notification.Name("keepAwakePromptRequested")
|
static let keepAwakePromptRequested = Notification.Name("keepAwakePromptRequested")
|
||||||
static let clockStyleDidUpdate = Notification.Name("clockStyleDidUpdate")
|
static let clockStyleDidUpdate = Notification.Name("clockStyleDidUpdate")
|
||||||
|
static let alarmsDidUpdate = Notification.Name("alarmsDidUpdate")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,35 +21,43 @@ struct KeepAwakePrompt: View {
|
|||||||
|
|
||||||
VStack(spacing: Design.Spacing.small) {
|
VStack(spacing: Design.Spacing.small) {
|
||||||
Text("Keep Awake for Alarms")
|
Text("Keep Awake for Alarms")
|
||||||
.typography(.title2)
|
.typography(.title2Bold)
|
||||||
.foregroundStyle(AppTextColors.primary)
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
Text("Enable Keep Awake so your alarm can play loudly and show the full screen while TheNoiseClock stays open.")
|
Text("Enable Keep Awake so your alarm can play loudly and show the full screen while TheNoiseClock stays open.")
|
||||||
.typography(.body)
|
.typography(.body)
|
||||||
.foregroundStyle(AppTextColors.secondary)
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, Design.Spacing.small)
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(spacing: Design.Spacing.small) {
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
Button(action: onEnable) {
|
Button(action: onEnable) {
|
||||||
Text("Enable Keep Awake")
|
Text("Enable Keep Awake")
|
||||||
|
.font(Typography.headingEmphasis.font)
|
||||||
|
.foregroundStyle(.white)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 50)
|
||||||
|
.background(AppAccent.primary)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
}
|
}
|
||||||
.buttonStyle(color: AppAccent.primary)
|
|
||||||
|
|
||||||
Button(action: onDismiss) {
|
Button(action: onDismiss) {
|
||||||
Text("Not Now")
|
Text("Not Now")
|
||||||
|
.font(Typography.bodyEmphasis.font)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 44)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
.foregroundStyle(AppTextColors.secondary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(Design.Spacing.xLarge)
|
.padding(Design.Spacing.xLarge)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(AppSurface.primary)
|
.background(AppSurface.primary)
|
||||||
.presentationDetents([.medium])
|
.presentationDetents([.height(340)])
|
||||||
.presentationCornerRadius(Design.CornerRadius.xxLarge)
|
.presentationCornerRadius(Design.CornerRadius.xxLarge)
|
||||||
|
.presentationBackground(AppSurface.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,12 +6,69 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
@testable import TheNoiseClock
|
@testable import The_Noise_Clock
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
struct TheNoiseClockTests {
|
struct TheNoiseClockTests {
|
||||||
|
|
||||||
@Test func example() async throws {
|
@Test
|
||||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
func defaultAlarmSoundIsMP3() {
|
||||||
|
#expect(AppConstants.SystemSounds.defaultSound.hasSuffix(".mp3"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func nextOccurrenceIsInFuture() {
|
||||||
|
let oneMinuteAgo = Calendar.current.date(byAdding: .minute, value: -1, to: Date()) ?? Date()
|
||||||
|
let next = oneMinuteAgo.nextOccurrence()
|
||||||
|
|
||||||
|
#expect(next > Date())
|
||||||
|
#expect(next.timeIntervalSinceNow < 26 * 60 * 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func clockStyleCodableRoundTripPreservesSettings() throws {
|
||||||
|
let style = ClockStyle()
|
||||||
|
style.showSeconds = true
|
||||||
|
style.showDate = false
|
||||||
|
style.keepAwake = true
|
||||||
|
style.fontFamily = .avenir
|
||||||
|
style.digitAnimationStyle = .glitch
|
||||||
|
|
||||||
|
let data = try JSONEncoder().encode(style)
|
||||||
|
let decoded = try JSONDecoder().decode(ClockStyle.self, from: data)
|
||||||
|
|
||||||
|
#expect(decoded.showSeconds)
|
||||||
|
#expect(decoded.showDate == false)
|
||||||
|
#expect(decoded.keepAwake)
|
||||||
|
#expect(decoded.fontFamily == .avenir)
|
||||||
|
#expect(decoded.digitAnimationStyle == .glitch)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 {
|
final class TheNoiseClockUITests: XCTestCase {
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
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
|
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
|
@MainActor
|
||||||
func testExample() throws {
|
func testAppStoreScreenshots_iPhone69() throws {
|
||||||
// UI tests must launch the application that they test.
|
|
||||||
let app = XCUIApplication()
|
let app = XCUIApplication()
|
||||||
|
app.launchArguments += [
|
||||||
|
"-onboarding.TheNoiseClock.hasCompletedWelcome", "YES",
|
||||||
|
"-onboarding.TheNoiseClock.hasLaunched", "YES"
|
||||||
|
]
|
||||||
app.launch()
|
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
|
@MainActor
|
||||||
func testLaunchPerformance() throws {
|
private func captureAlarmsViewPortraitAndLandscape(_ app: XCUIApplication) {
|
||||||
// This measures how long it takes to launch your application.
|
XCUIDevice.shared.orientation = .portrait
|
||||||
measure(metrics: [XCTApplicationLaunchMetric()]) {
|
sleep(1)
|
||||||
XCUIApplication().launch()
|
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.
|
/// Intent to stop an active alarm from the Live Activity or notification.
|
||||||
struct StopAlarmIntent: LiveActivityIntent {
|
struct StopAlarmIntent: LiveActivityIntent {
|
||||||
|
|
||||||
static var title: LocalizedStringResource = "Stop Alarm"
|
static let title: LocalizedStringResource = "Stop Alarm"
|
||||||
static var description = IntentDescription("Stops the currently ringing alarm")
|
static let description = IntentDescription("Stops the currently ringing alarm")
|
||||||
|
|
||||||
@Parameter(title: "Alarm ID")
|
@Parameter(title: "Alarm ID")
|
||||||
var alarmId: String
|
var alarmId: String
|
||||||
@ -49,8 +49,8 @@ struct StopAlarmIntent: LiveActivityIntent {
|
|||||||
/// Intent to snooze an active alarm from the Live Activity or notification.
|
/// Intent to snooze an active alarm from the Live Activity or notification.
|
||||||
struct SnoozeAlarmIntent: LiveActivityIntent {
|
struct SnoozeAlarmIntent: LiveActivityIntent {
|
||||||
|
|
||||||
static var title: LocalizedStringResource = "Snooze Alarm"
|
static let title: LocalizedStringResource = "Snooze Alarm"
|
||||||
static var description = IntentDescription("Snoozes the currently ringing alarm")
|
static let description = IntentDescription("Snoozes the currently ringing alarm")
|
||||||
|
|
||||||
@Parameter(title: "Alarm ID")
|
@Parameter(title: "Alarm ID")
|
||||||
var alarmId: String
|
var alarmId: String
|
||||||
@ -81,9 +81,9 @@ struct SnoozeAlarmIntent: LiveActivityIntent {
|
|||||||
/// Intent to open the app when the user taps the Live Activity.
|
/// Intent to open the app when the user taps the Live Activity.
|
||||||
struct OpenAlarmAppIntent: LiveActivityIntent {
|
struct OpenAlarmAppIntent: LiveActivityIntent {
|
||||||
|
|
||||||
static var title: LocalizedStringResource = "Open TheNoiseClock"
|
static let title: LocalizedStringResource = "Open TheNoiseClock"
|
||||||
static var description = IntentDescription("Opens the app to the alarm screen")
|
static let description = IntentDescription("Opens the app to the alarm screen")
|
||||||
static var openAppWhenRun = true
|
static let openAppWhenRun = true
|
||||||
|
|
||||||
@Parameter(title: "Alarm ID")
|
@Parameter(title: "Alarm ID")
|
||||||
var alarmId: String
|
var alarmId: String
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user