Token für Schrift, Symbolgröße, Abstand, Radius und Bewegung, dazu sieben Bauteile, die vorher in jeder Kachel neu gebaut wurden — Karte, Abschnittstitel, Hinweis, Pillenknopf, Regler, Ablegefläche, Haarlinie. Symbole bekommen eine **eigene** Staffel und nicht die der Schrift: ein 9-Punkt-Symbol neben 9-Punkt-Text wirkt kleiner, nicht gleich groß. Beide in eine Staffel zu zwingen hieße, an jeder Stelle gegen sie zu arbeiten. Das Flächenrezept — Tint, Bänderung, Kante — stand zweimal da, einmal für das Panel und einmal für Fenster. Es ist jetzt `OnyxStone`, generisch über die Form, damit auch die animierende Notch-Silhouette es benutzen kann. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
244 lines
9.0 KiB
Swift
244 lines
9.0 KiB
Swift
import SwiftUI
|
|
|
|
/// Die Bauteile der **Fenster** — Einstellungen, Onboarding, Popover.
|
|
///
|
|
/// **Getrennt von den Panel-Bauteilen, und genau das ist der Punkt.** Das Panel
|
|
/// ist schwarzer Stein und bleibt es; ein Fenster gehört dem System und folgt
|
|
/// dessen Erscheinung. Diese Trennung fehlte bisher: `Onyx.Color.textTertiary`
|
|
/// ist Weiß mit 34 % Deckkraft und stand in den Einstellungen — im Hellmodus
|
|
/// war das praktisch unsichtbar. Die Bauteile hier benutzen deshalb
|
|
/// Systemsemantik (`.secondary`, `.quaternary`, `controlBackgroundColor`), die
|
|
/// hell wie dunkel richtig herauskommt. Die Akzentfarbe von Onyx kommt über
|
|
/// `.tint` von außen dazu.
|
|
|
|
// MARK: - Karte
|
|
|
|
/// Eine Gruppe zusammengehörender Zeilen.
|
|
///
|
|
/// Zieht die Trennlinien **selbst** zwischen die Zeilen. Von Hand gesetzt
|
|
/// fehlen sie irgendwann irgendwo, oder es steht eine zu viel am Ende der
|
|
/// Gruppe — das ist keine Frage von Sorgfalt, sondern von Gelegenheit.
|
|
public struct OnyxFormCard<Content: View>: View {
|
|
|
|
private let header: String?
|
|
private let headerHint: String?
|
|
private let content: Content
|
|
|
|
/// - Parameters:
|
|
/// - header: Die Überschrift der Gruppe. Sie steht **in** der Karte und
|
|
/// ohne Trennlinie darunter — sie benennt die Zeilen, sie ist keine
|
|
/// davon.
|
|
/// - headerHint: Ein Satz, der für die **ganze** Gruppe gilt. Er gehört
|
|
/// hierher und nicht an die erste Zeile: dort läse er sich, als
|
|
/// erkläre er nur diese eine.
|
|
public init(_ header: String? = nil,
|
|
hint headerHint: String? = nil,
|
|
@ViewBuilder content: () -> Content) {
|
|
self.header = header
|
|
self.headerHint = headerHint
|
|
self.content = content()
|
|
}
|
|
|
|
public var body: some View {
|
|
VStack(spacing: 0) {
|
|
if header != nil || headerHint != nil {
|
|
VStack(alignment: .leading, spacing: Onyx.Space.xs) {
|
|
if let header {
|
|
Text(header).font(.headline)
|
|
}
|
|
if let headerHint {
|
|
Text(headerHint)
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, OnyxForm.rowPadding)
|
|
.padding(.top, OnyxForm.rowPadding)
|
|
.padding(.bottom, Onyx.Space.xxs)
|
|
}
|
|
|
|
Group(subviews: content) { rows in
|
|
ForEach(rows.indices, id: \.self) { index in
|
|
if index > 0 {
|
|
// Eingerückt wie in den Systemeinstellungen: eine Linie
|
|
// von Kante zu Kante zerschneidet die Karte, statt ihre
|
|
// Zeilen zu trennen.
|
|
Divider().padding(.leading, OnyxForm.rowPadding)
|
|
}
|
|
rows[index]
|
|
}
|
|
}
|
|
}
|
|
// Ein Material, keine Farbe.
|
|
//
|
|
// `controlBackgroundColor` ist deckend und macht aus jeder Karte eine
|
|
// Platte — das Fenster darunter kann dann so durchlässig sein, wie es
|
|
// will, man sieht nur zwischen den Karten etwas davon. Ein Material
|
|
// nimmt den Grund auf und bleibt trotzdem lesbar, weil es ihn
|
|
// gleichzeitig verwäscht.
|
|
.background(.ultraThinMaterial,
|
|
in: .rect(cornerRadius: OnyxForm.cardRadius, style: .continuous))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: OnyxForm.cardRadius, style: .continuous)
|
|
.strokeBorder(.quaternary, lineWidth: 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Zeile
|
|
|
|
/// Eine Einstellung: was sie ist, warum es sie gibt, und ihr Bedienelement.
|
|
///
|
|
/// Der Hinweis steht **unter** dem Titel, nicht daneben und nicht in einer
|
|
/// eigenen Zeile darunter. Daneben drängt er das Bedienelement an den Rand;
|
|
/// als eigene Zeile verliert er den Bezug zu dem, was er erklärt.
|
|
public struct OnyxFormRow<Trailing: View>: View {
|
|
|
|
/// Wie ernst die Zeile ist.
|
|
///
|
|
/// Die Signalfarben stehen fest und bedeuten etwas. Eine Warnung, die
|
|
/// aussieht wie eine Einstellung, ist keine.
|
|
public enum Tone {
|
|
case normal
|
|
case warning
|
|
case critical
|
|
|
|
/// `nil` heißt: nimm, was die Umgebung vorgibt — `.tint` beim Symbol,
|
|
/// `.primary` beim Titel. Ein fester Ersatzwert stünde beiden im Weg.
|
|
var style: AnyShapeStyle? {
|
|
switch self {
|
|
case .normal: nil
|
|
case .warning: AnyShapeStyle(Onyx.Color.warning)
|
|
case .critical: AnyShapeStyle(Onyx.Color.critical)
|
|
}
|
|
}
|
|
}
|
|
|
|
private let title: String
|
|
private let hint: String?
|
|
private let symbol: String?
|
|
private let tone: Tone
|
|
private let trailing: Trailing
|
|
|
|
public init(_ title: String,
|
|
hint: String? = nil,
|
|
symbol: String? = nil,
|
|
tone: Tone = .normal,
|
|
@ViewBuilder trailing: () -> Trailing) {
|
|
self.title = title
|
|
self.hint = hint
|
|
self.symbol = symbol
|
|
self.tone = tone
|
|
self.trailing = trailing()
|
|
}
|
|
|
|
public var body: some View {
|
|
HStack(alignment: .center, spacing: OnyxForm.rowPadding) {
|
|
if let symbol {
|
|
Image(systemName: symbol)
|
|
.frame(width: 18)
|
|
.foregroundStyle(tone.style ?? AnyShapeStyle(.tint))
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
|
|
Text(title)
|
|
.foregroundStyle(tone.style ?? AnyShapeStyle(.primary))
|
|
if let hint {
|
|
Text(hint)
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
// Ohne das bricht ein längerer Hinweis nicht um,
|
|
// sondern schiebt die ganze Zeile über die Kante.
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
trailing
|
|
}
|
|
.padding(.horizontal, OnyxForm.rowPadding)
|
|
.padding(.vertical, Onyx.Space.row)
|
|
}
|
|
}
|
|
|
|
public extension OnyxFormRow where Trailing == EmptyView {
|
|
init(_ title: String, hint: String? = nil, symbol: String? = nil, tone: Tone = .normal) {
|
|
self.init(title, hint: hint, symbol: symbol, tone: tone) { EmptyView() }
|
|
}
|
|
}
|
|
|
|
// MARK: - Seitenleiste
|
|
|
|
/// Ein Eintrag in der Seitenleiste: farbiges Plättchen, dann der Name.
|
|
///
|
|
/// Das Plättchen ist keine Verzierung. Eine Seitenleiste aus zwölf Zeilen
|
|
/// gleicher Schrift liest man jedes Mal von vorn; mit Farbe und Form findet
|
|
/// man den gesuchten Eintrag, ohne ihn zu lesen.
|
|
public struct OnyxSidebarLabel: View {
|
|
|
|
private let title: String
|
|
private let symbol: String
|
|
private let tint: Color
|
|
|
|
public init(_ title: String, symbol: String, tint: Color) {
|
|
self.title = title
|
|
self.symbol = symbol
|
|
self.tint = tint
|
|
}
|
|
|
|
public var body: some View {
|
|
HStack(spacing: Onyx.Space.md) {
|
|
RoundedRectangle(cornerRadius: Onyx.Radius.thumbnail, style: .continuous)
|
|
.fill(tint)
|
|
.frame(width: 20, height: 20)
|
|
.overlay {
|
|
Image(systemName: symbol)
|
|
.font(.system(size: Onyx.Symbol.small, weight: .semibold))
|
|
// Weiß, nicht `.primary`: das Plättchen ist in beiden
|
|
// Erscheinungen farbig und damit immer dunkel genug.
|
|
.foregroundStyle(.white)
|
|
}
|
|
Text(title)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Die Überschrift eines Abschnitts in einem Popover.
|
|
///
|
|
/// Sie stand zweimal im Baum, zeichengleich — einmal als Struktur in
|
|
/// `MetricPopovers`, einmal als Funktion in `NetworkPopover`. Dass beide gleich
|
|
/// aussahen, war Zufall und keine Zusage.
|
|
///
|
|
/// In der Akzentfarbe und nicht gedämpft: ein Popover ist klein, und die
|
|
/// Abschnitte darin sind das einzige Gerüst, an dem man sich festhält.
|
|
public struct OnyxPopoverTitle: View {
|
|
private let text: String
|
|
|
|
public init(_ text: String) { self.text = text }
|
|
|
|
public var body: some View {
|
|
Text(text)
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(Onyx.Color.accent)
|
|
.textCase(.uppercase)
|
|
}
|
|
}
|
|
|
|
// MARK: - Maße
|
|
|
|
/// Die wenigen Maße, die Karte, Zeile und Trennlinie miteinander teilen.
|
|
///
|
|
/// Nicht in `Onyx.Space`: das ist die Staffel für die dichte Kachelfläche.
|
|
/// Ein Fenster hat andere Verhältnisse, und beides in eine Staffel zu zwingen
|
|
/// hieße, eines von beidem falsch zu machen.
|
|
public enum OnyxForm {
|
|
public static let cardRadius: CGFloat = 10
|
|
public static let rowPadding: CGFloat = 14
|
|
/// Abstand zwischen zwei Karten.
|
|
public static let cardSpacing: CGFloat = 18
|
|
/// Rand um den Inhalt eines Bereichs.
|
|
public static let pagePadding: CGFloat = 20
|
|
}
|