import SwiftUI import OnyxDesign import OnyxNotch /// Führt die Live Activities und sagt der Notch, was gerade gilt. /// /// Die Entscheidung selbst trifft `ActivityQueue` — hier steht nur, was mit /// Zeit und Bildschirm zu tun hat: den Wecker für die nächste ablaufende /// stellen und den Controllern melden, ob überhaupt etwas anliegt. @MainActor @Observable public final class ActivityPresenter { /// Die Activity, die gerade gezeichnet wird. /// /// Bleibt nach dem Ende noch stehen, solange die Fläche einfährt — sonst /// wäre die View im selben Bild weg, in dem sich die Notch zurückziehen /// soll, und es gäbe nichts mehr zu sehen. Was **gilt**, sagt /// `isPresenting`. public private(set) var current: (any NotchActivity)? /// Ob die Activity noch gilt oder nur noch ausblendet. public private(set) var isPresenting = false /// Wird nach jeder Änderung gerufen — mit dem, was jetzt gilt. /// /// **Nicht nur bei „etwas / nichts".** Löst ein zweiter Lautstärkeschritt /// die erste Activity ab, bleibt „etwas liegt an" gleich, aber die Arme /// werden vielleicht anders breit. Wer nur auf den Wechsel von nichts zu /// etwas hört, verpasst das. @ObservationIgnored public var onChange: (((any NotchActivity)?) -> Void)? @ObservationIgnored private var queue = ActivityQueue() @ObservationIgnored private var activities: [String: any NotchActivity] = [:] @ObservationIgnored private var alarm: DispatchWorkItem? @ObservationIgnored private var retirement: DispatchWorkItem? @ObservationIgnored private let clock: () -> Date public init(clock: @escaping () -> Date = Date.init) { self.clock = clock } /// Meldet eine Activity an. Eine bereits stehende mit derselben Kennung /// wird ersetzt — samt neuer Standzeit. public func raise(_ activity: any NotchActivity) { activities[activity.id] = activity queue.raise(ActivityTicket(id: activity.id, rank: activity.rank, raised: clock(), duration: activity.duration)) settle() } public func withdraw(id: String) { activities[id] = nil queue.withdraw(id: id) settle() } /// Die Fläche, die die eingeklappte Notch dafür unter sich aufspannt. /// /// `nil`, sobald die Activity nicht mehr gilt — auch wenn sie noch /// gezeichnet wird. Genau daran fährt die Fläche ein und schneidet den /// Inhalt dabei weg. public var contentSize: CGSize? { isPresenting ? current?.size : nil } private func settle() { let now = clock() queue.prune(now: now) let next = queue.current(now: now).flatMap { activities[$0.id] } if let next { retirement?.cancel() retirement = nil current = next isPresenting = true } else if isPresenting { isPresenting = false scheduleRetirement() } onChange?(isPresenting ? current : nil) scheduleAlarm(after: now) } /// Weckt genau dann, wenn die nächste abläuft. /// /// Nicht abtasten: eine Notch, die zehnmal je Sekunde nachfragt, ob etwas /// vorbei ist, hält den Rechner wach — und zwar den ganzen Tag, für ein /// Ereignis alle paar Minuten. private func scheduleAlarm(after now: Date) { alarm?.cancel() alarm = nil guard let next = queue.nextExpiry(after: now) else { return } let work = DispatchWorkItem { [weak self] in MainActor.assumeIsolated { self?.settle() } } alarm = work DispatchQueue.main.asyncAfter(deadline: .now() + max(next.timeIntervalSince(now), 0), execute: work) } } private extension ActivityPresenter { /// Räumt die ausgeblendete Activity weg, wenn die Fläche eingefahren ist. /// /// Nicht sofort: solange die Notch schrumpft, muss noch etwas darin stehen. /// Die Dauer kommt aus dem Bewegungsprofil, damit sie bei „ruhig" mitwächst /// — eine feste Zahl schnitte den Inhalt dort mittendrin ab. func scheduleRetirement() { retirement?.cancel() let work = DispatchWorkItem { [weak self] in MainActor.assumeIsolated { guard let self, !self.isPresenting else { return } self.current = nil } } retirement = work DispatchQueue.main.asyncAfter(deadline: .now() + OnyxTheme.shared.motion.collapseDuration, execute: work) } }