Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2025-09-07 21:07:44 -05:00
parent 3df3e852ef
commit 5eea7ed1d8
7 changed files with 318 additions and 77 deletions

View File

@ -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
)
}

View File

@ -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)")
}
}
}

View File

@ -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())
}

View File

@ -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
}
}

View File

@ -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

View File

@ -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
}
}

View File

@ -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