mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Partial fixes for lock screen calendar
This commit is contained in:
parent
2453ca89ff
commit
f411676d02
10 changed files with 1348 additions and 27 deletions
|
|
@ -636,8 +636,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
let timerWidgetManager = LockScreenTimerWidgetManager.shared
|
||||
timerWidgetManager.handleLockStateChange(isLocked: LockScreenManager.shared.currentLockStatus)
|
||||
|
||||
// Ensure the reminder widget mirrors live activity snapshots as soon as they exist.
|
||||
_ = LockScreenReminderWidgetManager.shared
|
||||
}
|
||||
|
||||
private func registerOptionalShortcutHandlers() {
|
||||
|
|
|
|||
|
|
@ -1537,6 +1537,12 @@
|
|||
"comment" : "A time displayed in the timer panel on a lock screen.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"shouldTranslate" : false
|
||||
},
|
||||
"1 hour" : {
|
||||
|
||||
},
|
||||
"1 min" : {
|
||||
|
||||
},
|
||||
"1x" : {
|
||||
"extractionState" : "stale",
|
||||
|
|
@ -1548,6 +1554,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"2 hours" : {
|
||||
|
||||
},
|
||||
"3 items" : {
|
||||
"localizations" : {
|
||||
|
|
@ -1596,6 +1605,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"5 mins" : {
|
||||
|
||||
},
|
||||
"7 items" : {
|
||||
"localizations" : {
|
||||
|
|
@ -1644,6 +1656,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"10 mins" : {
|
||||
|
||||
},
|
||||
"15 colors" : {
|
||||
"localizations" : {
|
||||
|
|
@ -1660,6 +1675,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"15 mins" : {
|
||||
|
||||
},
|
||||
"20" : {
|
||||
"extractionState" : "stale",
|
||||
|
|
@ -1698,6 +1716,12 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"30 mins" : {
|
||||
|
||||
},
|
||||
"45 mins" : {
|
||||
|
||||
},
|
||||
"100" : {
|
||||
"comment" : "The text displayed below a 100% battery level in the lock screen.",
|
||||
|
|
@ -3008,6 +3032,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Alignment" : {
|
||||
|
||||
},
|
||||
"All" : {
|
||||
"localizations" : {
|
||||
|
|
@ -5158,6 +5185,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Calendar Widget" : {
|
||||
|
||||
},
|
||||
"Calendars" : {
|
||||
"localizations" : {
|
||||
|
|
@ -7046,6 +7076,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Controls the lock screen reminder chip and its positioning." : {
|
||||
|
||||
},
|
||||
"Controls the optional timer widget that floats above the media panel, including its classic, frosted, or liquid glass surface independent of the global material setting." : {
|
||||
"localizations" : {
|
||||
|
|
@ -8268,6 +8301,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Deselect All" : {
|
||||
|
||||
},
|
||||
"Detection Status" : {
|
||||
"comment" : "The header for a section that displays whether the screen recording detection feature is currently active or inactive.",
|
||||
|
|
@ -8633,6 +8669,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Displays your next upcoming calendar event above or below the weather capsule. Calendar selection here is independent from the Dynamic Island calendar filter." : {
|
||||
|
||||
},
|
||||
"Do Not Disturb" : {
|
||||
"localizations" : {
|
||||
|
|
@ -18968,6 +19007,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Reminder Widget" : {
|
||||
|
||||
},
|
||||
"Reminders" : {
|
||||
"localizations" : {
|
||||
|
|
@ -20830,6 +20872,15 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Show event after it starts" : {
|
||||
|
||||
},
|
||||
"Show events from all calendars" : {
|
||||
|
||||
},
|
||||
"Show events within the next" : {
|
||||
|
||||
},
|
||||
"Show floating pause/stop controls" : {
|
||||
"localizations" : {
|
||||
|
|
@ -23345,6 +23396,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Turn off 'Show event for entire duration' to use the post-start duration option." : {
|
||||
|
||||
},
|
||||
"Turn on customizable controls to rearrange media buttons." : {
|
||||
"comment" : "A description of the feature that allows users to rearrange the order of media buttons.",
|
||||
|
|
@ -24389,6 +24443,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vertical offset" : {
|
||||
|
||||
},
|
||||
"Vertical:" : {
|
||||
"extractionState" : "stale",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* Atoll (DynamicIsland)
|
||||
* Copyright (C) 2024-2026 Atoll Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct LockScreenCalendarEventRow: View {
|
||||
let line: String
|
||||
let isVisible: Bool
|
||||
let renderToken: Int
|
||||
let font: Font
|
||||
let alignment: Alignment
|
||||
let iconName: String
|
||||
let iconRenderingMode: SymbolRenderingMode
|
||||
let iconColors: [Color]
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 6) {
|
||||
calendarIcon
|
||||
.frame(width: 26, height: 26)
|
||||
|
||||
Text(line)
|
||||
.font(font)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.minimumScaleFactor(0.85)
|
||||
.allowsTightening(true)
|
||||
.layoutPriority(10)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: alignment)
|
||||
.padding(.horizontal, 2)
|
||||
.id(renderToken)
|
||||
.opacity(isVisible ? 1 : 0)
|
||||
.accessibilityHidden(!isVisible)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var calendarIcon: some View {
|
||||
let baseImage = Image(systemName: iconName)
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.id(iconRenderingModeIdentifier)
|
||||
|
||||
if iconRenderingModeIdentifier.lowercased().contains("palette"), iconColors.count >= 2 {
|
||||
baseImage
|
||||
.symbolRenderingMode(.palette)
|
||||
.foregroundStyle(iconColors[0], iconColors[1])
|
||||
} else if iconColors.count >= 2 {
|
||||
baseImage
|
||||
.symbolRenderingMode(iconRenderingMode)
|
||||
.foregroundStyle(iconColors[0], iconColors[1])
|
||||
} else if iconColors.count == 1 {
|
||||
baseImage
|
||||
.symbolRenderingMode(iconRenderingMode)
|
||||
.foregroundStyle(iconColors[0])
|
||||
} else {
|
||||
baseImage
|
||||
.symbolRenderingMode(iconRenderingMode)
|
||||
}
|
||||
}
|
||||
|
||||
private var iconRenderingModeIdentifier: String {
|
||||
String(describing: iconRenderingMode)
|
||||
}
|
||||
}
|
||||
|
|
@ -18,14 +18,40 @@
|
|||
|
||||
import SwiftUI
|
||||
import Defaults
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
|
||||
|
||||
struct LockScreenWeatherWidget: View {
|
||||
let snapshot: LockScreenWeatherSnapshot
|
||||
@ObservedObject private var focusManager = DoNotDisturbManager.shared
|
||||
@ObservedObject private var calendarManager = CalendarManager.shared
|
||||
@Default(.enableDoNotDisturbDetection) private var focusDetectionEnabled
|
||||
@Default(.showDoNotDisturbIndicator) private var focusIndicatorEnabled
|
||||
@Default(.enableLockScreenFocusWidget) private var lockScreenFocusWidgetEnabled
|
||||
@Default(.lockScreenShowCalendarCountdown) private var lockScreenShowCalendarCountdown
|
||||
@Default(.lockScreenShowCalendarEvent) private var lockScreenShowCalendarEvent
|
||||
@Default(.lockScreenShowCalendarEventEntireDuration) private var lockScreenShowCalendarEventEntireDuration
|
||||
@Default(.lockScreenShowCalendarEventAfterStartWindow) private var lockScreenShowCalendarEventAfterStartWindow
|
||||
@Default(.lockScreenShowCalendarTimeRemaining) private var lockScreenShowCalendarTimeRemaining
|
||||
@Default(.lockScreenShowCalendarStartTimeAfterBegins) private var lockScreenShowCalendarStartTimeAfterBegins
|
||||
@Default(.lockScreenCalendarEventLookaheadWindow) private var lockScreenCalendarEventLookaheadWindow
|
||||
@Default(.lockScreenWeatherWidgetRowOrder) private var lockScreenWeatherWidgetRowOrder
|
||||
@Default(.lockScreenCalendarSelectionMode) private var lockScreenCalendarSelectionMode
|
||||
@Default(.lockScreenSelectedCalendarIDs) private var lockScreenSelectedCalendarIDs
|
||||
@Default(.lockScreenShowCalendarEventAfterStartEnabled) private var lockScreenShowCalendarEventAfterStartEnabled
|
||||
@Default(.lockScreenReminderChipStyle) private var lockScreenReminderChipStyle
|
||||
@Default(.reminderSneakPeekDuration) private var reminderSneakPeekDuration
|
||||
|
||||
@State private var currentTime = Date()
|
||||
@State private var calendarRowVisible: Bool = false
|
||||
@State private var lastCalendarLine: String = ""
|
||||
@State private var calendarRowRenderToken: Int = 0
|
||||
@State private var widgetWidthRemeasureToken: Int = 0
|
||||
@State private var lastBadgeLogKey: String = ""
|
||||
private var currentCalendarEventID: String {
|
||||
nextCalendarEvent?.id ?? "no_event"
|
||||
}
|
||||
|
||||
private let inlinePrimaryFont = Font.system(size: 22, weight: .semibold, design: .rounded)
|
||||
private let inlineSecondaryFont = Font.system(size: 13, weight: .medium, design: .rounded)
|
||||
|
|
@ -38,9 +64,407 @@ struct LockScreenWeatherWidget: View {
|
|||
return formatter
|
||||
}()
|
||||
|
||||
// MARK: - Refresh
|
||||
/// Refreshes every 15 seconds (general refresh), and optionally also ticks exactly on minute boundaries
|
||||
/// *only when the screen is locked* and the calendar widget is enabled.
|
||||
private final class LockAwareTicker: ObservableObject {
|
||||
@Published var now: Date = Date()
|
||||
|
||||
private var refreshTimer: Timer?
|
||||
private var minuteTimer: Timer?
|
||||
|
||||
private var lockObserver: NSObjectProtocol?
|
||||
private var unlockObserver: NSObjectProtocol?
|
||||
|
||||
private var isLocked: Bool = false
|
||||
private var minuteAlignedEnabled: Bool = false
|
||||
|
||||
func start() {
|
||||
stop()
|
||||
installLockObservers()
|
||||
refreshLockStateFromSystem()
|
||||
startGeneralRefresh()
|
||||
fireNow()
|
||||
updateMinuteAlignedTimer()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
refreshTimer?.invalidate()
|
||||
refreshTimer = nil
|
||||
|
||||
minuteTimer?.invalidate()
|
||||
minuteTimer = nil
|
||||
|
||||
if let lockObserver { DistributedNotificationCenter.default().removeObserver(lockObserver) }
|
||||
if let unlockObserver { DistributedNotificationCenter.default().removeObserver(unlockObserver) }
|
||||
lockObserver = nil
|
||||
unlockObserver = nil
|
||||
}
|
||||
|
||||
func fireNow() {
|
||||
now = Date()
|
||||
}
|
||||
|
||||
/// Enable/disable the minute-aligned ticker (minute boundary ticks).
|
||||
/// It will only actually run when the screen is locked.
|
||||
func setMinuteAlignedEnabled(_ enabled: Bool) {
|
||||
minuteAlignedEnabled = enabled
|
||||
updateMinuteAlignedTimer()
|
||||
}
|
||||
|
||||
private func startGeneralRefresh() {
|
||||
refreshTimer = Timer.scheduledTimer(withTimeInterval: 15.0, repeats: true) { [weak self] _ in
|
||||
self?.fireNow()
|
||||
}
|
||||
refreshTimer?.tolerance = 0
|
||||
if let refreshTimer {
|
||||
RunLoop.main.add(refreshTimer, forMode: .common)
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshLockStateFromSystem() {
|
||||
if let dict = CGSessionCopyCurrentDictionary() as? [String: Any] {
|
||||
if let locked = dict["CGSSessionScreenIsLocked"] as? Bool {
|
||||
isLocked = locked
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func installLockObservers() {
|
||||
lockObserver = DistributedNotificationCenter.default().addObserver(
|
||||
forName: NSNotification.Name("com.apple.screenIsLocked"),
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.isLocked = true
|
||||
self.fireNow()
|
||||
self.updateMinuteAlignedTimer()
|
||||
}
|
||||
|
||||
unlockObserver = DistributedNotificationCenter.default().addObserver(
|
||||
forName: NSNotification.Name("com.apple.screenIsUnlocked"),
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.isLocked = false
|
||||
self.fireNow()
|
||||
self.updateMinuteAlignedTimer()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateMinuteAlignedTimer() {
|
||||
let shouldRun = isLocked && minuteAlignedEnabled
|
||||
if !shouldRun {
|
||||
minuteTimer?.invalidate()
|
||||
minuteTimer = nil
|
||||
return
|
||||
}
|
||||
|
||||
if minuteTimer != nil {
|
||||
return
|
||||
}
|
||||
|
||||
scheduleNextBoundaryAndRepeat()
|
||||
}
|
||||
|
||||
private func scheduleNextBoundaryAndRepeat() {
|
||||
let current = Date()
|
||||
let calendar = Calendar.current
|
||||
|
||||
var components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: current)
|
||||
components.second = 0
|
||||
let thisMinute = calendar.date(from: components) ?? current
|
||||
let nextMinute = calendar.date(byAdding: .minute, value: 1, to: thisMinute) ?? current.addingTimeInterval(60)
|
||||
|
||||
let initialDelay = max(0.0, nextMinute.timeIntervalSince(current))
|
||||
minuteTimer = Timer.scheduledTimer(withTimeInterval: initialDelay, repeats: false) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.fireNow()
|
||||
|
||||
self.minuteTimer?.invalidate()
|
||||
self.minuteTimer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { [weak self] _ in
|
||||
self?.fireNow()
|
||||
}
|
||||
self.minuteTimer?.tolerance = 0
|
||||
if let minuteTimer {
|
||||
RunLoop.main.add(minuteTimer, forMode: .common)
|
||||
}
|
||||
}
|
||||
minuteTimer?.tolerance = 0
|
||||
if let minuteTimer {
|
||||
RunLoop.main.add(minuteTimer, forMode: .common)
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
@StateObject private var minuteTicker = LockAwareTicker()
|
||||
|
||||
private enum CalendarLookaheadWindow: String {
|
||||
case mins15 = "15m"
|
||||
case mins30 = "30m"
|
||||
case hour1 = "1h"
|
||||
case hours3 = "3h"
|
||||
case hours6 = "6h"
|
||||
case hours12 = "12h"
|
||||
case restOfDay = "rest_of_day"
|
||||
case allTime = "all_time"
|
||||
|
||||
var minutes: Int? {
|
||||
switch self {
|
||||
case .mins15: return 15
|
||||
case .mins30: return 30
|
||||
case .hour1: return 60
|
||||
case .hours3: return 180
|
||||
case .hours6: return 360
|
||||
case .hours12: return 720
|
||||
case .restOfDay, .allTime: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum CalendarAfterStartWindow: String {
|
||||
case min1 = "1m"
|
||||
case mins5 = "5m"
|
||||
case mins10 = "10m"
|
||||
case mins15 = "15m"
|
||||
case mins30 = "30m"
|
||||
case mins45 = "45m"
|
||||
case hour1 = "1h"
|
||||
case hours2 = "2h"
|
||||
|
||||
var minutes: Int {
|
||||
switch self {
|
||||
case .min1: return 1
|
||||
case .mins5: return 5
|
||||
case .mins10: return 10
|
||||
case .mins15: return 15
|
||||
case .mins30: return 30
|
||||
case .mins45: return 45
|
||||
case .hour1: return 60
|
||||
case .hours2: return 120
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedLookaheadWindow: CalendarLookaheadWindow {
|
||||
let raw = lockScreenCalendarEventLookaheadWindow
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
|
||||
switch raw {
|
||||
case "all time", "all_time", "alltime":
|
||||
return .allTime
|
||||
case "rest of the day", "rest_of_day", "restofday":
|
||||
return .restOfDay
|
||||
case "15m", "15 mins", "15min", "15mins":
|
||||
return .mins15
|
||||
case "30m", "30 mins", "30min", "30mins":
|
||||
return .mins30
|
||||
case "1h", "1 hour", "1hour":
|
||||
return .hour1
|
||||
case "3h", "3 hours", "3hours":
|
||||
return .hours3
|
||||
case "6h", "6 hours", "6hours":
|
||||
return .hours6
|
||||
case "12h", "12 hours", "12hours":
|
||||
return .hours12
|
||||
default:
|
||||
return CalendarLookaheadWindow(rawValue: lockScreenCalendarEventLookaheadWindow) ?? .hours3
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedAfterStartWindow: CalendarAfterStartWindow {
|
||||
let raw = lockScreenShowCalendarEventAfterStartWindow
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
|
||||
switch raw {
|
||||
case "1m", "1 min", "1 minute", "1min":
|
||||
return .min1
|
||||
case "5m", "5 min", "5 mins", "5 minutes", "5min":
|
||||
return .mins5
|
||||
case "10m", "10 min", "10 mins", "10 minutes", "10min":
|
||||
return .mins10
|
||||
case "15m", "15 min", "15 mins", "15 minutes", "15min":
|
||||
return .mins15
|
||||
case "30m", "30 min", "30 mins", "30 minutes", "30min":
|
||||
return .mins30
|
||||
case "45m", "45 min", "45 mins", "45 minutes", "45min":
|
||||
return .mins45
|
||||
case "1h", "1 hr", "1 hour", "1hour":
|
||||
return .hour1
|
||||
case "2h", "2 hrs", "2 hours", "2hours", "2hour":
|
||||
return .hours2
|
||||
default:
|
||||
return CalendarAfterStartWindow(rawValue: lockScreenShowCalendarEventAfterStartWindow) ?? .mins5
|
||||
}
|
||||
}
|
||||
|
||||
private func lookaheadEndDate(from now: Date) -> Date? {
|
||||
switch selectedLookaheadWindow {
|
||||
case .allTime:
|
||||
return nil
|
||||
case .restOfDay:
|
||||
let calendar = Calendar.current
|
||||
let startOfToday = calendar.startOfDay(for: now)
|
||||
return calendar.date(byAdding: .day, value: 1, to: startOfToday)
|
||||
default:
|
||||
if let minutes = selectedLookaheadWindow.minutes {
|
||||
return Calendar.current.date(byAdding: .minute, value: minutes, to: now)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func isEventEligibleForLookahead(_ event: EventModel, now: Date) -> Bool {
|
||||
if event.isAllDay {
|
||||
return false
|
||||
}
|
||||
if lockScreenShowCalendarEventEntireDuration, event.start <= now, event.end >= now {
|
||||
return true
|
||||
}
|
||||
guard event.start > now else { return false }
|
||||
guard let endDate = lookaheadEndDate(from: now) else {
|
||||
return true
|
||||
}
|
||||
return event.start <= endDate
|
||||
}
|
||||
|
||||
private var lockScreenAllowedCalendarIDs: Set<String>? {
|
||||
guard lockScreenCalendarSelectionMode != "all" else { return nil }
|
||||
return lockScreenSelectedCalendarIDs
|
||||
}
|
||||
|
||||
private func passesLockScreenCalendarFilter(_ event: EventModel) -> Bool {
|
||||
guard let allowed = lockScreenAllowedCalendarIDs else { return true }
|
||||
guard !allowed.isEmpty else { return false }
|
||||
return allowed.contains(event.calendar.id)
|
||||
}
|
||||
|
||||
private var nextCalendarEvent: EventModel? {
|
||||
let now = currentTime
|
||||
|
||||
if lockScreenShowCalendarEventEntireDuration {
|
||||
let candidates = calendarManager.lockScreenEvents
|
||||
.filter { $0.end >= now }
|
||||
.filter(passesLockScreenCalendarFilter)
|
||||
.filter { isEventEligibleForLookahead($0, now: now) }
|
||||
.sorted { $0.start < $1.start }
|
||||
return candidates.first
|
||||
}
|
||||
|
||||
let graceMinutes = lockScreenShowCalendarEventAfterStartEnabled
|
||||
? selectedAfterStartWindow.minutes
|
||||
: 0
|
||||
|
||||
if graceMinutes > 0 {
|
||||
let graceEndForEvent: (EventModel) -> Date = { event in
|
||||
Calendar.current.date(byAdding: .minute, value: graceMinutes, to: event.start) ?? event.start
|
||||
}
|
||||
|
||||
let activeCandidates = calendarManager.lockScreenEvents
|
||||
.filter { !$0.isAllDay }
|
||||
.filter(passesLockScreenCalendarFilter)
|
||||
.filter { $0.start <= now && $0.end >= now }
|
||||
.filter { now <= graceEndForEvent($0) }
|
||||
.sorted { $0.start < $1.start }
|
||||
|
||||
if let active = activeCandidates.first {
|
||||
return active
|
||||
}
|
||||
}
|
||||
|
||||
let upcoming = calendarManager.lockScreenEvents
|
||||
.filter { $0.start > now }
|
||||
.filter(passesLockScreenCalendarFilter)
|
||||
.filter { isEventEligibleForLookahead($0, now: now) }
|
||||
.sorted { $0.start < $1.start }
|
||||
|
||||
return upcoming.first
|
||||
}
|
||||
|
||||
private var shouldShowCalendarRow: Bool {
|
||||
lockScreenShowCalendarEvent && nextCalendarEvent != nil
|
||||
}
|
||||
|
||||
private enum RowKind {
|
||||
case weather
|
||||
case focus
|
||||
case calendar
|
||||
}
|
||||
|
||||
private var orderedRowKinds: [RowKind] {
|
||||
switch lockScreenWeatherWidgetRowOrder {
|
||||
case "weather_focus_calendar":
|
||||
return [.weather, .focus, .calendar]
|
||||
case "weather_calendar_focus":
|
||||
return [.weather, .calendar, .focus]
|
||||
case "focus_weather_calendar":
|
||||
return [.focus, .weather, .calendar]
|
||||
case "focus_calendar_weather":
|
||||
return [.focus, .calendar, .weather]
|
||||
case "calendar_weather_focus":
|
||||
return [.calendar, .weather, .focus]
|
||||
case "calendar_focus_weather":
|
||||
return [.calendar, .focus, .weather]
|
||||
default:
|
||||
return [.weather, .calendar, .focus]
|
||||
}
|
||||
}
|
||||
|
||||
private var enabledRowKinds: [RowKind] {
|
||||
var enabled: Set<RowKind> = [.weather]
|
||||
if lockScreenShowCalendarEvent { enabled.insert(.calendar) }
|
||||
if shouldShowFocusWidget { enabled.insert(.focus) }
|
||||
return orderedRowKinds.filter { enabled.contains($0) }
|
||||
}
|
||||
|
||||
private var fullRowOrder: [RowKind] {
|
||||
orderedRowKinds
|
||||
}
|
||||
|
||||
private func isRowActive(_ kind: RowKind) -> Bool {
|
||||
switch kind {
|
||||
case .weather:
|
||||
return true
|
||||
case .focus:
|
||||
return shouldShowFocusWidget
|
||||
case .calendar:
|
||||
return nextCalendarEvent != nil
|
||||
}
|
||||
}
|
||||
|
||||
private var shouldCollapseGap: Bool {
|
||||
fullRowOrder.count == 3
|
||||
}
|
||||
|
||||
private var collapseDistance: CGFloat {
|
||||
26 + focusWidgetSpacing
|
||||
}
|
||||
|
||||
private var collapseOffsetForBottomRow: CGFloat {
|
||||
guard shouldCollapseGap else { return 0 }
|
||||
let middleKind = fullRowOrder[1]
|
||||
let bottomKind = fullRowOrder[2]
|
||||
|
||||
let middleActive = isRowActive(middleKind)
|
||||
let bottomActive = isRowActive(bottomKind)
|
||||
|
||||
return (!middleActive && bottomActive) ? -collapseDistance : 0
|
||||
}
|
||||
|
||||
private var isInline: Bool { snapshot.widgetStyle == .inline }
|
||||
private var stackAlignment: VerticalAlignment { isInline ? .firstTextBaseline : .top }
|
||||
private var stackSpacing: CGFloat { isInline ? 14 : 22 }
|
||||
private var mainRowAlignment: Alignment { isInline ? .leading : .center }
|
||||
private var secondaryRowAlignment: Alignment { isInline ? .leading : .center }
|
||||
private var focusRowAlignment: Alignment { .center }
|
||||
private var gaugeDiameter: CGFloat { 64 }
|
||||
private var topPadding: CGFloat { isInline ? 6 : 22 }
|
||||
private var bottomPadding: CGFloat { isInline ? 6 : 10 }
|
||||
|
|
@ -51,11 +475,13 @@ struct LockScreenWeatherWidget: View {
|
|||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: focusWidgetSpacing) {
|
||||
if shouldShowFocusWidget {
|
||||
focusWidget
|
||||
ForEach(Array(enabledRowKinds.enumerated()), id: \.offset) { index, kind in
|
||||
rowView(for: kind)
|
||||
.offset(y: offsetForRow(index: index))
|
||||
.animation(.easeInOut(duration: 0.3), value: collapseOffsetForBottomRow)
|
||||
}
|
||||
mainWidgetRow
|
||||
}
|
||||
.id(widgetWidthRemeasureToken)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.foregroundStyle(Color.white.opacity(0.65))
|
||||
.padding(.horizontal, 10)
|
||||
|
|
@ -63,10 +489,96 @@ struct LockScreenWeatherWidget: View {
|
|||
.padding(.bottom, bottomPadding)
|
||||
.background(Color.clear)
|
||||
.shadow(color: .black.opacity(0.35), radius: 8, x: 0, y: 3)
|
||||
.onReceive(minuteTicker.$now) { currentTime = $0 }
|
||||
.onAppear {
|
||||
minuteTicker.start()
|
||||
minuteTicker.setMinuteAlignedEnabled(lockScreenShowCalendarEvent)
|
||||
currentTime = Date()
|
||||
calendarRowVisible = shouldShowCalendarRow
|
||||
|
||||
Task {
|
||||
await calendarManager.checkCalendarAuthorization()
|
||||
await calendarManager.checkReminderAuthorization()
|
||||
await calendarManager.updateLockScreenEvents(force: true)
|
||||
}
|
||||
|
||||
if let event = nextCalendarEvent {
|
||||
lastCalendarLine = eventLineText(for: event)
|
||||
logBadgeColor(event: event, reason: "on-appear")
|
||||
}
|
||||
}
|
||||
.onChange(of: lockScreenShowCalendarEvent) { _, enabled in
|
||||
minuteTicker.setMinuteAlignedEnabled(enabled)
|
||||
}
|
||||
.onChange(of: lockScreenCalendarEventLookaheadWindow) { _, _ in
|
||||
Task { await calendarManager.updateLockScreenEvents(force: true) }
|
||||
}
|
||||
.onChange(of: lockScreenCalendarSelectionMode) { _, _ in
|
||||
Task { await calendarManager.updateLockScreenEvents(force: true) }
|
||||
}
|
||||
.onChange(of: lockScreenSelectedCalendarIDs) { _, _ in
|
||||
Task { await calendarManager.updateLockScreenEvents(force: true) }
|
||||
}
|
||||
.onDisappear { minuteTicker.stop() }
|
||||
.onChange(of: currentCalendarEventID) { _, _ in
|
||||
calendarRowRenderToken &+= 1
|
||||
if nextCalendarEvent != nil {
|
||||
widgetWidthRemeasureToken &+= 1
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.20) {
|
||||
calendarRowRenderToken &+= 1
|
||||
if nextCalendarEvent != nil {
|
||||
widgetWidthRemeasureToken &+= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: currentCalendarEventID) { _, _ in
|
||||
if let event = nextCalendarEvent {
|
||||
let newLine = eventLineText(for: event)
|
||||
if !newLine.isEmpty {
|
||||
lastCalendarLine = newLine
|
||||
}
|
||||
logBadgeColor(event: event, reason: "event-change")
|
||||
}
|
||||
}
|
||||
.onChange(of: lockScreenReminderChipStyle) { _, _ in
|
||||
if let event = nextCalendarEvent {
|
||||
logBadgeColor(event: event, reason: "chip-style-change")
|
||||
}
|
||||
}
|
||||
.onChange(of: shouldShowCalendarRow) { _, newValue in
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
calendarRowVisible = newValue
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(accessibilityLabel)
|
||||
}
|
||||
|
||||
private func offsetForRow(index: Int) -> CGFloat {
|
||||
if enabledRowKinds.count != 3 { return 0 }
|
||||
if index == 2 {
|
||||
return collapseOffsetForBottomRow
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func rowView(for kind: RowKind) -> some View {
|
||||
switch kind {
|
||||
case .weather:
|
||||
mainWidgetRow
|
||||
case .calendar:
|
||||
nextEventRow
|
||||
case .focus:
|
||||
focusWidget
|
||||
.opacity(shouldShowFocusWidget ? 1 : 0)
|
||||
.accessibilityHidden(!shouldShowFocusWidget)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
private var mainWidgetRow: some View {
|
||||
HStack(alignment: stackAlignment, spacing: stackSpacing) {
|
||||
if let charging = snapshot.charging {
|
||||
|
|
@ -95,6 +607,143 @@ struct LockScreenWeatherWidget: View {
|
|||
locationSegment
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: mainRowAlignment)
|
||||
}
|
||||
|
||||
private var nextEventRow: some View {
|
||||
let event = nextCalendarEvent
|
||||
let line = shouldShowCalendarRow ? eventLineText(for: event) : lastCalendarLine
|
||||
let iconConfig = calendarRowIconConfig(for: event)
|
||||
|
||||
return LockScreenCalendarEventRow(
|
||||
line: line,
|
||||
isVisible: calendarRowVisible,
|
||||
renderToken: calendarRowRenderToken,
|
||||
font: inlinePrimaryFont,
|
||||
alignment: secondaryRowAlignment,
|
||||
iconName: iconConfig.name,
|
||||
iconRenderingMode: iconConfig.renderingMode,
|
||||
iconColors: iconConfig.colors
|
||||
)
|
||||
}
|
||||
|
||||
private func calendarRowIconConfig(for event: EventModel?) -> (name: String, renderingMode: SymbolRenderingMode, colors: [Color]) {
|
||||
guard let event else {
|
||||
return (name: "calendar.badge", renderingMode: .hierarchical, colors: [Color.white.opacity(0.9)])
|
||||
}
|
||||
|
||||
let badgeColor = reminderChipColor(for: event)
|
||||
logBadgeColor(event: event, reason: "icon-config")
|
||||
|
||||
if event.type.isReminder || event.calendar.isReminder {
|
||||
if isReminderCritical(event) {
|
||||
return (name: "exclamationmark.triangle.fill", renderingMode: .multicolor, colors: [])
|
||||
}
|
||||
return (
|
||||
name: "calendar.badge",
|
||||
renderingMode: .palette,
|
||||
colors: [badgeColor, Color.white.opacity(0.6)]
|
||||
)
|
||||
}
|
||||
|
||||
let eventBadgeColor = eventAccentColor(for: event)
|
||||
return (
|
||||
name: "calendar.badge",
|
||||
renderingMode: .palette,
|
||||
colors: [eventBadgeColor, Color.white.opacity(0.6)]
|
||||
)
|
||||
}
|
||||
|
||||
private func logBadgeColor(event: EventModel, reason: String) {
|
||||
let key = "\(event.id)|\(lockScreenReminderChipStyle.rawValue)|\(isReminderCritical(event))|\(reason)"
|
||||
lastBadgeLogKey = key
|
||||
|
||||
let color = reminderChipColor(for: event)
|
||||
let nsColor = NSColor(color).usingColorSpace(.deviceRGB)
|
||||
if let nsColor {
|
||||
var red: CGFloat = 0
|
||||
var green: CGFloat = 0
|
||||
var blue: CGFloat = 0
|
||||
var alpha: CGFloat = 0
|
||||
nsColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
|
||||
let hex = String(format: "#%02X%02X%02X", Int(red * 255), Int(green * 255), Int(blue * 255))
|
||||
let alphaText = String(format: "%.2f", alpha)
|
||||
NSLog("LockScreenCalendarRow badge color: %@ alpha=%@ style=%@ critical=%@ reason=%@", hex, alphaText, lockScreenReminderChipStyle.rawValue, String(isReminderCritical(event)), reason)
|
||||
} else {
|
||||
NSLog("LockScreenCalendarRow badge color: <unavailable> style=%@ critical=%@ reason=%@", lockScreenReminderChipStyle.rawValue, String(isReminderCritical(event)), reason)
|
||||
}
|
||||
}
|
||||
|
||||
private func reminderChipColor(for event: EventModel) -> Color {
|
||||
if isReminderCritical(event) { return .red }
|
||||
switch lockScreenReminderChipStyle {
|
||||
case .monochrome:
|
||||
return Color.white.opacity(0.85)
|
||||
case .eventColor:
|
||||
let color = reminderAccentColor(for: event)
|
||||
return color.ensureMinimumBrightness(factor: 0.7)
|
||||
}
|
||||
}
|
||||
|
||||
private func reminderAccentColor(for event: EventModel) -> Color {
|
||||
Color(event.calendar.color)
|
||||
}
|
||||
|
||||
private func eventAccentColor(for event: EventModel) -> Color {
|
||||
Color(event.calendar.color).ensureMinimumBrightness(factor: 0.7)
|
||||
}
|
||||
|
||||
private func isReminderCritical(_ event: EventModel) -> Bool {
|
||||
guard event.type.isReminder || event.calendar.isReminder else { return false }
|
||||
let window = TimeInterval(reminderSneakPeekDuration)
|
||||
guard window > 0 else { return false }
|
||||
let remaining = event.start.timeIntervalSince(currentTime)
|
||||
return remaining > 0 && remaining <= window
|
||||
}
|
||||
|
||||
private func eventLineText(for event: EventModel?) -> String {
|
||||
guard let event else { return "" }
|
||||
let now = currentTime
|
||||
|
||||
if event.isAllDay {
|
||||
return "All day • \(event.title)"
|
||||
}
|
||||
|
||||
let timeString = event.start.formatted(date: .omitted, time: .shortened)
|
||||
|
||||
if event.start <= now && event.end >= now {
|
||||
let leading = lockScreenShowCalendarStartTimeAfterBegins ? "\(timeString) • " : ""
|
||||
let suffix: String
|
||||
if lockScreenShowCalendarTimeRemaining {
|
||||
let secondsLeft = event.end.timeIntervalSince(now)
|
||||
let minutesLeft = max(0, Int(ceil(secondsLeft / 60.0)))
|
||||
suffix = " • \(countdownText(fromMinutes: minutesLeft)) left"
|
||||
} else {
|
||||
suffix = ""
|
||||
}
|
||||
return "\(leading)\(event.title)\(suffix)"
|
||||
}
|
||||
|
||||
guard lockScreenShowCalendarCountdown else {
|
||||
return "\(timeString) • \(event.title)"
|
||||
}
|
||||
|
||||
let secondsUntilStart = event.start.timeIntervalSince(now)
|
||||
let totalMinutes = max(0, Int(ceil(secondsUntilStart / 60.0)))
|
||||
return "\(timeString) • \(event.title) • in \(countdownText(fromMinutes: totalMinutes))"
|
||||
}
|
||||
|
||||
private func countdownText(fromMinutes minutes: Int) -> String {
|
||||
if minutes < 60 {
|
||||
return "\(minutes)m"
|
||||
}
|
||||
|
||||
let hours = minutes / 60
|
||||
let mins = minutes % 60
|
||||
if mins == 0 {
|
||||
return "\(hours)h"
|
||||
}
|
||||
return "\(hours)h \(mins)m"
|
||||
}
|
||||
|
||||
private var sunriseTimeText: String? {
|
||||
|
|
@ -399,7 +1048,7 @@ struct LockScreenWeatherWidget: View {
|
|||
}
|
||||
|
||||
private var focusWidgetSpacing: CGFloat {
|
||||
guard shouldShowFocusWidget else { return 0 }
|
||||
guard lockScreenShowCalendarEvent || lockScreenFocusWidgetEnabled else { return 0 }
|
||||
return isInline ? 14 : 20
|
||||
}
|
||||
|
||||
|
|
@ -447,8 +1096,7 @@ struct LockScreenWeatherWidget: View {
|
|||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity, alignment: focusRowAlignment)
|
||||
.padding(.horizontal, 2)
|
||||
.accessibilityLabel("Focus active: \(focusDisplayName)")
|
||||
}
|
||||
|
|
@ -670,6 +1318,13 @@ struct LockScreenWeatherWidget: View {
|
|||
components.append("Focus active: \(focusDisplayName)")
|
||||
}
|
||||
|
||||
if lockScreenShowCalendarEvent, let event = nextCalendarEvent {
|
||||
let line = eventLineText(for: event)
|
||||
if !line.isEmpty {
|
||||
components.append("Calendar: \(line)")
|
||||
}
|
||||
}
|
||||
|
||||
return components.joined(separator: ". ")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -663,6 +663,14 @@ struct SettingsView: View {
|
|||
SettingsSearchEntry(tab: .calendar, title: "Enable reminder live activity", keywords: ["reminder", "live activity"], highlightID: SettingsTab.calendar.highlightID(for: "Enable reminder live activity")),
|
||||
SettingsSearchEntry(tab: .calendar, title: "Countdown style", keywords: ["reminder countdown"], highlightID: SettingsTab.calendar.highlightID(for: "Countdown style")),
|
||||
SettingsSearchEntry(tab: .calendar, title: "Show lock screen reminder", keywords: ["lock screen", "reminder widget"], highlightID: SettingsTab.calendar.highlightID(for: "Show lock screen reminder")),
|
||||
SettingsSearchEntry(tab: .calendar, title: "Show next calendar event", keywords: ["calendar widget", "lock screen", "next event"], highlightID: SettingsTab.calendar.highlightID(for: "Show next calendar event")),
|
||||
SettingsSearchEntry(tab: .calendar, title: "Show events within the next", keywords: ["calendar widget", "lookahead"], highlightID: SettingsTab.calendar.highlightID(for: "Show events within the next")),
|
||||
SettingsSearchEntry(tab: .calendar, title: "Show events from all calendars", keywords: ["calendar widget", "selection"], highlightID: SettingsTab.calendar.highlightID(for: "Show events from all calendars")),
|
||||
SettingsSearchEntry(tab: .calendar, title: "Show countdown", keywords: ["calendar widget", "countdown"], highlightID: SettingsTab.calendar.highlightID(for: "Show countdown")),
|
||||
SettingsSearchEntry(tab: .calendar, title: "Show event for entire duration", keywords: ["calendar widget", "duration"], highlightID: SettingsTab.calendar.highlightID(for: "Show event for entire duration")),
|
||||
SettingsSearchEntry(tab: .calendar, title: "Hide active event and show next upcoming event", keywords: ["calendar widget", "after start"], highlightID: SettingsTab.calendar.highlightID(for: "Hide active event and show next upcoming event")),
|
||||
SettingsSearchEntry(tab: .calendar, title: "Show time remaining", keywords: ["calendar widget", "remaining"], highlightID: SettingsTab.calendar.highlightID(for: "Show time remaining")),
|
||||
SettingsSearchEntry(tab: .calendar, title: "Show start time after event begins", keywords: ["calendar widget", "start time"], highlightID: SettingsTab.calendar.highlightID(for: "Show start time after event begins")),
|
||||
SettingsSearchEntry(tab: .calendar, title: "Chip color", keywords: ["reminder chip", "color"], highlightID: SettingsTab.calendar.highlightID(for: "Chip color")),
|
||||
SettingsSearchEntry(tab: .calendar, title: "Hide all-day events", keywords: ["calendar", "all-day"], highlightID: SettingsTab.calendar.highlightID(for: "Hide all-day events")),
|
||||
SettingsSearchEntry(tab: .calendar, title: "Hide completed reminders", keywords: ["reminder", "completed"], highlightID: SettingsTab.calendar.highlightID(for: "Hide completed reminders")),
|
||||
|
|
@ -717,6 +725,10 @@ struct SettingsView: View {
|
|||
SettingsSearchEntry(tab: .lockScreen, title: "Show AQI widget", keywords: ["air quality", "aqi"], highlightID: SettingsTab.lockScreen.highlightID(for: "Show AQI widget")),
|
||||
SettingsSearchEntry(tab: .lockScreen, title: "Air quality scale", keywords: ["aqi", "scale"], highlightID: SettingsTab.lockScreen.highlightID(for: "Air quality scale")),
|
||||
SettingsSearchEntry(tab: .lockScreen, title: "Use colored gauges", keywords: ["gauge tint", "monochrome"], highlightID: SettingsTab.lockScreen.highlightID(for: "Use colored gauges")),
|
||||
SettingsSearchEntry(tab: .lockScreen, title: "Show lock screen reminder", keywords: ["lock screen", "reminder widget"], highlightID: SettingsTab.lockScreen.highlightID(for: "Show lock screen reminder")),
|
||||
SettingsSearchEntry(tab: .lockScreen, title: "Chip color", keywords: ["reminder chip", "color"], highlightID: SettingsTab.lockScreen.highlightID(for: "Chip color")),
|
||||
SettingsSearchEntry(tab: .lockScreen, title: "Reminder alignment", keywords: ["reminder", "alignment", "position"], highlightID: SettingsTab.lockScreen.highlightID(for: "Reminder alignment")),
|
||||
SettingsSearchEntry(tab: .lockScreen, title: "Reminder vertical offset", keywords: ["reminder", "offset", "position"], highlightID: SettingsTab.lockScreen.highlightID(for: "Reminder vertical offset")),
|
||||
|
||||
// Extensions
|
||||
SettingsSearchEntry(tab: .extensions, title: "Enable third-party extensions", keywords: ["extensions", "authorization", "third party"], highlightID: SettingsTab.extensions.highlightID(for: "Enable third-party extensions")),
|
||||
|
|
@ -1161,7 +1173,7 @@ struct Downloads: View {
|
|||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
SwiftUI.Form {
|
||||
Section {
|
||||
Defaults.Toggle("Enable download detection", key: .enableDownloadListener)
|
||||
.settingsHighlight(id: highlightID("Enable download detection"))
|
||||
|
|
@ -2226,11 +2238,47 @@ struct CalendarSettings: View {
|
|||
@Default(.hideCompletedReminders) var hideCompletedReminders
|
||||
@Default(.showFullEventTitles) var showFullEventTitles
|
||||
@Default(.autoScrollToNextEvent) var autoScrollToNextEvent
|
||||
@Default(.lockScreenShowCalendarCountdown) private var lockScreenShowCalendarCountdown
|
||||
@Default(.lockScreenShowCalendarEvent) private var lockScreenShowCalendarEvent
|
||||
@Default(.lockScreenShowCalendarEventEntireDuration) private var lockScreenShowCalendarEventEntireDuration
|
||||
@Default(.lockScreenShowCalendarEventAfterStartWindow) private var lockScreenShowCalendarEventAfterStartWindow
|
||||
@Default(.lockScreenShowCalendarTimeRemaining) private var lockScreenShowCalendarTimeRemaining
|
||||
@Default(.lockScreenShowCalendarStartTimeAfterBegins) private var lockScreenShowCalendarStartTimeAfterBegins
|
||||
@Default(.lockScreenCalendarEventLookaheadWindow) private var lockScreenCalendarEventLookaheadWindow
|
||||
@Default(.lockScreenCalendarSelectionMode) private var lockScreenCalendarSelectionMode
|
||||
@Default(.lockScreenSelectedCalendarIDs) private var lockScreenSelectedCalendarIDs
|
||||
@Default(.lockScreenShowCalendarEventAfterStartEnabled) private var lockScreenShowCalendarEventAfterStartEnabled
|
||||
|
||||
private func highlightID(_ title: String) -> String {
|
||||
SettingsTab.calendar.highlightID(for: title)
|
||||
}
|
||||
|
||||
private enum CalendarLookaheadOption: String, CaseIterable, Identifiable {
|
||||
case mins15 = "15m"
|
||||
case mins30 = "30m"
|
||||
case hour1 = "1h"
|
||||
case hours3 = "3h"
|
||||
case hours6 = "6h"
|
||||
case hours12 = "12h"
|
||||
case restOfDay = "rest_of_day"
|
||||
case allTime = "all_time"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .mins15: return "15 mins"
|
||||
case .mins30: return "30 mins"
|
||||
case .hour1: return "1 hour"
|
||||
case .hours3: return "3 hours"
|
||||
case .hours6: return "6 hours"
|
||||
case .hours12: return "12 hours"
|
||||
case .restOfDay: return "Rest of the day"
|
||||
case .allTime: return "All time"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
if !calendarManager.hasCalendarAccess || !calendarManager.hasReminderAccess {
|
||||
|
|
@ -2256,7 +2304,7 @@ struct CalendarSettings: View {
|
|||
}
|
||||
} else {
|
||||
// Permissions status
|
||||
Section {
|
||||
Section(header: Text("Permissions")) {
|
||||
HStack {
|
||||
Text("Calendars")
|
||||
Spacer()
|
||||
|
|
@ -2269,8 +2317,6 @@ struct CalendarSettings: View {
|
|||
Text(statusText(for: calendarManager.reminderAuthorizationStatus))
|
||||
.foregroundColor(color(for: calendarManager.reminderAuthorizationStatus))
|
||||
}
|
||||
} header: {
|
||||
Text("Permissions")
|
||||
}
|
||||
|
||||
Defaults.Toggle("Show calendar", key: .showCalendar)
|
||||
|
|
@ -2353,8 +2399,135 @@ struct CalendarSettings: View {
|
|||
.settingsHighlight(id: highlightID("Chip color"))
|
||||
}
|
||||
|
||||
Section(
|
||||
header: Text("Calendar Widget"),
|
||||
footer: Text("Displays your next upcoming calendar event above or below the weather capsule. Calendar selection here is independent from the Dynamic Island calendar filter.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
) {
|
||||
Defaults.Toggle("Show next calendar event", key: .lockScreenShowCalendarEvent)
|
||||
.settingsHighlight(id: highlightID("Show next calendar event"))
|
||||
|
||||
LabeledContent("Show events within the next") {
|
||||
HStack {
|
||||
Spacer(minLength: 0)
|
||||
Picker("", selection: $lockScreenCalendarEventLookaheadWindow) {
|
||||
ForEach(CalendarLookaheadOption.allCases) { option in
|
||||
Text(option.title).tag(option.rawValue)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.disabled(!lockScreenShowCalendarEvent)
|
||||
.settingsHighlight(id: highlightID("Show events within the next"))
|
||||
|
||||
Toggle("Show events from all calendars", isOn: Binding(
|
||||
get: { lockScreenCalendarSelectionMode == "all" },
|
||||
set: { useAll in
|
||||
if useAll {
|
||||
lockScreenCalendarSelectionMode = "all"
|
||||
} else {
|
||||
lockScreenCalendarSelectionMode = "selected"
|
||||
lockScreenSelectedCalendarIDs = Set(calendarManager.eventCalendars.map { $0.id })
|
||||
}
|
||||
}
|
||||
))
|
||||
.disabled(!lockScreenShowCalendarEvent)
|
||||
.settingsHighlight(id: highlightID("Show events from all calendars"))
|
||||
|
||||
if lockScreenCalendarSelectionMode != "all" {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Deselect All") {
|
||||
lockScreenSelectedCalendarIDs = []
|
||||
}
|
||||
.buttonStyle(.link)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(calendarManager.eventCalendars, id: \.id) { calendar in
|
||||
Toggle(isOn: Binding(
|
||||
get: { lockScreenSelectedCalendarIDs.contains(calendar.id) },
|
||||
set: { isOn in
|
||||
if isOn {
|
||||
lockScreenSelectedCalendarIDs.insert(calendar.id)
|
||||
} else {
|
||||
lockScreenSelectedCalendarIDs.remove(calendar.id)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(Color(calendar.color))
|
||||
.frame(width: 8, height: 8)
|
||||
Text(calendar.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.padding(.leading, 2)
|
||||
.disabled(!lockScreenShowCalendarEvent)
|
||||
}
|
||||
|
||||
Defaults.Toggle("Show countdown", key: .lockScreenShowCalendarCountdown)
|
||||
.disabled(!lockScreenShowCalendarEvent)
|
||||
.settingsHighlight(id: highlightID("Show countdown"))
|
||||
|
||||
Defaults.Toggle("Show event for entire duration", key: .lockScreenShowCalendarEventEntireDuration)
|
||||
.disabled(!lockScreenShowCalendarEvent)
|
||||
.settingsHighlight(id: highlightID("Show event for entire duration"))
|
||||
.onChange(of: Defaults[.lockScreenShowCalendarEventEntireDuration]) { _, newValue in
|
||||
if newValue {
|
||||
Defaults[.lockScreenShowCalendarEventAfterStartEnabled] = false
|
||||
}
|
||||
}
|
||||
|
||||
Defaults.Toggle(
|
||||
"Hide active event and show next upcoming event",
|
||||
key: .lockScreenShowCalendarEventAfterStartEnabled
|
||||
)
|
||||
.disabled(!lockScreenShowCalendarEvent || lockScreenShowCalendarEventEntireDuration)
|
||||
.settingsHighlight(id: highlightID("Hide active event and show next upcoming event"))
|
||||
|
||||
LabeledContent("Show event after it starts") {
|
||||
HStack {
|
||||
Spacer(minLength: 0)
|
||||
Picker("", selection: $lockScreenShowCalendarEventAfterStartWindow) {
|
||||
Text("1 min").tag("1m")
|
||||
Text("5 mins").tag("5m")
|
||||
Text("10 mins").tag("10m")
|
||||
Text("15 mins").tag("15m")
|
||||
Text("30 mins").tag("30m")
|
||||
Text("45 mins").tag("45m")
|
||||
Text("1 hour").tag("1h")
|
||||
Text("2 hours").tag("2h")
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.disabled(!lockScreenShowCalendarEvent || lockScreenShowCalendarEventEntireDuration || !lockScreenShowCalendarEventAfterStartEnabled)
|
||||
|
||||
Text("Turn off 'Show event for entire duration' to use the post-start duration option.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Defaults.Toggle("Show time remaining", key: .lockScreenShowCalendarTimeRemaining)
|
||||
.disabled(!lockScreenShowCalendarEvent)
|
||||
.settingsHighlight(id: highlightID("Show time remaining"))
|
||||
|
||||
Defaults.Toggle("Show start time after event begins", key: .lockScreenShowCalendarStartTimeAfterBegins)
|
||||
.disabled(!lockScreenShowCalendarEvent)
|
||||
.settingsHighlight(id: highlightID("Show start time after event begins"))
|
||||
}
|
||||
|
||||
Section(header: Text("Select Calendars")) {
|
||||
List {
|
||||
ForEach(calendarManager.allCalendars, id: \.id) { calendar in
|
||||
Toggle(isOn: Binding(
|
||||
get: { calendarManager.getCalendarSelected(calendar) },
|
||||
|
|
@ -2371,7 +2544,6 @@ struct CalendarSettings: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
Task {
|
||||
await calendarManager.checkCalendarAuthorization()
|
||||
|
|
@ -3480,6 +3652,7 @@ struct Appearance: View {
|
|||
}
|
||||
|
||||
struct LockScreenSettings: View {
|
||||
@ObservedObject private var calendarManager = CalendarManager.shared
|
||||
@Default(.lockScreenGlassStyle) private var lockScreenGlassStyle
|
||||
@Default(.lockScreenGlassCustomizationMode) private var lockScreenGlassCustomizationMode
|
||||
@Default(.lockScreenMusicLiquidGlassVariant) private var lockScreenMusicLiquidGlassVariant
|
||||
|
|
@ -3499,7 +3672,21 @@ struct LockScreenSettings: View {
|
|||
@Default(.lockScreenWeatherShowsAQI) private var lockScreenWeatherShowsAQI
|
||||
@Default(.lockScreenWeatherShowsSunrise) private var lockScreenWeatherShowsSunrise
|
||||
@Default(.lockScreenWeatherAQIScale) private var lockScreenWeatherAQIScale
|
||||
@Default(.enableLockScreenReminderWidget) private var enableLockScreenReminderWidget
|
||||
@Default(.lockScreenReminderChipStyle) private var lockScreenReminderChipStyle
|
||||
@Default(.lockScreenReminderWidgetHorizontalAlignment) private var lockScreenReminderWidgetHorizontalAlignment
|
||||
@Default(.lockScreenReminderWidgetVerticalOffset) private var lockScreenReminderWidgetVerticalOffset
|
||||
@Default(.showStandardMediaControls) private var showStandardMediaControls
|
||||
@Default(.lockScreenShowCalendarCountdown) private var lockScreenShowCalendarCountdown
|
||||
@Default(.lockScreenShowCalendarEvent) private var lockScreenShowCalendarEvent
|
||||
@Default(.lockScreenShowCalendarEventEntireDuration) private var lockScreenShowCalendarEventEntireDuration
|
||||
@Default(.lockScreenShowCalendarEventAfterStartWindow) private var lockScreenShowCalendarEventAfterStartWindow
|
||||
@Default(.lockScreenShowCalendarTimeRemaining) private var lockScreenShowCalendarTimeRemaining
|
||||
@Default(.lockScreenShowCalendarStartTimeAfterBegins) private var lockScreenShowCalendarStartTimeAfterBegins
|
||||
@Default(.lockScreenCalendarEventLookaheadWindow) private var lockScreenCalendarEventLookaheadWindow
|
||||
@Default(.lockScreenCalendarSelectionMode) private var lockScreenCalendarSelectionMode
|
||||
@Default(.lockScreenSelectedCalendarIDs) private var lockScreenSelectedCalendarIDs
|
||||
@Default(.lockScreenShowCalendarEventAfterStartEnabled) private var lockScreenShowCalendarEventAfterStartEnabled
|
||||
|
||||
private func highlightID(_ title: String) -> String {
|
||||
SettingsTab.lockScreen.highlightID(for: title)
|
||||
|
|
@ -3509,6 +3696,48 @@ struct LockScreenSettings: View {
|
|||
Double(LiquidGlassVariant.supportedRange.lowerBound)...Double(LiquidGlassVariant.supportedRange.upperBound)
|
||||
}
|
||||
|
||||
private enum CalendarLookaheadOption: String, CaseIterable, Identifiable {
|
||||
case mins15 = "15m"
|
||||
case mins30 = "30m"
|
||||
case hour1 = "1h"
|
||||
case hours3 = "3h"
|
||||
case hours6 = "6h"
|
||||
case hours12 = "12h"
|
||||
case restOfDay = "rest_of_day"
|
||||
case allTime = "all_time"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .mins15: return "15 mins"
|
||||
case .mins30: return "30 mins"
|
||||
case .hour1: return "1 hour"
|
||||
case .hours3: return "3 hours"
|
||||
case .hours6: return "6 hours"
|
||||
case .hours12: return "12 hours"
|
||||
case .restOfDay: return "Rest of the day"
|
||||
case .allTime: return "All time"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum ReminderAlignmentOption: String, CaseIterable, Identifiable {
|
||||
case leading
|
||||
case center
|
||||
case trailing
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .leading: return "Left"
|
||||
case .center: return "Center"
|
||||
case .trailing: return "Right"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var musicVariantBinding: Binding<Double> {
|
||||
Binding(
|
||||
get: { Double(lockScreenMusicLiquidGlassVariant.rawValue) },
|
||||
|
|
@ -3764,6 +3993,47 @@ struct LockScreenSettings: View {
|
|||
Text("Enable the weather capsule and configure its layout, provider, units, and optional battery/AQI indicators.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Defaults.Toggle("Show lock screen reminder", key: .enableLockScreenReminderWidget)
|
||||
.settingsHighlight(id: highlightID("Show lock screen reminder"))
|
||||
|
||||
Picker("Chip color", selection: $lockScreenReminderChipStyle) {
|
||||
ForEach(LockScreenReminderChipStyle.allCases) { style in
|
||||
Text(style.rawValue).tag(style)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.disabled(!enableLockScreenReminderWidget)
|
||||
.settingsHighlight(id: highlightID("Chip color"))
|
||||
|
||||
Picker("Alignment", selection: $lockScreenReminderWidgetHorizontalAlignment) {
|
||||
ForEach(ReminderAlignmentOption.allCases) { option in
|
||||
Text(option.title).tag(option.rawValue)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.disabled(!enableLockScreenReminderWidget)
|
||||
.settingsHighlight(id: highlightID("Reminder alignment"))
|
||||
|
||||
HStack {
|
||||
Text("Vertical offset")
|
||||
Slider(
|
||||
value: $lockScreenReminderWidgetVerticalOffset,
|
||||
in: -160...160,
|
||||
step: 2
|
||||
)
|
||||
.disabled(!enableLockScreenReminderWidget)
|
||||
Text("\(Int(lockScreenReminderWidgetVerticalOffset)) px")
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 70, alignment: .trailing)
|
||||
}
|
||||
.settingsHighlight(id: highlightID("Reminder vertical offset"))
|
||||
} header: {
|
||||
Text("Reminder Widget")
|
||||
} footer: {
|
||||
Text("Controls the lock screen reminder chip and its positioning.")
|
||||
}
|
||||
|
||||
if BatteryActivityManager.shared.hasBattery() {
|
||||
Section {
|
||||
Defaults.Toggle("Show battery indicator", key: .lockScreenBatteryShowsBatteryGauge)
|
||||
|
|
@ -3800,6 +4070,133 @@ struct LockScreenSettings: View {
|
|||
Text("Displays the current Focus state above the weather capsule whenever Focus detection is enabled.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Defaults.Toggle("Show next calendar event", key: .lockScreenShowCalendarEvent)
|
||||
.settingsHighlight(id: highlightID("Show next calendar event"))
|
||||
|
||||
LabeledContent("Show events within the next") {
|
||||
HStack {
|
||||
Spacer(minLength: 0)
|
||||
Picker("", selection: $lockScreenCalendarEventLookaheadWindow) {
|
||||
ForEach(CalendarLookaheadOption.allCases) { option in
|
||||
Text(option.title).tag(option.rawValue)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.disabled(!lockScreenShowCalendarEvent)
|
||||
.settingsHighlight(id: highlightID("Show events within the next"))
|
||||
|
||||
Toggle("Show events from all calendars", isOn: Binding(
|
||||
get: { lockScreenCalendarSelectionMode == "all" },
|
||||
set: { useAll in
|
||||
if useAll {
|
||||
lockScreenCalendarSelectionMode = "all"
|
||||
} else {
|
||||
lockScreenCalendarSelectionMode = "selected"
|
||||
lockScreenSelectedCalendarIDs = Set(calendarManager.eventCalendars.map { $0.id })
|
||||
}
|
||||
}
|
||||
))
|
||||
.disabled(!lockScreenShowCalendarEvent)
|
||||
.settingsHighlight(id: highlightID("Show events from all calendars"))
|
||||
|
||||
if lockScreenCalendarSelectionMode != "all" {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Deselect All") {
|
||||
lockScreenSelectedCalendarIDs = []
|
||||
}
|
||||
.buttonStyle(.link)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(calendarManager.eventCalendars, id: \.id) { calendar in
|
||||
Toggle(isOn: Binding(
|
||||
get: { lockScreenSelectedCalendarIDs.contains(calendar.id) },
|
||||
set: { isOn in
|
||||
if isOn {
|
||||
lockScreenSelectedCalendarIDs.insert(calendar.id)
|
||||
} else {
|
||||
lockScreenSelectedCalendarIDs.remove(calendar.id)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(Color(calendar.color))
|
||||
.frame(width: 8, height: 8)
|
||||
Text(calendar.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.padding(.leading, 2)
|
||||
.disabled(!lockScreenShowCalendarEvent)
|
||||
}
|
||||
|
||||
Defaults.Toggle("Show countdown", key: .lockScreenShowCalendarCountdown)
|
||||
.disabled(!lockScreenShowCalendarEvent)
|
||||
.settingsHighlight(id: highlightID("Show countdown"))
|
||||
|
||||
Defaults.Toggle("Show event for entire duration", key: .lockScreenShowCalendarEventEntireDuration)
|
||||
.disabled(!lockScreenShowCalendarEvent)
|
||||
.settingsHighlight(id: highlightID("Show event for entire duration"))
|
||||
.onChange(of: Defaults[.lockScreenShowCalendarEventEntireDuration]) { _, newValue in
|
||||
if newValue {
|
||||
Defaults[.lockScreenShowCalendarEventAfterStartEnabled] = false
|
||||
}
|
||||
}
|
||||
|
||||
Defaults.Toggle(
|
||||
"Hide active event and show next upcoming event",
|
||||
key: .lockScreenShowCalendarEventAfterStartEnabled
|
||||
)
|
||||
.disabled(!lockScreenShowCalendarEvent || lockScreenShowCalendarEventEntireDuration)
|
||||
.settingsHighlight(id: highlightID("Hide active event and show next upcoming event"))
|
||||
|
||||
LabeledContent("Show event after it starts") {
|
||||
HStack {
|
||||
Spacer(minLength: 0)
|
||||
Picker("", selection: $lockScreenShowCalendarEventAfterStartWindow) {
|
||||
Text("1 min").tag("1m")
|
||||
Text("5 mins").tag("5m")
|
||||
Text("10 mins").tag("10m")
|
||||
Text("15 mins").tag("15m")
|
||||
Text("30 mins").tag("30m")
|
||||
Text("45 mins").tag("45m")
|
||||
Text("1 hour").tag("1h")
|
||||
Text("2 hours").tag("2h")
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.disabled(!lockScreenShowCalendarEvent || lockScreenShowCalendarEventEntireDuration || !lockScreenShowCalendarEventAfterStartEnabled)
|
||||
|
||||
Text("Turn off 'Show event for entire duration' to use the post-start duration option.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Defaults.Toggle("Show time remaining", key: .lockScreenShowCalendarTimeRemaining)
|
||||
.disabled(!lockScreenShowCalendarEvent)
|
||||
.settingsHighlight(id: highlightID("Show time remaining"))
|
||||
|
||||
Defaults.Toggle("Show start time after event begins", key: .lockScreenShowCalendarStartTimeAfterBegins)
|
||||
.disabled(!lockScreenShowCalendarEvent)
|
||||
.settingsHighlight(id: highlightID("Show start time after event begins"))
|
||||
} header: {
|
||||
Text("Calendar Widget")
|
||||
} footer: {
|
||||
Text("Displays your next upcoming calendar event above or below the weather capsule. Calendar selection here is independent from the Dynamic Island calendar filter.")
|
||||
}
|
||||
|
||||
LockScreenPositioningControls()
|
||||
|
||||
Section {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ class CalendarManager: ObservableObject {
|
|||
@Published var selectedCalendarIDs: Set<String> = []
|
||||
@Published var calendarAuthorizationStatus: EKAuthorizationStatus = .notDetermined
|
||||
@Published var reminderAuthorizationStatus: EKAuthorizationStatus = .notDetermined
|
||||
@Published var lockScreenEvents: [EventModel] = []
|
||||
|
||||
private var selectedCalendars: [CalendarModel] = []
|
||||
private let calendarService = CalendarService()
|
||||
|
|
@ -50,6 +51,9 @@ class CalendarManager: ObservableObject {
|
|||
private let eventStoreChangeThrottle: TimeInterval = 20
|
||||
private let selfInducedChangeSuppression: TimeInterval = 6
|
||||
private let eventFetchLimiter = EventFetchLimiter()
|
||||
private var lastLockScreenEventsFetchDate: Date?
|
||||
private let lockScreenRefreshInterval: TimeInterval = 15
|
||||
private var lockScreenRefreshTask: Task<Void, Never>?
|
||||
|
||||
var hasCalendarAccess: Bool { isAuthorized(calendarAuthorizationStatus) }
|
||||
var hasReminderAccess: Bool { isAuthorized(reminderAuthorizationStatus) }
|
||||
|
|
@ -57,6 +61,7 @@ class CalendarManager: ObservableObject {
|
|||
private init() {
|
||||
currentWeekStartDate = CalendarManager.startOfDay(Date())
|
||||
setupEventStoreChangedObserver()
|
||||
startLockScreenRefreshLoop()
|
||||
Task {
|
||||
await reloadCalendarAndReminderLists()
|
||||
}
|
||||
|
|
@ -67,6 +72,7 @@ class CalendarManager: ObservableObject {
|
|||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
pendingEventStoreRefreshTask?.cancel()
|
||||
lockScreenRefreshTask?.cancel()
|
||||
}
|
||||
|
||||
private func setupEventStoreChangedObserver() {
|
||||
|
|
@ -111,6 +117,7 @@ class CalendarManager: ObservableObject {
|
|||
pendingEventStoreRefreshTask = nil
|
||||
await reloadCalendarAndReminderLists()
|
||||
await maybeRefreshEventsAfterReload()
|
||||
await updateLockScreenEvents(force: true)
|
||||
nextAllowedEventStoreRefresh = Date().addingTimeInterval(eventStoreChangeThrottle)
|
||||
ignoreEventStoreChangesUntil = Date().addingTimeInterval(selfInducedChangeSuppression)
|
||||
}
|
||||
|
|
@ -154,12 +161,14 @@ class CalendarManager: ObservableObject {
|
|||
if granted {
|
||||
await reloadCalendarAndReminderLists()
|
||||
await updateEvents(force: true)
|
||||
await updateLockScreenEvents(force: true)
|
||||
}
|
||||
case .restricted, .denied:
|
||||
NSLog("Calendar access denied or restricted")
|
||||
case .authorized, .fullAccess:
|
||||
await reloadCalendarAndReminderLists()
|
||||
await updateEvents(force: true)
|
||||
await updateLockScreenEvents(force: true)
|
||||
case .writeOnly:
|
||||
NSLog("Calendar write only")
|
||||
@unknown default:
|
||||
|
|
@ -230,6 +239,7 @@ class CalendarManager: ObservableObject {
|
|||
Defaults[.calendarSelectionState] = selectionState
|
||||
updateSelectedCalendars()
|
||||
await updateEvents(force: true)
|
||||
await updateLockScreenEvents(force: true)
|
||||
}
|
||||
|
||||
static func startOfDay(_ date: Date) -> Date {
|
||||
|
|
@ -239,6 +249,88 @@ class CalendarManager: ObservableObject {
|
|||
func updateCurrentDate(_ date: Date) async {
|
||||
currentWeekStartDate = Calendar.current.startOfDay(for: date)
|
||||
await updateEvents(force: true)
|
||||
await updateLockScreenEvents(force: true)
|
||||
}
|
||||
|
||||
func updateLockScreenEvents(force: Bool = false) async {
|
||||
let now = Date()
|
||||
|
||||
if !force,
|
||||
let lastFetch = lastLockScreenEventsFetchDate,
|
||||
now.timeIntervalSince(lastFetch) < lockScreenRefreshInterval {
|
||||
return
|
||||
}
|
||||
|
||||
let lookaheadRaw = Defaults[.lockScreenCalendarEventLookaheadWindow]
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
|
||||
let isAllTimeLookahead =
|
||||
lookaheadRaw == "all_time" ||
|
||||
lookaheadRaw == "all time" ||
|
||||
lookaheadRaw == "alltime"
|
||||
|
||||
let isRestOfDay =
|
||||
lookaheadRaw == "rest_of_day" ||
|
||||
lookaheadRaw == "rest of the day" ||
|
||||
lookaheadRaw == "restofday"
|
||||
|
||||
func lookaheadMinutes(from raw: String) -> Int? {
|
||||
switch raw {
|
||||
case "15m", "15 min", "15 mins", "15min", "15mins": return 15
|
||||
case "30m", "30 min", "30 mins", "30min", "30mins": return 30
|
||||
case "1h", "1 hr", "1 hour", "1hour": return 60
|
||||
case "3h", "3 hr", "3 hours", "3hours": return 180
|
||||
case "6h", "6 hr", "6 hours", "6hours": return 360
|
||||
case "12h", "12 hr", "12 hours", "12hours": return 720
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
let calendar = Calendar.current
|
||||
let startOfToday = calendar.startOfDay(for: now)
|
||||
let startDate = calendar.date(byAdding: .day, value: -1, to: startOfToday) ?? startOfToday
|
||||
|
||||
let endDate: Date
|
||||
if isAllTimeLookahead {
|
||||
endDate = calendar.date(byAdding: .day, value: 365, to: now) ?? now.addingTimeInterval(365 * 24 * 3600)
|
||||
} else if isRestOfDay {
|
||||
endDate = calendar.date(byAdding: .day, value: 1, to: startOfToday) ?? startOfToday.addingTimeInterval(24 * 3600)
|
||||
} else if let minutes = lookaheadMinutes(from: lookaheadRaw) {
|
||||
endDate = calendar.date(byAdding: .minute, value: minutes, to: now) ?? now.addingTimeInterval(TimeInterval(minutes * 60))
|
||||
} else {
|
||||
endDate = calendar.date(byAdding: .minute, value: 180, to: now) ?? now.addingTimeInterval(180 * 60)
|
||||
}
|
||||
|
||||
let calendarIDs = allCalendars.map { $0.id }
|
||||
let service = calendarService
|
||||
|
||||
let fetched = await eventFetchLimiter.run {
|
||||
await service.events(from: startDate, to: endDate, calendars: calendarIDs)
|
||||
}
|
||||
|
||||
if lockScreenEvents == fetched {
|
||||
lastLockScreenEventsFetchDate = Date()
|
||||
return
|
||||
}
|
||||
|
||||
withAnimation(.smooth(duration: 0.25)) {
|
||||
lockScreenEvents = fetched
|
||||
}
|
||||
lastLockScreenEventsFetchDate = Date()
|
||||
}
|
||||
|
||||
private func startLockScreenRefreshLoop() {
|
||||
lockScreenRefreshTask?.cancel()
|
||||
lockScreenRefreshTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 60 * 1_000_000_000)
|
||||
if Task.isCancelled { break }
|
||||
guard self.hasCalendarAccess else { continue }
|
||||
await self.updateLockScreenEvents(force: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateEvents(force: Bool = false) async {
|
||||
|
|
|
|||
|
|
@ -94,6 +94,18 @@ final class LockScreenReminderWidgetManager: ObservableObject {
|
|||
self.handleSnapshotUpdate(self.reminderManager.lockScreenSnapshot)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
Defaults.publisher(.lockScreenReminderWidgetHorizontalAlignment, options: [])
|
||||
.sink { _ in
|
||||
LockScreenReminderWidgetPanelManager.shared.refreshPosition(animated: true)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
Defaults.publisher(.lockScreenReminderWidgetVerticalOffset, options: [])
|
||||
.sink { _ in
|
||||
LockScreenReminderWidgetPanelManager.shared.refreshPosition(animated: true)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func handleSnapshotUpdate(_ newSnapshot: LockScreenReminderWidgetSnapshot?) {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import AppKit
|
|||
import SwiftUI
|
||||
import SkyLightWindow
|
||||
import QuartzCore
|
||||
import Defaults
|
||||
|
||||
@MainActor
|
||||
final class LockScreenReminderWidgetPanelManager {
|
||||
|
|
@ -119,7 +120,18 @@ final class LockScreenReminderWidgetPanelManager {
|
|||
|
||||
private func frame(for size: CGSize, on screen: NSScreen) -> NSRect {
|
||||
let screenFrame = screen.frame
|
||||
let originX = screenFrame.midX - (size.width / 2)
|
||||
let horizontalPadding: CGFloat = 24
|
||||
let alignment = Defaults[.lockScreenReminderWidgetHorizontalAlignment].lowercased()
|
||||
let originX: CGFloat
|
||||
|
||||
switch alignment {
|
||||
case "leading", "left":
|
||||
originX = screenFrame.minX + horizontalPadding
|
||||
case "trailing", "right":
|
||||
originX = screenFrame.maxX - size.width - horizontalPadding
|
||||
default:
|
||||
originX = screenFrame.midX - (size.width / 2)
|
||||
}
|
||||
let musicTop = LockScreenPanelManager.shared.latestFrame?.maxY ?? (screenFrame.midY - 32)
|
||||
let timerFrame = LockScreenTimerWidgetPanelManager.shared.latestFrame
|
||||
|
||||
|
|
@ -148,7 +160,9 @@ final class LockScreenReminderWidgetPanelManager {
|
|||
proposedY = (lowerBound + clampedUpperBound) / 2
|
||||
}
|
||||
|
||||
proposedY = max(lowerBound, min(proposedY, clampedUpperBound))
|
||||
let rawOffset = CGFloat(Defaults[.lockScreenReminderWidgetVerticalOffset])
|
||||
let clampedOffset = min(max(rawOffset, -160), 160)
|
||||
proposedY = max(lowerBound, min(proposedY + clampedOffset, clampedUpperBound))
|
||||
|
||||
return NSRect(x: originX, y: proposedY, width: size.width, height: size.height)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,14 +114,18 @@ final class LockScreenWeatherPanelManager {
|
|||
let screenFrame = screen.frame
|
||||
let originX = screenFrame.midX - (size.width / 2)
|
||||
let verticalOffset = screenFrame.height * 0.15
|
||||
let maxY = screenFrame.maxY - size.height - 48
|
||||
let isCircular = snapshot.widgetStyle == .circular
|
||||
let topMargin: CGFloat = isCircular ? 120 : 48
|
||||
let maxY = screenFrame.maxY - size.height - topMargin
|
||||
let baseY = min(maxY, screenFrame.midY + verticalOffset)
|
||||
let loweredY = baseY - 36
|
||||
|
||||
let inlineLift: CGFloat = snapshot.widgetStyle == .inline ? 44 : 0
|
||||
let circularDrop: CGFloat = isCircular ? 28 : 0
|
||||
let sizeDrop = max(0, size.height - 80) * 0.35
|
||||
let userOffset = CGFloat(Defaults[.lockScreenWeatherVerticalOffset])
|
||||
let clampedOffset = min(max(userOffset, -160), 160)
|
||||
let adjustedY = loweredY + inlineLift + clampedOffset
|
||||
let adjustedY = loweredY + inlineLift + clampedOffset - circularDrop - sizeDrop
|
||||
let upperClampedY = min(maxY, adjustedY)
|
||||
let clampedY = max(screenFrame.minY + 80, upperClampedY)
|
||||
return NSRect(x: originX, y: clampedY, width: size.width, height: size.height)
|
||||
|
|
|
|||
|
|
@ -774,6 +774,19 @@ extension Defaults.Keys {
|
|||
static let lockScreenPanelUsesBlur = Key<Bool>("lockScreenPanelUsesBlur", default: true)
|
||||
static let lockScreenTimerWidgetUsesBlur = Key<Bool>("lockScreenTimerWidgetUsesBlur", default: false)
|
||||
static let lockScreenReminderChipStyle = Key<LockScreenReminderChipStyle>("lockScreenReminderChipStyle", default: .eventColor)
|
||||
static let lockScreenReminderWidgetHorizontalAlignment = Key<String>("lockScreenReminderWidgetHorizontalAlignment", default: "center")
|
||||
static let lockScreenReminderWidgetVerticalOffset = Key<Double>("lockScreenReminderWidgetVerticalOffset", default: 0)
|
||||
static let lockScreenShowCalendarEvent = Key<Bool>("lockScreenShowCalendarEvent", default: true)
|
||||
static let lockScreenCalendarEventLookaheadWindow = Key<String>("lockScreenCalendarEventLookaheadWindow", default: "3h")
|
||||
static let lockScreenCalendarSelectionMode = Key<String>("lockScreenCalendarSelectionMode", default: "all")
|
||||
static let lockScreenSelectedCalendarIDs = Key<Set<String>>("lockScreenSelectedCalendarIDs", default: [])
|
||||
static let lockScreenShowCalendarCountdown = Key<Bool>("lockScreenShowCalendarCountdown", default: true)
|
||||
static let lockScreenShowCalendarEventEntireDuration = Key<Bool>("lockScreenShowCalendarEventEntireDuration", default: true)
|
||||
static let lockScreenShowCalendarEventAfterStartEnabled = Key<Bool>("lockScreenShowCalendarEventAfterStartEnabled", default: false)
|
||||
static let lockScreenShowCalendarEventAfterStartWindow = Key<String>("lockScreenShowCalendarEventAfterStartWindow", default: "5m")
|
||||
static let lockScreenShowCalendarTimeRemaining = Key<Bool>("lockScreenShowCalendarTimeRemaining", default: true)
|
||||
static let lockScreenShowCalendarStartTimeAfterBegins = Key<Bool>("lockScreenShowCalendarStartTimeAfterBegins", default: true)
|
||||
static let lockScreenWeatherWidgetRowOrder = Key<String>("lockScreenWeatherWidgetRowOrder", default: "weather_calendar_focus")
|
||||
|
||||
// MARK: Battery
|
||||
static let showPowerStatusNotifications = Key<Bool>("showPowerStatusNotifications", default: true)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue