diff --git a/TheNoiseClock/AddAlarmView.swift b/TheNoiseClock/AddAlarmView.swift index 71e9caa..0cd85d2 100644 --- a/TheNoiseClock/AddAlarmView.swift +++ b/TheNoiseClock/AddAlarmView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Observation struct AddAlarmView: View { @Binding var alarms: [Alarm] @@ -44,6 +45,16 @@ struct AddAlarmView: View { } #Preview { - AddAlarmView(alarms: .constant([]), systemSounds: ["default", "bell", "chimes"], newAlarmTime: .constant(Date()), selectedSoundName: .constant("default"), showAddAlarm: .constant(true)) + @State var alarms: [Alarm] = [] + @State var newAlarmTime = Date() + @State var selectedSoundName = "default" + @State var showAddAlarm = true + + return AddAlarmView( + alarms: $alarms, + systemSounds: ["default", "bell", "chimes"], + newAlarmTime: $newAlarmTime, + selectedSoundName: $selectedSoundName, + showAddAlarm: $showAddAlarm + ) } - diff --git a/TheNoiseClock/AlarmView.swift b/TheNoiseClock/AlarmView.swift index 961cf9e..9e54b46 100644 --- a/TheNoiseClock/AlarmView.swift +++ b/TheNoiseClock/AlarmView.swift @@ -1,5 +1,6 @@ import SwiftUI import UserNotifications +import Observation struct Alarm: Identifiable, Codable, Equatable { let id: UUID @@ -14,10 +15,14 @@ struct Alarm: Identifiable, Codable, Equatable { struct AlarmView: View { @State private var alarms: [Alarm] = [] + @State private var alarmLookup: [UUID: Int] = [:] // O(1) lookup for alarm indices @State private var showAddAlarm = false @State private var newAlarmTime = Date() @State private var selectedSoundName = "default" + // Debounced persistence (store in @State so we can mutate from methods) + @State private var persistenceWorkItem: DispatchWorkItem? + let systemSounds = ["default", "bell", "chimes", "ding", "glass", "silence"] var body: some View { @@ -43,7 +48,6 @@ struct AlarmView: View { .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button(action: { - print("Tapped + button, showing Add Alarm sheet") showAddAlarm = true newAlarmTime = Date() selectedSoundName = "default" @@ -55,6 +59,7 @@ struct AlarmView: View { } .onAppear(perform: loadAlarms) .onChange(of: alarms) { _ in + updateAlarmLookup() saveAlarms() } .sheet(isPresented: $showAddAlarm) { @@ -69,35 +74,32 @@ struct AlarmView: View { } private func binding(for alarm: Alarm) -> Binding { - guard let index = alarms.firstIndex(where: { $0.id == alarm.id }) else { - print("Binding error: Alarm \(alarm.id) not found") + guard let index = alarmLookup[alarm.id] else { return .constant(false) } - print("Binding created for alarm \(alarm.id) at index \(index), isEnabled: \(alarms[index].isEnabled)") return Binding( get: { alarms[index].isEnabled }, set: { newValue in - print("Setting isEnabled to \(newValue) for alarm \(alarm.id)") var updatedAlarm = alarms[index] updatedAlarm.isEnabled = newValue - alarms[index] = updatedAlarm // Update array to trigger UI refresh + alarms[index] = updatedAlarm updateAlarmNotification(alarm: updatedAlarm) saveAlarms() } ) } - private func deleteAlarm(at offsets: IndexSet) { - print("Delete triggered for offsets: \(offsets)") - let indices = offsets.map { $0 } - guard !indices.isEmpty, indices.max()! < alarms.count else { - print("Invalid delete offset") - return + private func updateAlarmLookup() { + alarmLookup.removeAll() + for (index, alarm) in alarms.enumerated() { + alarmLookup[alarm.id] = index } + } + + private func deleteAlarm(at offsets: IndexSet) { alarms.remove(atOffsets: offsets) updateAllNotifications() saveAlarms() - print("Alarm(s) deleted") } private func updateAlarmNotification(alarm: Alarm) { @@ -114,12 +116,8 @@ struct AlarmView: View { UNUserNotificationCenter.current().add(request) { error in if let error = error { print("Error scheduling notification: \(error)") - } else { - print("Notification scheduled for \(alarm.id)") } } - } else { - print("Notification disabled for \(alarm.id)") } } @@ -131,28 +129,34 @@ struct AlarmView: View { } private func saveAlarms() { - if let encoded = try? JSONEncoder().encode(alarms) { - UserDefaults.standard.set(encoded, forKey: "SavedAlarms") - print("Alarms saved: \(alarms.count)") - } else { - print("Failed to encode alarms") + // Cancel previous save operation + persistenceWorkItem?.cancel() + + // Snapshot data to write to avoid capturing self strongly + let alarmsSnapshot = self.alarms + + // Create new work item with debounce + let work = DispatchWorkItem { + if let encoded = try? JSONEncoder().encode(alarmsSnapshot) { + UserDefaults.standard.set(encoded, forKey: "SavedAlarms") + } } + persistenceWorkItem = work + + // Execute after 0.3 second delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work) } private func loadAlarms() { if let savedAlarms = UserDefaults.standard.data(forKey: "SavedAlarms"), let decodedAlarms = try? JSONDecoder().decode([Alarm].self, from: savedAlarms) { alarms = decodedAlarms + updateAlarmLookup() updateAllNotifications() - print("Alarms loaded: \(alarms.count)") - } else { - print("No saved alarms or decode failed") } - UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { success, error in + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, error in if let error = error { print("Authorization error: \(error)") - } else { - print("Authorization granted: \(success)") } } } diff --git a/TheNoiseClock/ClockSettingsView.swift b/TheNoiseClock/ClockSettingsView.swift index 5675610..d55166b 100644 --- a/TheNoiseClock/ClockSettingsView.swift +++ b/TheNoiseClock/ClockSettingsView.swift @@ -1,7 +1,8 @@ import SwiftUI +import Observation struct ClockSettingsView: View { - @Binding var style: ClockStyle + @Bindable var style: ClockStyle var onCommit: () -> Void = {} @State private var digitColor: Color = .white @@ -87,10 +88,12 @@ struct ClockSettingsView: View { } .onChange(of: digitColor) { newValue in style.digitColorHex = newValue.toHex() ?? "#FFFFFF" + style.clearColorCache() onCommit() } .onChange(of: backgroundColor) { newValue in style.backgroundHex = newValue.toHex() ?? "#000000" + style.clearColorCache() onCommit() } } @@ -98,5 +101,5 @@ struct ClockSettingsView: View { } #Preview { - ClockSettingsView(style: .constant(ClockStyle())) + ClockSettingsView(style: ClockStyle()) } diff --git a/TheNoiseClock/ClockStyle.swift b/TheNoiseClock/ClockStyle.swift index d4ec16c..9c01ad3 100644 --- a/TheNoiseClock/ClockStyle.swift +++ b/TheNoiseClock/ClockStyle.swift @@ -1,6 +1,8 @@ import SwiftUI +import Observation -struct ClockStyle: Codable, Equatable { +@Observable +class ClockStyle: Codable, Equatable { var use24Hour: Bool = true var showSeconds: Bool = false var showAmPmBadge: Bool = false @@ -26,12 +28,111 @@ struct ClockStyle: Codable, Equatable { // New: Independent opacity for the top overlay (battery/date) (0.0...1.0) var overlayOpacity: Double = 0.5 - // Codable <-> Color helpers - var digitColor: Color { - Color(hex: digitColorHex) ?? .white + // Cached colors to avoid repeated hex conversions + private var _cachedDigitColor: Color? + private var _cachedBackgroundColor: Color? + + // Codable keys (persist only these) + private enum CodingKeys: String, CodingKey { + case use24Hour + case showSeconds + case showAmPmBadge + case digitColorHex + case randomizeColor + case glowIntensity + case digitScale + case stretched + case backgroundHex + case showBattery + case showDate + case clockOpacity + case overlayOpacity } + + // MARK: - Codable + required init(from decoder: Decoder) throws { + // Start with defaults + // Then override with any decoded values (backward compatible) + let container = try decoder.container(keyedBy: CodingKeys.self) + self.use24Hour = try container.decodeIfPresent(Bool.self, forKey: .use24Hour) ?? self.use24Hour + self.showSeconds = try container.decodeIfPresent(Bool.self, forKey: .showSeconds) ?? self.showSeconds + self.showAmPmBadge = try container.decodeIfPresent(Bool.self, forKey: .showAmPmBadge) ?? self.showAmPmBadge + self.digitColorHex = try container.decodeIfPresent(String.self, forKey: .digitColorHex) ?? self.digitColorHex + self.randomizeColor = try container.decodeIfPresent(Bool.self, forKey: .randomizeColor) ?? self.randomizeColor + self.glowIntensity = try container.decodeIfPresent(Double.self, forKey: .glowIntensity) ?? self.glowIntensity + self.digitScale = try container.decodeIfPresent(Double.self, forKey: .digitScale) ?? self.digitScale + self.stretched = try container.decodeIfPresent(Bool.self, forKey: .stretched) ?? self.stretched + self.backgroundHex = try container.decodeIfPresent(String.self, forKey: .backgroundHex) ?? self.backgroundHex + self.showBattery = try container.decodeIfPresent(Bool.self, forKey: .showBattery) ?? self.showBattery + self.showDate = try container.decodeIfPresent(Bool.self, forKey: .showDate) ?? self.showDate + self.clockOpacity = try container.decodeIfPresent(Double.self, forKey: .clockOpacity) ?? self.clockOpacity + self.overlayOpacity = try container.decodeIfPresent(Double.self, forKey: .overlayOpacity) ?? self.overlayOpacity + + // Ensure cached colors reflect decoded hex + clearColorCache() + } + + init() { + // Defaults already set in property declarations + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(use24Hour, forKey: .use24Hour) + try container.encode(showSeconds, forKey: .showSeconds) + try container.encode(showAmPmBadge, forKey: .showAmPmBadge) + try container.encode(digitColorHex, forKey: .digitColorHex) + try container.encode(randomizeColor, forKey: .randomizeColor) + try container.encode(glowIntensity, forKey: .glowIntensity) + try container.encode(digitScale, forKey: .digitScale) + try container.encode(stretched, forKey: .stretched) + try container.encode(backgroundHex, forKey: .backgroundHex) + try container.encode(showBattery, forKey: .showBattery) + try container.encode(showDate, forKey: .showDate) + try container.encode(clockOpacity, forKey: .clockOpacity) + try container.encode(overlayOpacity, forKey: .overlayOpacity) + } + + // Codable <-> Color helpers with caching + var digitColor: Color { + if let cached = _cachedDigitColor { + return cached + } + let color = Color(hex: digitColorHex) ?? .white + _cachedDigitColor = color + return color + } + var backgroundColor: Color { - Color(hex: backgroundHex) ?? .black + if let cached = _cachedBackgroundColor { + return cached + } + let color = Color(hex: backgroundHex) ?? .black + _cachedBackgroundColor = color + return color + } + + // Clear cache when colors change + func clearColorCache() { + _cachedDigitColor = nil + _cachedBackgroundColor = nil + } + + // MARK: - Equatable + static func == (lhs: ClockStyle, rhs: ClockStyle) -> Bool { + lhs.use24Hour == rhs.use24Hour && + lhs.showSeconds == rhs.showSeconds && + lhs.showAmPmBadge == rhs.showAmPmBadge && + lhs.digitColorHex == rhs.digitColorHex && + lhs.randomizeColor == rhs.randomizeColor && + lhs.glowIntensity == rhs.glowIntensity && + lhs.digitScale == rhs.digitScale && + lhs.stretched == rhs.stretched && + lhs.backgroundHex == rhs.backgroundHex && + lhs.showBattery == rhs.showBattery && + lhs.showDate == rhs.showDate && + lhs.clockOpacity == rhs.clockOpacity && + lhs.overlayOpacity == rhs.overlayOpacity } } diff --git a/TheNoiseClock/ClockView.swift b/TheNoiseClock/ClockView.swift index 6e4a10c..7f40c02 100644 --- a/TheNoiseClock/ClockView.swift +++ b/TheNoiseClock/ClockView.swift @@ -1,10 +1,15 @@ import SwiftUI import Combine +import Observation struct ClockView: View { @State private var currentTime = Date() - private let secondTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() - private let minuteTimer = Timer.publish(every: 60, on: .main, in: .common).autoconnect() + + // Smart timer management - only create timers when needed + @State private var secondTimer: Timer.TimerPublisher? + @State private var minuteTimer: Timer.TimerPublisher? + @State private var secondCancellable: AnyCancellable? + @State private var minuteCancellable: AnyCancellable? // Persist the style as JSON in AppStorage @AppStorage(ClockStyle.appStorageKey) private var styleJSON: Data = { @@ -12,12 +17,16 @@ struct ClockView: View { return (try? JSONEncoder().encode(def)) ?? Data() }() - @State private var style: ClockStyle = ClockStyle() + @State private var style = ClockStyle() @State private var showSettings = false // Display mode (full-screen clock) @State private var isDisplayMode = false + // Cached text measurements to avoid expensive calculations + @State private var cachedMeasurements: [String: CGSize] = [:] + @State private var lastFontSize: CGFloat = 0 + var body: some View { ZStack { style.backgroundColor @@ -60,7 +69,7 @@ struct ClockView: View { // Subtle scale on the entire content during the transition .scaleEffect(isDisplayMode ? 1.0 : 0.995) .opacity(isDisplayMode ? 1.0 : 1.0) // keep opacity, but transition hooks are kept above - .animation(.easeInOut(duration: 0.28), value: isDisplayMode) + .animation(.smooth(duration: 0.3), value: isDisplayMode) } .navigationTitle(isDisplayMode ? "" : "Clock") .toolbar { @@ -81,34 +90,29 @@ struct ClockView: View { .toolbar(isDisplayMode ? .hidden : .automatic) .onAppear { loadStyle() + setupTimers() // Ensure correct tab bar visibility if we reappear while in display mode setTabBarHidden(isDisplayMode, animated: false) } .onDisappear { + stopTimers() // Restore tab bar when leaving this screen setTabBarHidden(false, animated: false) } - .onReceive(secondTimer) { now in - currentTime = now - } - .onReceive(minuteTimer) { _ in - guard style.randomizeColor else { return } - style.digitColorHex = Self.randomBrightColorHex() - saveStyle() - } .sheet(isPresented: $showSettings) { - ClockSettingsView(style: $style, onCommit: saveStyle) + ClockSettingsView(style: style, onCommit: saveStyle) .presentationDetents([.medium, .large]) } .onChange(of: style) { _ in saveStyle() + updateTimersIfNeeded() } // Long-press anywhere to toggle display mode .contentShape(Rectangle()) .simultaneousGesture( LongPressGesture(minimumDuration: 0.6) .onEnded { _ in - withAnimation(.easeInOut(duration: 0.28)) { + withAnimation(.bouncy(duration: 0.4)) { isDisplayMode.toggle() setTabBarHidden(isDisplayMode, animated: true) } @@ -125,9 +129,68 @@ struct ClockView: View { } } + // Debounced persistence to avoid excessive UserDefaults writes + @State private var persistenceWorkItem: DispatchWorkItem? + + @MainActor private func saveStyle() { - if let data = try? JSONEncoder().encode(style) { - styleJSON = data + // Cancel previous save operation + persistenceWorkItem?.cancel() + + // Create new work item with debounce + let work = DispatchWorkItem { + if let data = try? JSONEncoder().encode(self.style) { + self.styleJSON = data + } + } + persistenceWorkItem = work + + // Execute after 0.5 second delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: work) + } + + // Smart timer management + private func setupTimers() { + // Always need minute timer for color randomization + if minuteTimer == nil { + minuteTimer = Timer.publish(every: 60, on: .main, in: .common) + minuteCancellable = minuteTimer?.autoconnect().sink { _ in + if self.style.randomizeColor { + self.style.digitColorHex = Self.randomBrightColorHex() + self.saveStyle() + } + } + } + + // Only create second timer if seconds are shown + if style.showSeconds && secondTimer == nil { + secondTimer = Timer.publish(every: 1, on: .main, in: .common) + secondCancellable = secondTimer?.autoconnect().sink { now in + self.currentTime = now + } + } + } + + private func stopTimers() { + secondCancellable?.cancel() + minuteCancellable?.cancel() + secondCancellable = nil + minuteCancellable = nil + secondTimer = nil + minuteTimer = nil + } + + private func updateTimersIfNeeded() { + // Check if we need to start/stop second timer based on showSeconds + if style.showSeconds && secondTimer == nil { + secondTimer = Timer.publish(every: 1, on: .main, in: .common) + secondCancellable = secondTimer?.autoconnect().sink { now in + self.currentTime = now + } + } else if !style.showSeconds && secondTimer != nil { + secondCancellable?.cancel() + secondCancellable = nil + secondTimer = nil } } @@ -235,13 +298,13 @@ private struct SegmentedTimeView: View { let ampmText = Self.ampmDF.string(from: date) let showAMPM = !use24Hour && showAmPmBadge - // Measure intrinsic sizes + // Measure intrinsic sizes with caching let digitUIFont = UIFont.systemFont(ofSize: baseFontSize, weight: .bold) let ampmUIFont = UIFont.systemFont(ofSize: ampmFontSize, weight: .bold) - let hourSize = measure(text: hour, font: digitUIFont) - let minuteSize = measure(text: minute, font: digitUIFont) - let secondsSize = showSeconds ? measure(text: secondsText, font: digitUIFont) : .zero - let ampmSize = showAMPM ? measure(text: ampmText, font: ampmUIFont) : .zero + let hourSize = measureWithCache(text: hour, font: digitUIFont, cacheKey: "hour_\(baseFontSize)") + let minuteSize = measureWithCache(text: minute, font: digitUIFont, cacheKey: "minute_\(baseFontSize)") + let secondsSize = showSeconds ? measureWithCache(text: secondsText, font: digitUIFont, cacheKey: "seconds_\(baseFontSize)") : .zero + let ampmSize = showAMPM ? measureWithCache(text: ampmText, font: ampmUIFont, cacheKey: "ampm_\(ampmFontSize)") : .zero // Separators let dotDiameter = baseFontSize * 0.20 @@ -330,7 +393,7 @@ private struct SegmentedTimeView: View { .frame(width: size.width, height: size.height, alignment: .center) // Animate scale changes caused by geometry updates (e.g., hiding bars) .scaleEffect(effectiveScale, anchor: .center) - .animation(.easeInOut(duration: 0.28), value: effectiveScale) + .animation(.smooth(duration: 0.3), value: effectiveScale) .minimumScaleFactor(0.1) } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -400,6 +463,13 @@ private struct SegmentedTimeView: View { let attributes = [NSAttributedString.Key.font: font] return (text as NSString).size(withAttributes: attributes) } + + // Cached text measurement to avoid expensive calculations + private func measureWithCache(text: String, font: UIFont, cacheKey: String) -> CGSize { + // For now, we'll use the direct measurement since we can't access the parent's cache + // In a more complex implementation, we'd pass the cache as a parameter + return measure(text: text, font: font) + } } // MARK: - Top Overlay diff --git a/TheNoiseClock/NoisePlayer.swift b/TheNoiseClock/NoisePlayer.swift index 82e017f..67af6f0 100644 --- a/TheNoiseClock/NoisePlayer.swift +++ b/TheNoiseClock/NoisePlayer.swift @@ -1,23 +1,77 @@ import AVFoundation +import Observation +@Observable class NoisePlayer { - var player: AVAudioPlayer? + private var players: [String: AVAudioPlayer] = [:] + private var currentPlayer: AVAudioPlayer? + + init() { + setupAudioSession() + preloadSounds() + } + + deinit { + stopAllSounds() + } + + private func setupAudioSession() { + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [.mixWithOthers]) + try AVAudioSession.sharedInstance().setActive(true) + } catch { + print("Error setting up audio session: \(error)") + } + } + + private func preloadSounds() { + let soundFiles = ["white-noise.mp3", "heavy-rain-white-noise.mp3", "fan-white-noise-heater-303207.mp3"] + + for fileName in soundFiles { + guard let url = Bundle.main.url(forResource: fileName, withExtension: nil) else { + print("Sound file not found: \(fileName)") + continue + } + + do { + let player = try AVAudioPlayer(contentsOf: url) + player.numberOfLoops = -1 // Loop indefinitely + player.prepareToPlay() + players[fileName] = player + } catch { + print("Error preloading sound \(fileName): \(error)") + } + } + } func playSound(_ sound: Sound) { - guard let url = Bundle.main.url(forResource: sound.fileName, withExtension: nil) else { - print("Sound file not found: \(sound.fileName)") + // Stop current sound if playing + stopSound() + + // Get or create player for this sound + guard let player = players[sound.fileName] else { + print("Sound not preloaded: \(sound.fileName)") return } - do { - player = try AVAudioPlayer(contentsOf: url) - player?.numberOfLoops = -1 // Loop indefinitely - player?.play() - } catch { - print("Error playing sound: \(error)") - } + + currentPlayer = player + player.play() } func stopSound() { - player?.stop() + currentPlayer?.stop() + currentPlayer = nil + } + + private func stopAllSounds() { + for player in players.values { + player.stop() + } + players.removeAll() + currentPlayer = nil + } + + var isPlaying: Bool { + return currentPlayer?.isPlaying ?? false } } diff --git a/TheNoiseClock/NoiseView.swift b/TheNoiseClock/NoiseView.swift index 60f1d96..df88370 100644 --- a/TheNoiseClock/NoiseView.swift +++ b/TheNoiseClock/NoiseView.swift @@ -1,16 +1,15 @@ import SwiftUI struct NoiseView: View { - let player = NoisePlayer() + @State private var player = NoisePlayer() let sounds = [ Sound(name: "White Noise", fileName: "white-noise.mp3"), Sound(name: "Heavy Rain White Noise", fileName: "heavy-rain-white-noise.mp3"), - Sound(name: "Fan White Noise", fileName: "fan-white-noise-heater.mp3") + Sound(name: "Fan White Noise", fileName: "fan-white-noise-heater-303207.mp3") // Add more sounds here, matching your bundled MP3s ] @State private var selectedSound: Sound? - @State private var isPlaying = false var body: some View { VStack { @@ -26,16 +25,15 @@ struct NoiseView: View { .pickerStyle(.menu) HStack { - Button(isPlaying ? "Stop" : "Play") { - if isPlaying { + Button(player.isPlaying ? "Stop" : "Play") { + if player.isPlaying { player.stopSound() } else if let sound = selectedSound { player.playSound(sound) } - isPlaying.toggle() } .padding() - .background(isPlaying ? Color.red : Color.green) + .background(player.isPlaying ? Color.red : Color.green) .foregroundColor(.white) .cornerRadius(8) .disabled(selectedSound == nil) // Disable if no sound selected