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
|
// MARK: - Properties
|
||||||
private var players: [String: AVAudioPlayer] = [:]
|
private var players: [String: AVAudioPlayer] = [:]
|
||||||
|
private let playersLock = NSLock()
|
||||||
private var currentPlayer: AVAudioPlayer?
|
private var currentPlayer: AVAudioPlayer?
|
||||||
public private(set) var currentSound: Sound?
|
public private(set) var currentSound: Sound?
|
||||||
private var shouldResumeAfterInterruption = false
|
private var shouldResumeAfterInterruption = false
|
||||||
@ -27,8 +28,11 @@ public class SoundPlayer {
|
|||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
private init() {
|
private init() {
|
||||||
setupAudioSession()
|
setupAudioSession()
|
||||||
preloadSounds()
|
|
||||||
setupAudioInterruptionHandling()
|
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 {
|
deinit {
|
||||||
@ -58,9 +62,16 @@ public class SoundPlayer {
|
|||||||
currentSound = sound
|
currentSound = sound
|
||||||
|
|
||||||
// Get or create player for this 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("❌ Sound not preloaded: \(sound.fileName)")
|
||||||
print("📁 Available sounds: \(players.keys)")
|
print("📁 Available sounds: \(availableKeys)")
|
||||||
|
|
||||||
// Try to load the sound dynamically as fallback
|
// Try to load the sound dynamically as fallback
|
||||||
guard let fileUrl = getURL(for: sound) else {
|
guard let fileUrl = getURL(for: sound) else {
|
||||||
@ -73,7 +84,9 @@ public class SoundPlayer {
|
|||||||
newPlayer.numberOfLoops = AudioConstants.Playback.numberOfLoops
|
newPlayer.numberOfLoops = AudioConstants.Playback.numberOfLoops
|
||||||
newPlayer.volume = volumeOverride ?? AudioConstants.Volume.default
|
newPlayer.volume = volumeOverride ?? AudioConstants.Volume.default
|
||||||
newPlayer.prepareToPlay()
|
newPlayer.prepareToPlay()
|
||||||
|
playersLock.lock()
|
||||||
players[sound.fileName] = newPlayer
|
players[sound.fileName] = newPlayer
|
||||||
|
playersLock.unlock()
|
||||||
currentPlayer = newPlayer
|
currentPlayer = newPlayer
|
||||||
let success = newPlayer.play()
|
let success = newPlayer.play()
|
||||||
print("🎵 Fallback play result: \(success ? "SUCCESS" : "FAILED")")
|
print("🎵 Fallback play result: \(success ? "SUCCESS" : "FAILED")")
|
||||||
@ -187,20 +200,27 @@ public class SoundPlayer {
|
|||||||
if settings.preloadSounds {
|
if settings.preloadSounds {
|
||||||
player.prepareToPlay()
|
player.prepareToPlay()
|
||||||
}
|
}
|
||||||
|
playersLock.lock()
|
||||||
players[sound.fileName] = player
|
players[sound.fileName] = player
|
||||||
|
playersLock.unlock()
|
||||||
print("✅ Loaded: \(sound.name) (\(sound.fileName))")
|
print("✅ Loaded: \(sound.name) (\(sound.fileName))")
|
||||||
} catch {
|
} catch {
|
||||||
print("❌ Error preloading sound \(sound.fileName): \(error)")
|
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() {
|
private func stopAllSounds() {
|
||||||
|
playersLock.lock()
|
||||||
for player in players.values {
|
for player in players.values {
|
||||||
player.stop()
|
player.stop()
|
||||||
}
|
}
|
||||||
players.removeAll()
|
players.removeAll()
|
||||||
|
playersLock.unlock()
|
||||||
currentPlayer = nil
|
currentPlayer = nil
|
||||||
currentSound = nil
|
currentSound = nil
|
||||||
shouldResumeAfterInterruption = false
|
shouldResumeAfterInterruption = false
|
||||||
|
|||||||
@ -51,7 +51,7 @@
|
|||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference 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; };
|
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; };
|
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; };
|
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
|
// MARK: - Body
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
Group {
|
||||||
// Main tab content
|
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) {
|
TabView(selection: $selectedTab) {
|
||||||
Tab("Clock", systemImage: "clock", value: AppTab.clock) {
|
Tab("Clock", systemImage: "clock", value: AppTab.clock) {
|
||||||
NavigationStack {
|
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)
|
ClockView(viewModel: clockViewModel, isOnClockTab: isOnClockTab)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -87,28 +104,11 @@ struct ContentView: View {
|
|||||||
Design.debugLog("[ContentView] Tab changed: \(oldValue) -> \(newValue)")
|
Design.debugLog("[ContentView] Tab changed: \(oldValue) -> \(newValue)")
|
||||||
if oldValue == .clock && newValue != .clock {
|
if oldValue == .clock && newValue != .clock {
|
||||||
Design.debugLog("[ContentView] Leaving clock tab, setting fullScreenMode to false")
|
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)
|
clockViewModel.setFullScreenMode(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(AppAccent.primary)
|
.tint(AppAccent.primary)
|
||||||
.background(Color.Branding.primary.ignoresSafeArea())
|
.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) {
|
.sheet(isPresented: $keepAwakePromptState.isPresented) {
|
||||||
KeepAwakePrompt(
|
KeepAwakePrompt(
|
||||||
onEnable: {
|
onEnable: {
|
||||||
@ -122,10 +122,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
Design.debugLog("[ContentView] App launched - initializing AlarmKit")
|
Design.debugLog("[ContentView] App launched - initializing AlarmKit")
|
||||||
|
|
||||||
// Reschedule all enabled alarms with AlarmKit on app launch
|
|
||||||
await alarmViewModel.rescheduleAllAlarms()
|
await alarmViewModel.rescheduleAllAlarms()
|
||||||
|
|
||||||
Design.debugLog("[ContentView] AlarmKit initialization complete")
|
Design.debugLog("[ContentView] AlarmKit initialization complete")
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .keepAwakePromptRequested)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .keepAwakePromptRequested)) { _ in
|
||||||
@ -133,7 +130,6 @@ struct ContentView: View {
|
|||||||
guard shouldShowKeepAwakePromptForTab() else { return }
|
guard shouldShowKeepAwakePromptForTab() else { return }
|
||||||
keepAwakePromptState.showIfNeeded(isKeepAwakeEnabled: clockViewModel.style.keepAwake)
|
keepAwakePromptState.showIfNeeded(isKeepAwakeEnabled: clockViewModel.style.keepAwake)
|
||||||
}
|
}
|
||||||
.animation(.spring(duration: 0.45, bounce: 0.2), value: onboardingState.hasCompletedWelcome)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func shouldShowKeepAwakePromptForTab() -> Bool {
|
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
|
/// Log available alarm sounds in the bundle for debugging
|
||||||
private func logAvailableAlarmSounds() {
|
private func logAvailableAlarmSounds() {
|
||||||
Design.debugLog("[alarmkit] ========== AVAILABLE ALARM SOUNDS ==========")
|
Design.debugLog("[alarmkit] ========== AVAILABLE ALARM SOUNDS ==========")
|
||||||
|
|||||||
@ -30,7 +30,7 @@ final class ClockStyle: Codable, Equatable {
|
|||||||
|
|
||||||
// MARK: - Night Mode Settings
|
// MARK: - Night Mode Settings
|
||||||
var nightModeEnabled: Bool = false
|
var nightModeEnabled: Bool = false
|
||||||
var autoNightMode: Bool = false
|
var autoNightMode: Bool = true
|
||||||
var scheduledNightMode: Bool = false
|
var scheduledNightMode: Bool = false
|
||||||
var nightModeStartTime: String = "22:00" // 10:00 PM
|
var nightModeStartTime: String = "22:00" // 10:00 PM
|
||||||
var nightModeEndTime: String = "06:00" // 6:00 AM
|
var nightModeEndTime: String = "06:00" // 6:00 AM
|
||||||
@ -48,14 +48,13 @@ final class ClockStyle: Codable, Equatable {
|
|||||||
var showDate: Bool = true
|
var showDate: Bool = true
|
||||||
var showNextAlarm: Bool = true
|
var showNextAlarm: Bool = true
|
||||||
var showNoiseControls: 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 clockOpacity: Double = AppConstants.Defaults.clockOpacity
|
||||||
var overlayOpacity: Double = AppConstants.Defaults.overlayOpacity
|
var overlayOpacity: Double = AppConstants.Defaults.overlayOpacity
|
||||||
|
|
||||||
// MARK: - Display Settings
|
// MARK: - Display Settings
|
||||||
var keepAwake: Bool = false // Keep screen awake in display mode
|
var keepAwake: Bool = false // Keep screen awake in display mode
|
||||||
var respectFocusModes: Bool = true // Respect Focus mode settings for audio
|
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
|
// MARK: - Cached Colors
|
||||||
private var _cachedDigitColor: Color?
|
private var _cachedDigitColor: Color?
|
||||||
@ -92,7 +91,6 @@ final class ClockStyle: Codable, Equatable {
|
|||||||
case overlayOpacity
|
case overlayOpacity
|
||||||
case keepAwake
|
case keepAwake
|
||||||
case respectFocusModes
|
case respectFocusModes
|
||||||
case liveActivitiesEnabled
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
@ -146,7 +144,6 @@ final class ClockStyle: Codable, Equatable {
|
|||||||
self.overlayOpacity = try container.decodeIfPresent(Double.self, forKey: .overlayOpacity) ?? self.overlayOpacity
|
self.overlayOpacity = try container.decodeIfPresent(Double.self, forKey: .overlayOpacity) ?? self.overlayOpacity
|
||||||
self.keepAwake = try container.decodeIfPresent(Bool.self, forKey: .keepAwake) ?? self.keepAwake
|
self.keepAwake = try container.decodeIfPresent(Bool.self, forKey: .keepAwake) ?? self.keepAwake
|
||||||
self.respectFocusModes = try container.decodeIfPresent(Bool.self, forKey: .respectFocusModes) ?? self.respectFocusModes
|
self.respectFocusModes = try container.decodeIfPresent(Bool.self, forKey: .respectFocusModes) ?? self.respectFocusModes
|
||||||
self.liveActivitiesEnabled = try container.decodeIfPresent(Bool.self, forKey: .liveActivitiesEnabled) ?? self.liveActivitiesEnabled
|
|
||||||
|
|
||||||
clearColorCache()
|
clearColorCache()
|
||||||
}
|
}
|
||||||
@ -182,7 +179,6 @@ final class ClockStyle: Codable, Equatable {
|
|||||||
try container.encode(overlayOpacity, forKey: .overlayOpacity)
|
try container.encode(overlayOpacity, forKey: .overlayOpacity)
|
||||||
try container.encode(keepAwake, forKey: .keepAwake)
|
try container.encode(keepAwake, forKey: .keepAwake)
|
||||||
try container.encode(respectFocusModes, forKey: .respectFocusModes)
|
try container.encode(respectFocusModes, forKey: .respectFocusModes)
|
||||||
try container.encode(liveActivitiesEnabled, forKey: .liveActivitiesEnabled)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
// MARK: - Computed Properties
|
||||||
@ -476,8 +472,7 @@ final class ClockStyle: Codable, Equatable {
|
|||||||
lhs.clockOpacity == rhs.clockOpacity &&
|
lhs.clockOpacity == rhs.clockOpacity &&
|
||||||
lhs.overlayOpacity == rhs.overlayOpacity &&
|
lhs.overlayOpacity == rhs.overlayOpacity &&
|
||||||
lhs.keepAwake == rhs.keepAwake &&
|
lhs.keepAwake == rhs.keepAwake &&
|
||||||
lhs.respectFocusModes == rhs.respectFocusModes &&
|
lhs.respectFocusModes == rhs.respectFocusModes
|
||||||
lhs.liveActivitiesEnabled == rhs.liveActivitiesEnabled
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -124,8 +124,6 @@ final class ClockViewModel {
|
|||||||
style.digitAnimationStyle = newStyle.digitAnimationStyle
|
style.digitAnimationStyle = newStyle.digitAnimationStyle
|
||||||
style.dateFormat = newStyle.dateFormat
|
style.dateFormat = newStyle.dateFormat
|
||||||
style.respectFocusModes = newStyle.respectFocusModes
|
style.respectFocusModes = newStyle.respectFocusModes
|
||||||
style.liveActivitiesEnabled = newStyle.liveActivitiesEnabled
|
|
||||||
|
|
||||||
|
|
||||||
saveStyle()
|
saveStyle()
|
||||||
updateTimersIfNeeded()
|
updateTimersIfNeeded()
|
||||||
|
|||||||
@ -28,18 +28,6 @@ struct AdvancedDisplaySection: View {
|
|||||||
accentColor: AppAccent.primary
|
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 {
|
if style.autoBrightness {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(AppBorder.subtle)
|
.fill(AppBorder.subtle)
|
||||||
|
|||||||
@ -60,7 +60,6 @@ struct OnboardingView: View {
|
|||||||
.tag(3)
|
.tag(3)
|
||||||
}
|
}
|
||||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||||
.animation(.easeInOut(duration: 0.3), value: currentPage)
|
|
||||||
|
|
||||||
OnboardingBottomControls(
|
OnboardingBottomControls(
|
||||||
currentPage: $currentPage,
|
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(
|
private static func weightedFontName(
|
||||||
name: FontFamily,
|
name: FontFamily,
|
||||||
weight: Font.Weight,
|
weight: Font.Weight,
|
||||||
|
|||||||
@ -10,9 +10,9 @@ import Foundation
|
|||||||
extension Date {
|
extension Date {
|
||||||
|
|
||||||
/// Format date for display in overlay with custom format
|
/// Format date for display in overlay with custom format
|
||||||
/// - Parameter format: Date format string (e.g., "d MMMM EEE")
|
/// - Parameter format: Date format string (e.g., "d MMM yyyy")
|
||||||
/// - Returns: Formatted date string
|
/// - Returns: Formatted date string
|
||||||
func formattedForOverlay(format: String = "d MMMM EEE") -> String {
|
func formattedForOverlay(format: String = "d MMM yyyy") -> String {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = format
|
formatter.dateFormat = format
|
||||||
return formatter.string(from: self)
|
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
|
/// Get next occurrence of this time
|
||||||
/// - Returns: Next occurrence of this time, or today if time hasn't passed
|
/// - Returns: Next occurrence of this time, or today if time hasn't passed
|
||||||
func nextOccurrence() -> Date {
|
func nextOccurrence() -> Date {
|
||||||
let calendar = Calendar.current
|
let calendar = Calendar.current
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let today = calendar.startOfDay(for: now)
|
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 {
|
guard let todayWithTime = calendar.date(byAdding: timeComponents, to: today) else {
|
||||||
return now
|
return now
|
||||||
|
|||||||
@ -10,17 +10,6 @@ import Bedrock
|
|||||||
|
|
||||||
extension View {
|
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
|
/// Apply standard button styling
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - isEnabled: Whether the button is enabled
|
/// - isEnabled: Whether the button is enabled
|
||||||
@ -35,28 +24,6 @@ extension View {
|
|||||||
.disabled(!isEnabled)
|
.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
|
/// Force view to update on orientation changes
|
||||||
/// - Returns: View that updates on orientation changes
|
/// - Returns: View that updates on orientation changes
|
||||||
func onOrientationChange() -> some View {
|
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
|
/// Apply standard content padding
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - horizontal: Horizontal padding amount
|
/// - horizontal: Horizontal padding amount
|
||||||
@ -106,22 +62,6 @@ extension View {
|
|||||||
|
|
||||||
// MARK: - View Modifiers
|
// 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
|
/// Modifier that forces view updates on orientation changes
|
||||||
struct OrientationChangeModifier: ViewModifier {
|
struct OrientationChangeModifier: ViewModifier {
|
||||||
@State private var orientation = UIDevice.current.orientation
|
@State private var orientation = UIDevice.current.orientation
|
||||||
|
|||||||
@ -7,31 +7,7 @@
|
|||||||
|
|
||||||
import Foundation
|
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 {
|
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 keepAwakePromptRequested = Notification.Name("keepAwakePromptRequested")
|
||||||
static let clockStyleDidUpdate = Notification.Name("clockStyleDidUpdate")
|
static let clockStyleDidUpdate = Notification.Name("clockStyleDidUpdate")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,8 +23,4 @@ final class KeepAwakePromptState {
|
|||||||
func dismiss() {
|
func dismiss() {
|
||||||
isPresented = false
|
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