import Foundation /// Ein Anspruch auf die eingeklappte Notch. /// /// Bewusst ohne Darstellung: was eine Live Activity *zeigt*, ist eine View und /// gehört nicht in die Entscheidung, **welche** gerade gilt. So lässt sich die /// Regel ohne Bildschirm prüfen — wie `NotchStateMachine` und `LayoutEngine`. public struct ActivityTicket: Equatable, Sendable, Identifiable { public let id: String /// Höher verdrängt niedriger. public let rank: Int /// Wann angemeldet. public let raised: Date /// Wie lange sie steht. `nil` heißt: bis sie zurückgezogen wird. public let duration: TimeInterval? public init(id: String, rank: Int, raised: Date, duration: TimeInterval?) { self.id = id self.rank = rank self.raised = raised self.duration = duration } /// Wann sie von selbst geht. `nil` bei dauerhaften. public var expiry: Date? { duration.map { raised.addingTimeInterval($0) } } public func hasExpired(at now: Date) -> Bool { guard let expiry else { return false } return now >= expiry } } /// Wer die eingeklappte Notch gerade bekommt. /// /// **Warum überhaupt eine Warteschlange.** Die Anlässe kommen unabhängig /// voneinander: die Lautstärke ändert sich, während ein Titel wechselt, während /// das Netzteil angesteckt wird. Ohne Regel gewinnt, wer zuletzt kam — und dann /// verschluckt ein beiläufiger Lautstärkeschritt die Meldung, dass der Akku /// fast leer ist. /// /// **Die Regel ist Rang vor Aktualität.** Bei gleichem Rang gilt die jüngere: /// zwei Lautstärkeschritte hintereinander sollen den zweiten zeigen, nicht den /// ersten stehen lassen. public struct ActivityQueue: Equatable, Sendable { private var tickets: [ActivityTicket] = [] public init() {} /// Alles, was gerade anliegt — ungefiltert, in Anmeldereihenfolge. public var all: [ActivityTicket] { tickets } /// Meldet einen Anspruch an. Ein bereits vorhandener mit derselben Kennung /// wird **ersetzt**, nicht ergänzt. /// /// Das ist der Normalfall und keine Ausnahme: wer die Lautstärke dreimal /// verstellt, meldet dreimal dieselbe Activity an und will dreimal die /// volle Standzeit — nicht drei Einträge in einer Schlange. public mutating func raise(_ ticket: ActivityTicket) { tickets.removeAll { $0.id == ticket.id } tickets.append(ticket) } public mutating func withdraw(id: String) { tickets.removeAll { $0.id == id } } /// Wirft weg, was abgelaufen ist. public mutating func prune(now: Date) { tickets.removeAll { $0.hasExpired(at: now) } } /// Welche gerade gilt. public func current(now: Date) -> ActivityTicket? { tickets .filter { !$0.hasExpired(at: now) } // `last` statt `max(by:)`: bei gleichem Rang gewinnt die zuletzt // angemeldete, und `tickets` steht in Anmeldereihenfolge. .reduce(into: ActivityTicket?.none) { best, candidate in guard let current = best else { best = candidate; return } if candidate.rank >= current.rank { best = candidate } } } /// Wann das nächste Mal etwas von selbst verschwindet. /// /// Der Presenter stellt darauf seinen Wecker. Ohne diese Auskunft müsste er /// abtasten — und eine Notch, die zehnmal je Sekunde nachfragt, ob etwas /// abgelaufen ist, hält den Rechner wach. public func nextExpiry(after now: Date) -> Date? { tickets.compactMap(\.expiry).filter { $0 > now }.min() } }