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)) + } +}