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: 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. /// /// Mehrere je Seite: der Arm ist bei fünf Kacheln rund 376 Punkte breit, und ein /// Wert braucht davon knapp sechzig. Vier nebeneinander sind kein Gedränge, /// sondern eine Zeile. enum BandAccessory: String, Codable, CaseIterable, Sendable { case clock case weather case battery case cpu case memory case temperature var localizedName: LocalizedStringKey { switch self { 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 kinds: [BandAccessory] { edge == .leading ? model.bandLeading : model.bandTrailing } var body: some View { HStack(spacing: Onyx.Space.xl) { ForEach(kinds, id: \.self) { kind in content(kind) } } .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) // Was nicht mehr hineinpasst, wird abgeschnitten statt den Arm über die // Notch hinauszuschieben. Die Obergrenze in den Einstellungen sorgt // dafür, dass es dazu im Normalfall nicht kommt. .fixedSize(horizontal: true, vertical: false) } @ViewBuilder private func content(_ kind: BandAccessory) -> some View { switch kind { 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) } } }