Akzentfarbe zieht sich durch

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 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-09-12 09:14:50 +02:00
co-authored by Claude Opus 5
parent 53dacc9f1d
commit 7cfc6e0667
10 changed files with 156 additions and 29 deletions
@@ -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 {
@@ -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
}
}
@@ -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
}
}
@@ -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))
}
}