From 7cfc6e0667f3f6351407b75c1fa75cd596caf50f Mon Sep 17 00:00:00 2001 From: Guido Date: Sat, 12 Sep 2026 09:14:50 +0200 Subject: [PATCH] Akzentfarbe zieht sich durch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picker-Werte leuchteten weiter in der Standardfarbe, während Schalter und Symbole daneben schon die gewählte trugen. Zwei Ursachen, beide behoben — welche davon im Einzelfall zog, ließ sich nicht nachstellen, weil der Bildschirm hinter der Anmeldung liegt. Erstens stand `.tint` im Scene-Body. Scene-Bodies werten Änderungen an beobachteten Objekten nicht zuverlässig neu aus; nach einem Farbwechsel blieb ein Teil der Oberfläche auf dem alten Wert stehen. Jetzt setzt RootView Tönung und Akzent — eine View wertet zuverlässig neu aus. Zweitens nahm der Vorgabewert der Umgebung blind die Standardfarbe. Listen und Formulare zeichnen ihre Zeilen teilweise in einem eigenen Darstellungskontext, in dem eigene Umgebungswerte verloren gehen; dort erschien dann Mint statt der gewählten Farbe. Der Vorgabewert liest jetzt die gespeicherte Auswahl. Damit das nicht wieder auseinanderläuft, liegt die Speicherung an einer Stelle: AccentOption.stored/store. Vorher hatte FlixyTheme eigene Schlüssel — zwei Kopien derselben Namen driften auseinander, und zwar unbemerkt. Dazu setzen die Formulare die Tönung ausdrücklich, statt sich auf Vererbung zu verlassen. 154 Tests, fünf davon zur Speicherung. Co-Authored-By: Claude Opus 5 --- Flixy/FlixyApp.swift | 11 +++- Flixy/Views/Library/LibraryFilterSheet.swift | 10 +++- Flixy/Views/Navigation/RootView.swift | 5 ++ Flixy/Views/Settings/DiagnosticsView.swift | 8 +++ Flixy/Views/Settings/HomeLayoutView.swift | 8 +++ Flixy/Views/Settings/SettingsView.swift | 8 +++ .../FlixyDesign/Color/AccentEnvironment.swift | 9 ++- .../FlixyDesign/Color/AccentOption.swift | 44 +++++++++++++++ .../Sources/FlixyDesign/FlixyTheme.swift | 27 +-------- .../FlixyDesignTests/ContrastTests.swift | 55 +++++++++++++++++++ 10 files changed, 156 insertions(+), 29 deletions(-) diff --git a/Flixy/FlixyApp.swift b/Flixy/FlixyApp.swift index ccf54ba..3a92c1f 100644 --- a/Flixy/FlixyApp.swift +++ b/Flixy/FlixyApp.swift @@ -62,12 +62,19 @@ struct FlixyApp: App { .environment(homeLayout) .environment(diagnostics) .environment(theme) - .flixyAccent(theme) + // Akzentfarbe und Tönung setzt RootView, nicht diese Stelle. + // + // Scene-Bodies werten Änderungen an beobachteten Objekten + // nicht zuverlässig neu aus. Stand `.tint` hier, blieb nach + // einem Farbwechsel ein Teil der Oberfläche auf dem alten + // Wert stehen — sichtbar etwa an Picker-Werten, die weiter in + // der Standardfarbe leuchteten, während die Schalter schon + // umgestellt waren. + // // Der eigentliche Schalter für „nur dunkel" steht in der // Info.plist (UIUserInterfaceStyle). Diese Zeile ist die // Absicherung für SwiftUI-Vorschauen, die sie nicht lesen. .preferredColorScheme(.dark) - .tint(theme.accentColor) .task { appDelegate.downloads = downloads await downloads.restore() diff --git a/Flixy/Views/Library/LibraryFilterSheet.swift b/Flixy/Views/Library/LibraryFilterSheet.swift index 71f46ff..b89482b 100644 --- a/Flixy/Views/Library/LibraryFilterSheet.swift +++ b/Flixy/Views/Library/LibraryFilterSheet.swift @@ -8,6 +8,7 @@ struct LibraryFilterSheet: View { let libraryKind: PlexLibrary.Kind? @Environment(\.dismiss) private var dismiss + @Environment(\.flixyAccent) private var accent @Environment(FlixySession.self) private var session @State private var genres: [PlexFilterOption] = [] @@ -62,7 +63,14 @@ struct LibraryFilterSheet: View { .disabled(!draft.isFiltering) } } - .scrollContentBackground(.hidden) + // Ausdrücklich, nicht nur geerbt. + // + // Listen und Formulare zeichnen ihre Zeilen teilweise in einem + // eigenen Darstellungskontext, in dem eigene Umgebungswerte verloren + // gehen können — sichtbar an Picker-Werten, die in der Standardfarbe + // leuchteten, während die Schalter daneben schon die gewählte trugen. + .tint(accent) + .scrollContentBackground(.hidden) .background(Color.flixyCanvas) .navigationTitle(Text("filter.title")) .navigationBarTitleDisplayMode(.inline) diff --git a/Flixy/Views/Navigation/RootView.swift b/Flixy/Views/Navigation/RootView.swift index 03a784c..fa5d5d2 100644 --- a/Flixy/Views/Navigation/RootView.swift +++ b/Flixy/Views/Navigation/RootView.swift @@ -10,6 +10,7 @@ import PlexKit /// Inhalt. Beide Wege nutzen dieselbe `SidebarView`. struct RootView: View { @Environment(FlixySession.self) private var session + @Environment(FlixyTheme.self) private var theme @Environment(PlaybackCoordinator.self) private var playback @Environment(\.horizontalSizeClass) private var sizeClass @@ -54,6 +55,10 @@ struct RootView: View { } } .background(Color.flixyCanvas) + // Hier statt in der Scene: eine View wertet Beobachtungsänderungen + // zuverlässig neu aus, ein Scene-Body nicht. + .flixyAccent(theme) + .tint(theme.accentColor) // Einmal an der Wurzel statt in jedem Bildschirm, von dem aus sich // etwas starten lässt — sonst gäbe es vier Kopien desselben Übergangs. .fullScreenCover(item: Bindable(playback).request) { request in diff --git a/Flixy/Views/Settings/DiagnosticsView.swift b/Flixy/Views/Settings/DiagnosticsView.swift index a818a51..b55ad39 100644 --- a/Flixy/Views/Settings/DiagnosticsView.swift +++ b/Flixy/Views/Settings/DiagnosticsView.swift @@ -9,6 +9,7 @@ import PlexKit /// Ein Screenshot davon sagt mehr als jede Beschreibung. struct DiagnosticsView: View { @Environment(FlixySession.self) private var session + @Environment(\.flixyAccent) private var accent @Environment(PlaybackDiagnostics.self) private var diagnostics var body: some View { @@ -75,6 +76,13 @@ struct DiagnosticsView: View { LabeledContent("settings.build", value: Branding.buildStamp) } } + // Ausdrücklich, nicht nur geerbt. + // + // Listen und Formulare zeichnen ihre Zeilen teilweise in einem + // eigenen Darstellungskontext, in dem eigene Umgebungswerte verloren + // gehen können — sichtbar an Picker-Werten, die in der Standardfarbe + // leuchteten, während die Schalter daneben schon die gewählte trugen. + .tint(accent) .scrollContentBackground(.hidden) .background(Color.flixyCanvas) .navigationTitle(Text("diagnostics.title")) diff --git a/Flixy/Views/Settings/HomeLayoutView.swift b/Flixy/Views/Settings/HomeLayoutView.swift index b76497d..f2ccb93 100644 --- a/Flixy/Views/Settings/HomeLayoutView.swift +++ b/Flixy/Views/Settings/HomeLayoutView.swift @@ -5,6 +5,7 @@ import PlexKit /// Reihen der Startseite ein- und ausblenden und umsortieren. struct HomeLayoutView: View { @Environment(FlixySession.self) private var session + @Environment(\.flixyAccent) private var accent @Environment(HomeLayoutSettings.self) private var layout @State private var rows: [HomeRow] = [] @@ -55,6 +56,13 @@ struct HomeLayoutView: View { Text("homeLayout.explanation") } } + // Ausdrücklich, nicht nur geerbt. + // + // Listen und Formulare zeichnen ihre Zeilen teilweise in einem + // eigenen Darstellungskontext, in dem eigene Umgebungswerte verloren + // gehen können — sichtbar an Picker-Werten, die in der Standardfarbe + // leuchteten, während die Schalter daneben schon die gewählte trugen. + .tint(accent) .scrollContentBackground(.hidden) } diff --git a/Flixy/Views/Settings/SettingsView.swift b/Flixy/Views/Settings/SettingsView.swift index 5e797dd..c697432 100644 --- a/Flixy/Views/Settings/SettingsView.swift +++ b/Flixy/Views/Settings/SettingsView.swift @@ -11,6 +11,7 @@ import PlexKit /// es das Geregelte auch gibt. struct SettingsView: View { @Environment(FlixySession.self) private var session + @Environment(\.flixyAccent) private var accent @Environment(PlaybackSettings.self) private var settings @Environment(DownloadCoordinator.self) private var downloads @State private var showsSignOutConfirmation = false @@ -89,6 +90,13 @@ struct SettingsView: View { } } } + // Ausdrücklich, nicht nur geerbt. + // + // Listen und Formulare zeichnen ihre Zeilen teilweise in einem + // eigenen Darstellungskontext, in dem eigene Umgebungswerte verloren + // gehen können — sichtbar an Picker-Werten, die in der Standardfarbe + // leuchteten, während die Schalter daneben schon die gewählte trugen. + .tint(accent) .scrollContentBackground(.hidden) .background(Color.flixyCanvas) .navigationTitle(Text("sidebar.settings")) diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Color/AccentEnvironment.swift b/Packages/FlixyKit/Sources/FlixyDesign/Color/AccentEnvironment.swift index 7a963b6..3422191 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/Color/AccentEnvironment.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/Color/AccentEnvironment.swift @@ -7,7 +7,14 @@ extension EnvironmentValues { /// 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 + /// Der Vorgabewert liest die gespeicherte Auswahl, statt blind die + /// Standardfarbe zu nehmen. + /// + /// Er greift überall dort, wo die Umgebung nicht ankommt — Listen und + /// Formulare zeichnen Zeilen teilweise in einem eigenen Kontext. Vorher + /// leuchtete dort die Standardfarbe, während daneben schon die gewählte + /// stand; jetzt stimmen beide. + @Entry public var flixyAccent: Color = AccentOption.storedAccentColor() } extension View { diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Color/AccentOption.swift b/Packages/FlixyKit/Sources/FlixyDesign/Color/AccentOption.swift index 3144d96..c943890 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/Color/AccentOption.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/Color/AccentOption.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftUI /// Eine wählbare Akzentfarbe. /// @@ -54,3 +55,46 @@ extension AccentOption { AccentOption(id: customIdentifier, name: "Custom", color: color) } } + +extension AccentOption { + /// Wo die Auswahl liegt. + /// + /// Hier statt in `FlixyTheme`, weil zwei Stellen sie brauchen: das Theme + /// und der Vorgabewert der Umgebung. Zwei Kopien derselben Schlüssel + /// driften auseinander, und zwar unbemerkt — es fällt erst auf, wenn eine + /// Farbe irgendwo nicht mitkommt. + enum StorageKey { + static let identifier = "flixy.accent.identifier" + static let customHex = "flixy.accent.customHex" + } + + /// Die gespeicherte Auswahl, oder die Voreinstellung. + public static func stored(in defaults: UserDefaults = .standard) -> AccentOption { + guard let identifier = defaults.string(forKey: StorageKey.identifier) else { + return .default + } + if identifier == customIdentifier { + // Fällt die gespeicherte Zeichenkette aus (etwa weil jemand die + // Voreinstellungen von Hand bearbeitet hat), ist die Vorgabe + // besser als eine Farbe, die niemand gewählt hat. + guard let hex = defaults.string(forKey: StorageKey.customHex), + let color = RGB(string: hex) + else { return .default } + return .custom(color) + } + return palette.first { $0.id == identifier } ?? .default + } + + /// Merkt sich die Auswahl. + public func store(in defaults: UserDefaults = .standard) { + defaults.set(id, forKey: StorageKey.identifier) + if id == Self.customIdentifier { + defaults.set(color.hexString, forKey: StorageKey.customHex) + } + } + + /// Die gespeicherte Farbe, bereits kontrastkorrigiert. + public static func storedAccentColor(in defaults: UserDefaults = .standard) -> Color { + Contrast.legible(stored(in: defaults).color, on: FlixyColor.canvas).swiftUIColor + } +} diff --git a/Packages/FlixyKit/Sources/FlixyDesign/FlixyTheme.swift b/Packages/FlixyKit/Sources/FlixyDesign/FlixyTheme.swift index 4520a2b..1ccaa6f 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/FlixyTheme.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/FlixyTheme.swift @@ -10,18 +10,13 @@ import SwiftUI 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) + self.selection = AccentOption.stored(in: defaults) } /// Die Farbe, die tatsächlich gezeichnet wird. @@ -41,25 +36,7 @@ public final class FlixyTheme { 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) - } + option.store(in: defaults) } - 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 - } } diff --git a/Packages/FlixyKit/Tests/FlixyDesignTests/ContrastTests.swift b/Packages/FlixyKit/Tests/FlixyDesignTests/ContrastTests.swift index a515224..5b21e30 100644 --- a/Packages/FlixyKit/Tests/FlixyDesignTests/ContrastTests.swift +++ b/Packages/FlixyKit/Tests/FlixyDesignTests/ContrastTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import FlixyDesign @@ -147,3 +148,57 @@ struct DefaultAccentTests { #expect(corrected == AccentOption.default.color) } } + +@Suite("Gespeicherte Akzentfarbe") +struct AccentStorageTests { + private func freshDefaults() -> UserDefaults { + UserDefaults(suiteName: "flixy.accent.tests.\(UUID().uuidString)")! + } + + @Test("Ohne gespeicherte Auswahl gilt die Voreinstellung") + func defaultWithoutSelection() { + #expect(AccentOption.stored(in: freshDefaults()) == .default) + } + + @Test("Eine kuratierte Auswahl kommt zurück") + func curatedRoundTrips() { + let defaults = freshDefaults() + let sky = AccentOption.palette.first { $0.id == "sky" }! + sky.store(in: defaults) + #expect(AccentOption.stored(in: defaults) == sky) + } + + @Test("Eine eigene Farbe kommt mit ihrem Wert zurück") + func customRoundTrips() { + let defaults = freshDefaults() + let purple = AccentOption.custom(RGB(hex: 0x8B5CF6)) + purple.store(in: defaults) + + let restored = AccentOption.stored(in: defaults) + #expect(restored.id == AccentOption.customIdentifier) + #expect(restored.color == RGB(hex: 0x8B5CF6)) + } + + @Test("Eine unlesbare eigene Farbe fällt auf die Voreinstellung zurück") + func brokenCustomFallsBack() { + // Entsteht, wenn jemand die Voreinstellungen von Hand bearbeitet. + let defaults = freshDefaults() + defaults.set(AccentOption.customIdentifier, forKey: AccentOption.StorageKey.identifier) + defaults.set("kein Hexwert", forKey: AccentOption.StorageKey.customHex) + + #expect(AccentOption.stored(in: defaults) == .default) + } + + @Test("Der Umgebungswert und das Theme zeigen dieselbe Farbe") + @MainActor + func environmentMatchesTheme() { + // Der eigentliche Punkt: gäbe es zwei getrennte Speicherwege, liefe + // einer der beiden irgendwann auf die Standardfarbe — und genau das + // sah man als Picker-Wert in Mint neben Schaltern in Violett. + let defaults = freshDefaults() + AccentOption.custom(RGB(hex: 0x8B5CF6)).store(in: defaults) + + let theme = FlixyTheme(defaults: defaults) + #expect(theme.accentColor == AccentOption.storedAccentColor(in: defaults)) + } +}