Die Begründung für „ein Wert je Seite" war falsch gerechnet: der Arm ist bei fünf Kacheln je 376 Punkte breit, und ein Wert braucht davon knapp sechzig. Die Zahl im Kopf war die der schmalen Insel, nicht die des ausgefahrenen Arms. Jetzt bis zu vier Elemente je Seite, in wählbarer Reihenfolge — Uhr, Wetter, Akku, CPU, Speicher, Temperatur. Kein Ziehen zum Umsortieren: bei höchstens vier Einträgen ist Herausnehmen und wieder Hinzufügen schneller als eine Ziehgeste, die man erst finden muss. „Nichts" ist keine Wahl mehr, sondern die leere Liste — zwei Wege, dasselbe zu sagen, waren einer zu viel. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
319 lines
11 KiB
Swift
319 lines
11 KiB
Swift
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.
|
|
///
|
|
/// 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)
|
|
}
|
|
}
|
|
}
|