modern ios26
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
4bf7994e31
commit
2f59f2aaf8
@ -56,8 +56,6 @@ struct Alarm: Identifiable, Codable, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func formattedTime() -> String {
|
func formattedTime() -> String {
|
||||||
let formatter = DateFormatter()
|
time.formatted(date: .omitted, time: .shortened)
|
||||||
formatter.timeStyle = .short
|
|
||||||
return formatter.string(from: time)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -264,11 +264,7 @@ final class AlarmKitService {
|
|||||||
|
|
||||||
/// Log available sound files in Library/Sounds for debugging
|
/// Log available sound files in Library/Sounds for debugging
|
||||||
private func logLibrarySounds() {
|
private func logLibrarySounds() {
|
||||||
guard let libraryURL = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first else {
|
let soundsDirectory = URL.libraryDirectory.appendingPathComponent("Sounds")
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let soundsDirectory = libraryURL.appendingPathComponent("Sounds")
|
|
||||||
Design.debugLog("[alarmkit] ========== LIBRARY/SOUNDS FILES ==========")
|
Design.debugLog("[alarmkit] ========== LIBRARY/SOUNDS FILES ==========")
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@ -374,15 +370,14 @@ final class AlarmKitService {
|
|||||||
/// Create an AlarmKit schedule from an Alarm model.
|
/// Create an AlarmKit schedule from an Alarm model.
|
||||||
private func createSchedule(for alarm: Alarm) -> AlarmKit.Alarm.Schedule {
|
private func createSchedule(for alarm: Alarm) -> AlarmKit.Alarm.Schedule {
|
||||||
// Log the raw alarm time
|
// Log the raw alarm time
|
||||||
let formatter = DateFormatter()
|
let debugFormat = Date.FormatStyle.dateTime.year().month().day().hour().minute().second().timeZone()
|
||||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss Z"
|
Design.debugLog("[alarmkit] Raw alarm.time: \(alarm.time.formatted(debugFormat))")
|
||||||
Design.debugLog("[alarmkit] Raw alarm.time: \(formatter.string(from: alarm.time))")
|
|
||||||
|
|
||||||
// Calculate the next trigger time
|
// Calculate the next trigger time
|
||||||
let triggerDate = alarm.nextTriggerTime()
|
let triggerDate = alarm.nextTriggerTime()
|
||||||
|
|
||||||
Design.debugLog("[alarmkit] Next trigger date: \(formatter.string(from: triggerDate))")
|
Design.debugLog("[alarmkit] Next trigger date: \(triggerDate.formatted(debugFormat))")
|
||||||
Design.debugLog("[alarmkit] Current time: \(formatter.string(from: Date.now))")
|
Design.debugLog("[alarmkit] Current time: \(Date.now.formatted(debugFormat))")
|
||||||
|
|
||||||
let secondsUntil = triggerDate.timeIntervalSinceNow
|
let secondsUntil = triggerDate.timeIntervalSinceNow
|
||||||
let minutesUntil = secondsUntil / 60
|
let minutesUntil = secondsUntil / 60
|
||||||
@ -396,7 +391,7 @@ final class AlarmKitService {
|
|||||||
// Use fixed schedule for one-time alarms
|
// Use fixed schedule for one-time alarms
|
||||||
let schedule = AlarmKit.Alarm.Schedule.fixed(triggerDate)
|
let schedule = AlarmKit.Alarm.Schedule.fixed(triggerDate)
|
||||||
|
|
||||||
Design.debugLog("[alarmkit] Schedule created: fixed at \(formatter.string(from: triggerDate))")
|
Design.debugLog("[alarmkit] Schedule created: fixed at \(triggerDate.formatted(debugFormat))")
|
||||||
return schedule
|
return schedule
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import Bedrock
|
|||||||
/// Service for managing alarm persistence.
|
/// Service for managing alarm persistence.
|
||||||
/// Alarm scheduling is handled by AlarmKitService.
|
/// Alarm scheduling is handled by AlarmKitService.
|
||||||
@Observable
|
@Observable
|
||||||
class AlarmService {
|
final class AlarmService {
|
||||||
|
|
||||||
// MARK: - Singleton
|
// MARK: - Singleton
|
||||||
static let shared = AlarmService()
|
static let shared = AlarmService()
|
||||||
@ -24,7 +24,7 @@ class AlarmService {
|
|||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
private(set) var alarms: [Alarm] = []
|
private(set) var alarms: [Alarm] = []
|
||||||
private var alarmLookup: [UUID: Int] = [:]
|
private var alarmLookup: [UUID: Int] = [:]
|
||||||
private var persistenceWorkItem: DispatchWorkItem?
|
private var persistenceTask: Task<Void, Never>?
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
init() {
|
init() {
|
||||||
@ -87,20 +87,16 @@ class AlarmService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func saveAlarms() {
|
private func saveAlarms() {
|
||||||
persistenceWorkItem?.cancel()
|
persistenceTask?.cancel()
|
||||||
|
|
||||||
let alarmsSnapshot = self.alarms
|
let alarmsSnapshot = self.alarms
|
||||||
let work = DispatchWorkItem {
|
persistenceTask = Task { @MainActor in
|
||||||
|
try? await Task.sleep(for: .seconds(AppConstants.PersistenceDelays.alarms))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
if let encoded = try? JSONEncoder().encode(alarmsSnapshot) {
|
if let encoded = try? JSONEncoder().encode(alarmsSnapshot) {
|
||||||
UserDefaults.standard.set(encoded, forKey: AppConstants.StorageKeys.savedAlarms)
|
UserDefaults.standard.set(encoded, forKey: AppConstants.StorageKeys.savedAlarms)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
persistenceWorkItem = work
|
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(
|
|
||||||
deadline: .now() + AppConstants.PersistenceDelays.alarms,
|
|
||||||
execute: work
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadAlarms() {
|
private func loadAlarms() {
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import Observation
|
|||||||
/// AlarmKit provides alarms that cut through Focus modes and silent mode,
|
/// AlarmKit provides alarms that cut through Focus modes and silent mode,
|
||||||
/// with built-in Live Activity countdown and system alarm UI.
|
/// with built-in Live Activity countdown and system alarm UI.
|
||||||
@Observable
|
@Observable
|
||||||
class AlarmViewModel {
|
final class AlarmViewModel {
|
||||||
|
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
private let alarmService: AlarmService
|
private let alarmService: AlarmService
|
||||||
|
|||||||
@ -92,6 +92,7 @@ struct AlarmView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "plus")
|
Image(systemName: "plus")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
|
.symbolEffect(.bounce, value: showAddAlarm)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -106,13 +107,16 @@ struct AlarmView: View {
|
|||||||
viewModel: viewModel,
|
viewModel: viewModel,
|
||||||
isPresented: $showAddAlarm
|
isPresented: $showAddAlarm
|
||||||
)
|
)
|
||||||
|
.presentationCornerRadius(Design.CornerRadius.xxLarge)
|
||||||
}
|
}
|
||||||
.sheet(item: $selectedAlarmForEdit) { alarm in
|
.sheet(item: $selectedAlarmForEdit) { alarm in
|
||||||
EditAlarmView(
|
EditAlarmView(
|
||||||
viewModel: viewModel,
|
viewModel: viewModel,
|
||||||
alarm: alarm
|
alarm: alarm
|
||||||
)
|
)
|
||||||
|
.presentationCornerRadius(Design.CornerRadius.xxLarge)
|
||||||
}
|
}
|
||||||
|
.sensoryFeedback(.impact(flexibility: .soft), trigger: showAddAlarm)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|||||||
@ -41,6 +41,7 @@ struct AlarmRowView: View {
|
|||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(AppStatus.warning)
|
.foregroundStyle(AppStatus.warning)
|
||||||
|
.symbolEffect(.pulse, options: .repeating)
|
||||||
Text("Foreground only for full alarm sound")
|
Text("Foreground only for full alarm sound")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(AppTextColors.tertiary)
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
|
|||||||
@ -47,6 +47,7 @@ struct EmptyAlarmsView: View {
|
|||||||
Button(action: onAddAlarm) {
|
Button(action: onAddAlarm) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "plus.circle.fill")
|
Image(systemName: "plus.circle.fill")
|
||||||
|
.symbolEffect(.bounce, options: .nonRepeating)
|
||||||
Text("Add Your First Alarm")
|
Text("Add Your First Alarm")
|
||||||
}
|
}
|
||||||
.typography(.bodyEmphasis)
|
.typography(.bodyEmphasis)
|
||||||
|
|||||||
@ -71,9 +71,7 @@ struct TimeUntilAlarmSection: View {
|
|||||||
} else if calendar.isDateInTomorrow(alarmTime) {
|
} else if calendar.isDateInTomorrow(alarmTime) {
|
||||||
return "Tomorrow"
|
return "Tomorrow"
|
||||||
} else {
|
} else {
|
||||||
let formatter = DateFormatter()
|
return alarmTime.formatted(.dateTime.weekday(.wide))
|
||||||
formatter.dateFormat = "EEEE"
|
|
||||||
return formatter.string(from: alarmTime)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -67,13 +67,13 @@ struct EditAlarmView: View {
|
|||||||
NavigationLink(destination: LabelEditView(label: $alarmLabel)) {
|
NavigationLink(destination: LabelEditView(label: $alarmLabel)) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "textformat")
|
Image(systemName: "textformat")
|
||||||
.foregroundColor(AppAccent.primary)
|
.foregroundStyle(AppAccent.primary)
|
||||||
.frame(width: 24)
|
.frame(width: 24)
|
||||||
Text("Label")
|
Text("Label")
|
||||||
.foregroundStyle(AppTextColors.primary)
|
.foregroundStyle(AppTextColors.primary)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(alarmLabel)
|
Text(alarmLabel)
|
||||||
.foregroundColor(AppTextColors.secondary)
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listRowBackground(AppSurface.card)
|
.listRowBackground(AppSurface.card)
|
||||||
@ -82,13 +82,13 @@ struct EditAlarmView: View {
|
|||||||
NavigationLink(destination: NotificationMessageEditView(message: $notificationMessage)) {
|
NavigationLink(destination: NotificationMessageEditView(message: $notificationMessage)) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "message")
|
Image(systemName: "message")
|
||||||
.foregroundColor(AppAccent.primary)
|
.foregroundStyle(AppAccent.primary)
|
||||||
.frame(width: 24)
|
.frame(width: 24)
|
||||||
Text("Message")
|
Text("Message")
|
||||||
.foregroundStyle(AppTextColors.primary)
|
.foregroundStyle(AppTextColors.primary)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(notificationMessage)
|
Text(notificationMessage)
|
||||||
.foregroundColor(AppTextColors.secondary)
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -98,13 +98,13 @@ struct EditAlarmView: View {
|
|||||||
NavigationLink(destination: SoundSelectionView(selectedSound: $selectedSoundName)) {
|
NavigationLink(destination: SoundSelectionView(selectedSound: $selectedSoundName)) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "music.note")
|
Image(systemName: "music.note")
|
||||||
.foregroundColor(AppAccent.primary)
|
.foregroundStyle(AppAccent.primary)
|
||||||
.frame(width: 24)
|
.frame(width: 24)
|
||||||
Text("Sound")
|
Text("Sound")
|
||||||
.foregroundStyle(AppTextColors.primary)
|
.foregroundStyle(AppTextColors.primary)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(getSoundDisplayName(selectedSoundName))
|
Text(getSoundDisplayName(selectedSoundName))
|
||||||
.foregroundColor(AppTextColors.secondary)
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listRowBackground(AppSurface.card)
|
.listRowBackground(AppSurface.card)
|
||||||
@ -113,13 +113,13 @@ struct EditAlarmView: View {
|
|||||||
NavigationLink(destination: SnoozeSelectionView(snoozeDuration: $snoozeDuration)) {
|
NavigationLink(destination: SnoozeSelectionView(snoozeDuration: $snoozeDuration)) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "clock.arrow.circlepath")
|
Image(systemName: "clock.arrow.circlepath")
|
||||||
.foregroundColor(AppAccent.primary)
|
.foregroundStyle(AppAccent.primary)
|
||||||
.frame(width: 24)
|
.frame(width: 24)
|
||||||
Text("Snooze")
|
Text("Snooze")
|
||||||
.foregroundStyle(AppTextColors.primary)
|
.foregroundStyle(AppTextColors.primary)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("for \(snoozeDuration) min")
|
Text("for \(snoozeDuration) min")
|
||||||
.foregroundColor(AppTextColors.secondary)
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listRowBackground(AppSurface.card)
|
.listRowBackground(AppSurface.card)
|
||||||
|
|||||||
@ -6,12 +6,12 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// Service for monitoring device battery level and state
|
/// Service for monitoring device battery level and state
|
||||||
@Observable
|
@Observable
|
||||||
class BatteryService {
|
@MainActor
|
||||||
|
final class BatteryService {
|
||||||
|
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
static let shared = BatteryService()
|
static let shared = BatteryService()
|
||||||
@ -19,11 +19,11 @@ class BatteryService {
|
|||||||
var batteryLevel: Int = 100
|
var batteryLevel: Int = 100
|
||||||
var isCharging: Bool = false
|
var isCharging: Bool = false
|
||||||
|
|
||||||
@ObservationIgnored private var cancellables = Set<AnyCancellable>()
|
@ObservationIgnored private var monitoringTask: Task<Void, Never>?
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
private init() {
|
private init() {
|
||||||
setupBatteryMonitoring()
|
startNotificationMonitoring()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public Methods
|
// MARK: - Public Methods
|
||||||
@ -41,22 +41,24 @@ class BatteryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
private func setupBatteryMonitoring() {
|
private func startNotificationMonitoring() {
|
||||||
#if canImport(UIKit)
|
monitoringTask = Task { [weak self] in
|
||||||
// Listen for battery level changes
|
await withTaskGroup(of: Void.self) { group in
|
||||||
NotificationCenter.default.publisher(for: UIDevice.batteryLevelDidChangeNotification)
|
// Monitor battery level changes
|
||||||
.sink { [weak self] _ in
|
group.addTask { @MainActor [weak self] in
|
||||||
self?.updateBatteryInfo()
|
for await _ in NotificationCenter.default.notifications(named: UIDevice.batteryLevelDidChangeNotification) {
|
||||||
|
self?.updateBatteryInfo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitor battery state changes
|
||||||
|
group.addTask { @MainActor [weak self] in
|
||||||
|
for await _ in NotificationCenter.default.notifications(named: UIDevice.batteryStateDidChangeNotification) {
|
||||||
|
self?.updateBatteryInfo()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
}
|
||||||
|
|
||||||
// Listen for battery state changes
|
|
||||||
NotificationCenter.default.publisher(for: UIDevice.batteryStateDidChangeNotification)
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
self?.updateBatteryInfo()
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateBatteryInfo() {
|
private func updateBatteryInfo() {
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
|
||||||
import Observation
|
import Observation
|
||||||
import AudioPlaybackKit
|
import AudioPlaybackKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@ -14,6 +13,7 @@ import Bedrock
|
|||||||
|
|
||||||
/// ViewModel for clock display and management
|
/// ViewModel for clock display and management
|
||||||
@Observable
|
@Observable
|
||||||
|
@MainActor
|
||||||
final class ClockViewModel {
|
final class ClockViewModel {
|
||||||
|
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
@ -27,15 +27,13 @@ final class ClockViewModel {
|
|||||||
// Ambient light service
|
// Ambient light service
|
||||||
private let ambientLightService = AmbientLightService.shared
|
private let ambientLightService = AmbientLightService.shared
|
||||||
|
|
||||||
// Timer management
|
// Async task management (replaces Combine publishers)
|
||||||
private var secondTimer: Timer.TimerPublisher?
|
@ObservationIgnored private var secondTimerTask: Task<Void, Never>?
|
||||||
private var minuteTimer: Timer.TimerPublisher?
|
@ObservationIgnored private var minuteTimerTask: Task<Void, Never>?
|
||||||
private var secondCancellable: AnyCancellable?
|
@ObservationIgnored private var styleObserverTask: Task<Void, Never>?
|
||||||
private var minuteCancellable: AnyCancellable?
|
|
||||||
private var styleObserver: NSObjectProtocol?
|
|
||||||
|
|
||||||
// Persistence
|
// Persistence
|
||||||
private var persistenceWorkItem: DispatchWorkItem?
|
@ObservationIgnored private var persistenceTask: Task<Void, Never>?
|
||||||
private var styleJSON: Data {
|
private var styleJSON: Data {
|
||||||
get {
|
get {
|
||||||
UserDefaults.standard.data(forKey: ClockStyle.appStorageKey) ?? {
|
UserDefaults.standard.data(forKey: ClockStyle.appStorageKey) ?? {
|
||||||
@ -57,11 +55,10 @@ final class ClockViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
stopTimers()
|
secondTimerTask?.cancel()
|
||||||
stopAmbientLightMonitoring()
|
minuteTimerTask?.cancel()
|
||||||
if let styleObserver {
|
styleObserverTask?.cancel()
|
||||||
NotificationCenter.default.removeObserver(styleObserver)
|
persistenceTask?.cancel()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public Interface
|
// MARK: - Public Interface
|
||||||
@ -153,82 +150,81 @@ final class ClockViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func observeStyleUpdates() {
|
private func observeStyleUpdates() {
|
||||||
styleObserver = NotificationCenter.default.addObserver(
|
styleObserverTask = Task { [weak self] in
|
||||||
forName: .clockStyleDidUpdate,
|
for await _ in NotificationCenter.default.notifications(named: .clockStyleDidUpdate) {
|
||||||
object: nil,
|
guard let self else { break }
|
||||||
queue: .main
|
self.loadStyle()
|
||||||
) { [weak self] _ in
|
self.updateTimersIfNeeded()
|
||||||
self?.loadStyle()
|
self.updateWakeLockState()
|
||||||
self?.updateTimersIfNeeded()
|
self.updateBrightness()
|
||||||
self?.updateWakeLockState()
|
}
|
||||||
self?.updateBrightness()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveStyle() {
|
func saveStyle() {
|
||||||
persistenceWorkItem?.cancel()
|
persistenceTask?.cancel()
|
||||||
|
|
||||||
let work = DispatchWorkItem {
|
let style = self.style
|
||||||
if let data = try? JSONEncoder().encode(self.style) {
|
persistenceTask = Task {
|
||||||
|
try? await Task.sleep(for: .seconds(AppConstants.PersistenceDelays.clockStyle))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
if let data = try? JSONEncoder().encode(style) {
|
||||||
self.styleJSON = data
|
self.styleJSON = data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
persistenceWorkItem = work
|
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(
|
|
||||||
deadline: .now() + AppConstants.PersistenceDelays.clockStyle,
|
|
||||||
execute: work
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupTimers() {
|
private func setupTimers() {
|
||||||
// Always need minute timer for color randomization
|
// Always need minute timer for color randomization and night mode checks
|
||||||
if minuteTimer == nil {
|
startMinuteTimer()
|
||||||
minuteTimer = Timer.publish(every: AppConstants.TimerIntervals.minute, on: .main, in: .common)
|
|
||||||
minuteCancellable = minuteTimer?.autoconnect().sink { _ in
|
// Only create second timer if seconds are shown
|
||||||
|
if style.showSeconds {
|
||||||
|
startSecondTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startMinuteTimer() {
|
||||||
|
minuteTimerTask?.cancel()
|
||||||
|
minuteTimerTask = Task { [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(for: .seconds(AppConstants.TimerIntervals.minute))
|
||||||
|
guard !Task.isCancelled, let self else { break }
|
||||||
|
|
||||||
if self.style.randomizeColor {
|
if self.style.randomizeColor {
|
||||||
self.style.digitColorHex = Color.randomBrightColorHex()
|
self.style.digitColorHex = Color.randomBrightColorHex()
|
||||||
self.saveStyle()
|
self.saveStyle()
|
||||||
self.updateBrightness() // Update brightness when color changes
|
self.updateBrightness()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for night mode state changes (scheduled night mode)
|
// Check for night mode state changes (scheduled night mode)
|
||||||
// Force a UI update by updating currentTime slightly
|
|
||||||
self.currentTime = Date()
|
self.currentTime = Date()
|
||||||
|
|
||||||
// Update brightness if night mode is active
|
|
||||||
self.updateBrightness()
|
self.updateBrightness()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Only create second timer if seconds are shown
|
|
||||||
if style.showSeconds && secondTimer == nil {
|
private func startSecondTimer() {
|
||||||
secondTimer = Timer.publish(every: AppConstants.TimerIntervals.second, on: .main, in: .common)
|
secondTimerTask?.cancel()
|
||||||
secondCancellable = secondTimer?.autoconnect().sink { now in
|
secondTimerTask = Task { [weak self] in
|
||||||
self.currentTime = now
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(for: .seconds(AppConstants.TimerIntervals.second))
|
||||||
|
guard !Task.isCancelled, let self else { break }
|
||||||
|
self.currentTime = Date()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func stopTimers() {
|
private func stopSecondTimer() {
|
||||||
secondCancellable?.cancel()
|
secondTimerTask?.cancel()
|
||||||
minuteCancellable?.cancel()
|
secondTimerTask = nil
|
||||||
secondCancellable = nil
|
|
||||||
minuteCancellable = nil
|
|
||||||
secondTimer = nil
|
|
||||||
minuteTimer = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateTimersIfNeeded() {
|
private func updateTimersIfNeeded() {
|
||||||
if style.showSeconds && secondTimer == nil {
|
if style.showSeconds && secondTimerTask == nil {
|
||||||
secondTimer = Timer.publish(every: AppConstants.TimerIntervals.second, on: .main, in: .common)
|
startSecondTimer()
|
||||||
secondCancellable = secondTimer?.autoconnect().sink { now in
|
} else if !style.showSeconds && secondTimerTask != nil {
|
||||||
self.currentTime = now
|
stopSecondTimer()
|
||||||
}
|
|
||||||
} else if !style.showSeconds && secondTimer != nil {
|
|
||||||
secondCancellable?.cancel()
|
|
||||||
secondCancellable = nil
|
|
||||||
secondTimer = nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -253,16 +249,10 @@ final class ClockViewModel {
|
|||||||
|
|
||||||
// Set up callback to respond to brightness changes
|
// Set up callback to respond to brightness changes
|
||||||
ambientLightService.onBrightnessChange = { [weak self] in
|
ambientLightService.onBrightnessChange = { [weak self] in
|
||||||
//Design.debugLog("[brightness] ClockViewModel: Received brightness change notification")
|
|
||||||
self?.updateBrightness()
|
self?.updateBrightness()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop ambient light monitoring
|
|
||||||
private func stopAmbientLightMonitoring() {
|
|
||||||
ambientLightService.stopMonitoring()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update brightness based on color theme and night mode settings
|
/// Update brightness based on color theme and night mode settings
|
||||||
private func updateBrightness() {
|
private func updateBrightness() {
|
||||||
if style.autoBrightness {
|
if style.autoBrightness {
|
||||||
|
|||||||
@ -25,8 +25,12 @@ struct BatteryOverlayView: View {
|
|||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: batteryIcon)
|
Image(systemName: batteryIcon)
|
||||||
.foregroundStyle(batteryColor)
|
.foregroundStyle(batteryColor)
|
||||||
|
.symbolEffect(.pulse, options: .repeating, isActive: isCharging)
|
||||||
|
.contentTransition(.symbolEffect(.replace))
|
||||||
Text("\(batteryLevel)%")
|
Text("\(batteryLevel)%")
|
||||||
.foregroundStyle(color)
|
.foregroundStyle(color)
|
||||||
|
.contentTransition(.numericText())
|
||||||
|
.animation(.snappy(duration: 0.3), value: batteryLevel)
|
||||||
}
|
}
|
||||||
.opacity(clamped)
|
.opacity(clamped)
|
||||||
.font(.callout.weight(.semibold))
|
.font(.callout.weight(.semibold))
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Combine
|
|
||||||
|
|
||||||
/// Component for displaying date overlay
|
/// Component for displaying date overlay
|
||||||
struct DateOverlayView: View {
|
struct DateOverlayView: View {
|
||||||
@ -17,8 +16,6 @@ struct DateOverlayView: View {
|
|||||||
let dateFormat: String
|
let dateFormat: String
|
||||||
|
|
||||||
@State private var dateString: String = ""
|
@State private var dateString: String = ""
|
||||||
@State private var minuteTimer: Timer.TimerPublisher?
|
|
||||||
@State private var minuteCancellable: AnyCancellable?
|
|
||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -30,31 +27,20 @@ struct DateOverlayView: View {
|
|||||||
.font(.callout.weight(.semibold))
|
.font(.callout.weight(.semibold))
|
||||||
.onAppear {
|
.onAppear {
|
||||||
updateDate()
|
updateDate()
|
||||||
startMinuteUpdates()
|
|
||||||
}
|
|
||||||
.onDisappear {
|
|
||||||
stopMinuteUpdates()
|
|
||||||
}
|
}
|
||||||
.onChange(of: dateFormat) { _, _ in
|
.onChange(of: dateFormat) { _, _ in
|
||||||
updateDate()
|
updateDate()
|
||||||
}
|
}
|
||||||
|
.task {
|
||||||
|
// Periodically update the date every minute
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(for: .seconds(AppConstants.TimerIntervals.minute))
|
||||||
|
updateDate()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
private func startMinuteUpdates() {
|
|
||||||
let pub = Timer.publish(every: AppConstants.TimerIntervals.minute, on: .main, in: .common)
|
|
||||||
minuteTimer = pub
|
|
||||||
minuteCancellable = pub.autoconnect().sink { _ in
|
|
||||||
updateDate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stopMinuteUpdates() {
|
|
||||||
minuteCancellable?.cancel()
|
|
||||||
minuteCancellable = nil
|
|
||||||
minuteTimer = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateDate() {
|
private func updateDate() {
|
||||||
dateString = Date().formattedForOverlay(format: dateFormat)
|
dateString = Date().formattedForOverlay(format: dateFormat)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,9 +18,7 @@ struct NextAlarmOverlay: View {
|
|||||||
|
|
||||||
private var alarmString: String {
|
private var alarmString: String {
|
||||||
guard let time = alarmTime else { return "" }
|
guard let time = alarmTime else { return "" }
|
||||||
let formatter = DateFormatter()
|
return time.formatted(date: .omitted, time: .shortened)
|
||||||
formatter.timeStyle = .short
|
|
||||||
return formatter.string(from: time)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
@ -30,6 +28,7 @@ struct NextAlarmOverlay: View {
|
|||||||
HStack(spacing: Design.Spacing.xxSmall) {
|
HStack(spacing: Design.Spacing.xxSmall) {
|
||||||
Image(systemName: "alarm.fill")
|
Image(systemName: "alarm.fill")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
.symbolEffect(.bounce, value: alarmTime)
|
||||||
|
|
||||||
Text(alarmString)
|
Text(alarmString)
|
||||||
.typography(.calloutEmphasis)
|
.typography(.calloutEmphasis)
|
||||||
|
|||||||
@ -26,12 +26,14 @@ struct NoiseMiniPlayer: View {
|
|||||||
HStack(spacing: Design.Spacing.small) {
|
HStack(spacing: Design.Spacing.small) {
|
||||||
Button(action: onToggle) {
|
Button(action: onToggle) {
|
||||||
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
|
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
|
||||||
|
.contentTransition(.symbolEffect(.replace))
|
||||||
.font(.system(size: 14, weight: .bold))
|
.font(.system(size: 14, weight: .bold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.frame(width: 28, height: 28)
|
.frame(width: 28, height: 28)
|
||||||
.background(AppAccent.primary.opacity(0.8))
|
.background(AppAccent.primary.opacity(0.8))
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
|
.sensoryFeedback(.impact(flexibility: .soft), trigger: isPlaying)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
Text(isPlaying ? "Playing" : "Paused")
|
Text(isPlaying ? "Playing" : "Paused")
|
||||||
|
|||||||
@ -54,6 +54,8 @@ struct AdvancedDisplaySection: View {
|
|||||||
Text("\(Int(style.effectiveBrightness * 100))%")
|
Text("\(Int(style.effectiveBrightness * 100))%")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(AppTextColors.secondary)
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
.contentTransition(.numericText())
|
||||||
|
.animation(.snappy(duration: 0.3), value: style.effectiveBrightness)
|
||||||
}
|
}
|
||||||
.padding(.vertical, Design.Spacing.medium)
|
.padding(.vertical, Design.Spacing.medium)
|
||||||
.padding(.horizontal, Design.Spacing.medium)
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
|||||||
@ -49,8 +49,9 @@ struct TimePickerView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func updateStringFromTime(_ time: Date) {
|
private func updateStringFromTime(_ time: Date) {
|
||||||
let formatter = DateFormatter()
|
let calendar = Calendar.current
|
||||||
formatter.dateFormat = "HH:mm"
|
let hour = calendar.component(.hour, from: time)
|
||||||
timeString = formatter.string(from: time)
|
let minute = calendar.component(.minute, from: time)
|
||||||
|
timeString = String(format: "%02d:%02d", hour, minute)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -81,7 +81,7 @@ struct SoundCategoryView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var categoryTabs: some View {
|
private var categoryTabs: some View {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal) {
|
||||||
HStack(spacing: Design.Spacing.small) {
|
HStack(spacing: Design.Spacing.small) {
|
||||||
ForEach(categories) { category in
|
ForEach(categories) { category in
|
||||||
CategoryTab(
|
CategoryTab(
|
||||||
@ -95,6 +95,7 @@ struct SoundCategoryView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, Design.Spacing.medium)
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
}
|
}
|
||||||
|
.scrollIndicators(.hidden)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var soundGrid: some View {
|
private var soundGrid: some View {
|
||||||
|
|||||||
@ -56,14 +56,8 @@ struct OnboardingWelcomePage: View {
|
|||||||
struct OnboardingClockText: View {
|
struct OnboardingClockText: View {
|
||||||
let date: Date
|
let date: Date
|
||||||
|
|
||||||
private static let formatter: DateFormatter = {
|
|
||||||
let df = DateFormatter()
|
|
||||||
df.dateFormat = "h:mm"
|
|
||||||
return df
|
|
||||||
}()
|
|
||||||
|
|
||||||
private var timeString: String {
|
private var timeString: String {
|
||||||
Self.formatter.string(from: date)
|
date.formatted(.dateTime.hour(.defaultDigits(amPM: .omitted)).minute(.twoDigits))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|||||||
@ -17,6 +17,7 @@ struct KeepAwakePrompt: View {
|
|||||||
Image(systemName: "bolt.fill")
|
Image(systemName: "bolt.fill")
|
||||||
.font(.system(size: 36, weight: .semibold))
|
.font(.system(size: 36, weight: .semibold))
|
||||||
.foregroundStyle(AppAccent.primary)
|
.foregroundStyle(AppAccent.primary)
|
||||||
|
.symbolEffect(.bounce, options: .nonRepeating)
|
||||||
|
|
||||||
VStack(spacing: Design.Spacing.small) {
|
VStack(spacing: Design.Spacing.small) {
|
||||||
Text("Keep Awake for Alarms")
|
Text("Keep Awake for Alarms")
|
||||||
@ -48,6 +49,7 @@ struct KeepAwakePrompt: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(AppSurface.primary)
|
.background(AppSurface.primary)
|
||||||
.presentationDetents([.medium])
|
.presentationDetents([.medium])
|
||||||
|
.presentationCornerRadius(Design.CornerRadius.xxLarge)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import Foundation
|
|||||||
import Observation
|
import Observation
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
class KeepAwakePromptState {
|
final class KeepAwakePromptState {
|
||||||
|
|
||||||
var isPresented = false
|
var isPresented = false
|
||||||
private var hasShownThisSession = false
|
private var hasShownThisSession = false
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user