Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
2f59f2aaf8
commit
3ed7da63f7
@ -18,6 +18,7 @@ public class SoundPlayer {
|
||||
|
||||
// MARK: - Properties
|
||||
private var players: [String: AVAudioPlayer] = [:]
|
||||
private let playersLock = NSLock()
|
||||
private var currentPlayer: AVAudioPlayer?
|
||||
public private(set) var currentSound: Sound?
|
||||
private var shouldResumeAfterInterruption = false
|
||||
@ -27,8 +28,11 @@ public class SoundPlayer {
|
||||
// MARK: - Initialization
|
||||
private init() {
|
||||
setupAudioSession()
|
||||
preloadSounds()
|
||||
setupAudioInterruptionHandling()
|
||||
// Preload sounds off the main thread to avoid blocking UI during app launch
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
self?.preloadSounds()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
@ -58,9 +62,16 @@ public class SoundPlayer {
|
||||
currentSound = sound
|
||||
|
||||
// Get or create player for this sound
|
||||
guard let player = players[sound.fileName] else {
|
||||
playersLock.lock()
|
||||
let player = players[sound.fileName]
|
||||
playersLock.unlock()
|
||||
|
||||
guard let player else {
|
||||
playersLock.lock()
|
||||
let availableKeys = Array(players.keys)
|
||||
playersLock.unlock()
|
||||
print("❌ Sound not preloaded: \(sound.fileName)")
|
||||
print("📁 Available sounds: \(players.keys)")
|
||||
print("📁 Available sounds: \(availableKeys)")
|
||||
|
||||
// Try to load the sound dynamically as fallback
|
||||
guard let fileUrl = getURL(for: sound) else {
|
||||
@ -73,7 +84,9 @@ public class SoundPlayer {
|
||||
newPlayer.numberOfLoops = AudioConstants.Playback.numberOfLoops
|
||||
newPlayer.volume = volumeOverride ?? AudioConstants.Volume.default
|
||||
newPlayer.prepareToPlay()
|
||||
playersLock.lock()
|
||||
players[sound.fileName] = newPlayer
|
||||
playersLock.unlock()
|
||||
currentPlayer = newPlayer
|
||||
let success = newPlayer.play()
|
||||
print("🎵 Fallback play result: \(success ? "SUCCESS" : "FAILED")")
|
||||
@ -187,20 +200,27 @@ public class SoundPlayer {
|
||||
if settings.preloadSounds {
|
||||
player.prepareToPlay()
|
||||
}
|
||||
playersLock.lock()
|
||||
players[sound.fileName] = player
|
||||
playersLock.unlock()
|
||||
print("✅ Loaded: \(sound.name) (\(sound.fileName))")
|
||||
} catch {
|
||||
print("❌ Error preloading sound \(sound.fileName): \(error)")
|
||||
}
|
||||
}
|
||||
print("📁 Preloading complete. Loaded \(players.count) sounds.")
|
||||
playersLock.lock()
|
||||
let count = players.count
|
||||
playersLock.unlock()
|
||||
print("📁 Preloading complete. Loaded \(count) sounds.")
|
||||
}
|
||||
|
||||
private func stopAllSounds() {
|
||||
playersLock.lock()
|
||||
for player in players.values {
|
||||
player.stop()
|
||||
}
|
||||
players.removeAll()
|
||||
playersLock.unlock()
|
||||
currentPlayer = nil
|
||||
currentSound = nil
|
||||
shouldResumeAfterInterruption = false
|
||||
|
||||
@ -51,7 +51,7 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
EA384AFB2E6E6B6000CA7D50 /* TheNoiseClock.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TheNoiseClock.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EA384AFB2E6E6B6000CA7D50 /* The Noise Clock.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "The Noise Clock.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EA384B082E6E6B6100CA7D50 /* TheNoiseClockTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TheNoiseClockTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EA384B122E6E6B6100CA7D50 /* TheNoiseClockUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TheNoiseClockUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EAD6E3AF5A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = TheNoiseClock/Configuration/Debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
|
||||
@ -45,14 +45,31 @@ struct ContentView: View {
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Main tab content
|
||||
Group {
|
||||
if !onboardingState.hasCompletedWelcome {
|
||||
// Show ONLY the onboarding — no heavy app views behind it.
|
||||
// This prevents ClockView, NoiseView, etc. from initializing
|
||||
// and competing for the main thread during page transitions.
|
||||
OnboardingView {
|
||||
onboardingState.completeWelcome()
|
||||
}
|
||||
.transition(.asymmetric(
|
||||
insertion: .opacity,
|
||||
removal: .opacity.combined(with: .move(edge: .bottom)).combined(with: .scale(scale: 0.9))
|
||||
))
|
||||
} else {
|
||||
mainTabView
|
||||
}
|
||||
}
|
||||
.animation(.spring(duration: 0.45, bounce: 0.2), value: onboardingState.hasCompletedWelcome)
|
||||
}
|
||||
|
||||
// MARK: - Main Tab View
|
||||
|
||||
private var mainTabView: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
Tab("Clock", systemImage: "clock", value: AppTab.clock) {
|
||||
NavigationStack {
|
||||
// Pass isOnClockTab so ClockView can make the right tab bar decision
|
||||
// Tab bar hides ONLY when: isOnClockTab && isDisplayMode
|
||||
// This prevents race conditions on tab switch
|
||||
ClockView(viewModel: clockViewModel, isOnClockTab: isOnClockTab)
|
||||
}
|
||||
}
|
||||
@ -87,28 +104,11 @@ struct ContentView: View {
|
||||
Design.debugLog("[ContentView] Tab changed: \(oldValue) -> \(newValue)")
|
||||
if oldValue == .clock && newValue != .clock {
|
||||
Design.debugLog("[ContentView] Leaving clock tab, setting fullScreenMode to false")
|
||||
// Safety net: also explicitly disable full-screen mode when leaving clock tab
|
||||
// The ClockView's toolbar modifier already responds to isOnClockTab changing
|
||||
clockViewModel.setFullScreenMode(false)
|
||||
}
|
||||
}
|
||||
.tint(AppAccent.primary)
|
||||
.background(Color.Branding.primary.ignoresSafeArea())
|
||||
// Note: AlarmKit handles the alarm UI via the system Lock Screen and Dynamic Island.
|
||||
// No in-app alarm screen is needed - users interact with alarms via the system UI.
|
||||
|
||||
// Onboarding overlay for first-time users
|
||||
if !onboardingState.hasCompletedWelcome {
|
||||
OnboardingView {
|
||||
onboardingState.completeWelcome()
|
||||
}
|
||||
.transition(.asymmetric(
|
||||
insertion: .opacity,
|
||||
removal: .opacity.combined(with: .move(edge: .bottom)).combined(with: .scale(scale: 0.9))
|
||||
))
|
||||
.zIndex(1) // Ensure it stays on top during transition
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $keepAwakePromptState.isPresented) {
|
||||
KeepAwakePrompt(
|
||||
onEnable: {
|
||||
@ -122,10 +122,7 @@ struct ContentView: View {
|
||||
}
|
||||
.task {
|
||||
Design.debugLog("[ContentView] App launched - initializing AlarmKit")
|
||||
|
||||
// Reschedule all enabled alarms with AlarmKit on app launch
|
||||
await alarmViewModel.rescheduleAllAlarms()
|
||||
|
||||
Design.debugLog("[ContentView] AlarmKit initialization complete")
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .keepAwakePromptRequested)) { _ in
|
||||
@ -133,7 +130,6 @@ struct ContentView: View {
|
||||
guard shouldShowKeepAwakePromptForTab() else { return }
|
||||
keepAwakePromptState.showIfNeeded(isKeepAwakeEnabled: clockViewModel.style.keepAwake)
|
||||
}
|
||||
.animation(.spring(duration: 0.45, bounce: 0.2), value: onboardingState.hasCompletedWelcome)
|
||||
}
|
||||
|
||||
private func shouldShowKeepAwakePromptForTab() -> Bool {
|
||||
|
||||
@ -262,19 +262,6 @@ final class AlarmKitService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Log available sound files in Library/Sounds for debugging
|
||||
private func logLibrarySounds() {
|
||||
let soundsDirectory = URL.libraryDirectory.appendingPathComponent("Sounds")
|
||||
Design.debugLog("[alarmkit] ========== LIBRARY/SOUNDS FILES ==========")
|
||||
|
||||
do {
|
||||
let files = try FileManager.default.contentsOfDirectory(atPath: soundsDirectory.path)
|
||||
Design.debugLog("[alarmkit] Files in Library/Sounds: \(files)")
|
||||
} catch {
|
||||
Design.debugLog("[alarmkit] Library/Sounds directory doesn't exist or is empty")
|
||||
}
|
||||
}
|
||||
|
||||
/// Log available alarm sounds in the bundle for debugging
|
||||
private func logAvailableAlarmSounds() {
|
||||
Design.debugLog("[alarmkit] ========== AVAILABLE ALARM SOUNDS ==========")
|
||||
|
||||
@ -30,7 +30,7 @@ final class ClockStyle: Codable, Equatable {
|
||||
|
||||
// MARK: - Night Mode Settings
|
||||
var nightModeEnabled: Bool = false
|
||||
var autoNightMode: Bool = false
|
||||
var autoNightMode: Bool = true
|
||||
var scheduledNightMode: Bool = false
|
||||
var nightModeStartTime: String = "22:00" // 10:00 PM
|
||||
var nightModeEndTime: String = "06:00" // 6:00 AM
|
||||
@ -48,14 +48,13 @@ final class ClockStyle: Codable, Equatable {
|
||||
var showDate: Bool = true
|
||||
var showNextAlarm: Bool = true
|
||||
var showNoiseControls: Bool = true
|
||||
var dateFormat: String = "d MMMM EEE" // Default: "7 September Mon"
|
||||
var dateFormat: String = "d MMM yyyy" // Default: "7 Sept 2026"
|
||||
var clockOpacity: Double = AppConstants.Defaults.clockOpacity
|
||||
var overlayOpacity: Double = AppConstants.Defaults.overlayOpacity
|
||||
|
||||
// MARK: - Display Settings
|
||||
var keepAwake: Bool = false // Keep screen awake in display mode
|
||||
var respectFocusModes: Bool = true // Respect Focus mode settings for audio
|
||||
var liveActivitiesEnabled: Bool = false // Show active alarm in Dynamic Island/Lock Screen
|
||||
|
||||
// MARK: - Cached Colors
|
||||
private var _cachedDigitColor: Color?
|
||||
@ -92,7 +91,6 @@ final class ClockStyle: Codable, Equatable {
|
||||
case overlayOpacity
|
||||
case keepAwake
|
||||
case respectFocusModes
|
||||
case liveActivitiesEnabled
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
@ -146,7 +144,6 @@ final class ClockStyle: Codable, Equatable {
|
||||
self.overlayOpacity = try container.decodeIfPresent(Double.self, forKey: .overlayOpacity) ?? self.overlayOpacity
|
||||
self.keepAwake = try container.decodeIfPresent(Bool.self, forKey: .keepAwake) ?? self.keepAwake
|
||||
self.respectFocusModes = try container.decodeIfPresent(Bool.self, forKey: .respectFocusModes) ?? self.respectFocusModes
|
||||
self.liveActivitiesEnabled = try container.decodeIfPresent(Bool.self, forKey: .liveActivitiesEnabled) ?? self.liveActivitiesEnabled
|
||||
|
||||
clearColorCache()
|
||||
}
|
||||
@ -182,7 +179,6 @@ final class ClockStyle: Codable, Equatable {
|
||||
try container.encode(overlayOpacity, forKey: .overlayOpacity)
|
||||
try container.encode(keepAwake, forKey: .keepAwake)
|
||||
try container.encode(respectFocusModes, forKey: .respectFocusModes)
|
||||
try container.encode(liveActivitiesEnabled, forKey: .liveActivitiesEnabled)
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
@ -476,8 +472,7 @@ final class ClockStyle: Codable, Equatable {
|
||||
lhs.clockOpacity == rhs.clockOpacity &&
|
||||
lhs.overlayOpacity == rhs.overlayOpacity &&
|
||||
lhs.keepAwake == rhs.keepAwake &&
|
||||
lhs.respectFocusModes == rhs.respectFocusModes &&
|
||||
lhs.liveActivitiesEnabled == rhs.liveActivitiesEnabled
|
||||
lhs.respectFocusModes == rhs.respectFocusModes
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -124,8 +124,6 @@ final class ClockViewModel {
|
||||
style.digitAnimationStyle = newStyle.digitAnimationStyle
|
||||
style.dateFormat = newStyle.dateFormat
|
||||
style.respectFocusModes = newStyle.respectFocusModes
|
||||
style.liveActivitiesEnabled = newStyle.liveActivitiesEnabled
|
||||
|
||||
|
||||
saveStyle()
|
||||
updateTimersIfNeeded()
|
||||
|
||||
@ -28,18 +28,6 @@ struct AdvancedDisplaySection: View {
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
|
||||
Rectangle()
|
||||
.fill(AppBorder.subtle)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
|
||||
SettingsToggle(
|
||||
title: "Live Activities",
|
||||
subtitle: "Show alarms on Lock Screen/Dynamic Island while ringing",
|
||||
isOn: $style.liveActivitiesEnabled,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
|
||||
if style.autoBrightness {
|
||||
Rectangle()
|
||||
.fill(AppBorder.subtle)
|
||||
|
||||
@ -60,7 +60,6 @@ struct OnboardingView: View {
|
||||
.tag(3)
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.animation(.easeInOut(duration: 0.3), value: currentPage)
|
||||
|
||||
OnboardingBottomControls(
|
||||
currentPage: $currentPage,
|
||||
|
||||
@ -152,25 +152,6 @@ struct FontUtils {
|
||||
)
|
||||
}
|
||||
|
||||
private static func tightBoundingBox(
|
||||
for text: String,
|
||||
withFont font: UIFont
|
||||
) -> CGSize {
|
||||
let attributedString = NSAttributedString(
|
||||
string: text,
|
||||
attributes: [.font: font]
|
||||
)
|
||||
let rect = attributedString.boundingRect(
|
||||
with: CGSize(
|
||||
width: CGFloat.greatestFiniteMagnitude,
|
||||
height: CGFloat.greatestFiniteMagnitude
|
||||
),
|
||||
options: [.usesLineFragmentOrigin, .usesFontLeading],
|
||||
context: nil
|
||||
)
|
||||
return CGSize(width: ceil(rect.width), height: ceil(rect.height))
|
||||
}
|
||||
|
||||
private static func weightedFontName(
|
||||
name: FontFamily,
|
||||
weight: Font.Weight,
|
||||
|
||||
@ -10,9 +10,9 @@ import Foundation
|
||||
extension Date {
|
||||
|
||||
/// Format date for display in overlay with custom format
|
||||
/// - Parameter format: Date format string (e.g., "d MMMM EEE")
|
||||
/// - Parameter format: Date format string (e.g., "d MMM yyyy")
|
||||
/// - Returns: Formatted date string
|
||||
func formattedForOverlay(format: String = "d MMMM EEE") -> String {
|
||||
func formattedForOverlay(format: String = "d MMM yyyy") -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = format
|
||||
return formatter.string(from: self)
|
||||
@ -41,25 +41,13 @@ extension Date {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get time components for alarm scheduling
|
||||
/// - Returns: DateComponents with hour and minute
|
||||
func timeComponents() -> DateComponents {
|
||||
return Calendar.current.dateComponents([.hour, .minute], from: self)
|
||||
}
|
||||
|
||||
/// Check if date is today
|
||||
/// - Returns: True if date is today
|
||||
func isToday() -> Bool {
|
||||
return Calendar.current.isDateInToday(self)
|
||||
}
|
||||
|
||||
/// Get next occurrence of this time
|
||||
/// - Returns: Next occurrence of this time, or today if time hasn't passed
|
||||
func nextOccurrence() -> Date {
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
let today = calendar.startOfDay(for: now)
|
||||
let timeComponents = self.timeComponents()
|
||||
let timeComponents = calendar.dateComponents([.hour, .minute], from: self)
|
||||
|
||||
guard let todayWithTime = calendar.date(byAdding: timeComponents, to: today) else {
|
||||
return now
|
||||
|
||||
@ -10,17 +10,6 @@ import Bedrock
|
||||
|
||||
extension View {
|
||||
|
||||
/// Apply standard card styling
|
||||
/// - Returns: View with card styling applied
|
||||
func cardStyle() -> some View {
|
||||
self
|
||||
.background(AppSurface.overlay, in: RoundedRectangle(cornerRadius: Design.CornerRadius.appLarge))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.appLarge)
|
||||
.stroke(AppBorder.subtle, lineWidth: Design.LineWidth.thin)
|
||||
)
|
||||
}
|
||||
|
||||
/// Apply standard button styling
|
||||
/// - Parameters:
|
||||
/// - isEnabled: Whether the button is enabled
|
||||
@ -35,28 +24,6 @@ extension View {
|
||||
.disabled(!isEnabled)
|
||||
}
|
||||
|
||||
|
||||
/// Apply responsive font sizing that updates on orientation and layout changes
|
||||
/// - Parameters:
|
||||
/// - baseSize: Base font size
|
||||
/// - isPortrait: Whether in portrait orientation
|
||||
/// - showSeconds: Whether seconds are displayed (for time components)
|
||||
/// - showAmPm: Whether AM/PM is displayed
|
||||
/// - Returns: View with responsive font sizing
|
||||
func responsiveFontSize(
|
||||
baseSize: CGFloat,
|
||||
isPortrait: Bool,
|
||||
showSeconds: Bool = false,
|
||||
showAmPm: Bool = false
|
||||
) -> some View {
|
||||
self.modifier(ResponsiveFontModifier(
|
||||
baseSize: baseSize,
|
||||
isPortrait: isPortrait,
|
||||
showSeconds: showSeconds,
|
||||
showAmPm: showAmPm
|
||||
))
|
||||
}
|
||||
|
||||
/// Force view to update on orientation changes
|
||||
/// - Returns: View that updates on orientation changes
|
||||
func onOrientationChange() -> some View {
|
||||
@ -81,17 +48,6 @@ extension View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply standard section header styling with title
|
||||
/// - Parameter title: The title text
|
||||
/// - Returns: View with section header styling
|
||||
func sectionHeader(title: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||
Text(title)
|
||||
.sectionTitleStyle()
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply standard content padding
|
||||
/// - Parameters:
|
||||
/// - horizontal: Horizontal padding amount
|
||||
@ -106,22 +62,6 @@ extension View {
|
||||
|
||||
// MARK: - View Modifiers
|
||||
|
||||
/// Modifier for responsive font sizing that updates on layout changes
|
||||
struct ResponsiveFontModifier: ViewModifier {
|
||||
let baseSize: CGFloat
|
||||
let isPortrait: Bool
|
||||
let showSeconds: Bool
|
||||
let showAmPm: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.font(.system(size: baseSize, weight: .bold, design: .rounded))
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
|
||||
// Force view update on orientation change
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Modifier that forces view updates on orientation changes
|
||||
struct OrientationChangeModifier: ViewModifier {
|
||||
@State private var orientation = UIDevice.current.orientation
|
||||
|
||||
@ -7,31 +7,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
enum AlarmNotificationConstants {
|
||||
static let categoryIdentifier = "ALARM_CATEGORY"
|
||||
static let snoozeActionIdentifier = "SNOOZE_ACTION"
|
||||
static let stopActionIdentifier = "STOP_ACTION"
|
||||
}
|
||||
|
||||
enum AlarmNotificationKeys {
|
||||
static let alarmId = "alarmId"
|
||||
static let soundName = "soundName"
|
||||
static let repeats = "repeats"
|
||||
static let isSnooze = "isSnooze"
|
||||
static let originalAlarmId = "originalAlarmId"
|
||||
static let label = "label"
|
||||
static let notificationMessage = "notificationMessage"
|
||||
static let snoozeDuration = "snoozeDuration"
|
||||
static let isVibrationEnabled = "isVibrationEnabled"
|
||||
static let volume = "volume"
|
||||
static let title = "title"
|
||||
static let body = "body"
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let alarmDidFire = Notification.Name("alarmDidFire")
|
||||
static let alarmDidStop = Notification.Name("alarmDidStop")
|
||||
static let alarmDidSnooze = Notification.Name("alarmDidSnooze")
|
||||
static let keepAwakePromptRequested = Notification.Name("keepAwakePromptRequested")
|
||||
static let clockStyleDidUpdate = Notification.Name("clockStyleDidUpdate")
|
||||
}
|
||||
|
||||
@ -23,8 +23,4 @@ final class KeepAwakePromptState {
|
||||
func dismiss() {
|
||||
isPresented = false
|
||||
}
|
||||
|
||||
func resetSessionFlag() {
|
||||
hasShownThisSession = false
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,97 +0,0 @@
|
||||
//
|
||||
// NotificationUtils.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Created by Matt Bruce on 9/7/25.
|
||||
//
|
||||
|
||||
import UserNotifications
|
||||
import Foundation
|
||||
import Bedrock
|
||||
|
||||
/// Notification helper functions
|
||||
enum NotificationUtils {
|
||||
|
||||
/// Request notification permissions
|
||||
/// - Returns: True if permission granted
|
||||
static func requestPermissions() async -> Bool {
|
||||
do {
|
||||
let granted = try await UNUserNotificationCenter.current().requestAuthorization(
|
||||
options: [.alert, .sound, .badge]
|
||||
)
|
||||
return granted
|
||||
} catch {
|
||||
Design.debugLog("[general] Error requesting notification permissions: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Create notification content for alarm
|
||||
/// - Parameters:
|
||||
/// - title: Notification title
|
||||
/// - body: Notification body
|
||||
/// - soundName: Sound name for notification
|
||||
/// - Returns: Configured notification content
|
||||
static func createAlarmContent(title: String, body: String, soundName: String) -> UNMutableNotificationContent {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
content.categoryIdentifier = AlarmNotificationConstants.categoryIdentifier
|
||||
|
||||
if soundName == "default" {
|
||||
content.sound = UNNotificationSound.default
|
||||
Design.debugLog("[settings] Using default notification sound")
|
||||
} else if Bundle.main.url(forResource: soundName, withExtension: nil) != nil {
|
||||
// Use the sound name directly since sounds.json now references CAF files
|
||||
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: soundName))
|
||||
Design.debugLog("[settings] Using custom alarm sound: \(soundName)")
|
||||
Design.debugLog("[settings] Sound file should be in main bundle: \(soundName)")
|
||||
} else {
|
||||
content.sound = UNNotificationSound.default
|
||||
Design.debugLog("[settings] Alarm sound not found in main bundle, falling back to default: \(soundName)")
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
/// Create calendar trigger for alarm
|
||||
/// - Parameter date: Date for alarm
|
||||
/// - Returns: Calendar notification trigger
|
||||
static func createCalendarTrigger(for date: Date) -> UNCalendarNotificationTrigger {
|
||||
let components = Calendar.current.dateComponents([.hour, .minute], from: date)
|
||||
return UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
|
||||
}
|
||||
|
||||
/// Schedule notification
|
||||
/// - Parameters:
|
||||
/// - identifier: Unique identifier for notification
|
||||
/// - content: Notification content
|
||||
/// - trigger: Notification trigger
|
||||
/// - Returns: True if scheduled successfully
|
||||
static func scheduleNotification(
|
||||
identifier: String,
|
||||
content: UNMutableNotificationContent,
|
||||
trigger: UNNotificationTrigger
|
||||
) async -> Bool {
|
||||
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
|
||||
|
||||
do {
|
||||
try await UNUserNotificationCenter.current().add(request)
|
||||
return true
|
||||
} catch {
|
||||
Design.debugLog("[general] Error scheduling notification: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove notification by identifier
|
||||
/// - Parameter identifier: Notification identifier to remove
|
||||
static func removeNotification(identifier: String) {
|
||||
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [identifier])
|
||||
}
|
||||
|
||||
/// Remove all pending notifications
|
||||
static func removeAllNotifications() {
|
||||
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user