Phase 0: Projektgerüst, Design-Tokens und Navigation

Legt das Fundament für den Plex-Client an:

- XcodeGen-Projekt nach dem Muster von Onyx (com.scarriffleservices,
  strict concurrency, Projektdatei nicht eingecheckt)
- FlixyKit mit vier Modulen: PlexKit, FlixyDesign, FlixyPlayback,
  FlixyDownloads. macOS steht als Plattform mit drin, damit die
  Logik-Tests ohne Simulator laufen.
- FlixyDesign: dunkle Farb-Tokens, Schriftskala, Poster- und
  Querformat-Kacheln, Aufmacher-Bild. Die Akzentfarbe fließt über die
  Umgebung und wird per WCAG-Rechnung lesbar gehalten.
- PlexKit: Kopfzeilen-Identität, Transport-Protokoll, Fehlerarten und
  die Medienmodelle samt nachsichtigem Decoder.
- FlixyPlayback: VideoPlayerEngine-Protokoll und PlaybackDecider — die
  eine Funktion, an der Phase 2 das Transcoding andockt.
- Ausfahrbare Seitenleiste auf dem iPhone, Split-View auf dem iPad.
- 45 Tests, alle grün.

VLCKit 4.0.0-a24 ist vorab geprüft: baut über SPM unter Swift 6 mit
strict concurrency. Die iOS-Slice ist mit 239 MB allerdings gewichtig,
deshalb bekommt sie in Phase 1 ein eigenes Target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-09-11 16:04:40 +02:00
co-authored by Claude Opus 5
commit 1ed64bf166
54 changed files with 3584 additions and 0 deletions
@@ -0,0 +1,25 @@
import SwiftUI
extension EnvironmentValues {
/// Die aktuelle Akzentfarbe, bereits kontrastkorrigiert.
///
/// Bewusst eine schlichte `Color` in der Umgebung statt einer Abhängigkeit
/// auf `FlixyTheme`: Bausteine wie `WatchProgressBar` sollen sich in einer
/// Vorschau mit einer beliebigen Farbe zeigen lassen, ohne dass dafür ein
/// ganzes Theme-Objekt gebaut werden muss.
@Entry public var flixyAccent: Color = AccentOption.default.color.swiftUIColor
}
extension View {
/// Setzt die Akzentfarbe für diesen Teilbaum.
///
/// Wird einmal an der Wurzel aus `FlixyTheme` gesetzt; darunter greift
/// alles automatisch darauf zu.
public func flixyAccent(_ color: Color) -> some View {
environment(\.flixyAccent, color)
}
public func flixyAccent(_ theme: FlixyTheme) -> some View {
environment(\.flixyAccent, theme.accentColor)
}
}
@@ -0,0 +1,51 @@
import Foundation
/// Eine wählbare Akzentfarbe.
///
/// Die kuratierten Werte sind so gewählt, dass sie auf `FlixyColor.canvas`
/// mindestens 4,5:1 erreichen — geprüft von `AccentPaletteTests`, damit beim
/// Nachlegen einer Farbe nicht versehentlich etwas Unlesbares hineinrutscht.
public struct AccentOption: Sendable, Identifiable {
public let id: String
public let name: LocalizedStringResource
public let color: RGB
public init(id: String, name: LocalizedStringResource, color: RGB) {
self.id = id
self.name = name
self.color = color
}
}
extension AccentOption: Equatable {
public static func == (lhs: AccentOption, rhs: AccentOption) -> Bool { lhs.id == rhs.id }
}
extension AccentOption: Hashable {
public func hash(into hasher: inout Hasher) { hasher.combine(id) }
}
extension AccentOption {
/// Die kuratierte Auswahl aus den Einstellungen.
public static let palette: [AccentOption] = [
AccentOption(id: "ember", name: "Ember", color: RGB(hex: 0xE5484D)),
AccentOption(id: "tangerine", name: "Tangerine", color: RGB(hex: 0xF76B15)),
AccentOption(id: "amber", name: "Amber", color: RGB(hex: 0xFFB224)),
AccentOption(id: "lime", name: "Lime", color: RGB(hex: 0x99D52A)),
AccentOption(id: "mint", name: "Mint", color: RGB(hex: 0x3DD68C)),
AccentOption(id: "teal", name: "Teal", color: RGB(hex: 0x12A594)),
AccentOption(id: "sky", name: "Sky", color: RGB(hex: 0x52A9FF)),
AccentOption(id: "indigo", name: "Indigo", color: RGB(hex: 0x7C77E8)),
AccentOption(id: "orchid", name: "Orchid", color: RGB(hex: 0xC04AC9)),
AccentOption(id: "rose", name: "Rose", color: RGB(hex: 0xF76190)),
]
public static let `default` = palette[0]
/// Die Kennung, unter der eine frei gewählte Farbe abgelegt wird.
public static let customIdentifier = "custom"
public static func custom(_ color: RGB) -> AccentOption {
AccentOption(id: customIdentifier, name: "Custom", color: color)
}
}
@@ -0,0 +1,89 @@
import Foundation
/// Kontrastrechnung nach WCAG 2.1.
///
/// Gebraucht wird sie für die eigene Akzentfarbe: Flixy lässt jede Farbe zu,
/// aber ein dunkles Marineblau auf fast schwarzem Grund ist als Schrift oder
/// Symbol unlesbar. `legible(_:on:)` hebt solche Farben so weit an, bis sie
/// den geforderten Kontrast erreichen — und lässt alles in Ruhe, was ihn
/// ohnehin schon hat.
public enum Contrast {
/// Der Grenzwert aus WCAG für normalen Text.
public static let minimumForText: Double = 4.5
/// Der Grenzwert für große Schrift und Bedienelemente.
public static let minimumForLargeText: Double = 3.0
/// Relative Leuchtdichte eines Farbwerts (0 = Schwarz, 1 = Weiß).
public static func relativeLuminance(_ color: RGB) -> Double {
func linear(_ channel: Double) -> Double {
// Der Knick bei 0,03928 ist kein Rundungsfehler, sondern Teil der
// sRGB-Definition: unterhalb davon ist die Kurve linear.
channel <= 0.03928 ? channel / 12.92 : pow((channel + 0.055) / 1.055, 2.4)
}
return 0.2126 * linear(color.red)
+ 0.7152 * linear(color.green)
+ 0.0722 * linear(color.blue)
}
/// Kontrastverhältnis zweier Farben, zwischen 1 (gleich) und 21 (Schwarz zu Weiß).
public static func ratio(_ a: RGB, _ b: RGB) -> Double {
let la = relativeLuminance(a)
let lb = relativeLuminance(b)
return (max(la, lb) + 0.05) / (min(la, lb) + 0.05)
}
/// Hebt `color` gerade so weit an, dass sie auf `background` lesbar wird.
///
/// Zuerst wird die Helligkeit angehoben, weil das den Farbton erhält — eine
/// gewählte Akzentfarbe soll erkennbar dieselbe bleiben. Reicht selbst
/// volle Helligkeit nicht (etwa bei einem kräftigen Rot), wird zusätzlich
/// die Sättigung zurückgenommen; die Farbe wandert dann Richtung Weiß.
public static func legible(
_ color: RGB,
on background: RGB,
minimumRatio: Double = minimumForText
) -> RGB {
guard ratio(color, background) < minimumRatio else { return color }
let base = HSB(color)
// Beide Schritte sind monoton — mehr Helligkeit bzw. weniger Sättigung
// erhöhen die Leuchtdichte immer. Deshalb genügt eine binäre Suche,
// und sie terminiert garantiert.
if let brightened = search(
from: base.brightness, to: 1,
build: { HSB(hue: base.hue, saturation: base.saturation, brightness: $0).rgb },
satisfies: { ratio($0, background) >= minimumRatio }
) {
return brightened
}
return search(
from: base.saturation, to: 0,
build: { HSB(hue: base.hue, saturation: $0, brightness: 1).rgb },
satisfies: { ratio($0, background) >= minimumRatio }
) ?? RGB(hex: 0xFFFFFF)
}
/// Sucht den Wert nahe `from`, der die Bedingung gerade eben erfüllt.
/// `to` ist das Extrem; erfüllt selbst das die Bedingung nicht, gibt es
/// keine Lösung auf dieser Achse.
private static func search(
from: Double,
to extreme: Double,
build: (Double) -> RGB,
satisfies: (RGB) -> Bool
) -> RGB? {
guard satisfies(build(extreme)) else { return nil }
var unsatisfying = from
var satisfying = extreme
// 12 Halbierungen bringen den Wert auf unter 1/4096 genau — feiner als
// jeder Unterschied, der auf einem Bildschirm noch sichtbar wäre.
for _ in 0..<12 {
let middle = (unsatisfying + satisfying) / 2
if satisfies(build(middle)) { satisfying = middle } else { unsatisfying = middle }
}
return build(satisfying)
}
}
@@ -0,0 +1,45 @@
import SwiftUI
/// Die Flächen- und Schriftfarben der App.
///
/// Flixy kennt nur den dunklen Modus — es gibt deshalb bewusst keine
/// Hell-/Dunkel-Paare, sondern je einen festen Wert. Die einzige Farbe, die
/// sich zur Laufzeit ändert, ist der Akzent; er kommt aus `FlixyTheme`.
public enum FlixyColor {
// Flächen, von hinten nach vorn gestaffelt.
/// Der Seitenhintergrund.
public static let canvas = RGB(hex: 0x0B0B0D)
/// Karten, Seitenleiste, Kopfbereiche.
public static let surface = RGB(hex: 0x141417)
/// Blätter, Menüs, alles was über dem Inhalt schwebt.
public static let elevated = RGB(hex: 0x1D1D21)
/// Platzhalterkacheln und gedrückte Zustände.
public static let placeholder = RGB(hex: 0x26262B)
// Schrift.
public static let textPrimary = RGB(hex: 0xF5F5F7)
public static let textSecondary = RGB(hex: 0xA0A0A8)
/// Für Beiwerk wie Jahreszahlen und Laufzeiten.
public static let textMuted = RGB(hex: 0x6E6E76)
/// Rot für zerstörerische Aktionen — bewusst nicht der Akzent, damit
/// „Löschen" auch dann warnend aussieht, wenn jemand Rot als Akzent wählt.
public static let destructive = RGB(hex: 0xE5484D)
}
extension Color {
public static let flixyCanvas = FlixyColor.canvas.swiftUIColor
public static let flixySurface = FlixyColor.surface.swiftUIColor
public static let flixyElevated = FlixyColor.elevated.swiftUIColor
public static let flixyPlaceholder = FlixyColor.placeholder.swiftUIColor
public static let flixyTextPrimary = FlixyColor.textPrimary.swiftUIColor
public static let flixyTextSecondary = FlixyColor.textSecondary.swiftUIColor
public static let flixyTextMuted = FlixyColor.textMuted.swiftUIColor
public static let flixyDestructive = FlixyColor.destructive.swiftUIColor
/// Trennlinien und Ränder. Als Weiß mit Deckkraft statt als fester Wert,
/// damit sie auf jeder Flächenstufe gleich zurückhaltend wirken.
public static let flixySeparator = Color.white.opacity(0.10)
/// Die Abdunklung hinter der ausgefahrenen Seitenleiste.
public static let flixyScrim = Color.black.opacity(0.55)
}
@@ -0,0 +1,66 @@
import Foundation
/// Farbton, Sättigung, Helligkeit — jeweils 0…1.
///
/// Die Kontrastkorrektur arbeitet in diesem Raum, weil sich darin die
/// Eigenschaft „gleiche Farbe, nur heller" als einzelne Zahl ausdrücken lässt.
/// In RGB müsste man dafür alle drei Kanäle gleichzeitig verschieben.
public struct HSB: Sendable, Equatable {
public var hue: Double
public var saturation: Double
public var brightness: Double
public init(hue: Double, saturation: Double, brightness: Double) {
// Der Farbton ist zyklisch: 1,2 ist dasselbe wie 0,2. Abschneiden wäre
// hier falsch, weil Rot dabei zu Magenta würde.
self.hue = hue.truncatingRemainder(dividingBy: 1).nonNegativeHue()
self.saturation = saturation.clamped()
self.brightness = brightness.clamped()
}
public init(_ rgb: RGB) {
let maximum = max(rgb.red, rgb.green, rgb.blue)
let minimum = min(rgb.red, rgb.green, rgb.blue)
let delta = maximum - minimum
let hue: Double
if delta == 0 {
hue = 0
} else if maximum == rgb.red {
hue = ((rgb.green - rgb.blue) / delta).truncatingRemainder(dividingBy: 6) / 6
} else if maximum == rgb.green {
hue = ((rgb.blue - rgb.red) / delta + 2) / 6
} else {
hue = ((rgb.red - rgb.green) / delta + 4) / 6
}
self.init(
hue: hue,
saturation: maximum == 0 ? 0 : delta / maximum,
brightness: maximum
)
}
public var rgb: RGB {
let sector = hue * 6
let index = Int(sector) % 6
let fraction = sector - Double(Int(sector))
let p = brightness * (1 - saturation)
let q = brightness * (1 - saturation * fraction)
let t = brightness * (1 - saturation * (1 - fraction))
switch index {
case 0: return RGB(red: brightness, green: t, blue: p)
case 1: return RGB(red: q, green: brightness, blue: p)
case 2: return RGB(red: p, green: brightness, blue: t)
case 3: return RGB(red: p, green: q, blue: brightness)
case 4: return RGB(red: t, green: p, blue: brightness)
default: return RGB(red: brightness, green: p, blue: q)
}
}
}
extension Double {
fileprivate func nonNegativeHue() -> Double { self < 0 ? self + 1 : self }
}
@@ -0,0 +1,65 @@
import SwiftUI
/// Eine Farbe als drei Kanäle im Bereich 0…1, unabhängig von SwiftUI.
///
/// `SwiftUI.Color` lässt sich nicht zuverlässig wieder auslesen — es ist ein
/// undurchsichtiger Wert, der erst beim Zeichnen aufgelöst wird. Für
/// Kontrastrechnungen brauchen wir die Zahlen aber vorher, deshalb dieser
/// eigene Typ. `Color` entsteht erst am Ende, in `swiftUIColor`.
public struct RGB: Sendable, Equatable, Hashable, Codable {
public var red: Double
public var green: Double
public var blue: Double
public init(red: Double, green: Double, blue: Double) {
self.red = red.clamped()
self.green = green.clamped()
self.blue = blue.clamped()
}
/// Aus einem Hexwert der Form `0xRRGGBB`.
public init(hex: UInt32) {
self.init(
red: Double((hex >> 16) & 0xFF) / 255,
green: Double((hex >> 8) & 0xFF) / 255,
blue: Double(hex & 0xFF) / 255
)
}
/// Aus einer Eingabe wie `#1DB954`, `1db954` oder `#1DB`.
/// Gibt `nil` zurück, wenn die Zeichenkette keine Farbe ergibt — der
/// Einstellungsdialog kann damit direkt eine Fehlermeldung zeigen.
public init?(string: String) {
var text = string.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
if text.hasPrefix("#") { text.removeFirst() }
// Kurzform: #ABC meint #AABBCC.
if text.count == 3 {
text = text.map { "\($0)\($0)" }.joined()
}
guard text.count == 6, let value = UInt32(text, radix: 16) else { return nil }
self.init(hex: value)
}
public var hexString: String {
String(
format: "#%02X%02X%02X",
Int((red * 255).rounded()),
Int((green * 255).rounded()),
Int((blue * 255).rounded())
)
}
public var swiftUIColor: Color {
// Explizit sRGB. Ohne Angabe deutet SwiftUI die Werte je nach Kontext
// unterschiedlich, und dann stimmt der gerechnete Kontrast nicht mehr
// mit dem überein, was auf dem Bildschirm landet.
Color(.sRGB, red: red, green: green, blue: blue, opacity: 1)
}
}
extension Double {
func clamped(to range: ClosedRange<Double> = 0...1) -> Double {
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
}
}
@@ -0,0 +1,44 @@
import SwiftUI
/// Ein Bild vom Server mit Lade- und Fehlerzustand.
///
/// Kapselt `AsyncImage`, damit der Austausch gegen einen eigenen, zwischen-
/// speichernden Lader später genau eine Datei betrifft. Die Startseite zeigt
/// dutzende Bilder gleichzeitig; ohne Zwischenspeicher lädt `AsyncImage` sie
/// bei jedem Erscheinen neu.
public struct FlixyArtwork: View {
private let url: URL?
private let aspectRatio: CGFloat
private let fallbackSymbol: String
public init(url: URL?, aspectRatio: CGFloat, fallbackSymbol: String = "film") {
self.url = url
self.aspectRatio = aspectRatio
self.fallbackSymbol = fallbackSymbol
}
public var body: some View {
Rectangle()
.fill(Color.flixyPlaceholder)
.aspectRatio(aspectRatio, contentMode: .fit)
.overlay {
AsyncImage(url: url, transaction: Transaction(animation: .easeOut(duration: 0.2))) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
case .failure:
// Kein Fehlertext: bei einer Reihe aus zwanzig Kacheln
// wäre das mehr Lärm als Hilfe. Das Symbol sagt genug.
Image(systemName: fallbackSymbol)
.font(.title2)
.foregroundStyle(Color.flixyTextMuted)
case .empty:
ShimmerPlaceholder()
@unknown default:
ShimmerPlaceholder()
}
}
}
.clipped()
}
}
@@ -0,0 +1,76 @@
import SwiftUI
/// Das Aufmacher-Bild oben auf der Startseite.
///
/// Die Aktionsknöpfe kommen von außen, weil ein Film „Abspielen" und
/// „Herunterladen" braucht, eine angefangene Folge dagegen „Weiterschauen".
/// Der Baustein legt nur Bild, Verlauf und Textsatz fest.
public struct HeroBanner<Actions: View>: View {
private let title: String
private let metadata: String?
private let summary: String?
private let artworkURL: URL?
private let actions: Actions
public init(
title: String,
metadata: String? = nil,
summary: String? = nil,
artworkURL: URL?,
@ViewBuilder actions: () -> Actions
) {
self.title = title
self.metadata = metadata
self.summary = summary
self.artworkURL = artworkURL
self.actions = actions()
}
public var body: some View {
FlixyArtwork(url: artworkURL, aspectRatio: 3.0 / 4.0)
.overlay(alignment: .bottom) {
// Der Verlauf endet in der Hintergrundfarbe der Seite, nicht in
// Schwarz: sonst entsteht genau dort, wo der Aufmacher in die
// erste Reihe übergeht, eine sichtbare Kante.
LinearGradient(
stops: [
.init(color: .clear, location: 0),
.init(color: Color.flixyCanvas.opacity(0.75), location: 0.45),
.init(color: Color.flixyCanvas, location: 1),
],
startPoint: .top,
endPoint: .bottom
)
.frame(maxHeight: .infinity, alignment: .bottom)
.allowsHitTesting(false)
}
.overlay(alignment: .bottomLeading) {
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
Text(title)
.font(.flixyHero)
.foregroundStyle(Color.flixyTextPrimary)
.lineLimit(2)
if let metadata {
Text(metadata)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
}
if let summary {
Text(summary)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
.lineLimit(2)
}
HStack(spacing: FlixySpacing.regular) {
actions
}
.padding(.top, FlixySpacing.tight)
}
.padding(.horizontal, FlixySpacing.screenMargin)
.padding(.bottom, FlixySpacing.loose)
}
}
}
@@ -0,0 +1,47 @@
import SwiftUI
/// Ein Titel im Querformat — die Kachelform der Startseite.
///
/// Anders als das Poster zeigt sie ein Szenenbild und trägt den Titel unter
/// dem Bild, weil Szenenbilder selten Schrift enthalten.
public struct LandscapeCard: View {
private let title: String
private let subtitle: String?
private let artworkURL: URL?
private let progress: Double?
public static let aspectRatio: CGFloat = 16.0 / 9.0
public init(title: String, subtitle: String? = nil, artworkURL: URL?, progress: Double? = nil) {
self.title = title
self.subtitle = subtitle
self.artworkURL = artworkURL
self.progress = progress
}
public var body: some View {
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
FlixyArtwork(url: artworkURL, aspectRatio: Self.aspectRatio, fallbackSymbol: "play.rectangle")
.overlay(alignment: .bottom) {
if let progress {
WatchProgressBar(fraction: progress)
}
}
.clipShape(.rect(cornerRadius: FlixyRadius.card))
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.flixyCardTitle)
.foregroundStyle(Color.flixyTextPrimary)
.lineLimit(1)
if let subtitle {
Text(subtitle)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.lineLimit(1)
}
}
}
.accessibilityElement(children: .combine)
}
}
@@ -0,0 +1,50 @@
import SwiftUI
/// Ein Titel im Hochformat, wie er in Bibliotheksrastern steht.
public struct PosterCard: View {
private let title: String
private let subtitle: String?
private let artworkURL: URL?
private let progress: Double?
/// Das Seitenverhältnis von Kinoplakaten. Plex liefert Poster in diesem
/// Format; ein anderer Wert würde die Bilder beschneiden.
public static let aspectRatio: CGFloat = 2.0 / 3.0
public init(title: String, subtitle: String? = nil, artworkURL: URL?, progress: Double? = nil) {
self.title = title
self.subtitle = subtitle
self.artworkURL = artworkURL
self.progress = progress
}
public var body: some View {
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
FlixyArtwork(url: artworkURL, aspectRatio: Self.aspectRatio)
.clipShape(.rect(cornerRadius: FlixyRadius.card))
.overlay(alignment: .bottom) {
if let progress {
WatchProgressBar(fraction: progress)
}
}
.clipShape(.rect(cornerRadius: FlixyRadius.card))
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.flixyCardTitle)
.foregroundStyle(Color.flixyTextPrimary)
.lineLimit(1)
if let subtitle {
Text(subtitle)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.lineLimit(1)
}
}
}
// Ohne diese Zusammenfassung liest VoiceOver Bild, Titel und Balken als
// drei getrennte Elemente vor — bei einer Reihe aus zwanzig Kacheln
// wird das Durchwischen dadurch dreimal so lang.
.accessibilityElement(children: .combine)
}
}
@@ -0,0 +1,41 @@
import SwiftUI
/// Die Platzhalterfläche, solange ein Bild noch lädt.
///
/// Ein wandernder Lichtschein statt eines Spinners: er zeigt die endgültige
/// Form des Inhalts schon an, wodurch die Startseite beim Aufbauen nicht
/// springt.
public struct ShimmerPlaceholder: View {
@State private var phase: CGFloat = -1
/// Bewegte Flächen sind für manche Menschen unangenehm; bei aktivierter
/// Systemeinstellung bleibt der Platzhalter deshalb einfach ruhig stehen.
@Environment(\.accessibilityReduceMotion) private var reduceMotion
public init() {}
public var body: some View {
Rectangle()
.fill(Color.flixyPlaceholder)
.overlay {
if !reduceMotion {
GeometryReader { proxy in
LinearGradient(
colors: [.clear, Color.white.opacity(0.06), .clear],
startPoint: .leading,
endPoint: .trailing
)
.frame(width: proxy.size.width * 0.6)
.offset(x: phase * proxy.size.width * 1.6)
}
}
}
.clipped()
.onAppear {
guard !reduceMotion else { return }
withAnimation(.linear(duration: 1.4).repeatForever(autoreverses: false)) {
phase = 1
}
}
.accessibilityHidden(true)
}
}
@@ -0,0 +1,28 @@
import SwiftUI
/// Der dünne Balken unter angefangenen Titeln.
public struct WatchProgressBar: View {
/// 0…1. Werte außerhalb werden abgeschnitten, damit ein fehlerhafter
/// Serverwert den Balken nicht über die Kachel hinausschieben kann.
private let fraction: Double
@Environment(\.flixyAccent) private var accent
public init(fraction: Double) {
self.fraction = fraction.clamped()
}
public var body: some View {
GeometryReader { proxy in
ZStack(alignment: .leading) {
Rectangle().fill(Color.white.opacity(0.15))
Rectangle()
.fill(accent)
.frame(width: proxy.size.width * fraction)
}
}
.frame(height: 3)
.accessibilityElement()
.accessibilityLabel(Text("progress.watched", bundle: .module))
.accessibilityValue(Text(fraction.formatted(.percent.precision(.fractionLength(0)))))
}
}
@@ -0,0 +1,41 @@
import SwiftUI
/// Die Schriftskala der App.
///
/// Durchgehend die Systemschrift mit `Font.TextStyle` als Grundlage, damit
/// Dynamic Type wirkt. Die Rolle steht im Namen, nicht die Größe — sonst
/// führt jede Anpassung zu einer Suche quer durch alle Views.
extension Font {
/// Der Titel im Aufmacher-Bild.
public static let flixyHero = Font.system(.largeTitle, design: .default, weight: .bold)
/// Titel auf Detailseiten.
public static let flixyTitle = Font.system(.title2, design: .default, weight: .semibold)
/// Überschrift einer Reihe auf der Startseite.
public static let flixyRowHeader = Font.system(.headline, design: .default, weight: .semibold)
/// Titel unter einem Poster.
public static let flixyCardTitle = Font.system(.subheadline, design: .default, weight: .medium)
/// Fließtext, Beschreibungen.
public static let flixyBody = Font.system(.body)
/// Jahreszahl, Laufzeit, Auflösung — alles, was neben dem Titel steht.
public static let flixyCaption = Font.system(.caption)
}
/// Abstände.
///
/// Eine Skala statt frei gewählter Zahlen: dadurch stimmen die Ränder zwischen
/// Bildschirmen überein, ohne dass man sie einzeln nachmisst.
public enum FlixySpacing {
public static let tight: CGFloat = 4
public static let snug: CGFloat = 8
public static let regular: CGFloat = 12
public static let loose: CGFloat = 16
public static let section: CGFloat = 24
/// Der seitliche Rand des Inhalts zum Bildschirmrand.
public static let screenMargin: CGFloat = 16
}
public enum FlixyRadius {
public static let card: CGFloat = 8
public static let sheet: CGFloat = 16
public static let control: CGFloat = 10
}
@@ -0,0 +1,65 @@
import SwiftUI
/// Hält die gewählte Akzentfarbe und sorgt dafür, dass sie lesbar bleibt.
///
/// Nach außen gibt es nur `accent` — schon kontrastkorrigiert. Views sollen
/// sich nie fragen müssen, ob die eingestellte Farbe dunkel genug ist, um
/// darauf zu achten.
@MainActor
@Observable
public final class FlixyTheme {
private let defaults: UserDefaults
private enum Key {
static let selectedIdentifier = "flixy.accent.identifier"
static let customHex = "flixy.accent.customHex"
}
/// Was in den Einstellungen ausgewählt ist — unkorrigiert, damit die
/// Farbkachel genau das zeigt, was angetippt wurde.
public private(set) var selection: AccentOption
public init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.selection = Self.restore(from: defaults)
}
/// Die Farbe, die tatsächlich gezeichnet wird.
///
/// Bei den kuratierten Werten ändert die Korrektur nichts — sie erfüllen
/// den Grenzwert bereits. Sie greift nur bei eigenen Farben.
public var accent: RGB {
Contrast.legible(selection.color, on: FlixyColor.canvas)
}
public var accentColor: Color { accent.swiftUIColor }
/// Ob die Korrektur bei der aktuellen Auswahl eingegriffen hat. Der
/// Einstellungsdialog kann darauf hinweisen, statt den Nutzer rätseln zu
/// lassen, warum seine Farbe heller aussieht als gewählt.
public var accentWasAdjusted: Bool { accent != selection.color }
public func select(_ option: AccentOption) {
selection = option
defaults.set(option.id, forKey: Key.selectedIdentifier)
if option.id == AccentOption.customIdentifier {
defaults.set(option.color.hexString, forKey: Key.customHex)
}
}
private static func restore(from defaults: UserDefaults) -> AccentOption {
guard let identifier = defaults.string(forKey: Key.selectedIdentifier) else {
return .default
}
if identifier == AccentOption.customIdentifier {
// Fällt die gespeicherte Zeichenkette aus (etwa weil jemand die
// Voreinstellungen von Hand bearbeitet hat), ist der Standard
// besser als eine Farbe, die niemand gewählt hat.
guard let hex = defaults.string(forKey: Key.customHex),
let color = RGB(string: hex)
else { return .default }
return .custom(color)
}
return AccentOption.palette.first { $0.id == identifier } ?? .default
}
}
@@ -0,0 +1,17 @@
{
"sourceLanguage" : "en",
"strings" : {
"progress.watched" : {
"comment" : "Barrierefreiheits-Beschriftung des Fortschrittsbalkens unter angefangenen Titeln",
"localizations" : {
"de" : {
"stringUnit" : { "state" : "translated", "value" : "Fortschritt" }
},
"en" : {
"stringUnit" : { "state" : "translated", "value" : "Progress" }
}
}
}
},
"version" : "1.0"
}