Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
3df3e852ef
commit
5eea7ed1d8
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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<Bool> {
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user