Live Activities unter der Notch, nicht daneben
Neben ihr wären die Arme zwangsläufig gleich breit — die Silhouette zeichnet den Steg mittig um die Hardware. Ein Symbol links und ein Balken rechts hieße dann 132 Punkte Schwarz für ein Symbol. Darunter hängt die Fläche frei und wird so breit, wie ihr Inhalt es braucht. Lautstärke, Strom, Titelwechsel und Helligkeit. Gemeldet wird nur, was sich **geändert** hat: `PowerWatcher` schweigt beim ersten gelesenen Zustand, sonst begrüßte Onyx jeden Start mit „am Netz". Die Helligkeit kostete einen Spike. Von drei Wegen trägt nur `DisplayServices`, und nur für den eingebauten Bildschirm. `CoreDisplay` — der Weg, den jede Anleitung empfiehlt — gibt für alle Bildschirme 1.0 zurück, auch für den, der auf 62 % stand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c05ccb8ae1
commit
9b600de9a9
@@ -0,0 +1,315 @@
|
||||
import SwiftUI
|
||||
import OnyxDesign
|
||||
import OnyxNotch
|
||||
import ActivityProvider
|
||||
import MetricsProvider
|
||||
import WeatherProvider
|
||||
|
||||
/// Was links und rechts der Notch steht, solange das Panel offen ist.
|
||||
///
|
||||
/// **Warum dort und nicht in einer Kachel.** Der Streifen auf Menüleistenhöhe
|
||||
/// war bisher durchsichtiges, ungenutztes Fenster — bei fünf Kacheln je 376
|
||||
/// Punkte. Eine Kachel dafür zu opfern hieße, ein Zwölftel des Panels für zwei
|
||||
/// Zahlen herzugeben. Hier kosten sie nichts.
|
||||
///
|
||||
/// **Zwei Werte, nicht zehn.** Der Streifen ist so hoch wie die Notch, also
|
||||
/// rund 38 Punkte. Was hier steht, muss im Vorbeisehen lesbar sein; eine Reihe
|
||||
/// aus sechs Kürzeln wäre eine zweite Menüleiste, und eine hat man schon.
|
||||
struct WeatherAccessory: View {
|
||||
|
||||
let model: WeatherModel
|
||||
|
||||
var body: some View {
|
||||
if case .ready(let snapshot) = model.state {
|
||||
HStack(spacing: Onyx.Space.sm) {
|
||||
Image(systemName: snapshot.condition.symbolName(isDaylight: snapshot.isDaylight))
|
||||
.font(Onyx.Font.footnote)
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
Text(snapshot.temperature.onyxFormatted)
|
||||
.font(Onyx.Font.metricSmall)
|
||||
.foregroundStyle(Onyx.Color.textPrimary)
|
||||
}
|
||||
// Abstand zur Bildschirmkante links, und Luft zur Notch rechts:
|
||||
// ein Wert, der die schwarze Hardware berührt, sieht aus, als wäre
|
||||
// er abgeschnitten.
|
||||
.padding(.leading, Onyx.Space.xl)
|
||||
.padding(.trailing, Onyx.Space.lg)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct BatteryAccessory: View {
|
||||
|
||||
let model: MetricsModel
|
||||
|
||||
var body: some View {
|
||||
if let battery = model.snapshot.battery {
|
||||
HStack(spacing: Onyx.Space.sm) {
|
||||
Text("\(Int((battery.charge * 100).rounded())) %")
|
||||
.font(Onyx.Font.metricSmall)
|
||||
.foregroundStyle(Onyx.Color.textPrimary)
|
||||
BatteryGlyphView(charge: battery.charge,
|
||||
isCharging: battery.isCharging,
|
||||
isPluggedIn: battery.isPluggedIn,
|
||||
height: 13)
|
||||
}
|
||||
.padding(.leading, Onyx.Space.lg)
|
||||
.padding(.trailing, Onyx.Space.xl)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Live Activities
|
||||
|
||||
/// Was beim Regeln der Lautstärke unter der Notch steht.
|
||||
///
|
||||
/// Kurz und ohne Anspruch: 1,6 Sekunden. Länger stünde sie noch da, wenn man
|
||||
/// längst weiterarbeitet — und eine Anzeige, die nach der Handlung übrig
|
||||
/// bleibt, liest man als Fehler.
|
||||
struct VolumeActivity: NotchActivity {
|
||||
|
||||
let id = "volume"
|
||||
let rank = ActivityRank.response
|
||||
let duration: TimeInterval? = 1.6
|
||||
|
||||
let level: Double
|
||||
let isMuted: Bool
|
||||
|
||||
/// Symbol, Schiene, Zahl — in einer Zeile unter der Notch.
|
||||
var size: CGSize { CGSize(width: 196, height: 34) }
|
||||
|
||||
private var symbol: String {
|
||||
if isMuted { return "speaker.slash.fill" }
|
||||
switch level {
|
||||
case ..<0.001: return "speaker.fill"
|
||||
case ..<0.34: return "speaker.wave.1.fill"
|
||||
case ..<0.67: return "speaker.wave.2.fill"
|
||||
default: return "speaker.wave.3.fill"
|
||||
}
|
||||
}
|
||||
|
||||
func makeContent() -> AnyView {
|
||||
let track: CGFloat = 96
|
||||
let fraction = isMuted ? 0 : min(max(level, 0), 1)
|
||||
return AnyView(
|
||||
HStack(spacing: Onyx.Space.lg) {
|
||||
Image(systemName: symbol)
|
||||
.font(Onyx.Font.footnote)
|
||||
.foregroundStyle(Onyx.Color.textPrimary)
|
||||
.frame(width: 16)
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Onyx.Color.track)
|
||||
.frame(width: track, height: 5)
|
||||
Capsule().fill(Onyx.Color.textPrimary)
|
||||
.frame(width: track * fraction, height: 5)
|
||||
}
|
||||
|
||||
Text("\(Int((min(max(level, 0), 1) * 100).rounded()))")
|
||||
.font(Onyx.Font.metricSmall)
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
// Feste Breite: sonst wandert die Schiene, wenn aus 9 %
|
||||
// eine 10 wird.
|
||||
.frame(width: 24, alignment: .trailing)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Arm des Bandes — nur ausgefahren, und nur das Zubehör.
|
||||
///
|
||||
/// Die Live Activities hängen unter der Notch und nicht hier: neben ihr müssten
|
||||
/// beide Arme gleich breit sein, und ein Symbol links neben einem Balken rechts
|
||||
/// hieße über hundert Punkte Schwarz für das Symbol.
|
||||
struct NotchBandSlot<Content: View>: View {
|
||||
|
||||
let presentation: NotchPresentation
|
||||
@ViewBuilder let content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
if presentation.isExpanded { content() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Live Activity unter der Notch.
|
||||
///
|
||||
/// Als eigene View und nicht als Verzweigung im Aufrufer: der Presenter ist
|
||||
/// beobachtbar, und nur innerhalb eines `body` merkt SwiftUI die Abhängigkeit.
|
||||
struct NotchAmbientSlot: View {
|
||||
|
||||
let presenter: ActivityPresenter
|
||||
|
||||
var body: some View {
|
||||
if let activity = presenter.current {
|
||||
activity.makeContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Die Helligkeit des eingebauten Bildschirms.
|
||||
///
|
||||
/// Dieselbe Form wie die Lautstärke — Symbol, Schiene, Zahl. Das ist kein
|
||||
/// Sparen an Gestaltung, sondern die Aussage: es ist dasselbe Ding. Zwei
|
||||
/// verschiedene Darstellungen für „ein Regler hat sich bewegt" wären eine
|
||||
/// Behauptung, dass es zwei verschiedene Vorgänge sind.
|
||||
struct BrightnessActivity: NotchActivity {
|
||||
|
||||
let id = "brightness"
|
||||
let rank = ActivityRank.response
|
||||
let duration: TimeInterval? = 1.6
|
||||
|
||||
let level: Double
|
||||
|
||||
var size: CGSize { CGSize(width: 196, height: 34) }
|
||||
|
||||
private var symbol: String {
|
||||
// Die Sonne füllt sich mit — dasselbe Symbol, das macOS benutzt.
|
||||
level < 0.5 ? "sun.min.fill" : "sun.max.fill"
|
||||
}
|
||||
|
||||
func makeContent() -> AnyView {
|
||||
let track: CGFloat = 96
|
||||
let fraction = min(max(level, 0), 1)
|
||||
return AnyView(
|
||||
HStack(spacing: Onyx.Space.lg) {
|
||||
Image(systemName: symbol)
|
||||
.font(Onyx.Font.footnote)
|
||||
.foregroundStyle(Onyx.Color.textPrimary)
|
||||
.frame(width: 16)
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Onyx.Color.track)
|
||||
.frame(width: track, height: 5)
|
||||
Capsule().fill(Onyx.Color.textPrimary)
|
||||
.frame(width: track * fraction, height: 5)
|
||||
}
|
||||
|
||||
Text("\(Int((fraction * 100).rounded()))")
|
||||
.font(Onyx.Font.metricSmall)
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
.frame(width: 24, alignment: .trailing)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Was im Band stehen darf
|
||||
|
||||
/// Die Elemente, die links und rechts der Notch sitzen können.
|
||||
///
|
||||
/// **Eine Wahl je Seite, keine Liste.** Der Arm ist bei fünf Kacheln rund 200
|
||||
/// Punkte breit und liegt auf Menüleistenhöhe — dort passt ein Wert, nicht drei.
|
||||
/// Eine Mehrfachauswahl anzubieten hieße, ein Gedränge zu ermöglichen, das man
|
||||
/// hinterher wieder wegräumen muss.
|
||||
enum BandAccessory: String, Codable, CaseIterable, Sendable {
|
||||
case none
|
||||
case clock
|
||||
case weather
|
||||
case battery
|
||||
case cpu
|
||||
case memory
|
||||
case temperature
|
||||
|
||||
var localizedName: LocalizedStringKey {
|
||||
switch self {
|
||||
case .none: "band.none"
|
||||
case .clock: "band.clock"
|
||||
case .weather: "band.weather"
|
||||
case .battery: "band.battery"
|
||||
case .cpu: "band.cpu"
|
||||
case .memory: "band.memory"
|
||||
case .temperature: "band.temperature"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Bandplatz, der zeigt, was dafür eingestellt ist.
|
||||
///
|
||||
/// Liest die Einstellung **im Rumpf** und nicht beim Erzeugen: der Panelinhalt
|
||||
/// wird je Bildschirm einmal gebaut, und ein beim Bauen gelesener Wert bliebe
|
||||
/// für immer der, der beim Start galt.
|
||||
struct BandSlotView: View {
|
||||
|
||||
let model: AppModel
|
||||
let edge: HorizontalAlignment
|
||||
let weather: WeatherModel?
|
||||
let metrics: MetricsModel?
|
||||
|
||||
private var kind: BandAccessory {
|
||||
edge == .leading ? model.bandLeading : model.bandTrailing
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.padding(.leading, edge == .leading ? Onyx.Space.xl : Onyx.Space.lg)
|
||||
.padding(.trailing, edge == .leading ? Onyx.Space.lg : Onyx.Space.xl)
|
||||
.frame(maxWidth: .infinity, alignment: edge == .leading ? .leading : .trailing)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
switch kind {
|
||||
case .none:
|
||||
EmptyView()
|
||||
case .clock:
|
||||
// Die eine Angabe, die keine Quelle braucht.
|
||||
TimelineView(.everyMinute) { context in
|
||||
value(context.date.formatted(date: .omitted, time: .shortened),
|
||||
symbol: nil)
|
||||
}
|
||||
case .weather:
|
||||
if let weather, case .ready(let snapshot) = weather.state {
|
||||
value(snapshot.temperature.onyxFormatted,
|
||||
symbol: snapshot.condition.symbolName(isDaylight: snapshot.isDaylight))
|
||||
}
|
||||
case .battery:
|
||||
if let battery = metrics?.snapshot.battery {
|
||||
HStack(spacing: Onyx.Space.sm) {
|
||||
Text("\(Int((battery.charge * 100).rounded())) %")
|
||||
.font(Onyx.Font.metricSmall)
|
||||
.foregroundStyle(Onyx.Color.textPrimary)
|
||||
BatteryGlyphView(charge: battery.charge,
|
||||
isCharging: battery.isCharging,
|
||||
isPluggedIn: battery.isPluggedIn,
|
||||
height: 13)
|
||||
}
|
||||
}
|
||||
case .cpu:
|
||||
if let metrics {
|
||||
value(MetricFormat.percent(metrics.snapshot.cpu.total), symbol: "cpu")
|
||||
}
|
||||
case .memory:
|
||||
if let metrics {
|
||||
value(MetricFormat.percent(metrics.snapshot.memory.usedFraction),
|
||||
symbol: "memorychip")
|
||||
}
|
||||
case .temperature:
|
||||
// Der wärmste Fühler: einer von zwanzig auszuwählen wäre eine
|
||||
// Einstellung in einer Einstellung.
|
||||
if let hottest = metrics?.snapshot.sensors.map(\.celsius).max() {
|
||||
value("\(Int(hottest.rounded())) °C", symbol: "thermometer.medium")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Symbol und Wert — dieselbe Form für alles, was hier stehen kann.
|
||||
///
|
||||
/// Damit ist die Seite austauschbar: was links passt, passt auch rechts.
|
||||
private func value(_ text: String, symbol: String?) -> some View {
|
||||
HStack(spacing: Onyx.Space.sm) {
|
||||
if let symbol {
|
||||
Image(systemName: symbol)
|
||||
.font(Onyx.Font.footnote)
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
}
|
||||
Text(text)
|
||||
.font(Onyx.Font.metricSmall)
|
||||
.foregroundStyle(Onyx.Color.textPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user