Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
3df3e852ef
commit
5eea7ed1d8
@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Observation
|
||||||
|
|
||||||
struct AddAlarmView: View {
|
struct AddAlarmView: View {
|
||||||
@Binding var alarms: [Alarm]
|
@Binding var alarms: [Alarm]
|
||||||
@ -44,6 +45,16 @@ struct AddAlarmView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#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 SwiftUI
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
import Observation
|
||||||
|
|
||||||
struct Alarm: Identifiable, Codable, Equatable {
|
struct Alarm: Identifiable, Codable, Equatable {
|
||||||
let id: UUID
|
let id: UUID
|
||||||
@ -14,10 +15,14 @@ struct Alarm: Identifiable, Codable, Equatable {
|
|||||||
|
|
||||||
struct AlarmView: View {
|
struct AlarmView: View {
|
||||||
@State private var alarms: [Alarm] = []
|
@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 showAddAlarm = false
|
||||||
@State private var newAlarmTime = Date()
|
@State private var newAlarmTime = Date()
|
||||||
@State private var selectedSoundName = "default"
|
@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"]
|
let systemSounds = ["default", "bell", "chimes", "ding", "glass", "silence"]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -43,7 +48,6 @@ struct AlarmView: View {
|
|||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
print("Tapped + button, showing Add Alarm sheet")
|
|
||||||
showAddAlarm = true
|
showAddAlarm = true
|
||||||
newAlarmTime = Date()
|
newAlarmTime = Date()
|
||||||
selectedSoundName = "default"
|
selectedSoundName = "default"
|
||||||
@ -55,6 +59,7 @@ struct AlarmView: View {
|
|||||||
}
|
}
|
||||||
.onAppear(perform: loadAlarms)
|
.onAppear(perform: loadAlarms)
|
||||||
.onChange(of: alarms) { _ in
|
.onChange(of: alarms) { _ in
|
||||||
|
updateAlarmLookup()
|
||||||
saveAlarms()
|
saveAlarms()
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showAddAlarm) {
|
.sheet(isPresented: $showAddAlarm) {
|
||||||
@ -69,35 +74,32 @@ struct AlarmView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func binding(for alarm: Alarm) -> Binding<Bool> {
|
private func binding(for alarm: Alarm) -> Binding<Bool> {
|
||||||
guard let index = alarms.firstIndex(where: { $0.id == alarm.id }) else {
|
guard let index = alarmLookup[alarm.id] else {
|
||||||
print("Binding error: Alarm \(alarm.id) not found")
|
|
||||||
return .constant(false)
|
return .constant(false)
|
||||||
}
|
}
|
||||||
print("Binding created for alarm \(alarm.id) at index \(index), isEnabled: \(alarms[index].isEnabled)")
|
|
||||||
return Binding(
|
return Binding(
|
||||||
get: { alarms[index].isEnabled },
|
get: { alarms[index].isEnabled },
|
||||||
set: { newValue in
|
set: { newValue in
|
||||||
print("Setting isEnabled to \(newValue) for alarm \(alarm.id)")
|
|
||||||
var updatedAlarm = alarms[index]
|
var updatedAlarm = alarms[index]
|
||||||
updatedAlarm.isEnabled = newValue
|
updatedAlarm.isEnabled = newValue
|
||||||
alarms[index] = updatedAlarm // Update array to trigger UI refresh
|
alarms[index] = updatedAlarm
|
||||||
updateAlarmNotification(alarm: updatedAlarm)
|
updateAlarmNotification(alarm: updatedAlarm)
|
||||||
saveAlarms()
|
saveAlarms()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deleteAlarm(at offsets: IndexSet) {
|
private func updateAlarmLookup() {
|
||||||
print("Delete triggered for offsets: \(offsets)")
|
alarmLookup.removeAll()
|
||||||
let indices = offsets.map { $0 }
|
for (index, alarm) in alarms.enumerated() {
|
||||||
guard !indices.isEmpty, indices.max()! < alarms.count else {
|
alarmLookup[alarm.id] = index
|
||||||
print("Invalid delete offset")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteAlarm(at offsets: IndexSet) {
|
||||||
alarms.remove(atOffsets: offsets)
|
alarms.remove(atOffsets: offsets)
|
||||||
updateAllNotifications()
|
updateAllNotifications()
|
||||||
saveAlarms()
|
saveAlarms()
|
||||||
print("Alarm(s) deleted")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateAlarmNotification(alarm: Alarm) {
|
private func updateAlarmNotification(alarm: Alarm) {
|
||||||
@ -114,12 +116,8 @@ struct AlarmView: View {
|
|||||||
UNUserNotificationCenter.current().add(request) { error in
|
UNUserNotificationCenter.current().add(request) { error in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
print("Error scheduling notification: \(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() {
|
private func saveAlarms() {
|
||||||
if let encoded = try? JSONEncoder().encode(alarms) {
|
// Cancel previous save operation
|
||||||
UserDefaults.standard.set(encoded, forKey: "SavedAlarms")
|
persistenceWorkItem?.cancel()
|
||||||
print("Alarms saved: \(alarms.count)")
|
|
||||||
} else {
|
// Snapshot data to write to avoid capturing self strongly
|
||||||
print("Failed to encode alarms")
|
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() {
|
private func loadAlarms() {
|
||||||
if let savedAlarms = UserDefaults.standard.data(forKey: "SavedAlarms"),
|
if let savedAlarms = UserDefaults.standard.data(forKey: "SavedAlarms"),
|
||||||
let decodedAlarms = try? JSONDecoder().decode([Alarm].self, from: savedAlarms) {
|
let decodedAlarms = try? JSONDecoder().decode([Alarm].self, from: savedAlarms) {
|
||||||
alarms = decodedAlarms
|
alarms = decodedAlarms
|
||||||
|
updateAlarmLookup()
|
||||||
updateAllNotifications()
|
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 {
|
if let error = error {
|
||||||
print("Authorization error: \(error)")
|
print("Authorization error: \(error)")
|
||||||
} else {
|
|
||||||
print("Authorization granted: \(success)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Observation
|
||||||
|
|
||||||
struct ClockSettingsView: View {
|
struct ClockSettingsView: View {
|
||||||
@Binding var style: ClockStyle
|
@Bindable var style: ClockStyle
|
||||||
var onCommit: () -> Void = {}
|
var onCommit: () -> Void = {}
|
||||||
|
|
||||||
@State private var digitColor: Color = .white
|
@State private var digitColor: Color = .white
|
||||||
@ -87,10 +88,12 @@ struct ClockSettingsView: View {
|
|||||||
}
|
}
|
||||||
.onChange(of: digitColor) { newValue in
|
.onChange(of: digitColor) { newValue in
|
||||||
style.digitColorHex = newValue.toHex() ?? "#FFFFFF"
|
style.digitColorHex = newValue.toHex() ?? "#FFFFFF"
|
||||||
|
style.clearColorCache()
|
||||||
onCommit()
|
onCommit()
|
||||||
}
|
}
|
||||||
.onChange(of: backgroundColor) { newValue in
|
.onChange(of: backgroundColor) { newValue in
|
||||||
style.backgroundHex = newValue.toHex() ?? "#000000"
|
style.backgroundHex = newValue.toHex() ?? "#000000"
|
||||||
|
style.clearColorCache()
|
||||||
onCommit()
|
onCommit()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -98,5 +101,5 @@ struct ClockSettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
ClockSettingsView(style: .constant(ClockStyle()))
|
ClockSettingsView(style: ClockStyle())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Observation
|
||||||
|
|
||||||
struct ClockStyle: Codable, Equatable {
|
@Observable
|
||||||
|
class ClockStyle: Codable, Equatable {
|
||||||
var use24Hour: Bool = true
|
var use24Hour: Bool = true
|
||||||
var showSeconds: Bool = false
|
var showSeconds: Bool = false
|
||||||
var showAmPmBadge: 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)
|
// New: Independent opacity for the top overlay (battery/date) (0.0...1.0)
|
||||||
var overlayOpacity: Double = 0.5
|
var overlayOpacity: Double = 0.5
|
||||||
|
|
||||||
// Codable <-> Color helpers
|
// Cached colors to avoid repeated hex conversions
|
||||||
var digitColor: Color {
|
private var _cachedDigitColor: Color?
|
||||||
Color(hex: digitColorHex) ?? .white
|
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 {
|
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 SwiftUI
|
||||||
import Combine
|
import Combine
|
||||||
|
import Observation
|
||||||
|
|
||||||
struct ClockView: View {
|
struct ClockView: View {
|
||||||
@State private var currentTime = Date()
|
@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
|
// Persist the style as JSON in AppStorage
|
||||||
@AppStorage(ClockStyle.appStorageKey) private var styleJSON: Data = {
|
@AppStorage(ClockStyle.appStorageKey) private var styleJSON: Data = {
|
||||||
@ -12,12 +17,16 @@ struct ClockView: View {
|
|||||||
return (try? JSONEncoder().encode(def)) ?? Data()
|
return (try? JSONEncoder().encode(def)) ?? Data()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@State private var style: ClockStyle = ClockStyle()
|
@State private var style = ClockStyle()
|
||||||
@State private var showSettings = false
|
@State private var showSettings = false
|
||||||
|
|
||||||
// Display mode (full-screen clock)
|
// Display mode (full-screen clock)
|
||||||
@State private var isDisplayMode = false
|
@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 {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
style.backgroundColor
|
style.backgroundColor
|
||||||
@ -60,7 +69,7 @@ struct ClockView: View {
|
|||||||
// Subtle scale on the entire content during the transition
|
// Subtle scale on the entire content during the transition
|
||||||
.scaleEffect(isDisplayMode ? 1.0 : 0.995)
|
.scaleEffect(isDisplayMode ? 1.0 : 0.995)
|
||||||
.opacity(isDisplayMode ? 1.0 : 1.0) // keep opacity, but transition hooks are kept above
|
.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")
|
.navigationTitle(isDisplayMode ? "" : "Clock")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@ -81,34 +90,29 @@ struct ClockView: View {
|
|||||||
.toolbar(isDisplayMode ? .hidden : .automatic)
|
.toolbar(isDisplayMode ? .hidden : .automatic)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
loadStyle()
|
loadStyle()
|
||||||
|
setupTimers()
|
||||||
// Ensure correct tab bar visibility if we reappear while in display mode
|
// Ensure correct tab bar visibility if we reappear while in display mode
|
||||||
setTabBarHidden(isDisplayMode, animated: false)
|
setTabBarHidden(isDisplayMode, animated: false)
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
|
stopTimers()
|
||||||
// Restore tab bar when leaving this screen
|
// Restore tab bar when leaving this screen
|
||||||
setTabBarHidden(false, animated: false)
|
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) {
|
.sheet(isPresented: $showSettings) {
|
||||||
ClockSettingsView(style: $style, onCommit: saveStyle)
|
ClockSettingsView(style: style, onCommit: saveStyle)
|
||||||
.presentationDetents([.medium, .large])
|
.presentationDetents([.medium, .large])
|
||||||
}
|
}
|
||||||
.onChange(of: style) { _ in
|
.onChange(of: style) { _ in
|
||||||
saveStyle()
|
saveStyle()
|
||||||
|
updateTimersIfNeeded()
|
||||||
}
|
}
|
||||||
// Long-press anywhere to toggle display mode
|
// Long-press anywhere to toggle display mode
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.simultaneousGesture(
|
.simultaneousGesture(
|
||||||
LongPressGesture(minimumDuration: 0.6)
|
LongPressGesture(minimumDuration: 0.6)
|
||||||
.onEnded { _ in
|
.onEnded { _ in
|
||||||
withAnimation(.easeInOut(duration: 0.28)) {
|
withAnimation(.bouncy(duration: 0.4)) {
|
||||||
isDisplayMode.toggle()
|
isDisplayMode.toggle()
|
||||||
setTabBarHidden(isDisplayMode, animated: true)
|
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() {
|
private func saveStyle() {
|
||||||
if let data = try? JSONEncoder().encode(style) {
|
// Cancel previous save operation
|
||||||
styleJSON = data
|
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 ampmText = Self.ampmDF.string(from: date)
|
||||||
let showAMPM = !use24Hour && showAmPmBadge
|
let showAMPM = !use24Hour && showAmPmBadge
|
||||||
|
|
||||||
// Measure intrinsic sizes
|
// Measure intrinsic sizes with caching
|
||||||
let digitUIFont = UIFont.systemFont(ofSize: baseFontSize, weight: .bold)
|
let digitUIFont = UIFont.systemFont(ofSize: baseFontSize, weight: .bold)
|
||||||
let ampmUIFont = UIFont.systemFont(ofSize: ampmFontSize, weight: .bold)
|
let ampmUIFont = UIFont.systemFont(ofSize: ampmFontSize, weight: .bold)
|
||||||
let hourSize = measure(text: hour, font: digitUIFont)
|
let hourSize = measureWithCache(text: hour, font: digitUIFont, cacheKey: "hour_\(baseFontSize)")
|
||||||
let minuteSize = measure(text: minute, font: digitUIFont)
|
let minuteSize = measureWithCache(text: minute, font: digitUIFont, cacheKey: "minute_\(baseFontSize)")
|
||||||
let secondsSize = showSeconds ? measure(text: secondsText, font: digitUIFont) : .zero
|
let secondsSize = showSeconds ? measureWithCache(text: secondsText, font: digitUIFont, cacheKey: "seconds_\(baseFontSize)") : .zero
|
||||||
let ampmSize = showAMPM ? measure(text: ampmText, font: ampmUIFont) : .zero
|
let ampmSize = showAMPM ? measureWithCache(text: ampmText, font: ampmUIFont, cacheKey: "ampm_\(ampmFontSize)") : .zero
|
||||||
|
|
||||||
// Separators
|
// Separators
|
||||||
let dotDiameter = baseFontSize * 0.20
|
let dotDiameter = baseFontSize * 0.20
|
||||||
@ -330,7 +393,7 @@ private struct SegmentedTimeView: View {
|
|||||||
.frame(width: size.width, height: size.height, alignment: .center)
|
.frame(width: size.width, height: size.height, alignment: .center)
|
||||||
// Animate scale changes caused by geometry updates (e.g., hiding bars)
|
// Animate scale changes caused by geometry updates (e.g., hiding bars)
|
||||||
.scaleEffect(effectiveScale, anchor: .center)
|
.scaleEffect(effectiveScale, anchor: .center)
|
||||||
.animation(.easeInOut(duration: 0.28), value: effectiveScale)
|
.animation(.smooth(duration: 0.3), value: effectiveScale)
|
||||||
.minimumScaleFactor(0.1)
|
.minimumScaleFactor(0.1)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
@ -400,6 +463,13 @@ private struct SegmentedTimeView: View {
|
|||||||
let attributes = [NSAttributedString.Key.font: font]
|
let attributes = [NSAttributedString.Key.font: font]
|
||||||
return (text as NSString).size(withAttributes: attributes)
|
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
|
// MARK: - Top Overlay
|
||||||
|
|||||||
@ -1,23 +1,77 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
@Observable
|
||||||
class NoisePlayer {
|
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) {
|
func playSound(_ sound: Sound) {
|
||||||
guard let url = Bundle.main.url(forResource: sound.fileName, withExtension: nil) else {
|
// Stop current sound if playing
|
||||||
print("Sound file not found: \(sound.fileName)")
|
stopSound()
|
||||||
|
|
||||||
|
// Get or create player for this sound
|
||||||
|
guard let player = players[sound.fileName] else {
|
||||||
|
print("Sound not preloaded: \(sound.fileName)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
do {
|
|
||||||
player = try AVAudioPlayer(contentsOf: url)
|
currentPlayer = player
|
||||||
player?.numberOfLoops = -1 // Loop indefinitely
|
player.play()
|
||||||
player?.play()
|
|
||||||
} catch {
|
|
||||||
print("Error playing sound: \(error)")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopSound() {
|
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
|
import SwiftUI
|
||||||
|
|
||||||
struct NoiseView: View {
|
struct NoiseView: View {
|
||||||
let player = NoisePlayer()
|
@State private var player = NoisePlayer()
|
||||||
let sounds = [
|
let sounds = [
|
||||||
Sound(name: "White Noise", fileName: "white-noise.mp3"),
|
Sound(name: "White Noise", fileName: "white-noise.mp3"),
|
||||||
Sound(name: "Heavy Rain White Noise", fileName: "heavy-rain-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
|
// Add more sounds here, matching your bundled MP3s
|
||||||
]
|
]
|
||||||
|
|
||||||
@State private var selectedSound: Sound?
|
@State private var selectedSound: Sound?
|
||||||
@State private var isPlaying = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
@ -26,16 +25,15 @@ struct NoiseView: View {
|
|||||||
.pickerStyle(.menu)
|
.pickerStyle(.menu)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Button(isPlaying ? "Stop" : "Play") {
|
Button(player.isPlaying ? "Stop" : "Play") {
|
||||||
if isPlaying {
|
if player.isPlaying {
|
||||||
player.stopSound()
|
player.stopSound()
|
||||||
} else if let sound = selectedSound {
|
} else if let sound = selectedSound {
|
||||||
player.playSound(sound)
|
player.playSound(sound)
|
||||||
}
|
}
|
||||||
isPlaying.toggle()
|
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(isPlaying ? Color.red : Color.green)
|
.background(player.isPlaying ? Color.red : Color.green)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
.disabled(selectedSound == nil) // Disable if no sound selected
|
.disabled(selectedSound == nil) // Disable if no sound selected
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user