Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
a1cb0f4b1f
commit
5e45be0a2a
30
PRD.md
30
PRD.md
@ -89,38 +89,32 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di
|
||||
- **Responsive layout**: Optimized for both portrait and landscape orientations
|
||||
- **AudioPlaybackKit integration**: Powered by reusable Swift package for audio functionality
|
||||
|
||||
### 6. Advanced Alarm System
|
||||
### 6. Advanced Alarm System (Powered by AlarmKit)
|
||||
- **AlarmKit integration**: iOS 26+ AlarmKit framework for reliable alarms that cut through Focus modes and silent mode
|
||||
- **Multiple alarms**: Create and manage unlimited alarms
|
||||
- **Rich alarm editor**: Full-featured alarm creation and editing interface
|
||||
- **Time selection**: Wheel-style date picker with optimized font sizing for maximum readability
|
||||
- **Dynamic alarm sounds**: Configurable alarm sounds loaded from dedicated alarm-sounds.json configuration
|
||||
- **Dynamic alarm sounds**: MP3 alarm sounds loaded from AlarmSounds folder
|
||||
- **Sound preview**: Play/stop functionality for testing alarm sounds before selection
|
||||
- **Sound organization**: Alarm sounds organized in dedicated AlarmSounds.bundle with categories
|
||||
- **Custom labels**: User-defined alarm names and descriptions
|
||||
- **Repeat schedules**: Set alarms to repeat on specific weekdays or daily
|
||||
- **Sound selection**: Choose from extensive alarm sounds with live preview
|
||||
- **Volume control**: Adjustable alarm volume (0-100%)
|
||||
- **Vibration settings**: Enable/disable vibration for each alarm
|
||||
- **Snooze functionality**: Configurable snooze duration (5, 7, 8, 9, 10, 15, 20 minutes)
|
||||
- **Smart notifications**: Automatic scheduling for one-time and repeating alarms
|
||||
- **Snooze functionality**: AlarmKit countdown feature for snooze support
|
||||
- **Live Activity countdown**: Shows 5 minutes before alarm fires on Lock Screen and Dynamic Island
|
||||
- **Dynamic Island**: Compact and expanded views with countdown timer
|
||||
- **Lock Screen widget**: Full countdown display with alarm label
|
||||
- **Enable/disable toggles**: Individual alarm control with instant feedback
|
||||
- **Notification integration**: Uses iOS UserNotifications framework with proper scheduling
|
||||
- **Background limitations**: Full alarm sound and screen require the app to be foregrounded; background alarms use notification sound
|
||||
- **Inline alarm warnings**: Enabled alarms show a foreground-only warning when Keep Awake is off
|
||||
- **Keep Awake prompt**: In-app popup enables Keep Awake without digging into settings
|
||||
- **Keep Awake guidance**: Banner messaging explains why Keep Awake improves alarm reliability
|
||||
- **Onboarding enablement**: Onboarding offers a one-tap Keep Awake enable action
|
||||
- **Live Activity**: Dynamic Island/Lock Screen shows only while an alarm is ringing (user-enabled)
|
||||
- **Live Activity availability**: Requires Live Activities permission in iOS Settings
|
||||
- **AlarmKit authorization**: Requires user permission via NSAlarmKitUsageDescription
|
||||
- **Persistent storage**: Alarms saved to UserDefaults with backward compatibility
|
||||
- **Alarm management**: Add, edit, delete, and duplicate alarms
|
||||
- **Next trigger preview**: Shows when the next alarm will fire
|
||||
- **Responsive time picker**: Font sizes adapt to available space and orientation
|
||||
- **AlarmKitService**: Centralized service for AlarmKit integration
|
||||
- **AlarmSoundService integration**: Dedicated service for alarm-specific sound management
|
||||
- **In-app alarm screen**: Full-screen alarm UI with Snooze/Stop when the app is active
|
||||
- **Foreground alarm sound**: In-app playback of the selected alarm sound and volume
|
||||
- **Notification tap routing**: Tapping an alarm notification opens the alarm screen
|
||||
- **Foreground handling**: Alarm notifications surface as in-app UI instead of banners
|
||||
- **App Intents**: StopAlarmIntent and SnoozeAlarmIntent for Live Activity button actions
|
||||
|
||||
## Advanced Clock Display Features
|
||||
|
||||
@ -412,7 +406,7 @@ TheNoiseClock/
|
||||
│ │ ├── Models/
|
||||
│ │ │ └── SoundCategory.swift # Shared sound category definitions
|
||||
│ │ ├── LiveActivity/
|
||||
│ │ │ └── AlarmActivityAttributes.swift # Live Activity attributes shared with widget
|
||||
│ │ │ └── NoiseClockAlarmMetadata.swift # AlarmKit metadata shared with widget
|
||||
│ │ └── Utilities/
|
||||
│ │ ├── ColorUtils.swift # Color manipulation utilities
|
||||
│ │ ├── NotificationUtils.swift # Notification helper functions
|
||||
@ -460,7 +454,7 @@ TheNoiseClock/
|
||||
│ │ │ ├── Services/
|
||||
│ │ │ │ ├── AlarmService.swift
|
||||
│ │ │ │ ├── AlarmSoundService.swift
|
||||
│ │ │ │ ├── AlarmLiveActivityManager.swift
|
||||
│ │ │ │ ├── AlarmKitService.swift # AlarmKit integration (iOS 26+)
|
||||
│ │ │ │ ├── FocusModeService.swift
|
||||
│ │ │ │ ├── NotificationService.swift
|
||||
│ │ │ │ └── NotificationDelegate.swift
|
||||
|
||||
23
README.md
23
README.md
@ -1,6 +1,6 @@
|
||||
# TheNoiseClock
|
||||
|
||||
TheNoiseClock is a SwiftUI iOS app that blends a bold, full-screen digital clock with white noise playback and a rich alarm system. It is optimized for iOS 18+ with Swift 6, built on a modular architecture, and styled with the Bedrock design system.
|
||||
TheNoiseClock is a SwiftUI iOS app that blends a bold, full-screen digital clock with white noise playback and a rich alarm system. It is optimized for iOS 26+ with Swift 6, built on a modular architecture, and styled with the Bedrock design system. Alarms use AlarmKit for reliable wake-up alerts that cut through Focus modes and silent mode.
|
||||
|
||||
---
|
||||
|
||||
@ -36,19 +36,16 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and
|
||||
- Seamless looping with background audio support
|
||||
- Quick preview on long-press, instant play/stop controls
|
||||
|
||||
**Alarms**
|
||||
**Alarms (Powered by AlarmKit)**
|
||||
- Unlimited alarms with labels, repeat schedules, and snooze options
|
||||
- Alarm sound library with preview
|
||||
- Alarm sound library with preview (MP3 format)
|
||||
- Vibration and volume controls per alarm
|
||||
- Focus-mode aware scheduling
|
||||
- AlarmKit integration: alarms cut through Focus modes and silent mode
|
||||
- Live Activity countdown shows 5 minutes before alarm fires
|
||||
- Dynamic Island displays countdown and alarm status
|
||||
- Lock Screen shows alarm countdown with custom UI
|
||||
- Full-screen in-app alarm screen with Snooze/Stop when active
|
||||
- In-app alarm sound playback using the selected alarm sound
|
||||
- Tapping alarm notifications opens the alarm screen
|
||||
- Background limitations: full alarm sound/screen requires the app to be open in the foreground
|
||||
- Keep Awake prompt enables staying on-screen for alarms
|
||||
- Enabled alarm rows show a foreground-only warning when Keep Awake is off
|
||||
- Live Activity shows only while an alarm is ringing (user-enabled)
|
||||
- Live Activity requires Live Activities permission in iOS Settings
|
||||
- Snooze support via AlarmKit's countdown feature
|
||||
|
||||
**Display Mode**
|
||||
- Long-press to enter immersive display mode
|
||||
@ -78,8 +75,8 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and
|
||||
|
||||
## Requirements
|
||||
|
||||
- iOS 18.0+
|
||||
- Xcode 16+
|
||||
- iOS 26.0+
|
||||
- Xcode 26+
|
||||
- Swift 6
|
||||
|
||||
---
|
||||
|
||||
@ -10,7 +10,6 @@
|
||||
EA384E832E6F806200CA7D50 /* AudioPlaybackKit in Frameworks */ = {isa = PBXBuildFile; productRef = EA384D3D2E6F554D00CA7D50 /* AudioPlaybackKit */; };
|
||||
EAC051B12F2E64AB007F87EA /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EAC051B02F2E64AB007F87EA /* Bedrock */; };
|
||||
EAF1C0DE2F3A4B5C0011223E /* TheNoiseClockWidget.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = EAF1C0DE2F3A4B5C00112234 /* TheNoiseClockWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
EAF1C0DE2F3A4B5C00112242 /* TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF1C0DE2F3A4B5C00112241 /* TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@ -58,7 +57,6 @@
|
||||
EAD6E3AF5A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = TheNoiseClock/Configuration/Debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
EAD6E3B05A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = TheNoiseClock/Configuration/Release.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
EAF1C0DE2F3A4B5C00112234 /* TheNoiseClockWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TheNoiseClockWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EAF1C0DE2F3A4B5C00112241 /* TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift; sourceTree = SOURCE_ROOT; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@ -169,7 +167,6 @@
|
||||
children = (
|
||||
EAD6E3AF5A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Debug.xcconfig */,
|
||||
EAD6E3B05A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Release.xcconfig */,
|
||||
EAF1C0DE2F3A4B5C00112241 /* TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift */,
|
||||
);
|
||||
name = "Recovered References";
|
||||
sourceTree = "<group>";
|
||||
@ -378,7 +375,6 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
EAF1C0DE2F3A4B5C00112242 /* TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@ -542,7 +538,7 @@
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@ -576,7 +572,7 @@
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@ -691,7 +687,7 @@
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = TheNoiseClockWidget/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(WIDGET_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@ -710,7 +706,7 @@
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = TheNoiseClockWidget/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(WIDGET_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
||||
@ -7,12 +7,12 @@
|
||||
<key>TheNoiseClock.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>3</integer>
|
||||
<integer>2</integer>
|
||||
</dict>
|
||||
<key>TheNoiseClockWidget.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>2</integer>
|
||||
<integer>3</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
@ -134,14 +134,19 @@ struct ContentView: View {
|
||||
}
|
||||
)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .alarmDidFire)) { notification in
|
||||
alarmViewModel.handleAlarmNotification(userInfo: notification.userInfo)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .alarmDidStop)) { _ in
|
||||
alarmViewModel.stopActiveAlarm()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .alarmDidSnooze)) { _ in
|
||||
alarmViewModel.stopActiveAlarm()
|
||||
// Note: AlarmKit handles alarm alerts directly via the system.
|
||||
// The in-app alarm screen is shown for alarms that are in the alerting state.
|
||||
// AlarmKit's Live Activity provides the countdown and alerting UI on Lock Screen and Dynamic Island.
|
||||
.task {
|
||||
Design.debugLog("[ContentView] App launched - initializing AlarmKit")
|
||||
|
||||
// Reschedule all enabled alarms with AlarmKit on app launch
|
||||
await alarmViewModel.rescheduleAllAlarms()
|
||||
|
||||
// Start observing AlarmKit alarm updates
|
||||
alarmViewModel.startObservingAlarmUpdates()
|
||||
|
||||
Design.debugLog("[ContentView] AlarmKit initialization complete")
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .keepAwakePromptRequested)) { _ in
|
||||
guard onboardingState.hasCompletedWelcome else { return }
|
||||
|
||||
117
TheNoiseClock/Features/Alarms/Intents/AlarmIntents.swift
Normal file
117
TheNoiseClock/Features/Alarms/Intents/AlarmIntents.swift
Normal file
@ -0,0 +1,117 @@
|
||||
//
|
||||
// AlarmIntents.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Created by Matt Bruce on 2/2/26.
|
||||
//
|
||||
|
||||
import AlarmKit
|
||||
import AppIntents
|
||||
import Foundation
|
||||
|
||||
// MARK: - Stop Alarm Intent
|
||||
|
||||
/// Intent to stop an active alarm from the Live Activity or notification.
|
||||
struct StopAlarmIntent: LiveActivityIntent {
|
||||
|
||||
static var title: LocalizedStringResource = "Stop Alarm"
|
||||
static var description = IntentDescription("Stops the currently ringing alarm")
|
||||
|
||||
@Parameter(title: "Alarm ID")
|
||||
var alarmID: String
|
||||
|
||||
static var supportedModes: IntentModes { .background }
|
||||
|
||||
init() {
|
||||
self.alarmID = ""
|
||||
}
|
||||
|
||||
init(alarmID: String) {
|
||||
self.alarmID = alarmID
|
||||
}
|
||||
|
||||
func perform() throws -> some IntentResult {
|
||||
guard let uuid = UUID(uuidString: alarmID) else {
|
||||
throw AlarmIntentError.invalidAlarmID
|
||||
}
|
||||
|
||||
try AlarmManager.shared.stop(id: uuid)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Snooze Alarm Intent
|
||||
|
||||
/// Intent to snooze an active alarm from the Live Activity or notification.
|
||||
struct SnoozeAlarmIntent: LiveActivityIntent {
|
||||
|
||||
static var title: LocalizedStringResource = "Snooze Alarm"
|
||||
static var description = IntentDescription("Snoozes the currently ringing alarm")
|
||||
|
||||
@Parameter(title: "Alarm ID")
|
||||
var alarmID: String
|
||||
|
||||
static var supportedModes: IntentModes { .background }
|
||||
|
||||
init() {
|
||||
self.alarmID = ""
|
||||
}
|
||||
|
||||
init(alarmID: String) {
|
||||
self.alarmID = alarmID
|
||||
}
|
||||
|
||||
func perform() throws -> some IntentResult {
|
||||
guard let uuid = UUID(uuidString: alarmID) else {
|
||||
throw AlarmIntentError.invalidAlarmID
|
||||
}
|
||||
|
||||
// Use countdown to postpone the alarm by its configured snooze duration
|
||||
try AlarmManager.shared.countdown(id: uuid)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Open App Intent
|
||||
|
||||
/// Intent to open the app when the alarm fires.
|
||||
struct OpenAlarmAppIntent: LiveActivityIntent {
|
||||
|
||||
static var title: LocalizedStringResource = "Open TheNoiseClock"
|
||||
static var description = IntentDescription("Opens the app to the alarm screen")
|
||||
|
||||
@Parameter(title: "Alarm ID")
|
||||
var alarmID: String
|
||||
|
||||
static var supportedModes: IntentModes { .foreground(.immediate) }
|
||||
|
||||
init() {
|
||||
self.alarmID = ""
|
||||
}
|
||||
|
||||
init(alarmID: String) {
|
||||
self.alarmID = alarmID
|
||||
}
|
||||
|
||||
func perform() throws -> some IntentResult {
|
||||
// The app will be opened due to .foreground(.immediate)
|
||||
// The alarm screen will be shown based on the active alarm state
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum AlarmIntentError: Error, LocalizedError {
|
||||
case invalidAlarmID
|
||||
case alarmNotFound
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidAlarmID:
|
||||
return "Invalid alarm ID"
|
||||
case .alarmNotFound:
|
||||
return "Alarm not found"
|
||||
}
|
||||
}
|
||||
}
|
||||
285
TheNoiseClock/Features/Alarms/Services/AlarmKitService.swift
Normal file
285
TheNoiseClock/Features/Alarms/Services/AlarmKitService.swift
Normal file
@ -0,0 +1,285 @@
|
||||
//
|
||||
// AlarmKitService.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Created by Matt Bruce on 2/2/26.
|
||||
//
|
||||
|
||||
import AlarmKit
|
||||
import Bedrock
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Service for managing alarms using AlarmKit (iOS 26+).
|
||||
/// AlarmKit alarms cut through Focus modes and silent mode.
|
||||
@MainActor
|
||||
final class AlarmKitService {
|
||||
|
||||
// MARK: - Singleton
|
||||
|
||||
static let shared = AlarmKitService()
|
||||
private let manager = AlarmManager.shared
|
||||
|
||||
private init() {
|
||||
Design.debugLog("[alarmkit] AlarmKitService initialized")
|
||||
Design.debugLog("[alarmkit] Authorization state: \(manager.authorizationState)")
|
||||
}
|
||||
|
||||
// MARK: - Authorization
|
||||
|
||||
/// The current authorization state for AlarmKit
|
||||
var authorizationState: AlarmManager.AuthorizationState {
|
||||
manager.authorizationState
|
||||
}
|
||||
|
||||
/// Request authorization to schedule alarms.
|
||||
/// - Returns: `true` if authorized, `false` otherwise.
|
||||
func requestAuthorization() async -> Bool {
|
||||
Design.debugLog("[alarmkit] Requesting authorization, current state: \(manager.authorizationState)")
|
||||
|
||||
switch manager.authorizationState {
|
||||
case .notDetermined:
|
||||
do {
|
||||
let state = try await manager.requestAuthorization()
|
||||
Design.debugLog("[alarmkit] Authorization result: \(state)")
|
||||
return state == .authorized
|
||||
} catch {
|
||||
Design.debugLog("[alarmkit] Authorization error: \(error)")
|
||||
return false
|
||||
}
|
||||
case .authorized:
|
||||
Design.debugLog("[alarmkit] Already authorized")
|
||||
return true
|
||||
case .denied:
|
||||
Design.debugLog("[alarmkit] Authorization denied - user must enable in Settings")
|
||||
return false
|
||||
@unknown default:
|
||||
Design.debugLog("[alarmkit] Unknown authorization state")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Scheduling
|
||||
|
||||
/// Schedule an alarm using AlarmKit.
|
||||
/// - Parameter alarm: The alarm to schedule.
|
||||
func scheduleAlarm(_ alarm: Alarm) async throws {
|
||||
Design.debugLog("[alarmkit] ========== SCHEDULING ALARM ==========")
|
||||
Design.debugLog("[alarmkit] Label: \(alarm.label)")
|
||||
Design.debugLog("[alarmkit] Time: \(alarm.time)")
|
||||
Design.debugLog("[alarmkit] Sound: \(alarm.soundName)")
|
||||
Design.debugLog("[alarmkit] Volume: \(alarm.volume)")
|
||||
Design.debugLog("[alarmkit] ID: \(alarm.id)")
|
||||
|
||||
// Ensure we're authorized
|
||||
if manager.authorizationState != .authorized {
|
||||
Design.debugLog("[alarmkit] Not authorized, requesting...")
|
||||
let authorized = await requestAuthorization()
|
||||
guard authorized else {
|
||||
Design.debugLog("[alarmkit] Authorization failed, cannot schedule alarm")
|
||||
throw AlarmKitError.notAuthorized
|
||||
}
|
||||
}
|
||||
|
||||
// Create the sound for the alarm
|
||||
let alarmSound = createAlarmSound(for: alarm)
|
||||
Design.debugLog("[alarmkit] Created alarm sound: \(alarmSound)")
|
||||
|
||||
// Create the alert presentation with stop button and sound
|
||||
let stopButton = AlarmButton(
|
||||
text: "Stop",
|
||||
textColor: .red,
|
||||
systemImageName: "stop.fill"
|
||||
)
|
||||
|
||||
let snoozeButton = AlarmButton(
|
||||
text: "Snooze",
|
||||
textColor: .blue,
|
||||
systemImageName: "zzz"
|
||||
)
|
||||
|
||||
let alert = AlarmPresentation.Alert(
|
||||
title: LocalizedStringResource(stringLiteral: alarm.label),
|
||||
sound: alarmSound,
|
||||
stopButton: stopButton,
|
||||
snoozeButton: snoozeButton
|
||||
)
|
||||
Design.debugLog("[alarmkit] Created alert with sound and buttons")
|
||||
|
||||
// Create metadata for the alarm
|
||||
let metadata = NoiseClockAlarmMetadata(
|
||||
alarmId: alarm.id.uuidString,
|
||||
soundName: alarm.soundName,
|
||||
snoozeDuration: alarm.snoozeDuration,
|
||||
label: alarm.label,
|
||||
volume: alarm.volume
|
||||
)
|
||||
Design.debugLog("[alarmkit] Created metadata: \(metadata)")
|
||||
|
||||
// Create alarm attributes
|
||||
let attributes = AlarmAttributes<NoiseClockAlarmMetadata>(
|
||||
presentation: AlarmPresentation(alert: alert),
|
||||
metadata: metadata,
|
||||
tintColor: Color.pink
|
||||
)
|
||||
Design.debugLog("[alarmkit] Created attributes with tint color")
|
||||
|
||||
// Create the schedule
|
||||
let schedule = createSchedule(for: alarm)
|
||||
Design.debugLog("[alarmkit] Created schedule: \(schedule)")
|
||||
|
||||
// Create countdown duration (5 min before alarm, 1 min after)
|
||||
let countdownDuration = AlarmKit.Alarm.CountdownDuration(
|
||||
preAlert: 300, // 5 minutes before
|
||||
postAlert: 60 // 1 minute after
|
||||
)
|
||||
Design.debugLog("[alarmkit] Countdown duration: preAlert=300s, postAlert=60s")
|
||||
|
||||
// Create the alarm configuration
|
||||
let configuration = AlarmManager.AlarmConfiguration<NoiseClockAlarmMetadata>(
|
||||
countdownDuration: countdownDuration,
|
||||
schedule: schedule,
|
||||
attributes: attributes
|
||||
)
|
||||
Design.debugLog("[alarmkit] Created configuration")
|
||||
|
||||
// Schedule the alarm
|
||||
do {
|
||||
let scheduledAlarm = try await manager.schedule(
|
||||
id: alarm.id,
|
||||
configuration: configuration
|
||||
)
|
||||
Design.debugLog("[alarmkit] ✅ ALARM SCHEDULED SUCCESSFULLY")
|
||||
Design.debugLog("[alarmkit] Scheduled ID: \(scheduledAlarm.id)")
|
||||
Design.debugLog("[alarmkit] Scheduled state: \(scheduledAlarm.state)")
|
||||
} catch {
|
||||
Design.debugLog("[alarmkit] ❌ SCHEDULING FAILED: \(error)")
|
||||
throw AlarmKitError.schedulingFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sound Configuration
|
||||
|
||||
/// Create an AlarmKit sound from the alarm's sound name.
|
||||
private func createAlarmSound(for alarm: Alarm) -> AlarmKit.AlertSound {
|
||||
let soundName = alarm.soundName
|
||||
Design.debugLog("[alarmkit] Creating sound for: \(soundName)")
|
||||
|
||||
// Check if it's a bundled sound file (has extension)
|
||||
if soundName.contains(".") {
|
||||
// Extract filename without extension for named sound
|
||||
let soundFileName = soundName
|
||||
Design.debugLog("[alarmkit] Using named sound file: \(soundFileName)")
|
||||
return .named(soundFileName)
|
||||
} else {
|
||||
// Assume it's a named sound resource
|
||||
Design.debugLog("[alarmkit] Using named sound: \(soundName)")
|
||||
return .named(soundName)
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel a scheduled alarm.
|
||||
/// - Parameter id: The UUID of the alarm to cancel.
|
||||
func cancelAlarm(id: UUID) {
|
||||
Design.debugLog("[alarmkit] Cancelling alarm: \(id)")
|
||||
do {
|
||||
try manager.cancel(id: id)
|
||||
Design.debugLog("[alarmkit] ✅ Alarm cancelled: \(id)")
|
||||
} catch {
|
||||
Design.debugLog("[alarmkit] ❌ Cancel error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop an active alarm that is currently alerting.
|
||||
/// - Parameter id: The UUID of the alarm to stop.
|
||||
func stopAlarm(id: UUID) {
|
||||
Design.debugLog("[alarmkit] Stopping alarm: \(id)")
|
||||
do {
|
||||
try manager.stop(id: id)
|
||||
Design.debugLog("[alarmkit] ✅ Alarm stopped: \(id)")
|
||||
} catch {
|
||||
Design.debugLog("[alarmkit] ❌ Stop error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Snooze an active alarm by starting its countdown again.
|
||||
/// - Parameter id: The UUID of the alarm to snooze.
|
||||
func snoozeAlarm(id: UUID) {
|
||||
Design.debugLog("[alarmkit] Snoozing alarm: \(id)")
|
||||
do {
|
||||
try manager.countdown(id: id)
|
||||
Design.debugLog("[alarmkit] ✅ Alarm snoozed: \(id)")
|
||||
} catch {
|
||||
Design.debugLog("[alarmkit] ❌ Snooze error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Alarm Updates
|
||||
|
||||
/// Async sequence that emits the current set of alarms whenever changes occur.
|
||||
var alarmUpdates: some AsyncSequence<[AlarmKit.Alarm], Never> {
|
||||
manager.alarmUpdates
|
||||
}
|
||||
|
||||
/// Log current state of all scheduled alarms
|
||||
func logCurrentAlarms() {
|
||||
Design.debugLog("[alarmkit] ========== CURRENT ALARMS ==========")
|
||||
Task {
|
||||
for await alarms in manager.alarmUpdates {
|
||||
Design.debugLog("[alarmkit] Found \(alarms.count) alarm(s) in AlarmKit")
|
||||
for alarm in alarms {
|
||||
Design.debugLog("[alarmkit] - ID: \(alarm.id)")
|
||||
Design.debugLog("[alarmkit] State: \(alarm.state)")
|
||||
}
|
||||
break // Just log once
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
/// Create an AlarmKit schedule from an Alarm model.
|
||||
private func createSchedule(for alarm: Alarm) -> AlarmKit.Alarm.Schedule {
|
||||
// Extract time components
|
||||
let calendar = Calendar.current
|
||||
let components = calendar.dateComponents([.hour, .minute], from: alarm.time)
|
||||
|
||||
let hour = components.hour ?? 0
|
||||
let minute = components.minute ?? 0
|
||||
|
||||
Design.debugLog("[alarmkit] Creating schedule for \(hour):\(String(format: "%02d", minute))")
|
||||
|
||||
let time = AlarmKit.Alarm.Schedule.Relative.Time(
|
||||
hour: hour,
|
||||
minute: minute
|
||||
)
|
||||
|
||||
// For now, create a one-time alarm (non-repeating)
|
||||
// Future: Support weekly repeating alarms based on alarm.repeatDays
|
||||
let schedule = AlarmKit.Alarm.Schedule.relative(
|
||||
AlarmKit.Alarm.Schedule.Relative(
|
||||
time: time,
|
||||
repeats: .never
|
||||
)
|
||||
)
|
||||
|
||||
Design.debugLog("[alarmkit] Schedule created: relative, repeats=never")
|
||||
return schedule
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum AlarmKitError: Error, LocalizedError {
|
||||
case notAuthorized
|
||||
case schedulingFailed(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notAuthorized:
|
||||
return "AlarmKit is not authorized. Please enable alarm permissions in Settings."
|
||||
case .schedulingFailed(let error):
|
||||
return "Failed to schedule alarm: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,86 +0,0 @@
|
||||
//
|
||||
// AlarmLiveActivityManager.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Created by Matt Bruce on 2/2/26.
|
||||
//
|
||||
|
||||
import ActivityKit
|
||||
import Bedrock
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class AlarmLiveActivityManager {
|
||||
private var currentActivity: Activity<AlarmActivityAttributes>?
|
||||
|
||||
func startOrUpdate(for alarm: Alarm) {
|
||||
guard #available(iOS 16.1, *) else { return }
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
|
||||
Design.debugLog("[alarms] Live Activities not authorized")
|
||||
return
|
||||
}
|
||||
|
||||
let attributes = AlarmActivityAttributes(id: alarm.id)
|
||||
let alarmDate = alarm.nextTriggerTime()
|
||||
let contentState = AlarmActivityAttributes.ContentState(
|
||||
alarmDate: alarmDate,
|
||||
label: alarm.label
|
||||
)
|
||||
|
||||
if let activity = currentActivity {
|
||||
if activity.attributes.id != alarm.id {
|
||||
Task {
|
||||
await activity.end(nil, dismissalPolicy: .immediate)
|
||||
currentActivity = nil
|
||||
startOrUpdate(for: alarm)
|
||||
}
|
||||
return
|
||||
} else {
|
||||
Task {
|
||||
await activity.update(ActivityContent(state: contentState, staleDate: alarmDate))
|
||||
}
|
||||
Design.debugLog("[alarms] Live Activity updated for \(alarm.label) at \(alarmDate)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
let activity = try Activity.request(
|
||||
attributes: attributes,
|
||||
content: ActivityContent(state: contentState, staleDate: alarmDate),
|
||||
pushType: nil
|
||||
)
|
||||
currentActivity = activity
|
||||
Design.debugLog("[alarms] Live Activity started for \(alarm.label) at \(alarmDate)")
|
||||
} catch {
|
||||
Design.debugLog("[alarms] Live Activity request failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func endActivity() {
|
||||
guard #available(iOS 16.1, *) else { return }
|
||||
guard let activity = currentActivity else { return }
|
||||
Task {
|
||||
await activity.end(nil, dismissalPolicy: .immediate)
|
||||
currentActivity = nil
|
||||
Design.debugLog("[alarms] Live Activity ended")
|
||||
}
|
||||
}
|
||||
|
||||
func refresh(for alarms: [Alarm], isEnabled: Bool) {
|
||||
guard isEnabled else {
|
||||
Design.debugLog("[alarms] Live Activities disabled in app settings")
|
||||
endActivity()
|
||||
return
|
||||
}
|
||||
guard let nextAlarm = alarms
|
||||
.filter({ $0.isEnabled })
|
||||
.min(by: { $0.nextTriggerTime() < $1.nextTriggerTime() }) else {
|
||||
Design.debugLog("[alarms] No enabled alarms; ending Live Activity")
|
||||
endActivity()
|
||||
return
|
||||
}
|
||||
Design.debugLog("[alarms] Live Activity refresh for \(nextAlarm.label)")
|
||||
startOrUpdate(for: nextAlarm)
|
||||
}
|
||||
}
|
||||
@ -4,13 +4,17 @@
|
||||
//
|
||||
// Created by Matt Bruce on 9/7/25.
|
||||
//
|
||||
// NOTE: This service now only handles alarm persistence.
|
||||
// Alarm scheduling is handled by AlarmKitService (iOS 26+).
|
||||
// The old notification scheduling code has been removed.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
import Observation
|
||||
import Bedrock
|
||||
|
||||
/// Service for managing alarms and notifications
|
||||
/// Service for managing alarm persistence.
|
||||
/// Alarm scheduling is handled by AlarmKitService.
|
||||
@Observable
|
||||
class AlarmService {
|
||||
|
||||
@ -18,48 +22,48 @@ class AlarmService {
|
||||
private(set) var alarms: [Alarm] = []
|
||||
private var alarmLookup: [UUID: Int] = [:]
|
||||
private var persistenceWorkItem: DispatchWorkItem?
|
||||
private let focusModeService = FocusModeService.shared
|
||||
|
||||
// MARK: - Initialization
|
||||
init() {
|
||||
loadAlarms()
|
||||
Task {
|
||||
// Request permissions through FocusModeService for better compatibility
|
||||
let granted = await focusModeService.requestNotificationPermissions()
|
||||
if !granted {
|
||||
// Fallback to original method
|
||||
_ = await NotificationUtils.requestPermissions()
|
||||
}
|
||||
}
|
||||
Design.debugLog("[alarms] AlarmService initialized with \(alarms.count) alarms")
|
||||
}
|
||||
|
||||
// MARK: - Public Interface
|
||||
|
||||
/// Add an alarm to storage. Does NOT schedule - caller should use AlarmKitService.
|
||||
func addAlarm(_ alarm: Alarm) {
|
||||
Design.debugLog("[alarms] AlarmService.addAlarm: \(alarm.label) at \(alarm.time)")
|
||||
alarms.append(alarm)
|
||||
updateAlarmLookup()
|
||||
scheduleNotification(for: alarm)
|
||||
saveAlarms()
|
||||
}
|
||||
|
||||
/// Update an alarm in storage. Does NOT reschedule - caller should use AlarmKitService.
|
||||
func updateAlarm(_ alarm: Alarm) {
|
||||
guard let index = alarmLookup[alarm.id] else { return }
|
||||
guard let index = alarmLookup[alarm.id] else {
|
||||
Design.debugLog("[alarms] AlarmService.updateAlarm: alarm not found \(alarm.id)")
|
||||
return
|
||||
}
|
||||
Design.debugLog("[alarms] AlarmService.updateAlarm: \(alarm.label) enabled=\(alarm.isEnabled)")
|
||||
alarms[index] = alarm
|
||||
updateAlarmLookup()
|
||||
scheduleNotification(for: alarm)
|
||||
saveAlarms()
|
||||
}
|
||||
|
||||
/// Delete an alarm from storage. Does NOT cancel - caller should use AlarmKitService.
|
||||
func deleteAlarm(id: UUID) {
|
||||
Design.debugLog("[alarms] AlarmService.deleteAlarm: \(id)")
|
||||
alarms.removeAll { $0.id == id }
|
||||
updateAlarmLookup()
|
||||
NotificationUtils.removeNotification(identifier: id.uuidString)
|
||||
saveAlarms()
|
||||
}
|
||||
|
||||
/// Toggle an alarm's enabled state. Does NOT reschedule - caller should use AlarmKitService.
|
||||
func toggleAlarm(id: UUID) {
|
||||
guard let index = alarmLookup[id] else { return }
|
||||
alarms[index].isEnabled.toggle()
|
||||
scheduleNotification(for: alarms[index])
|
||||
Design.debugLog("[alarms] AlarmService.toggleAlarm: \(id) now enabled=\(alarms[index].isEnabled)")
|
||||
saveAlarms()
|
||||
}
|
||||
|
||||
@ -75,50 +79,6 @@ class AlarmService {
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleNotification(for alarm: Alarm) {
|
||||
// Remove existing notification
|
||||
NotificationUtils.removeNotification(identifier: alarm.id.uuidString)
|
||||
|
||||
// Schedule new notification if enabled
|
||||
if alarm.isEnabled {
|
||||
Task {
|
||||
let respectFocusModes = currentRespectFocusModes()
|
||||
let liveActivitiesEnabled = isLiveActivitiesEnabled()
|
||||
let body = liveActivitiesEnabled ? "" : alarm.notificationMessage
|
||||
Design.debugLog("[alarms] AlarmService schedule \(alarm.label). LiveActivities=\(liveActivitiesEnabled) body=\(body.isEmpty ? "<empty>" : "present")")
|
||||
// Use FocusModeService for better Focus mode compatibility
|
||||
focusModeService.scheduleAlarmNotification(
|
||||
identifier: alarm.id.uuidString,
|
||||
title: alarm.label,
|
||||
body: body,
|
||||
date: alarm.time,
|
||||
soundName: alarm.soundName,
|
||||
repeats: false, // For now, set to false since Alarm model doesn't have repeatDays
|
||||
respectFocusModes: respectFocusModes,
|
||||
snoozeDuration: alarm.snoozeDuration,
|
||||
isVibrationEnabled: alarm.isVibrationEnabled,
|
||||
volume: alarm.volume
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func currentRespectFocusModes() -> Bool {
|
||||
guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey),
|
||||
let style = try? JSONDecoder().decode(ClockStyle.self, from: data) else {
|
||||
return ClockStyle().respectFocusModes
|
||||
}
|
||||
return style.respectFocusModes
|
||||
}
|
||||
|
||||
private func isLiveActivitiesEnabled() -> Bool {
|
||||
guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey),
|
||||
let style = try? JSONDecoder().decode(ClockStyle.self, from: data) else {
|
||||
return ClockStyle().liveActivitiesEnabled
|
||||
}
|
||||
return style.liveActivitiesEnabled
|
||||
}
|
||||
|
||||
private func saveAlarms() {
|
||||
persistenceWorkItem?.cancel()
|
||||
|
||||
@ -141,11 +101,13 @@ class AlarmService {
|
||||
let decodedAlarms = try? JSONDecoder().decode([Alarm].self, from: savedAlarms) {
|
||||
alarms = decodedAlarms
|
||||
updateAlarmLookup()
|
||||
|
||||
// Reschedule all enabled alarms
|
||||
for alarm in alarms where alarm.isEnabled {
|
||||
scheduleNotification(for: alarm)
|
||||
}
|
||||
Design.debugLog("[alarms] Loaded \(alarms.count) alarms from storage")
|
||||
// Note: AlarmKit scheduling is handled by AlarmViewModel.rescheduleAllAlarms()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all enabled alarms (for rescheduling with AlarmKit)
|
||||
func getEnabledAlarms() -> [Alarm] {
|
||||
return alarms.filter { $0.isEnabled }
|
||||
}
|
||||
}
|
||||
|
||||
@ -119,4 +119,67 @@ class AlarmSoundService {
|
||||
func getAlarmSound(fileName: String) -> Sound? {
|
||||
return getAlarmSounds().first { $0.fileName == fileName }
|
||||
}
|
||||
|
||||
/// Get the file path URL for a sound by filename
|
||||
/// - Parameter fileName: The sound filename (e.g., "classic-alarm.mp3")
|
||||
/// - Returns: The file URL if found, nil otherwise
|
||||
func getSoundPath(for fileName: String) -> URL? {
|
||||
Design.debugLog("[audio] Looking for sound file: \(fileName)")
|
||||
|
||||
// Try AlarmSounds.bundle first
|
||||
if let bundleURL = Bundle.main.url(forResource: "AlarmSounds", withExtension: "bundle"),
|
||||
let alarmBundle = Bundle(url: bundleURL) {
|
||||
|
||||
// Try with full filename
|
||||
let nameWithoutExtension = (fileName as NSString).deletingPathExtension
|
||||
let ext = (fileName as NSString).pathExtension
|
||||
|
||||
if let url = alarmBundle.url(forResource: nameWithoutExtension, withExtension: ext) {
|
||||
Design.debugLog("[audio] ✅ Found in AlarmSounds.bundle: \(url)")
|
||||
return url
|
||||
}
|
||||
Design.debugLog("[audio] Not found in AlarmSounds.bundle with extension '\(ext)'")
|
||||
|
||||
// Try common extensions
|
||||
for tryExt in ["mp3", "caf", "wav", "m4a"] {
|
||||
if let url = alarmBundle.url(forResource: nameWithoutExtension, withExtension: tryExt) {
|
||||
Design.debugLog("[audio] ✅ Found in AlarmSounds.bundle with .\(tryExt): \(url)")
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try AlarmSounds folder in main bundle
|
||||
let nameWithoutExtension = (fileName as NSString).deletingPathExtension
|
||||
let ext = (fileName as NSString).pathExtension
|
||||
|
||||
if let url = Bundle.main.url(forResource: "AlarmSounds/\(nameWithoutExtension)", withExtension: ext) {
|
||||
Design.debugLog("[audio] ✅ Found in AlarmSounds folder: \(url)")
|
||||
return url
|
||||
}
|
||||
|
||||
// Try main bundle directly
|
||||
if let url = Bundle.main.url(forResource: nameWithoutExtension, withExtension: ext) {
|
||||
Design.debugLog("[audio] ✅ Found in main bundle: \(url)")
|
||||
return url
|
||||
}
|
||||
|
||||
Design.debugLog("[audio] ❌ Sound file not found: \(fileName)")
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Log all available alarm sounds
|
||||
func logAvailableSounds() {
|
||||
Design.debugLog("[audio] ========== AVAILABLE ALARM SOUNDS ==========")
|
||||
let sounds = getAlarmSounds()
|
||||
Design.debugLog("[audio] Found \(sounds.count) alarm sound(s)")
|
||||
for sound in sounds {
|
||||
Design.debugLog("[audio] - \(sound.name): \(sound.fileName)")
|
||||
if let path = getSoundPath(for: sound.fileName) {
|
||||
Design.debugLog("[audio] Path: \(path.lastPathComponent)")
|
||||
} else {
|
||||
Design.debugLog("[audio] ⚠️ FILE NOT FOUND")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,26 +5,31 @@
|
||||
// Created by Matt Bruce on 9/7/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import UserNotifications
|
||||
import AlarmKit
|
||||
import AudioPlaybackKit
|
||||
import Bedrock
|
||||
import Bedrock
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// ViewModel for alarm management
|
||||
/// ViewModel for alarm management using AlarmKit (iOS 26+).
|
||||
/// AlarmKit provides alarms that cut through Focus modes and silent mode,
|
||||
/// with built-in Live Activity countdown support.
|
||||
@Observable
|
||||
class AlarmViewModel {
|
||||
|
||||
// MARK: - Properties
|
||||
private let alarmService: AlarmService
|
||||
private let notificationService: NotificationService
|
||||
private let alarmKitService = AlarmKitService.shared
|
||||
private let alarmSoundService = AlarmSoundService.shared
|
||||
private let soundPlayer = SoundPlayer.shared
|
||||
private let liveActivityManager = AlarmLiveActivityManager()
|
||||
|
||||
var activeAlarm: Alarm?
|
||||
|
||||
/// Whether AlarmKit is authorized
|
||||
var isAlarmKitAuthorized: Bool {
|
||||
alarmKitService.authorizationState == .authorized
|
||||
}
|
||||
|
||||
var alarms: [Alarm] {
|
||||
alarmService.alarms
|
||||
}
|
||||
@ -34,55 +39,51 @@ class AlarmViewModel {
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
init(alarmService: AlarmService = AlarmService(),
|
||||
notificationService: NotificationService = NotificationService()) {
|
||||
init(alarmService: AlarmService = AlarmService()) {
|
||||
self.alarmService = alarmService
|
||||
self.notificationService = notificationService
|
||||
|
||||
// Register alarm service with notification delegate for snooze handling
|
||||
NotificationDelegate.shared.setAlarmService(alarmService)
|
||||
}
|
||||
|
||||
// MARK: - Authorization
|
||||
|
||||
/// Request AlarmKit authorization. Should be called during onboarding.
|
||||
func requestAlarmKitAuthorization() async -> Bool {
|
||||
return await alarmKitService.requestAuthorization()
|
||||
}
|
||||
|
||||
// MARK: - Public Interface
|
||||
func addAlarm(_ alarm: Alarm) async {
|
||||
alarmService.addAlarm(alarm)
|
||||
|
||||
// Schedule notification if alarm is enabled
|
||||
// Schedule with AlarmKit if alarm is enabled
|
||||
if alarm.isEnabled {
|
||||
Design.debugLog("[alarms] Scheduling alarm notification for \(alarm.label). LiveActivities=\(isLiveActivitiesEnabled())")
|
||||
await notificationService.scheduleAlarmNotification(
|
||||
id: alarm.id.uuidString,
|
||||
title: alarm.label,
|
||||
body: isLiveActivitiesEnabled() ? "" : alarm.notificationMessage,
|
||||
soundName: alarm.soundName,
|
||||
date: alarm.time
|
||||
)
|
||||
requestKeepAwakePromptIfNeeded()
|
||||
Design.debugLog("[alarms] Scheduling AlarmKit alarm for \(alarm.label)")
|
||||
do {
|
||||
try await alarmKitService.scheduleAlarm(alarm)
|
||||
} catch {
|
||||
Design.debugLog("[alarms] AlarmKit scheduling failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateAlarm(_ alarm: Alarm) async {
|
||||
alarmService.updateAlarm(alarm)
|
||||
|
||||
// Reschedule notification
|
||||
// Cancel existing and reschedule if enabled
|
||||
alarmKitService.cancelAlarm(id: alarm.id)
|
||||
|
||||
if alarm.isEnabled {
|
||||
Design.debugLog("[alarms] Rescheduling alarm notification for \(alarm.label). LiveActivities=\(isLiveActivitiesEnabled())")
|
||||
await notificationService.scheduleAlarmNotification(
|
||||
id: alarm.id.uuidString,
|
||||
title: alarm.label,
|
||||
body: isLiveActivitiesEnabled() ? "" : alarm.notificationMessage,
|
||||
soundName: alarm.soundName,
|
||||
date: alarm.time
|
||||
)
|
||||
requestKeepAwakePromptIfNeeded()
|
||||
} else {
|
||||
notificationService.cancelNotification(id: alarm.id.uuidString)
|
||||
Design.debugLog("[alarms] Rescheduling AlarmKit alarm for \(alarm.label)")
|
||||
do {
|
||||
try await alarmKitService.scheduleAlarm(alarm)
|
||||
} catch {
|
||||
Design.debugLog("[alarms] AlarmKit rescheduling failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteAlarm(id: UUID) async {
|
||||
// Cancel notification first
|
||||
notificationService.cancelNotification(id: id.uuidString)
|
||||
// Cancel AlarmKit alarm first
|
||||
alarmKitService.cancelAlarm(id: id)
|
||||
|
||||
// Then delete from storage
|
||||
alarmService.deleteAlarm(id: id)
|
||||
@ -94,19 +95,16 @@ class AlarmViewModel {
|
||||
alarm.isEnabled.toggle()
|
||||
alarmService.updateAlarm(alarm)
|
||||
|
||||
// Schedule or cancel notification based on new state
|
||||
// Schedule or cancel based on new state
|
||||
if alarm.isEnabled {
|
||||
Design.debugLog("[alarms] Enabling alarm \(alarm.label). LiveActivities=\(isLiveActivitiesEnabled())")
|
||||
await notificationService.scheduleAlarmNotification(
|
||||
id: alarm.id.uuidString,
|
||||
title: alarm.label,
|
||||
body: isLiveActivitiesEnabled() ? "" : alarm.notificationMessage,
|
||||
soundName: alarm.soundName,
|
||||
date: alarm.time
|
||||
)
|
||||
requestKeepAwakePromptIfNeeded()
|
||||
Design.debugLog("[alarms] Enabling AlarmKit alarm \(alarm.label)")
|
||||
do {
|
||||
try await alarmKitService.scheduleAlarm(alarm)
|
||||
} catch {
|
||||
Design.debugLog("[alarms] AlarmKit scheduling failed: \(error)")
|
||||
}
|
||||
} else {
|
||||
notificationService.cancelNotification(id: id.uuidString)
|
||||
alarmKitService.cancelAlarm(id: id)
|
||||
}
|
||||
}
|
||||
|
||||
@ -137,182 +135,134 @@ class AlarmViewModel {
|
||||
volume: volume
|
||||
)
|
||||
}
|
||||
|
||||
func requestNotificationPermissions() async -> Bool {
|
||||
return await notificationService.requestPermissions()
|
||||
}
|
||||
|
||||
func requestKeepAwakePromptIfNeeded() {
|
||||
guard !isKeepAwakeEnabled() else { return }
|
||||
NotificationCenter.default.post(name: .keepAwakePromptRequested, object: nil)
|
||||
}
|
||||
|
||||
// MARK: - Active Alarm Handling
|
||||
|
||||
func handleAlarmNotification(userInfo: [AnyHashable: Any]?) {
|
||||
guard let userInfo else { return }
|
||||
Design.debugLog("[alarms] handleAlarmNotification userInfo keys: \(Array(userInfo.keys))")
|
||||
if let alarm = resolveAlarm(from: userInfo) {
|
||||
startActiveAlarm(alarm)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop an active alarm using AlarmKit.
|
||||
/// - Parameter id: The UUID of the alarm to stop.
|
||||
@MainActor
|
||||
func stopActiveAlarm() {
|
||||
func stopAlarm(id: UUID) {
|
||||
alarmKitService.stopAlarm(id: id)
|
||||
|
||||
// Also stop any in-app sound if playing
|
||||
soundPlayer.stopSound()
|
||||
if let alarm = activeAlarm, let stored = alarmService.getAlarm(id: alarm.id) {
|
||||
|
||||
// Disable the alarm after it fires (one-time alarms)
|
||||
if let stored = alarmService.getAlarm(id: id) {
|
||||
var updated = stored
|
||||
updated.isEnabled = false
|
||||
alarmService.updateAlarm(updated)
|
||||
notificationService.cancelNotification(id: updated.id.uuidString)
|
||||
}
|
||||
|
||||
activeAlarm = nil
|
||||
liveActivityManager.endActivity()
|
||||
Design.debugLog("[alarms] Alarm stopped: \(id)")
|
||||
}
|
||||
|
||||
/// Snooze an active alarm using AlarmKit's countdown feature.
|
||||
/// - Parameter id: The UUID of the alarm to snooze.
|
||||
@MainActor
|
||||
func snoozeAlarm(id: UUID) {
|
||||
alarmKitService.snoozeAlarm(id: id)
|
||||
|
||||
// Stop any in-app sound if playing
|
||||
soundPlayer.stopSound()
|
||||
|
||||
activeAlarm = nil
|
||||
Design.debugLog("[alarms] Alarm snoozed: \(id)")
|
||||
}
|
||||
|
||||
/// Legacy method for backward compatibility with notification-based alarms.
|
||||
@MainActor
|
||||
func stopActiveAlarm() {
|
||||
guard let alarm = activeAlarm else { return }
|
||||
stopAlarm(id: alarm.id)
|
||||
}
|
||||
|
||||
/// Legacy method for backward compatibility with notification-based alarms.
|
||||
@MainActor
|
||||
func snoozeActiveAlarm() {
|
||||
guard let alarm = activeAlarm else { return }
|
||||
scheduleSnoozeNotification(for: alarm)
|
||||
stopActiveAlarm()
|
||||
snoozeAlarm(id: alarm.id)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func startActiveAlarm(_ alarm: Alarm) {
|
||||
if activeAlarm?.id == alarm.id {
|
||||
return
|
||||
}
|
||||
activeAlarm = alarm
|
||||
playAlarmSound(for: alarm)
|
||||
Design.debugLog("[alarms] Alarm fired: \(alarm.label). LiveActivities=\(isLiveActivitiesEnabled())")
|
||||
if isLiveActivitiesEnabled() {
|
||||
liveActivityManager.startOrUpdate(for: alarm)
|
||||
}
|
||||
}
|
||||
// MARK: - AlarmKit Updates
|
||||
|
||||
private func playAlarmSound(for alarm: Alarm) {
|
||||
let resolvedSound = alarmSoundService.getAlarmSound(fileName: alarm.soundName)
|
||||
?? alarmSoundService.getDefaultAlarmSound()
|
||||
guard let sound = resolvedSound else { return }
|
||||
soundPlayer.playSound(sound, volume: alarm.volume)
|
||||
}
|
||||
|
||||
private func isKeepAwakeEnabled() -> Bool {
|
||||
guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey),
|
||||
let style = try? JSONDecoder().decode(ClockStyle.self, from: data) else {
|
||||
return ClockStyle().keepAwake
|
||||
}
|
||||
return style.keepAwake
|
||||
}
|
||||
|
||||
private func isLiveActivitiesEnabled() -> Bool {
|
||||
guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey),
|
||||
let style = try? JSONDecoder().decode(ClockStyle.self, from: data) else {
|
||||
return ClockStyle().liveActivitiesEnabled
|
||||
}
|
||||
return style.liveActivitiesEnabled
|
||||
}
|
||||
|
||||
private func scheduleSnoozeNotification(for alarm: Alarm) {
|
||||
let snoozeTime = Date().addingTimeInterval(TimeInterval(alarm.snoozeDuration * 60))
|
||||
let snoozeAlarm = Alarm(
|
||||
id: UUID(),
|
||||
time: snoozeTime,
|
||||
isEnabled: true,
|
||||
soundName: alarm.soundName,
|
||||
label: "\(alarm.label) (Snoozed)",
|
||||
notificationMessage: "Snoozed: \(alarm.notificationMessage)",
|
||||
snoozeDuration: alarm.snoozeDuration,
|
||||
isVibrationEnabled: alarm.isVibrationEnabled,
|
||||
isLightFlashEnabled: alarm.isLightFlashEnabled,
|
||||
volume: alarm.volume
|
||||
)
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = snoozeAlarm.label
|
||||
content.body = snoozeAlarm.notificationMessage
|
||||
if snoozeAlarm.soundName == "default" {
|
||||
content.sound = .default
|
||||
} else {
|
||||
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: snoozeAlarm.soundName))
|
||||
}
|
||||
content.categoryIdentifier = AlarmNotificationConstants.categoryIdentifier
|
||||
content.userInfo = [
|
||||
AlarmNotificationKeys.alarmId: snoozeAlarm.id.uuidString,
|
||||
AlarmNotificationKeys.soundName: snoozeAlarm.soundName,
|
||||
AlarmNotificationKeys.isSnooze: true,
|
||||
AlarmNotificationKeys.originalAlarmId: alarm.id.uuidString,
|
||||
AlarmNotificationKeys.label: snoozeAlarm.label,
|
||||
AlarmNotificationKeys.notificationMessage: snoozeAlarm.notificationMessage,
|
||||
AlarmNotificationKeys.snoozeDuration: snoozeAlarm.snoozeDuration,
|
||||
AlarmNotificationKeys.isVibrationEnabled: snoozeAlarm.isVibrationEnabled,
|
||||
AlarmNotificationKeys.volume: snoozeAlarm.volume
|
||||
]
|
||||
|
||||
let trigger = UNTimeIntervalNotificationTrigger(
|
||||
timeInterval: snoozeAlarm.time.timeIntervalSinceNow,
|
||||
repeats: false
|
||||
)
|
||||
|
||||
let request = UNNotificationRequest(
|
||||
identifier: snoozeAlarm.id.uuidString,
|
||||
content: content,
|
||||
trigger: trigger
|
||||
)
|
||||
|
||||
/// Start observing AlarmKit alarm updates.
|
||||
/// Call this when the app becomes active to sync with system alarm state.
|
||||
func startObservingAlarmUpdates() {
|
||||
Design.debugLog("[alarmkit] Starting to observe alarm updates")
|
||||
Task {
|
||||
do {
|
||||
try await UNUserNotificationCenter.current().add(request)
|
||||
} catch {
|
||||
// Intentionally silent; notification system logs errors
|
||||
for await alarms in alarmKitService.alarmUpdates {
|
||||
await handleAlarmUpdates(alarms)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func resolveAlarm(from userInfo: [AnyHashable: Any]) -> Alarm? {
|
||||
if let alarmIdString = userInfo[AlarmNotificationKeys.alarmId] as? String,
|
||||
let alarmId = UUID(uuidString: alarmIdString),
|
||||
let alarm = alarmService.getAlarm(id: alarmId) {
|
||||
return alarm
|
||||
@MainActor
|
||||
private func handleAlarmUpdates(_ alarms: [AlarmKit.Alarm]) {
|
||||
Design.debugLog("[alarmkit] Received alarm update: \(alarms.count) alarm(s)")
|
||||
|
||||
for alarm in alarms {
|
||||
Design.debugLog("[alarmkit] Alarm \(alarm.id): state=\(alarm.state)")
|
||||
|
||||
switch alarm.state {
|
||||
case .alerting:
|
||||
// Alarm is currently ringing - find the matching app alarm
|
||||
if let appAlarm = alarmService.getAlarm(id: alarm.id) {
|
||||
activeAlarm = appAlarm
|
||||
Design.debugLog("[alarmkit] 🔔 ALARM ALERTING: \(appAlarm.label)")
|
||||
// Play alarm sound in-app as backup
|
||||
playAlarmSound(appAlarm)
|
||||
} else {
|
||||
Design.debugLog("[alarmkit] ⚠️ Alerting alarm not found in storage: \(alarm.id)")
|
||||
}
|
||||
case .countdown:
|
||||
Design.debugLog("[alarmkit] ⏱️ Alarm counting down: \(alarm.id)")
|
||||
default:
|
||||
Design.debugLog("[alarmkit] Other state for alarm \(alarm.id): \(alarm.state)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Play alarm sound in-app (backup for AlarmKit sound)
|
||||
@MainActor
|
||||
private func playAlarmSound(_ alarm: Alarm) {
|
||||
Design.debugLog("[alarmkit] Playing in-app alarm sound: \(alarm.soundName)")
|
||||
|
||||
// Get the Sound object from AlarmSoundService
|
||||
if let sound = alarmSoundService.getAlarmSound(fileName: alarm.soundName) {
|
||||
Design.debugLog("[alarmkit] Sound found: \(sound.name)")
|
||||
soundPlayer.playSound(sound, volume: alarm.volume)
|
||||
} else {
|
||||
Design.debugLog("[alarmkit] ⚠️ Sound not found for: \(alarm.soundName)")
|
||||
// Try to find any default alarm sound
|
||||
if let defaultSound = alarmSoundService.getDefaultAlarmSound() {
|
||||
Design.debugLog("[alarmkit] Using default sound: \(defaultSound.name)")
|
||||
soundPlayer.playSound(defaultSound, volume: alarm.volume)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - App Lifecycle
|
||||
|
||||
/// Reschedule all enabled alarms with AlarmKit.
|
||||
/// Call this on app launch to ensure alarms are registered.
|
||||
func rescheduleAllAlarms() async {
|
||||
Design.debugLog("[alarmkit] ========== RESCHEDULING ALL ALARMS ==========")
|
||||
|
||||
let enabledAlarms = alarmService.getEnabledAlarms()
|
||||
Design.debugLog("[alarmkit] Found \(enabledAlarms.count) enabled alarm(s)")
|
||||
|
||||
for alarm in enabledAlarms {
|
||||
Design.debugLog("[alarmkit] Scheduling: \(alarm.label) at \(alarm.time)")
|
||||
do {
|
||||
try await alarmKitService.scheduleAlarm(alarm)
|
||||
} catch {
|
||||
Design.debugLog("[alarmkit] ❌ Failed to reschedule \(alarm.label): \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
let title = (userInfo[AlarmNotificationKeys.label] as? String) ?? (userInfo[AlarmNotificationKeys.title] as? String)
|
||||
let body = (userInfo[AlarmNotificationKeys.notificationMessage] as? String) ?? (userInfo[AlarmNotificationKeys.body] as? String)
|
||||
let soundName = (userInfo[AlarmNotificationKeys.soundName] as? String) ?? AppConstants.SystemSounds.defaultSound
|
||||
let snoozeDuration = intValue(from: userInfo[AlarmNotificationKeys.snoozeDuration]) ?? 9
|
||||
let isVibrationEnabled = boolValue(from: userInfo[AlarmNotificationKeys.isVibrationEnabled]) ?? true
|
||||
let volume = floatValue(from: userInfo[AlarmNotificationKeys.volume]) ?? 1.0
|
||||
|
||||
return Alarm(
|
||||
time: Date(),
|
||||
isEnabled: true,
|
||||
soundName: soundName,
|
||||
label: title ?? "Alarm",
|
||||
notificationMessage: body ?? "Your alarm is ringing",
|
||||
snoozeDuration: snoozeDuration,
|
||||
isVibrationEnabled: isVibrationEnabled,
|
||||
isLightFlashEnabled: false,
|
||||
volume: volume
|
||||
)
|
||||
}
|
||||
|
||||
private func intValue(from value: Any?) -> Int? {
|
||||
if let intValue = value as? Int { return intValue }
|
||||
if let number = value as? NSNumber { return number.intValue }
|
||||
return nil
|
||||
}
|
||||
|
||||
private func floatValue(from value: Any?) -> Float? {
|
||||
if let floatValue = value as? Float { return floatValue }
|
||||
if let doubleValue = value as? Double { return Float(doubleValue) }
|
||||
if let number = value as? NSNumber { return number.floatValue }
|
||||
return nil
|
||||
}
|
||||
|
||||
private func boolValue(from value: Any?) -> Bool? {
|
||||
if let boolValue = value as? Bool { return boolValue }
|
||||
if let number = value as? NSNumber { return number.boolValue }
|
||||
return nil
|
||||
Design.debugLog("[alarmkit] ========== RESCHEDULING COMPLETE ==========")
|
||||
alarmKitService.logCurrentAlarms()
|
||||
}
|
||||
}
|
||||
|
||||
@ -57,7 +57,7 @@ struct AlarmScreen: View {
|
||||
|
||||
#Preview {
|
||||
AlarmScreen(
|
||||
alarm: Alarm(time: Date(), soundName: "digital-alarm.caf", label: "Wake Up", notificationMessage: "Alarm", snoozeDuration: 9, isVibrationEnabled: true, isLightFlashEnabled: false, volume: 1.0),
|
||||
alarm: Alarm(time: Date(), soundName: "digital-alarm.mp3", label: "Wake Up", notificationMessage: "Alarm", snoozeDuration: 9, isVibrationEnabled: true, isLightFlashEnabled: false, volume: 1.0),
|
||||
onSnooze: {},
|
||||
onStop: {}
|
||||
)
|
||||
|
||||
@ -83,9 +83,9 @@ struct AlarmView: View {
|
||||
}
|
||||
.onAppear {
|
||||
Task {
|
||||
await viewModel.requestNotificationPermissions()
|
||||
// Request AlarmKit authorization when the alarms tab appears
|
||||
await viewModel.requestAlarmKitAuthorization()
|
||||
}
|
||||
viewModel.requestKeepAwakePromptIfNeeded()
|
||||
}
|
||||
.sheet(isPresented: $showAddAlarm) {
|
||||
AddAlarmView(
|
||||
|
||||
@ -3,9 +3,12 @@
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Streamlined onboarding flow optimized for time-to-value.
|
||||
// Shows real clock immediately, requests permissions with context,
|
||||
// Shows real clock immediately, requests AlarmKit permission,
|
||||
// and gets users to their "aha moment" fast.
|
||||
//
|
||||
// Updated for AlarmKit (iOS 26+) - alarms now cut through
|
||||
// Focus modes and silent mode automatically.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
@ -19,10 +22,9 @@ struct OnboardingView: View {
|
||||
let onComplete: () -> Void
|
||||
|
||||
@State private var currentPage = 0
|
||||
@State private var notificationPermissionGranted = false
|
||||
@State private var showCelebration = false
|
||||
@State private var alarmKitPermissionGranted = false
|
||||
@State private var keepAwakeEnabled = false
|
||||
@State private var liveActivitiesEnabled = false
|
||||
@State private var showCelebration = false
|
||||
|
||||
private let totalPages = 3
|
||||
|
||||
@ -124,84 +126,49 @@ struct OnboardingView: View {
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
}
|
||||
|
||||
// MARK: - Page 2: Permissions (Contextual)
|
||||
// MARK: - Page 2: AlarmKit Permissions
|
||||
|
||||
private var permissionsPage: some View {
|
||||
VStack(spacing: Design.Spacing.xxLarge) {
|
||||
Spacer()
|
||||
|
||||
// Alarm icon with context
|
||||
// Alarm icon with animated waves
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(AppAccent.primary.opacity(0.15))
|
||||
.frame(width: 120, height: 120)
|
||||
|
||||
Image(systemName: "alarm.fill")
|
||||
Image(systemName: "alarm.waves.left.and.right.fill")
|
||||
.font(.system(size: 50, weight: .medium))
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.symbolEffect(.pulse, options: .repeating)
|
||||
}
|
||||
|
||||
Text("Never miss an alarm")
|
||||
Text("Alarms that actually work")
|
||||
.typography(.heroBold)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("Enable notifications so alarms work even when the app is closed or your phone is locked.")
|
||||
Text("Works in silent mode, Focus mode, and even when your phone is locked.")
|
||||
.typography(.body)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
Text("For reliable alarms, keep TheNoiseClock on-screen.")
|
||||
.typography(.body)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
|
||||
Button {
|
||||
enableKeepAwake()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: keepAwakeEnabled ? "checkmark.circle.fill" : "bolt.fill")
|
||||
Text(keepAwakeEnabled ? "Keep Awake Enabled" : "Enable Keep Awake")
|
||||
}
|
||||
.typography(.bodyEmphasis)
|
||||
.foregroundStyle(keepAwakeEnabled ? AppStatus.success : .white)
|
||||
.frame(maxWidth: 280)
|
||||
.padding(Design.Spacing.medium)
|
||||
.background(keepAwakeEnabled ? AppStatus.success.opacity(0.15) : AppAccent.primary)
|
||||
.cornerRadius(Design.CornerRadius.medium)
|
||||
}
|
||||
.disabled(keepAwakeEnabled)
|
||||
}
|
||||
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
Text("Show alarms on the Dynamic Island and Lock Screen while they are ringing.")
|
||||
.typography(.body)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
|
||||
Button {
|
||||
enableLiveActivities()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: liveActivitiesEnabled ? "checkmark.circle.fill" : "sparkles")
|
||||
Text(liveActivitiesEnabled ? "Live Activities Enabled" : "Enable Live Activities")
|
||||
}
|
||||
.typography(.bodyEmphasis)
|
||||
.foregroundStyle(liveActivitiesEnabled ? AppStatus.success : .white)
|
||||
.frame(maxWidth: 280)
|
||||
.padding(Design.Spacing.medium)
|
||||
.background(liveActivitiesEnabled ? AppStatus.success.opacity(0.15) : AppAccent.primary)
|
||||
.cornerRadius(Design.CornerRadius.medium)
|
||||
}
|
||||
.disabled(liveActivitiesEnabled)
|
||||
// Feature bullets
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||
alarmFeatureRow(icon: "moon.zzz.fill", text: "Cuts through Do Not Disturb")
|
||||
alarmFeatureRow(icon: "lock.iphone", text: "Shows countdown on Lock Screen")
|
||||
alarmFeatureRow(icon: "iphone.badge.play", text: "Works when app is closed")
|
||||
}
|
||||
.padding(.top, Design.Spacing.medium)
|
||||
|
||||
// Permission button or success state
|
||||
permissionButton
|
||||
.padding(.top, Design.Spacing.large)
|
||||
|
||||
// Optional: Keep Awake for bedside clock mode
|
||||
keepAwakeSection
|
||||
.padding(.top, Design.Spacing.medium)
|
||||
|
||||
Spacer()
|
||||
@ -210,18 +177,58 @@ struct OnboardingView: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.onAppear {
|
||||
keepAwakeEnabled = isKeepAwakeEnabled()
|
||||
liveActivitiesEnabled = isLiveActivitiesEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
private var keepAwakeSection: some View {
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
Text("Want the clock always visible?")
|
||||
.typography(.callout)
|
||||
.foregroundStyle(AppTextColors.tertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Button {
|
||||
enableKeepAwake()
|
||||
} label: {
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
Image(systemName: keepAwakeEnabled ? "checkmark.circle.fill" : "bolt.fill")
|
||||
Text(keepAwakeEnabled ? "Keep Awake Enabled" : "Enable Keep Awake")
|
||||
}
|
||||
.typography(.callout)
|
||||
.foregroundStyle(keepAwakeEnabled ? AppStatus.success : AppTextColors.secondary)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
.padding(.vertical, Design.Spacing.small)
|
||||
.background(keepAwakeEnabled ? AppStatus.success.opacity(0.15) : AppSurface.secondary)
|
||||
.cornerRadius(Design.CornerRadius.medium)
|
||||
}
|
||||
.disabled(keepAwakeEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
private func alarmFeatureRow(icon: String, text: String) -> some View {
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.frame(width: 28)
|
||||
|
||||
Text(text)
|
||||
.typography(.body)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
}
|
||||
|
||||
private var permissionButton: some View {
|
||||
Group {
|
||||
if notificationPermissionGranted {
|
||||
if alarmKitPermissionGranted {
|
||||
// Success state
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 24))
|
||||
Text("You're all set!")
|
||||
Text("Alarms enabled!")
|
||||
}
|
||||
.foregroundStyle(AppStatus.success)
|
||||
.typography(.bodyEmphasis)
|
||||
@ -229,13 +236,13 @@ struct OnboardingView: View {
|
||||
.background(AppStatus.success.opacity(0.15))
|
||||
.cornerRadius(Design.CornerRadius.medium)
|
||||
} else {
|
||||
// Request button
|
||||
// Request AlarmKit authorization
|
||||
Button {
|
||||
requestNotificationPermission()
|
||||
requestAlarmKitPermission()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "bell.badge.fill")
|
||||
Text("Enable Notifications")
|
||||
Image(systemName: "alarm.fill")
|
||||
Text("Enable Alarms")
|
||||
}
|
||||
.typography(.bodyEmphasis)
|
||||
.foregroundStyle(.white)
|
||||
@ -269,7 +276,7 @@ struct OnboardingView: View {
|
||||
.typography(.heroBold)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
|
||||
Text("Tap below to start using your new bedside clock. Try long-pressing the clock for immersive mode!")
|
||||
Text("Your alarms will work even in silent mode and Focus mode. Try long-pressing the clock for immersive mode!")
|
||||
.typography(.body)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@ -277,9 +284,9 @@ struct OnboardingView: View {
|
||||
|
||||
// Quick tips
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||
tipRow(icon: "alarm.fill", text: "Create your first alarm")
|
||||
tipRow(icon: "hand.tap", text: "Long-press clock for full screen")
|
||||
tipRow(icon: "speaker.wave.2", text: "Tap Noise to play sounds")
|
||||
tipRow(icon: "plus", text: "Tap + on Alarms to add one")
|
||||
}
|
||||
.padding(.top, Design.Spacing.medium)
|
||||
|
||||
@ -384,11 +391,12 @@ struct OnboardingView: View {
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func requestNotificationPermission() {
|
||||
private func requestAlarmKitPermission() {
|
||||
Task {
|
||||
let granted = await NotificationUtils.requestPermissions()
|
||||
// Request AlarmKit authorization (iOS 26+)
|
||||
let granted = await AlarmKitService.shared.requestAuthorization()
|
||||
withAnimation(.spring(duration: 0.3)) {
|
||||
notificationPermissionGranted = granted
|
||||
alarmKitPermissionGranted = granted
|
||||
}
|
||||
// Auto-advance after permission granted
|
||||
if granted {
|
||||
@ -399,7 +407,7 @@ struct OnboardingView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func enableKeepAwake() {
|
||||
var style = loadClockStyle()
|
||||
style.keepAwake = true
|
||||
@ -409,24 +417,10 @@ struct OnboardingView: View {
|
||||
keepAwakeEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
private func enableLiveActivities() {
|
||||
var style = loadClockStyle()
|
||||
style.liveActivitiesEnabled = true
|
||||
saveClockStyle(style)
|
||||
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
|
||||
withAnimation(.spring(duration: 0.3)) {
|
||||
liveActivitiesEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
private func isKeepAwakeEnabled() -> Bool {
|
||||
loadClockStyle().keepAwake
|
||||
}
|
||||
|
||||
private func isLiveActivitiesEnabled() -> Bool {
|
||||
loadClockStyle().liveActivitiesEnabled
|
||||
}
|
||||
|
||||
private func loadClockStyle() -> ClockStyle {
|
||||
guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey),
|
||||
|
||||
@ -14,5 +14,7 @@
|
||||
<string>$(APPCLIP_DOMAIN)</string>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>NSAlarmKitUsageDescription</key>
|
||||
<string>TheNoiseClock uses alarms to wake you up at your scheduled time, even when your device is in silent mode or Focus mode.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
{
|
||||
"id": "digital-alarm",
|
||||
"name": "Digital Alarm",
|
||||
"fileName": "digital-alarm.caf",
|
||||
"fileName": "digital-alarm.mp3",
|
||||
"description": "Classic digital alarm sound",
|
||||
"category": "alarm",
|
||||
"bundleName": null,
|
||||
@ -12,7 +12,7 @@
|
||||
{
|
||||
"id": "buzzing-alarm",
|
||||
"name": "Buzzing Alarm",
|
||||
"fileName": "buzzing-alarm.caf",
|
||||
"fileName": "buzzing-alarm.mp3",
|
||||
"description": "Buzzing sound for gentle wake-up",
|
||||
"category": "alarm",
|
||||
"bundleName": null
|
||||
@ -20,7 +20,7 @@
|
||||
{
|
||||
"id": "classic-alarm",
|
||||
"name": "Classic Alarm",
|
||||
"fileName": "classic-alarm.caf",
|
||||
"fileName": "classic-alarm.mp3",
|
||||
"description": "Traditional alarm sound",
|
||||
"category": "alarm",
|
||||
"bundleName": null
|
||||
@ -28,7 +28,7 @@
|
||||
{
|
||||
"id": "beep-alarm",
|
||||
"name": "Beep Alarm",
|
||||
"fileName": "beep-alarm.caf",
|
||||
"fileName": "beep-alarm.mp3",
|
||||
"description": "Short beep alarm sound",
|
||||
"category": "alarm",
|
||||
"bundleName": null
|
||||
@ -36,7 +36,7 @@
|
||||
{
|
||||
"id": "siren-alarm",
|
||||
"name": "Siren Alarm",
|
||||
"fileName": "siren-alarm.caf",
|
||||
"fileName": "siren-alarm.mp3",
|
||||
"description": "Emergency siren alarm for heavy sleepers",
|
||||
"category": "alarm",
|
||||
"bundleName": null
|
||||
|
||||
Binary file not shown.
BIN
TheNoiseClock/Resources/AlarmSounds/beep-alarm.mp3
Normal file
BIN
TheNoiseClock/Resources/AlarmSounds/beep-alarm.mp3
Normal file
Binary file not shown.
Binary file not shown.
BIN
TheNoiseClock/Resources/AlarmSounds/buzzing-alarm.mp3
Normal file
BIN
TheNoiseClock/Resources/AlarmSounds/buzzing-alarm.mp3
Normal file
Binary file not shown.
Binary file not shown.
BIN
TheNoiseClock/Resources/AlarmSounds/classic-alarm.mp3
Normal file
BIN
TheNoiseClock/Resources/AlarmSounds/classic-alarm.mp3
Normal file
Binary file not shown.
Binary file not shown.
BIN
TheNoiseClock/Resources/AlarmSounds/digital-alarm.mp3
Normal file
BIN
TheNoiseClock/Resources/AlarmSounds/digital-alarm.mp3
Normal file
Binary file not shown.
Binary file not shown.
BIN
TheNoiseClock/Resources/AlarmSounds/siren-alarm.mp3
Normal file
BIN
TheNoiseClock/Resources/AlarmSounds/siren-alarm.mp3
Normal file
Binary file not shown.
@ -12,7 +12,7 @@ enum AppConstants {
|
||||
|
||||
// MARK: - App Information
|
||||
static let appName = "TheNoiseClock"
|
||||
static let minimumIOSVersion = "18.0"
|
||||
static let minimumIOSVersion = "26.0"
|
||||
|
||||
// MARK: - Storage Keys
|
||||
enum StorageKeys {
|
||||
@ -59,7 +59,7 @@ enum AppConstants {
|
||||
|
||||
// MARK: - System Sounds
|
||||
enum SystemSounds {
|
||||
static let defaultSound = "digital-alarm.caf"
|
||||
static let defaultSound = "digital-alarm.mp3"
|
||||
static let availableSounds = ["default", "bell", "chimes", "ding", "glass", "silence"]
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
//
|
||||
// AlarmActivityAttributes.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Created by Matt Bruce on 2/2/26.
|
||||
//
|
||||
|
||||
import ActivityKit
|
||||
import Foundation
|
||||
|
||||
struct AlarmActivityAttributes: ActivityAttributes {
|
||||
struct ContentState: Codable, Hashable {
|
||||
var alarmDate: Date
|
||||
var label: String
|
||||
}
|
||||
|
||||
var id: UUID
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
//
|
||||
// NoiseClockAlarmMetadata.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Created by Matt Bruce on 2/2/26.
|
||||
//
|
||||
|
||||
import AlarmKit
|
||||
import Foundation
|
||||
|
||||
/// Metadata for alarm Live Activities, shared between app and widget extension.
|
||||
/// Must conform to AlarmMetadata and be nonisolated for cross-actor use.
|
||||
nonisolated struct NoiseClockAlarmMetadata: AlarmMetadata {
|
||||
/// The unique identifier for the alarm
|
||||
var alarmId: String
|
||||
|
||||
/// The sound file name to play when the alarm fires
|
||||
var soundName: String
|
||||
|
||||
/// The snooze duration in minutes
|
||||
var snoozeDuration: Int
|
||||
|
||||
/// The alarm label to display
|
||||
var label: String
|
||||
|
||||
/// Volume level (0.0 to 1.0)
|
||||
var volume: Float
|
||||
}
|
||||
@ -5,51 +5,175 @@
|
||||
// Created by Matt Bruce on 2/2/26.
|
||||
//
|
||||
|
||||
import ActivityKit
|
||||
import AlarmKit
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
/// Live Activity widget for alarm countdown and alerting states.
|
||||
/// Uses AlarmKit's AlarmAttributes for automatic countdown management.
|
||||
struct AlarmLiveActivityWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: AlarmActivityAttributes.self) { context in
|
||||
VStack(spacing: 8) {
|
||||
Text("Next Alarm")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(context.state.label)
|
||||
.font(.headline)
|
||||
Text(context.state.alarmDate, style: .time)
|
||||
.font(.title2.weight(.bold))
|
||||
}
|
||||
.padding()
|
||||
ActivityConfiguration(for: AlarmAttributes<NoiseClockAlarmMetadata>.self) { context in
|
||||
// Lock Screen presentation
|
||||
LockScreenAlarmView(
|
||||
attributes: context.attributes,
|
||||
state: context.state
|
||||
)
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
// Expanded regions
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
Text("Alarm")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let metadata = context.attributes.metadata {
|
||||
AlarmTitleView(metadata: metadata)
|
||||
} else {
|
||||
Text("Alarm")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
Text(context.state.alarmDate, style: .time)
|
||||
.font(.caption2)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.center) {
|
||||
Text(context.state.label)
|
||||
.font(.headline)
|
||||
AlarmProgressView(state: context.state)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
Text("Alarm at \(context.state.alarmDate, style: .time)")
|
||||
.font(.caption)
|
||||
ExpandedAlarmView(
|
||||
attributes: context.attributes,
|
||||
state: context.state
|
||||
)
|
||||
}
|
||||
} compactLeading: {
|
||||
Image(systemName: "alarm")
|
||||
// Compact leading - countdown text
|
||||
CountdownTextView(state: context.state)
|
||||
.foregroundStyle(context.attributes.tintColor)
|
||||
} compactTrailing: {
|
||||
Text(context.state.alarmDate, style: .time)
|
||||
.font(.caption2)
|
||||
// Compact trailing - progress ring
|
||||
AlarmProgressView(state: context.state)
|
||||
.frame(maxWidth: 32)
|
||||
} minimal: {
|
||||
Image(systemName: "alarm")
|
||||
// Minimal - just an alarm icon
|
||||
Image(systemName: "alarm.fill")
|
||||
.foregroundStyle(context.attributes.tintColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lock Screen View
|
||||
|
||||
struct LockScreenAlarmView: View {
|
||||
let attributes: AlarmAttributes<NoiseClockAlarmMetadata>
|
||||
let state: AlarmPresentationState
|
||||
|
||||
private var alarmLabel: String {
|
||||
attributes.metadata?.label ?? "Alarm"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
// Alarm label
|
||||
Text(alarmLabel)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
// Countdown state
|
||||
if case .countdown(let countdown) = state.mode {
|
||||
VStack(spacing: 4) {
|
||||
Text("Alarm in")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(timerInterval: Date.now...countdown.fireDate, countsDown: true)
|
||||
.font(.system(size: 48, weight: .bold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(attributes.tintColor)
|
||||
}
|
||||
} else if case .paused = state.mode {
|
||||
Text("Paused")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
// Other states (alerting, etc.)
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: "alarm.waves.left.and.right.fill")
|
||||
.font(.system(size: 32))
|
||||
.foregroundStyle(attributes.tintColor)
|
||||
.symbolEffect(.pulse)
|
||||
Text("Alarm Ringing")
|
||||
.font(.title3.weight(.semibold))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Expanded Dynamic Island View
|
||||
|
||||
struct ExpandedAlarmView: View {
|
||||
let attributes: AlarmAttributes<NoiseClockAlarmMetadata>
|
||||
let state: AlarmPresentationState
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
CountdownTextView(state: state)
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
AlarmProgressView(state: state)
|
||||
.frame(maxHeight: 30)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Countdown Text View
|
||||
|
||||
struct CountdownTextView: View {
|
||||
let state: AlarmPresentationState
|
||||
|
||||
var body: some View {
|
||||
if case .countdown(let countdown) = state.mode {
|
||||
Text(timerInterval: Date.now...countdown.fireDate, countsDown: true)
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
} else if case .paused = state.mode {
|
||||
Text("Paused")
|
||||
} else {
|
||||
Text("Now!")
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Progress View
|
||||
|
||||
struct AlarmProgressView: View {
|
||||
let state: AlarmPresentationState
|
||||
|
||||
var body: some View {
|
||||
if case .countdown(let countdown) = state.mode {
|
||||
ProgressView(
|
||||
timerInterval: Date.now...countdown.fireDate,
|
||||
label: { EmptyView() },
|
||||
currentValueLabel: { Text("") }
|
||||
)
|
||||
.progressViewStyle(.circular)
|
||||
} else if case .paused = state.mode {
|
||||
Image(systemName: "pause.fill")
|
||||
} else {
|
||||
Image(systemName: "alarm.waves.left.and.right.fill")
|
||||
.symbolEffect(.pulse)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Title View
|
||||
|
||||
struct AlarmTitleView: View {
|
||||
let metadata: NoiseClockAlarmMetadata
|
||||
|
||||
var body: some View {
|
||||
Text(metadata.label)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
32
TheNoiseClockWidget/NoiseClockAlarmMetadata.swift
Normal file
32
TheNoiseClockWidget/NoiseClockAlarmMetadata.swift
Normal file
@ -0,0 +1,32 @@
|
||||
//
|
||||
// NoiseClockAlarmMetadata.swift
|
||||
// TheNoiseClockWidget
|
||||
//
|
||||
// Created by Matt Bruce on 2/2/26.
|
||||
//
|
||||
// NOTE: This file must be kept in sync with the main app's version.
|
||||
// In Xcode, add the main app's NoiseClockAlarmMetadata.swift to both targets
|
||||
// and remove this file, or use a shared Swift package.
|
||||
//
|
||||
|
||||
import AlarmKit
|
||||
import Foundation
|
||||
|
||||
/// Metadata for alarm Live Activities, shared between app and widget extension.
|
||||
/// Must conform to AlarmMetadata and be nonisolated for cross-actor use.
|
||||
nonisolated struct NoiseClockAlarmMetadata: AlarmMetadata {
|
||||
/// The unique identifier for the alarm
|
||||
var alarmId: String
|
||||
|
||||
/// The sound file name to play when the alarm fires
|
||||
var soundName: String
|
||||
|
||||
/// The snooze duration in minutes
|
||||
var snoozeDuration: Int
|
||||
|
||||
/// The alarm label to display
|
||||
var label: String
|
||||
|
||||
/// Volume level (0.0 to 1.0)
|
||||
var volume: Float
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user