Compare commits
No commits in common. "3b45fe2114fde4a2faf9718f3f980d8d9d03a83a" and "e03689f38c627abce76a3f87fe37cbac3a6193f0" have entirely different histories.
3b45fe2114
...
e03689f38c
46
PRD.md
46
PRD.md
@ -99,7 +99,6 @@ 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
|
||||||
@ -111,8 +110,6 @@ 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
|
||||||
@ -120,9 +117,6 @@ 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
|
||||||
@ -490,7 +484,8 @@ TheNoiseClock/
|
|||||||
│ │ └── OnboardingPageView.swift
|
│ │ └── OnboardingPageView.swift
|
||||||
│ └── Resources/
|
│ └── Resources/
|
||||||
│ ├── LaunchScreen.storyboard # Branded native launch screen
|
│ ├── LaunchScreen.storyboard # Branded native launch screen
|
||||||
│ ├── SoundsSettings.json # Shared audio settings
|
│ ├── sounds.json # Ambient sound configuration and definitions
|
||||||
|
│ ├── 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
|
||||||
@ -498,12 +493,11 @@ 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
|
||||||
│ │ ├── sounds.json # Alarm sound metadata
|
│ │ ├── digital-alarm.caf
|
||||||
│ │ ├── digital-alarm.mp3
|
│ │ ├── classic-alarm.caf
|
||||||
│ │ ├── classic-alarm.mp3
|
│ │ ├── beep-alarm.caf
|
||||||
│ │ ├── beep-alarm.mp3
|
│ │ ├── siren-alarm.caf
|
||||||
│ │ ├── siren-alarm.mp3
|
│ │ └── buzzing-alarm.caf
|
||||||
│ │ └── 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
|
||||||
@ -733,8 +727,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 (iPhone 17 Pro Max)
|
# Build for iOS Simulator (iPad mini)
|
||||||
xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2' build
|
xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPad mini (A17 Pro),OS=18.1' 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
|
||||||
@ -746,7 +740,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=iPhone 17 Pro Max,OS=26.2' build 2>&1 | grep -E "(error:|warning:|failed)" | head -10
|
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
|
||||||
|
|
||||||
# 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
|
||||||
@ -755,7 +749,15 @@ swift -frontend -parse TheNoiseClock/Views/Clock/Components/DigitView.swift
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Available Simulators
|
#### Available Simulators
|
||||||
Use **iPhone 17 Pro Max (iOS 26.2)** as the primary simulator for build and test validation.
|
The following simulators are available for testing:
|
||||||
|
- **iPad mini (A17 Pro)** - Primary testing device
|
||||||
|
- **iPad (10th generation)**
|
||||||
|
- **iPad Air 11-inch (M2)**
|
||||||
|
- **iPad Air 13-inch (M2)**
|
||||||
|
- **iPad Pro 11-inch (M4)**
|
||||||
|
- **iPad Pro 13-inch (M4)**
|
||||||
|
- **iPhone 16, 16 Plus, 16 Pro, 16 Pro Max**
|
||||||
|
- **iPhone SE (3rd generation)**
|
||||||
|
|
||||||
#### 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
|
||||||
@ -770,16 +772,6 @@ Use **iPhone 17 Pro Max (iOS 26.2)** as the primary simulator for build and test
|
|||||||
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,13 +90,7 @@ 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=iPhone 17 Pro Max,OS=26.2' build
|
xcodebuild -project TheNoiseClock/TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPad mini (A17 Pro),OS=18.1' 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -131,24 +125,6 @@ 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 Foundation Extensions */ = {isa = PBXBuildFile; fileRef = EAF1C0DE2F3A4B5C00112234 /* TheNoiseClockWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
EAF1C0DE2F3A4B5C0011223E /* TheNoiseClockWidget.appex in Embed App 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 Foundation Extensions */ = {
|
EAF1C0DE2F3A4B5C0011223D /* Embed App Extensions */ = {
|
||||||
isa = PBXCopyFilesBuildPhase;
|
isa = PBXCopyFilesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
dstPath = "";
|
dstPath = "";
|
||||||
dstSubfolderSpec = 13;
|
dstSubfolderSpec = 13;
|
||||||
files = (
|
files = (
|
||||||
EAF1C0DE2F3A4B5C0011223E /* TheNoiseClockWidget.appex in Embed Foundation Extensions */,
|
EAF1C0DE2F3A4B5C0011223E /* TheNoiseClockWidget.appex in Embed App Extensions */,
|
||||||
);
|
);
|
||||||
name = "Embed Foundation Extensions";
|
name = "Embed App Extensions";
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
@ -63,9 +63,6 @@
|
|||||||
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 */;
|
||||||
@ -184,7 +181,7 @@
|
|||||||
EA384AF72E6E6B6000CA7D50 /* Sources */,
|
EA384AF72E6E6B6000CA7D50 /* Sources */,
|
||||||
EA384AF82E6E6B6000CA7D50 /* Frameworks */,
|
EA384AF82E6E6B6000CA7D50 /* Frameworks */,
|
||||||
EA384AF92E6E6B6000CA7D50 /* Resources */,
|
EA384AF92E6E6B6000CA7D50 /* Resources */,
|
||||||
EAF1C0DE2F3A4B5C0011223D /* Embed Foundation Extensions */,
|
EAF1C0DE2F3A4B5C0011223D /* Embed App Extensions */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@ -277,7 +274,7 @@
|
|||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = 1;
|
BuildIndependentTargetsInParallel = 1;
|
||||||
LastSwiftUpdateCheck = 2600;
|
LastSwiftUpdateCheck = 2600;
|
||||||
LastUpgradeCheck = 2630;
|
LastUpgradeCheck = 2600;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
EA384AFA2E6E6B6000CA7D50 = {
|
EA384AFA2E6E6B6000CA7D50 = {
|
||||||
CreatedOnToolsVersion = 26.0;
|
CreatedOnToolsVersion = 26.0;
|
||||||
@ -462,7 +459,6 @@
|
|||||||
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";
|
||||||
};
|
};
|
||||||
@ -521,7 +517,6 @@
|
|||||||
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;
|
||||||
};
|
};
|
||||||
@ -556,7 +551,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 = 6.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
@ -590,7 +585,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 = 6.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
@ -612,9 +607,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 = 6.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/The Noise Clock.app/The Noise Clock";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TheNoiseClock.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TheNoiseClock";
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
@ -635,9 +630,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 = 6.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/The Noise Clock.app/The Noise Clock";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TheNoiseClock.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TheNoiseClock";
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
@ -656,7 +651,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 = 6.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
TEST_TARGET_NAME = TheNoiseClock;
|
TEST_TARGET_NAME = TheNoiseClock;
|
||||||
};
|
};
|
||||||
@ -677,7 +672,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 = 6.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
TEST_TARGET_NAME = TheNoiseClock;
|
TEST_TARGET_NAME = TheNoiseClock;
|
||||||
};
|
};
|
||||||
@ -697,7 +692,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 = 6.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
@ -716,7 +711,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 = 6.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
|
|||||||
@ -1,110 +0,0 @@
|
|||||||
<?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>2</integer>
|
<integer>1</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>TheNoiseClockWidget.xcscheme_^#shared#^_</key>
|
<key>TheNoiseClockWidget.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>3</integer>
|
<integer>0</integer>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@ -50,20 +50,9 @@ 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 {
|
||||||
onComplete: {
|
onboardingState.completeWelcome()
|
||||||
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 let title: LocalizedStringResource = "Stop Alarm"
|
static var title: LocalizedStringResource = "Stop Alarm"
|
||||||
static let description = IntentDescription("Stops the currently ringing alarm")
|
static var 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 let title: LocalizedStringResource = "Snooze Alarm"
|
static var title: LocalizedStringResource = "Snooze Alarm"
|
||||||
static let description = IntentDescription("Snoozes the currently ringing alarm")
|
static var 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 let title: LocalizedStringResource = "Open TheNoiseClock"
|
static var title: LocalizedStringResource = "Open TheNoiseClock"
|
||||||
static let description = IntentDescription("Opens the app to the alarm screen")
|
static var description = IntentDescription("Opens the app to the alarm screen")
|
||||||
static let openAppWhenRun = true
|
static var openAppWhenRun = true
|
||||||
|
|
||||||
@Parameter(title: "Alarm ID")
|
@Parameter(title: "Alarm ID")
|
||||||
var alarmId: String
|
var alarmId: String
|
||||||
|
|||||||
@ -12,8 +12,6 @@ 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
|
||||||
@ -22,26 +20,11 @@ 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",
|
||||||
@ -53,7 +36,6 @@ 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
|
||||||
@ -63,23 +45,6 @@ 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
|
||||||
@ -87,77 +52,10 @@ 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,28 +19,29 @@ 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: \(AlarmManager.shared.authorizationState)")
|
Design.debugLog("[alarmkit] Authorization state: \(manager.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 {
|
||||||
AlarmManager.shared.authorizationState
|
manager.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: \(AlarmManager.shared.authorizationState)")
|
Design.debugLog("[alarmkit] Requesting authorization, current state: \(manager.authorizationState)")
|
||||||
|
|
||||||
switch AlarmManager.shared.authorizationState {
|
switch manager.authorizationState {
|
||||||
case .notDetermined:
|
case .notDetermined:
|
||||||
do {
|
do {
|
||||||
let state = try await AlarmManager.shared.requestAuthorization()
|
let state = try await manager.requestAuthorization()
|
||||||
Design.debugLog("[alarmkit] Authorization result: \(state)")
|
Design.debugLog("[alarmkit] Authorization result: \(state)")
|
||||||
return state == .authorized
|
return state == .authorized
|
||||||
} catch {
|
} catch {
|
||||||
@ -72,7 +73,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 AlarmManager.shared.authorizationState != .authorized {
|
if manager.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 {
|
||||||
@ -159,7 +160,7 @@ final class AlarmKitService {
|
|||||||
|
|
||||||
// Schedule the alarm
|
// Schedule the alarm
|
||||||
do {
|
do {
|
||||||
let scheduledAlarm = try await AlarmManager.shared.schedule(
|
let scheduledAlarm = try await manager.schedule(
|
||||||
id: alarm.id,
|
id: alarm.id,
|
||||||
configuration: configuration
|
configuration: configuration
|
||||||
)
|
)
|
||||||
@ -298,7 +299,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 AlarmManager.shared.cancel(id: id)
|
try manager.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)")
|
||||||
@ -310,7 +311,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 AlarmManager.shared.stop(id: id)
|
try manager.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)")
|
||||||
@ -322,7 +323,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 AlarmManager.shared.countdown(id: id)
|
try manager.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)")
|
||||||
@ -333,14 +334,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> {
|
||||||
AlarmManager.shared.alarmUpdates
|
manager.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 AlarmManager.shared.alarmUpdates {
|
for await alarms in manager.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)")
|
||||||
@ -359,24 +360,6 @@ 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()
|
||||||
|
|
||||||
@ -398,19 +381,6 @@ 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: .alarmsDidUpdate, object: nil)
|
NotificationCenter.default.post(name: .clockStyleDidUpdate, 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: .alarmsDidUpdate, object: nil)
|
NotificationCenter.default.post(name: .clockStyleDidUpdate, 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: .alarmsDidUpdate, object: nil)
|
NotificationCenter.default.post(name: .clockStyleDidUpdate, 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: .alarmsDidUpdate, object: nil)
|
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAlarm(id: UUID) -> Alarm? {
|
func getAlarm(id: UUID) -> Alarm? {
|
||||||
|
|||||||
@ -10,85 +10,41 @@ import AudioPlaybackKit
|
|||||||
import Bedrock
|
import Bedrock
|
||||||
|
|
||||||
/// Extension service for alarm-specific sound functionality
|
/// Extension service for alarm-specific sound functionality
|
||||||
final class AlarmSoundService {
|
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 {
|
||||||
lock.lock()
|
guard let bundleURL = Bundle.main.url(forResource: "AlarmSounds", withExtension: "bundle"),
|
||||||
if let cachedConfiguration {
|
let alarmBundle = Bundle(url: bundleURL),
|
||||||
lock.unlock()
|
let url = alarmBundle.url(forResource: "sounds", withExtension: "json") else {
|
||||||
return cachedConfiguration
|
fatalError("❌ sounds.json not found in AlarmSounds.bundle. Ensure the bundle and file exist.")
|
||||||
}
|
|
||||||
lock.unlock()
|
|
||||||
|
|
||||||
let configuration: SoundConfiguration
|
|
||||||
if let bundleURL = Bundle.main.url(forResource: "AlarmSounds", withExtension: "bundle"),
|
|
||||||
let alarmBundle = Bundle(url: bundleURL),
|
|
||||||
let url = alarmBundle.url(forResource: "sounds", withExtension: "json") {
|
|
||||||
do {
|
|
||||||
let data = try Data(contentsOf: url)
|
|
||||||
let soundsOnly = try JSONDecoder().decode(SoundsOnly.self, from: data)
|
|
||||||
configuration = SoundConfiguration(sounds: soundsOnly.sounds, settings: loadAudioSettings())
|
|
||||||
} catch {
|
|
||||||
Design.debugLog("[audio] Warning: Failed to decode alarm sound config, using fallback sounds: \(error)")
|
|
||||||
configuration = SoundConfiguration(sounds: fallbackAlarmSounds(), settings: loadAudioSettings())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Design.debugLog("[audio] Warning: AlarmSounds.bundle/sounds.json missing, using fallback sounds")
|
|
||||||
configuration = SoundConfiguration(sounds: fallbackAlarmSounds(), settings: loadAudioSettings())
|
|
||||||
}
|
|
||||||
|
|
||||||
lock.lock()
|
|
||||||
cachedConfiguration = configuration
|
|
||||||
lock.unlock()
|
|
||||||
return configuration
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load audio settings from SoundsSettings.json
|
|
||||||
private func loadAudioSettings() -> AudioSettings {
|
|
||||||
lock.lock()
|
|
||||||
if let cachedSettings {
|
|
||||||
lock.unlock()
|
|
||||||
return cachedSettings
|
|
||||||
}
|
|
||||||
lock.unlock()
|
|
||||||
|
|
||||||
let settings: AudioSettings
|
|
||||||
guard let url = Bundle.main.url(forResource: "SoundsSettings", withExtension: "json") else {
|
|
||||||
Design.debugLog("[general] Warning: SoundsSettings.json not found, using default alarm settings")
|
|
||||||
settings = AudioSettings(
|
|
||||||
defaultVolume: 1.0,
|
|
||||||
defaultLoopCount: -1,
|
|
||||||
preloadSounds: true,
|
|
||||||
preloadStrategy: "category",
|
|
||||||
audioSessionCategory: "playback",
|
|
||||||
audioSessionMode: "default",
|
|
||||||
audioSessionOptions: ["mixWithOthers"]
|
|
||||||
)
|
|
||||||
lock.lock()
|
|
||||||
cachedSettings = settings
|
|
||||||
lock.unlock()
|
|
||||||
return settings
|
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let data = try Data(contentsOf: url)
|
let data = try Data(contentsOf: url)
|
||||||
settings = try JSONDecoder().decode(AudioSettings.self, from: data)
|
let soundsOnly = try JSONDecoder().decode(SoundsOnly.self, from: data)
|
||||||
//Design.debugLog("[settings] Loaded audio settings for alarms from SoundsSettings.json")
|
|
||||||
|
// Load settings from separate SoundsSettings.json file
|
||||||
|
let settings = loadAudioSettings()
|
||||||
|
|
||||||
|
return SoundConfiguration(sounds: soundsOnly.sounds, settings: settings)
|
||||||
} catch {
|
} catch {
|
||||||
Design.debugLog("[general] Warning: Error loading audio settings for alarms, using defaults: \(error)")
|
fatalError("❌ Error loading alarm sound configuration: \(error)")
|
||||||
settings = AudioSettings(
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load audio settings from SoundsSettings.json
|
||||||
|
private func loadAudioSettings() -> AudioSettings {
|
||||||
|
guard let url = Bundle.main.url(forResource: "SoundsSettings", withExtension: "json") else {
|
||||||
|
Design.debugLog("[general] Warning: SoundsSettings.json not found, using default alarm settings")
|
||||||
|
return AudioSettings(
|
||||||
defaultVolume: 1.0,
|
defaultVolume: 1.0,
|
||||||
defaultLoopCount: -1,
|
defaultLoopCount: -1,
|
||||||
preloadSounds: true,
|
preloadSounds: true,
|
||||||
@ -99,56 +55,23 @@ final class AlarmSoundService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
lock.lock()
|
do {
|
||||||
cachedSettings = settings
|
let data = try Data(contentsOf: url)
|
||||||
lock.unlock()
|
let settings = try JSONDecoder().decode(AudioSettings.self, from: data)
|
||||||
return settings
|
//Design.debugLog("[settings] Loaded audio settings for alarms from SoundsSettings.json")
|
||||||
}
|
return settings
|
||||||
|
} catch {
|
||||||
private func fallbackAlarmSounds() -> [Sound] {
|
Design.debugLog("[general] Warning: Error loading audio settings for alarms, using defaults: \(error)")
|
||||||
[
|
return AudioSettings(
|
||||||
Sound(
|
defaultVolume: 1.0,
|
||||||
id: "digital-alarm",
|
defaultLoopCount: -1,
|
||||||
name: "Digital Alarm",
|
preloadSounds: true,
|
||||||
fileName: "digital-alarm.mp3",
|
preloadStrategy: "category",
|
||||||
category: Self.alarmCategoryId,
|
audioSessionCategory: "playback",
|
||||||
description: "Classic digital alarm sound",
|
audioSessionMode: "default",
|
||||||
bundleName: "AlarmSounds",
|
audioSessionOptions: ["mixWithOthers"]
|
||||||
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,12 +14,7 @@ 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
|
||||||
@ -38,9 +33,6 @@ final class AlarmViewModel {
|
|||||||
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) {
|
||||||
self.alarmService = alarmService
|
self.alarmService = alarmService
|
||||||
@ -55,8 +47,7 @@ final class AlarmViewModel {
|
|||||||
|
|
||||||
// MARK: - Alarm CRUD Operations
|
// MARK: - Alarm CRUD Operations
|
||||||
|
|
||||||
@discardableResult
|
func addAlarm(_ alarm: Alarm) async {
|
||||||
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
|
||||||
@ -66,22 +57,11 @@ 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
func updateAlarm(_ alarm: Alarm) async {
|
||||||
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
|
||||||
@ -93,24 +73,8 @@ 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 {
|
||||||
@ -123,7 +87,6 @@ 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)
|
||||||
@ -135,9 +98,6 @@ 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)
|
||||||
@ -150,7 +110,6 @@ 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",
|
||||||
@ -163,7 +122,6 @@ 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,
|
||||||
@ -196,29 +154,4 @@ 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,24 +17,20 @@ 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 repeatWeekdays: [Int] = []
|
@State private var selectedSoundName = "digital-alarm.caf"
|
||||||
@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, repeatWeekdays: repeatWeekdays)
|
TimeUntilAlarmSection(alarmTime: newAlarmTime)
|
||||||
|
|
||||||
// List for settings below
|
// List for settings below
|
||||||
List {
|
List {
|
||||||
@ -78,20 +74,6 @@ 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 {
|
||||||
@ -104,12 +86,6 @@ struct AddAlarmView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AlertOptionsSection(
|
|
||||||
isVibrationEnabled: $isVibrationEnabled,
|
|
||||||
isLightFlashEnabled: $isLightFlashEnabled,
|
|
||||||
volume: $volume
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.listStyle(.insetGrouped)
|
.listStyle(.insetGrouped)
|
||||||
}
|
}
|
||||||
@ -122,58 +98,34 @@ struct AddAlarmView: View {
|
|||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
.foregroundStyle(AppAccent.primary)
|
.foregroundStyle(AppAccent.primary)
|
||||||
.accessibilityIdentifier("alarms.add.cancelButton")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button(isSaving ? "Saving..." : "Save") {
|
Button("Save") {
|
||||||
Task {
|
Task {
|
||||||
await saveAlarm()
|
let newAlarm = viewModel.createNewAlarm(
|
||||||
|
time: newAlarmTime,
|
||||||
|
soundName: selectedSoundName,
|
||||||
|
label: alarmLabel,
|
||||||
|
notificationMessage: notificationMessage,
|
||||||
|
snoozeDuration: snoozeDuration,
|
||||||
|
isVibrationEnabled: isVibrationEnabled,
|
||||||
|
isLightFlashEnabled: isLightFlashEnabled,
|
||||||
|
volume: volume
|
||||||
|
)
|
||||||
|
await viewModel.addAlarm(newAlarm)
|
||||||
|
isPresented = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.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(sortedAlarms) { alarm in
|
ForEach(viewModel.alarms) { alarm in
|
||||||
AlarmRowView(
|
AlarmRowView(
|
||||||
alarm: alarm,
|
alarm: alarm,
|
||||||
onToggle: {
|
onToggle: {
|
||||||
@ -82,7 +82,6 @@ struct AlarmView: View {
|
|||||||
.font(.title2)
|
.font(.title2)
|
||||||
.symbolEffect(.bounce, value: showAddAlarm)
|
.symbolEffect(.bounce, value: showAddAlarm)
|
||||||
}
|
}
|
||||||
.accessibilityIdentifier("alarms.addButton")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@ -106,37 +105,9 @@ 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,10 +32,6 @@ 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)
|
||||||
@ -65,16 +61,11 @@ 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) {
|
||||||
|
|||||||
@ -1,48 +0,0 @@
|
|||||||
//
|
|
||||||
// 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,5 +70,6 @@ struct EmptyAlarmsView: View {
|
|||||||
// MARK: - Preview
|
// MARK: - Preview
|
||||||
#Preview {
|
#Preview {
|
||||||
EmptyAlarmsView {
|
EmptyAlarmsView {
|
||||||
|
print("Add alarm tapped")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,96 +0,0 @@
|
|||||||
//
|
|
||||||
// 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,12 +10,6 @@ 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) {
|
||||||
@ -36,13 +30,21 @@ 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 {
|
||||||
@ -59,12 +61,17 @@ struct TimeUntilAlarmSection: View {
|
|||||||
|
|
||||||
private var dayText: String {
|
private var dayText: String {
|
||||||
let calendar = Calendar.current
|
let calendar = Calendar.current
|
||||||
if calendar.isDateInToday(nextAlarmTime) {
|
let now = Date()
|
||||||
|
|
||||||
|
// If alarm time is in the past today, show tomorrow
|
||||||
|
if calendar.isDateInToday(alarmTime) && alarmTime < now {
|
||||||
|
return "Tomorrow"
|
||||||
|
} else if calendar.isDateInToday(alarmTime) {
|
||||||
return "Today"
|
return "Today"
|
||||||
} else if calendar.isDateInTomorrow(nextAlarmTime) {
|
} else if calendar.isDateInTomorrow(alarmTime) {
|
||||||
return "Tomorrow"
|
return "Tomorrow"
|
||||||
} else {
|
} else {
|
||||||
return nextAlarmTime.formatted(.dateTime.weekday(.wide))
|
return alarmTime.formatted(.dateTime.weekday(.wide))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,6 @@ 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
|
||||||
@ -27,9 +26,6 @@ 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) {
|
||||||
@ -38,7 +34,6 @@ 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)
|
||||||
@ -54,7 +49,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, repeatWeekdays: repeatWeekdays)
|
TimeUntilAlarmSection(alarmTime: alarmTime)
|
||||||
|
|
||||||
// List for settings below
|
// List for settings below
|
||||||
List {
|
List {
|
||||||
@ -104,22 +99,6 @@ 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 {
|
||||||
@ -134,13 +113,6 @@ 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)
|
||||||
@ -155,62 +127,38 @@ struct EditAlarmView: View {
|
|||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
.foregroundStyle(AppAccent.primary)
|
.foregroundStyle(AppAccent.primary)
|
||||||
.accessibilityIdentifier("alarms.edit.cancelButton")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button(isSaving ? "Saving..." : "Save") {
|
Button("Save") {
|
||||||
Task {
|
Task {
|
||||||
await saveAlarm()
|
let updatedAlarm = Alarm(
|
||||||
|
id: alarm.id, // Keep the same ID
|
||||||
|
time: alarmTime,
|
||||||
|
isEnabled: alarm.isEnabled, // Keep the same enabled state
|
||||||
|
soundName: selectedSoundName,
|
||||||
|
label: alarmLabel,
|
||||||
|
notificationMessage: notificationMessage,
|
||||||
|
snoozeDuration: snoozeDuration,
|
||||||
|
isVibrationEnabled: isVibrationEnabled,
|
||||||
|
isLightFlashEnabled: isLightFlashEnabled,
|
||||||
|
volume: volume
|
||||||
|
)
|
||||||
|
await viewModel.updateAlarm(updatedAlarm)
|
||||||
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.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,8 +19,7 @@ final class BatteryService {
|
|||||||
var batteryLevel: Int = 100
|
var batteryLevel: Int = 100
|
||||||
var isCharging: Bool = false
|
var isCharging: Bool = false
|
||||||
|
|
||||||
@ObservationIgnored private var levelNotificationTask: Task<Void, Never>?
|
@ObservationIgnored private var monitoringTask: Task<Void, Never>?
|
||||||
@ObservationIgnored private var stateNotificationTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
private init() {
|
private init() {
|
||||||
@ -43,20 +42,21 @@ final class BatteryService {
|
|||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
private func startNotificationMonitoring() {
|
private func startNotificationMonitoring() {
|
||||||
levelNotificationTask?.cancel()
|
monitoringTask = Task { [weak self] in
|
||||||
stateNotificationTask?.cancel()
|
await withTaskGroup(of: Void.self) { group in
|
||||||
|
// Monitor battery level changes
|
||||||
|
group.addTask { @MainActor [weak self] in
|
||||||
|
for await _ in NotificationCenter.default.notifications(named: UIDevice.batteryLevelDidChangeNotification) {
|
||||||
|
self?.updateBatteryInfo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
levelNotificationTask = Task { [weak self] in
|
// Monitor battery state changes
|
||||||
for await _ in NotificationCenter.default.notifications(named: UIDevice.batteryLevelDidChangeNotification) {
|
group.addTask { @MainActor [weak self] in
|
||||||
guard !Task.isCancelled else { break }
|
for await _ in NotificationCenter.default.notifications(named: UIDevice.batteryStateDidChangeNotification) {
|
||||||
self?.updateBatteryInfo()
|
self?.updateBatteryInfo()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stateNotificationTask = Task { [weak self] in
|
|
||||||
for await _ in NotificationCenter.default.notifications(named: UIDevice.batteryStateDidChangeNotification) {
|
|
||||||
guard !Task.isCancelled else { break }
|
|
||||||
self?.updateBatteryInfo()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -125,7 +125,6 @@ struct ClockSettingsView: View {
|
|||||||
.onDisappear {
|
.onDisappear {
|
||||||
onCommit(style)
|
onCommit(style)
|
||||||
}
|
}
|
||||||
.accessibilityIdentifier("settings.screen")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -123,7 +123,6 @@ struct ClockView: View {
|
|||||||
resetIdleTimer()
|
resetIdleTimer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.accessibilityIdentifier("clock.screen")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Idle Timer
|
// MARK: - Idle Timer
|
||||||
@ -132,9 +131,7 @@ 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
|
||||||
Task { @MainActor in
|
enterFullScreenFromIdle()
|
||||||
enterFullScreenFromIdle()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -43,25 +43,8 @@ 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,7 +27,6 @@ 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()
|
||||||
@ -69,7 +68,6 @@ 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,13 +32,11 @@ 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
|
|
||||||
.sorted(by: { $0.time.nextOccurrence() < $1.time.nextOccurrence() })
|
let _ = print("TopOverlayView: Rendering. Alarms count: \(alarmService.alarms.count), Enabled: \(alarmService.enabledAlarms.count)")
|
||||||
.first
|
|
||||||
|
|
||||||
VStack(spacing: Design.Spacing.small) {
|
VStack(spacing: Design.Spacing.small) {
|
||||||
// Row 1: Date and Battery (Standard Status Bar positions)
|
HStack {
|
||||||
HStack(alignment: .center) {
|
|
||||||
if showDate {
|
if showDate {
|
||||||
DateOverlayView(color: color, opacity: opacity, dateFormat: dateFormat)
|
DateOverlayView(color: color, opacity: opacity, dateFormat: dateFormat)
|
||||||
}
|
}
|
||||||
@ -54,11 +52,9 @@ struct TopOverlayView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top, Design.Spacing.xxSmall) // Minimal top padding for status bar alignment
|
|
||||||
|
|
||||||
// Row 2: Alarms and Noise Controls (Below Dynamic Island)
|
HStack(alignment: .top) {
|
||||||
HStack(alignment: .center) {
|
if showNextAlarm, let nextAlarm = alarmService.enabledAlarms.sorted(by: { $0.time.nextOccurrence() < $1.time.nextOccurrence() }).first {
|
||||||
if showNextAlarm, let nextAlarm = nextEnabledAlarm {
|
|
||||||
NextAlarmOverlay(
|
NextAlarmOverlay(
|
||||||
alarmTime: nextAlarm.time.nextOccurrence(),
|
alarmTime: nextAlarm.time.nextOccurrence(),
|
||||||
color: color,
|
color: color,
|
||||||
@ -84,13 +80,12 @@ 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 alarm changes
|
.id(clockUpdateTrigger) // Force re-render on style or alarm changes
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .alarmsDidUpdate)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .clockStyleDidUpdate)) { _ in
|
||||||
clockUpdateTrigger.toggle()
|
clockUpdateTrigger.toggle()
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
|||||||
@ -78,8 +78,6 @@ 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,7 +47,6 @@ 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 = "" }) {
|
||||||
@ -82,7 +81,6 @@ 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,7 +43,6 @@ 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 {
|
||||||
@ -60,7 +59,6 @@ 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,10 +25,6 @@ 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,8 +37,6 @@ 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,9 +13,6 @@ 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 {
|
||||||
@ -37,10 +34,8 @@ 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. You can then set repeat days and customize alert behavior.")
|
Text("Works in silent mode, Focus mode, and even when your phone is locked.")
|
||||||
.typography(.body)
|
.typography(.body)
|
||||||
.foregroundStyle(AppTextColors.secondary)
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@ -98,7 +93,6 @@ struct OnboardingPermissionsPage: View {
|
|||||||
.background(AppAccent.primary)
|
.background(AppAccent.primary)
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
}
|
}
|
||||||
.accessibilityIdentifier("onboarding.enableAlarmsButton")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,7 +120,6 @@ struct OnboardingPermissionsPage: View {
|
|||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
}
|
}
|
||||||
.disabled(keepAwakeEnabled)
|
.disabled(keepAwakeEnabled)
|
||||||
.accessibilityIdentifier("onboarding.enableKeepAwakeButton")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,7 +127,7 @@ struct OnboardingPermissionsPage: View {
|
|||||||
|
|
||||||
private func requestAlarmKitPermission() {
|
private func requestAlarmKitPermission() {
|
||||||
Task {
|
Task {
|
||||||
let granted = await requestAlarmPermission()
|
let granted = await AlarmKitService.shared.requestAuthorization()
|
||||||
withAnimation(.spring(duration: 0.3)) {
|
withAnimation(.spring(duration: 0.3)) {
|
||||||
alarmKitPermissionGranted = granted
|
alarmKitPermissionGranted = granted
|
||||||
}
|
}
|
||||||
@ -146,11 +139,32 @@ struct OnboardingPermissionsPage: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func enableKeepAwake() {
|
private func enableKeepAwake() {
|
||||||
onEnableKeepAwake()
|
let style = loadClockStyle()
|
||||||
|
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
|
||||||
@ -159,9 +173,6 @@ 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 your way with custom alarms"
|
text: "Wake up gently, on your terms"
|
||||||
)
|
)
|
||||||
OnboardingFeatureRow(
|
OnboardingFeatureRow(
|
||||||
icon: "clock.fill",
|
icon: "clock.fill",
|
||||||
|
|||||||
@ -20,9 +20,6 @@ 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
|
||||||
@ -53,9 +50,6 @@ 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 }
|
||||||
}
|
}
|
||||||
@ -90,11 +84,7 @@ struct OnboardingView: View {
|
|||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
OnboardingView {
|
OnboardingView {
|
||||||
} requestAlarmPermission: {
|
print("Onboarding complete")
|
||||||
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: @MainActor SurfaceColorProvider {
|
public enum NoiseClockSurfaceColors: 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: @MainActor SurfaceColorProvider {
|
|||||||
|
|
||||||
// MARK: - NoiseClock Text Colors
|
// MARK: - NoiseClock Text Colors
|
||||||
|
|
||||||
public enum NoiseClockTextColors: @MainActor TextColorProvider {
|
public enum NoiseClockTextColors: 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: @MainActor TextColorProvider {
|
|||||||
|
|
||||||
// MARK: - NoiseClock Accent Colors
|
// MARK: - NoiseClock Accent Colors
|
||||||
|
|
||||||
public enum NoiseClockAccentColors: @MainActor AccentColorProvider {
|
public enum NoiseClockAccentColors: 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: @MainActor AccentColorProvider {
|
|||||||
|
|
||||||
// MARK: - NoiseClock Button Colors
|
// MARK: - NoiseClock Button Colors
|
||||||
|
|
||||||
public enum NoiseClockButtonColors: @MainActor ButtonColorProvider {
|
public enum NoiseClockButtonColors: 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: @MainActor ButtonColorProvider {
|
|||||||
|
|
||||||
// MARK: - NoiseClock Status Colors
|
// MARK: - NoiseClock Status Colors
|
||||||
|
|
||||||
public enum NoiseClockStatusColors: @MainActor StatusColorProvider {
|
public enum NoiseClockStatusColors: 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: @MainActor StatusColorProvider {
|
|||||||
|
|
||||||
// MARK: - NoiseClock Border Colors
|
// MARK: - NoiseClock Border Colors
|
||||||
|
|
||||||
public enum NoiseClockBorderColors: @MainActor BorderColorProvider {
|
public enum NoiseClockBorderColors: 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: @MainActor BorderColorProvider {
|
|||||||
|
|
||||||
// MARK: - NoiseClock Interactive Colors
|
// MARK: - NoiseClock Interactive Colors
|
||||||
|
|
||||||
public enum NoiseClockInteractiveColors: @MainActor InteractiveColorProvider {
|
public enum NoiseClockInteractiveColors: 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: @MainActor InteractiveColorProvider {
|
|||||||
|
|
||||||
// MARK: - NoiseClock Theme
|
// MARK: - NoiseClock Theme
|
||||||
|
|
||||||
public enum NoiseClockTheme: @MainActor AppColorTheme {
|
public enum NoiseClockTheme: 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,29 +8,14 @@
|
|||||||
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
|
||||||
formatter.locale = .autoupdatingCurrent
|
return formatter.string(from: self)
|
||||||
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,5 +10,4 @@ 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,43 +21,35 @@ struct KeepAwakePrompt: View {
|
|||||||
|
|
||||||
VStack(spacing: Design.Spacing.small) {
|
VStack(spacing: Design.Spacing.small) {
|
||||||
Text("Keep Awake for Alarms")
|
Text("Keep Awake for Alarms")
|
||||||
.typography(.title2Bold)
|
.typography(.title2)
|
||||||
.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.medium) {
|
VStack(spacing: Design.Spacing.small) {
|
||||||
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, maxHeight: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(AppSurface.primary)
|
.background(AppSurface.primary)
|
||||||
.presentationDetents([.height(340)])
|
.presentationDetents([.medium])
|
||||||
.presentationCornerRadius(Design.CornerRadius.xxLarge)
|
.presentationCornerRadius(Design.CornerRadius.xxLarge)
|
||||||
.presentationBackground(AppSurface.primary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,69 +6,12 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
@testable import The_Noise_Clock
|
@testable import TheNoiseClock
|
||||||
import Foundation
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct TheNoiseClockTests {
|
struct TheNoiseClockTests {
|
||||||
|
|
||||||
@Test
|
@Test func example() async throws {
|
||||||
func defaultAlarmSoundIsMP3() {
|
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||||
#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,357 +10,32 @@ 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 testAppStoreScreenshots_iPhone69() throws {
|
func testExample() 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()
|
||||||
|
|
||||||
dismissOnboardingIfNeeded(app)
|
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
||||||
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
|
||||||
private func captureAlarmsViewPortraitAndLandscape(_ app: XCUIApplication) {
|
func testLaunchPerformance() throws {
|
||||||
XCUIDevice.shared.orientation = .portrait
|
// This measures how long it takes to launch your application.
|
||||||
sleep(1)
|
measure(metrics: [XCTApplicationLaunchMetric()]) {
|
||||||
openTab(named: "Alarms", in: app)
|
XCUIApplication().launch()
|
||||||
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 let title: LocalizedStringResource = "Stop Alarm"
|
static var title: LocalizedStringResource = "Stop Alarm"
|
||||||
static let description = IntentDescription("Stops the currently ringing alarm")
|
static var 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 let title: LocalizedStringResource = "Snooze Alarm"
|
static var title: LocalizedStringResource = "Snooze Alarm"
|
||||||
static let description = IntentDescription("Snoozes the currently ringing alarm")
|
static var 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 let title: LocalizedStringResource = "Open TheNoiseClock"
|
static var title: LocalizedStringResource = "Open TheNoiseClock"
|
||||||
static let description = IntentDescription("Opens the app to the alarm screen")
|
static var description = IntentDescription("Opens the app to the alarm screen")
|
||||||
static let openAppWhenRun = true
|
static var openAppWhenRun = true
|
||||||
|
|
||||||
@Parameter(title: "Alarm ID")
|
@Parameter(title: "Alarm ID")
|
||||||
var alarmId: String
|
var alarmId: String
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user