modern ios26

Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
Matt Bruce 2026-02-07 10:58:16 -06:00
parent 4bf7994e31
commit 2f59f2aaf8
21 changed files with 134 additions and 158 deletions

View File

@ -56,8 +56,6 @@ struct Alarm: Identifiable, Codable, Equatable {
}
func formattedTime() -> String {
let formatter = DateFormatter()
formatter.timeStyle = .short
return formatter.string(from: time)
time.formatted(date: .omitted, time: .shortened)
}
}

View File

@ -264,11 +264,7 @@ final class AlarmKitService {
/// Log available sound files in Library/Sounds for debugging
private func logLibrarySounds() {
guard let libraryURL = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first else {
return
}
let soundsDirectory = libraryURL.appendingPathComponent("Sounds")
let soundsDirectory = URL.libraryDirectory.appendingPathComponent("Sounds")
Design.debugLog("[alarmkit] ========== LIBRARY/SOUNDS FILES ==========")
do {
@ -374,15 +370,14 @@ final class AlarmKitService {
/// Create an AlarmKit schedule from an Alarm model.
private func createSchedule(for alarm: Alarm) -> AlarmKit.Alarm.Schedule {
// Log the raw alarm time
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss Z"
Design.debugLog("[alarmkit] Raw alarm.time: \(formatter.string(from: alarm.time))")
let debugFormat = Date.FormatStyle.dateTime.year().month().day().hour().minute().second().timeZone()
Design.debugLog("[alarmkit] Raw alarm.time: \(alarm.time.formatted(debugFormat))")
// Calculate the next trigger time
let triggerDate = alarm.nextTriggerTime()
Design.debugLog("[alarmkit] Next trigger date: \(formatter.string(from: triggerDate))")
Design.debugLog("[alarmkit] Current time: \(formatter.string(from: Date.now))")
Design.debugLog("[alarmkit] Next trigger date: \(triggerDate.formatted(debugFormat))")
Design.debugLog("[alarmkit] Current time: \(Date.now.formatted(debugFormat))")
let secondsUntil = triggerDate.timeIntervalSinceNow
let minutesUntil = secondsUntil / 60
@ -396,7 +391,7 @@ final class AlarmKitService {
// Use fixed schedule for one-time alarms
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
}
}

View File

@ -16,7 +16,7 @@ import Bedrock
/// Service for managing alarm persistence.
/// Alarm scheduling is handled by AlarmKitService.
@Observable
class AlarmService {
final class AlarmService {
// MARK: - Singleton
static let shared = AlarmService()
@ -24,7 +24,7 @@ class AlarmService {
// MARK: - Properties
private(set) var alarms: [Alarm] = []
private var alarmLookup: [UUID: Int] = [:]
private var persistenceWorkItem: DispatchWorkItem?
private var persistenceTask: Task<Void, Never>?
// MARK: - Initialization
init() {
@ -87,20 +87,16 @@ class AlarmService {
}
private func saveAlarms() {
persistenceWorkItem?.cancel()
persistenceTask?.cancel()
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) {
UserDefaults.standard.set(encoded, forKey: AppConstants.StorageKeys.savedAlarms)
}
}
persistenceWorkItem = work
DispatchQueue.main.asyncAfter(
deadline: .now() + AppConstants.PersistenceDelays.alarms,
execute: work
)
}
private func loadAlarms() {

View File

@ -14,7 +14,7 @@ import Observation
/// AlarmKit provides alarms that cut through Focus modes and silent mode,
/// with built-in Live Activity countdown and system alarm UI.
@Observable
class AlarmViewModel {
final class AlarmViewModel {
// MARK: - Properties
private let alarmService: AlarmService

View File

@ -92,6 +92,7 @@ struct AlarmView: View {
} label: {
Image(systemName: "plus")
.font(.title2)
.symbolEffect(.bounce, value: showAddAlarm)
}
}
}
@ -106,13 +107,16 @@ struct AlarmView: View {
viewModel: viewModel,
isPresented: $showAddAlarm
)
.presentationCornerRadius(Design.CornerRadius.xxLarge)
}
.sheet(item: $selectedAlarmForEdit) { alarm in
EditAlarmView(
viewModel: viewModel,
alarm: alarm
)
.presentationCornerRadius(Design.CornerRadius.xxLarge)
}
.sensoryFeedback(.impact(flexibility: .soft), trigger: showAddAlarm)
}
// MARK: - Private Methods

View File

@ -41,6 +41,7 @@ struct AlarmRowView: View {
Image(systemName: "exclamationmark.triangle.fill")
.font(.caption2)
.foregroundStyle(AppStatus.warning)
.symbolEffect(.pulse, options: .repeating)
Text("Foreground only for full alarm sound")
.font(.caption2)
.foregroundStyle(AppTextColors.tertiary)

View File

@ -47,6 +47,7 @@ struct EmptyAlarmsView: View {
Button(action: onAddAlarm) {
HStack {
Image(systemName: "plus.circle.fill")
.symbolEffect(.bounce, options: .nonRepeating)
Text("Add Your First Alarm")
}
.typography(.bodyEmphasis)

View File

@ -71,9 +71,7 @@ struct TimeUntilAlarmSection: View {
} else if calendar.isDateInTomorrow(alarmTime) {
return "Tomorrow"
} else {
let formatter = DateFormatter()
formatter.dateFormat = "EEEE"
return formatter.string(from: alarmTime)
return alarmTime.formatted(.dateTime.weekday(.wide))
}
}
}

View File

@ -67,13 +67,13 @@ struct EditAlarmView: View {
NavigationLink(destination: LabelEditView(label: $alarmLabel)) {
HStack {
Image(systemName: "textformat")
.foregroundColor(AppAccent.primary)
.foregroundStyle(AppAccent.primary)
.frame(width: 24)
Text("Label")
.foregroundStyle(AppTextColors.primary)
Spacer()
Text(alarmLabel)
.foregroundColor(AppTextColors.secondary)
.foregroundStyle(AppTextColors.secondary)
}
}
.listRowBackground(AppSurface.card)
@ -82,13 +82,13 @@ struct EditAlarmView: View {
NavigationLink(destination: NotificationMessageEditView(message: $notificationMessage)) {
HStack {
Image(systemName: "message")
.foregroundColor(AppAccent.primary)
.foregroundStyle(AppAccent.primary)
.frame(width: 24)
Text("Message")
.foregroundStyle(AppTextColors.primary)
Spacer()
Text(notificationMessage)
.foregroundColor(AppTextColors.secondary)
.foregroundStyle(AppTextColors.secondary)
.lineLimit(1)
}
}
@ -98,13 +98,13 @@ struct EditAlarmView: View {
NavigationLink(destination: SoundSelectionView(selectedSound: $selectedSoundName)) {
HStack {
Image(systemName: "music.note")
.foregroundColor(AppAccent.primary)
.foregroundStyle(AppAccent.primary)
.frame(width: 24)
Text("Sound")
.foregroundStyle(AppTextColors.primary)
Spacer()
Text(getSoundDisplayName(selectedSoundName))
.foregroundColor(AppTextColors.secondary)
.foregroundStyle(AppTextColors.secondary)
}
}
.listRowBackground(AppSurface.card)
@ -113,13 +113,13 @@ struct EditAlarmView: View {
NavigationLink(destination: SnoozeSelectionView(snoozeDuration: $snoozeDuration)) {
HStack {
Image(systemName: "clock.arrow.circlepath")
.foregroundColor(AppAccent.primary)
.foregroundStyle(AppAccent.primary)
.frame(width: 24)
Text("Snooze")
.foregroundStyle(AppTextColors.primary)
Spacer()
Text("for \(snoozeDuration) min")
.foregroundColor(AppTextColors.secondary)
.foregroundStyle(AppTextColors.secondary)
}
}
.listRowBackground(AppSurface.card)

View File

@ -6,12 +6,12 @@
//
import Foundation
import Combine
import UIKit
/// Service for monitoring device battery level and state
@Observable
class BatteryService {
@MainActor
final class BatteryService {
// MARK: - Properties
static let shared = BatteryService()
@ -19,11 +19,11 @@ class BatteryService {
var batteryLevel: Int = 100
var isCharging: Bool = false
@ObservationIgnored private var cancellables = Set<AnyCancellable>()
@ObservationIgnored private var monitoringTask: Task<Void, Never>?
// MARK: - Initialization
private init() {
setupBatteryMonitoring()
startNotificationMonitoring()
}
// MARK: - Public Methods
@ -41,22 +41,24 @@ class BatteryService {
}
// MARK: - Private Methods
private func setupBatteryMonitoring() {
#if canImport(UIKit)
// Listen for battery level changes
NotificationCenter.default.publisher(for: UIDevice.batteryLevelDidChangeNotification)
.sink { [weak self] _ in
self?.updateBatteryInfo()
private func startNotificationMonitoring() {
monitoringTask = Task { [weak self] in
await withTaskGroup(of: Void.self) { group in
// Monitor battery level changes
group.addTask { @MainActor [weak self] in
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() {

View File

@ -6,7 +6,6 @@
//
import Foundation
import Combine
import Observation
import AudioPlaybackKit
import SwiftUI
@ -14,6 +13,7 @@ import Bedrock
/// ViewModel for clock display and management
@Observable
@MainActor
final class ClockViewModel {
// MARK: - Properties
@ -27,15 +27,13 @@ final class ClockViewModel {
// Ambient light service
private let ambientLightService = AmbientLightService.shared
// Timer management
private var secondTimer: Timer.TimerPublisher?
private var minuteTimer: Timer.TimerPublisher?
private var secondCancellable: AnyCancellable?
private var minuteCancellable: AnyCancellable?
private var styleObserver: NSObjectProtocol?
// Async task management (replaces Combine publishers)
@ObservationIgnored private var secondTimerTask: Task<Void, Never>?
@ObservationIgnored private var minuteTimerTask: Task<Void, Never>?
@ObservationIgnored private var styleObserverTask: Task<Void, Never>?
// Persistence
private var persistenceWorkItem: DispatchWorkItem?
@ObservationIgnored private var persistenceTask: Task<Void, Never>?
private var styleJSON: Data {
get {
UserDefaults.standard.data(forKey: ClockStyle.appStorageKey) ?? {
@ -57,11 +55,10 @@ final class ClockViewModel {
}
deinit {
stopTimers()
stopAmbientLightMonitoring()
if let styleObserver {
NotificationCenter.default.removeObserver(styleObserver)
}
secondTimerTask?.cancel()
minuteTimerTask?.cancel()
styleObserverTask?.cancel()
persistenceTask?.cancel()
}
// MARK: - Public Interface
@ -153,82 +150,81 @@ final class ClockViewModel {
}
private func observeStyleUpdates() {
styleObserver = NotificationCenter.default.addObserver(
forName: .clockStyleDidUpdate,
object: nil,
queue: .main
) { [weak self] _ in
self?.loadStyle()
self?.updateTimersIfNeeded()
self?.updateWakeLockState()
self?.updateBrightness()
styleObserverTask = Task { [weak self] in
for await _ in NotificationCenter.default.notifications(named: .clockStyleDidUpdate) {
guard let self else { break }
self.loadStyle()
self.updateTimersIfNeeded()
self.updateWakeLockState()
self.updateBrightness()
}
}
}
func saveStyle() {
persistenceWorkItem?.cancel()
persistenceTask?.cancel()
let work = DispatchWorkItem {
if let data = try? JSONEncoder().encode(self.style) {
let style = 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
}
}
persistenceWorkItem = work
DispatchQueue.main.asyncAfter(
deadline: .now() + AppConstants.PersistenceDelays.clockStyle,
execute: work
)
}
private func setupTimers() {
// Always need minute timer for color randomization
if minuteTimer == nil {
minuteTimer = Timer.publish(every: AppConstants.TimerIntervals.minute, on: .main, in: .common)
minuteCancellable = minuteTimer?.autoconnect().sink { _ in
// Always need minute timer for color randomization and night mode checks
startMinuteTimer()
// 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 {
self.style.digitColorHex = Color.randomBrightColorHex()
self.saveStyle()
self.updateBrightness() // Update brightness when color changes
self.updateBrightness()
}
// Check for night mode state changes (scheduled night mode)
// Force a UI update by updating currentTime slightly
self.currentTime = Date()
// Update brightness if night mode is active
self.updateBrightness()
}
}
// Only create second timer if seconds are shown
if style.showSeconds && secondTimer == nil {
secondTimer = Timer.publish(every: AppConstants.TimerIntervals.second, on: .main, in: .common)
secondCancellable = secondTimer?.autoconnect().sink { now in
self.currentTime = now
}
private func startSecondTimer() {
secondTimerTask?.cancel()
secondTimerTask = Task { [weak self] in
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() {
secondCancellable?.cancel()
minuteCancellable?.cancel()
secondCancellable = nil
minuteCancellable = nil
secondTimer = nil
minuteTimer = nil
private func stopSecondTimer() {
secondTimerTask?.cancel()
secondTimerTask = nil
}
private func updateTimersIfNeeded() {
if style.showSeconds && secondTimer == nil {
secondTimer = Timer.publish(every: AppConstants.TimerIntervals.second, 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
if style.showSeconds && secondTimerTask == nil {
startSecondTimer()
} else if !style.showSeconds && secondTimerTask != nil {
stopSecondTimer()
}
}
@ -253,16 +249,10 @@ final class ClockViewModel {
// Set up callback to respond to brightness changes
ambientLightService.onBrightnessChange = { [weak self] in
//Design.debugLog("[brightness] ClockViewModel: Received brightness change notification")
self?.updateBrightness()
}
}
/// Stop ambient light monitoring
private func stopAmbientLightMonitoring() {
ambientLightService.stopMonitoring()
}
/// Update brightness based on color theme and night mode settings
private func updateBrightness() {
if style.autoBrightness {

View File

@ -25,8 +25,12 @@ struct BatteryOverlayView: View {
HStack(spacing: 4) {
Image(systemName: batteryIcon)
.foregroundStyle(batteryColor)
.symbolEffect(.pulse, options: .repeating, isActive: isCharging)
.contentTransition(.symbolEffect(.replace))
Text("\(batteryLevel)%")
.foregroundStyle(color)
.contentTransition(.numericText())
.animation(.snappy(duration: 0.3), value: batteryLevel)
}
.opacity(clamped)
.font(.callout.weight(.semibold))

View File

@ -6,7 +6,6 @@
//
import SwiftUI
import Combine
/// Component for displaying date overlay
struct DateOverlayView: View {
@ -17,8 +16,6 @@ struct DateOverlayView: View {
let dateFormat: String
@State private var dateString: String = ""
@State private var minuteTimer: Timer.TimerPublisher?
@State private var minuteCancellable: AnyCancellable?
// MARK: - Body
var body: some View {
@ -30,31 +27,20 @@ struct DateOverlayView: View {
.font(.callout.weight(.semibold))
.onAppear {
updateDate()
startMinuteUpdates()
}
.onDisappear {
stopMinuteUpdates()
}
.onChange(of: dateFormat) { _, _ in
updateDate()
}
.task {
// Periodically update the date every minute
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(AppConstants.TimerIntervals.minute))
updateDate()
}
}
}
// 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() {
dateString = Date().formattedForOverlay(format: dateFormat)
}

View File

@ -18,9 +18,7 @@ struct NextAlarmOverlay: View {
private var alarmString: String {
guard let time = alarmTime else { return "" }
let formatter = DateFormatter()
formatter.timeStyle = .short
return formatter.string(from: time)
return time.formatted(date: .omitted, time: .shortened)
}
// MARK: - Body
@ -30,6 +28,7 @@ struct NextAlarmOverlay: View {
HStack(spacing: Design.Spacing.xxSmall) {
Image(systemName: "alarm.fill")
.font(.caption)
.symbolEffect(.bounce, value: alarmTime)
Text(alarmString)
.typography(.calloutEmphasis)

View File

@ -26,12 +26,14 @@ struct NoiseMiniPlayer: View {
HStack(spacing: Design.Spacing.small) {
Button(action: onToggle) {
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
.contentTransition(.symbolEffect(.replace))
.font(.system(size: 14, weight: .bold))
.foregroundStyle(.white)
.frame(width: 28, height: 28)
.background(AppAccent.primary.opacity(0.8))
.clipShape(Circle())
}
.sensoryFeedback(.impact(flexibility: .soft), trigger: isPlaying)
VStack(alignment: .leading, spacing: 0) {
Text(isPlaying ? "Playing" : "Paused")

View File

@ -54,6 +54,8 @@ struct AdvancedDisplaySection: View {
Text("\(Int(style.effectiveBrightness * 100))%")
.font(.subheadline)
.foregroundStyle(AppTextColors.secondary)
.contentTransition(.numericText())
.animation(.snappy(duration: 0.3), value: style.effectiveBrightness)
}
.padding(.vertical, Design.Spacing.medium)
.padding(.horizontal, Design.Spacing.medium)

View File

@ -49,8 +49,9 @@ struct TimePickerView: View {
}
private func updateStringFromTime(_ time: Date) {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm"
timeString = formatter.string(from: time)
let calendar = Calendar.current
let hour = calendar.component(.hour, from: time)
let minute = calendar.component(.minute, from: time)
timeString = String(format: "%02d:%02d", hour, minute)
}
}

View File

@ -81,7 +81,7 @@ struct SoundCategoryView: View {
}
private var categoryTabs: some View {
ScrollView(.horizontal, showsIndicators: false) {
ScrollView(.horizontal) {
HStack(spacing: Design.Spacing.small) {
ForEach(categories) { category in
CategoryTab(
@ -95,6 +95,7 @@ struct SoundCategoryView: View {
}
.padding(.horizontal, Design.Spacing.medium)
}
.scrollIndicators(.hidden)
}
private var soundGrid: some View {

View File

@ -56,14 +56,8 @@ struct OnboardingWelcomePage: View {
struct OnboardingClockText: View {
let date: Date
private static let formatter: DateFormatter = {
let df = DateFormatter()
df.dateFormat = "h:mm"
return df
}()
private var timeString: String {
Self.formatter.string(from: date)
date.formatted(.dateTime.hour(.defaultDigits(amPM: .omitted)).minute(.twoDigits))
}
var body: some View {

View File

@ -17,6 +17,7 @@ struct KeepAwakePrompt: View {
Image(systemName: "bolt.fill")
.font(.system(size: 36, weight: .semibold))
.foregroundStyle(AppAccent.primary)
.symbolEffect(.bounce, options: .nonRepeating)
VStack(spacing: Design.Spacing.small) {
Text("Keep Awake for Alarms")
@ -48,6 +49,7 @@ struct KeepAwakePrompt: View {
.frame(maxWidth: .infinity)
.background(AppSurface.primary)
.presentationDetents([.medium])
.presentationCornerRadius(Design.CornerRadius.xxLarge)
}
}

View File

@ -9,7 +9,7 @@ import Foundation
import Observation
@Observable
class KeepAwakePromptState {
final class KeepAwakePromptState {
var isPresented = false
private var hasShownThisSession = false