Add separate light/dark mode accent colors

Adds an option in Appearance settings to use different accent colors
in light and dark mode, with a toggle to keep a single shared color
(the default, preserving existing behavior).

- New synced settings keys: accentColorDark, customAccentColorDark,
  useSeparateDarkAccentColor; dark values fall back to the light
  selection until explicitly set, so no migration is needed
- resolvedAccentColor returns a dynamic platform color (UIColor trait
  provider / NSColor appearance provider) when the toggle is on, so
  the root tint and all direct readers adapt to light/dark
  automatically without consumer changes
- Accent Color section shows two stacked preset+custom grids with
  Light/Dark headers when enabled, extracted into AccentColorGrid;
  CustomAccentColorButton now takes bindings so the macOS color panel
  edits whichever target was last opened
- New DarkAccentColorTests covering sync contract, default-off state,
  fallbacks, and toggle-off resolution

Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o
This commit is contained in:
Arkadiusz Fal
2026-07-05 21:06:26 +02:00
parent b47888fe04
commit b632f11b2f
6 changed files with 250 additions and 28 deletions

View File

@@ -14,6 +14,9 @@ enum SettingsKey: String, CaseIterable {
case theme case theme
case accentColor case accentColor
case customAccentColor case customAccentColor
case accentColorDark
case customAccentColorDark
case useSeparateDarkAccentColor
case showWatchedCheckmark case showWatchedCheckmark
// Playback // Playback

View File

@@ -7,9 +7,9 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
#if os(iOS) #if canImport(UIKit)
import UIKit import UIKit
#elseif os(macOS) #elseif canImport(AppKit)
import AppKit import AppKit
#endif #endif
@@ -50,11 +50,78 @@ extension SettingsManager {
} }
} }
/// The effective accent color, resolving `.custom` to the user-picked value. /// Whether the user picked a separate accent color for dark mode.
var resolvedAccentColor: Color { var useSeparateDarkAccentColor: Bool {
get {
if let cached = _useSeparateDarkAccentColor { return cached }
return bool(for: .useSeparateDarkAccentColor, default: false)
}
set {
_useSeparateDarkAccentColor = newValue
set(newValue, for: .useSeparateDarkAccentColor)
}
}
/// Accent color choice for dark mode. Falls back to the light selection until explicitly set.
var accentColorDark: AccentColor {
get {
if let cached = _accentColorDark { return cached }
if let raw = string(for: .accentColorDark), let value = AccentColor(rawValue: raw) {
return value
}
return accentColor
}
set {
_accentColorDark = newValue
set(newValue.rawValue, for: .accentColorDark)
}
}
/// Custom accent color for dark mode. Falls back to the light custom color until explicitly set.
var customAccentColorDark: Color {
get {
if let hex = _customAccentColorDark ?? string(for: .customAccentColorDark),
let color = Color(hex: hex) {
return color
}
return customAccentColor
}
set {
let hex = newValue.toHexString()
_customAccentColorDark = hex
set(hex, for: .customAccentColorDark)
}
}
/// Light (or shared) accent color, resolving `.custom` to the user-picked value.
private var resolvedLightAccentColor: Color {
accentColor == .custom ? customAccentColor : accentColor.color accentColor == .custom ? customAccentColor : accentColor.color
} }
/// Dark accent color, resolving `.custom` to the user-picked value.
private var resolvedDarkAccentColor: Color {
accentColorDark == .custom ? customAccentColorDark : accentColorDark.color
}
/// The effective accent color. When a separate dark color is enabled this is a
/// dynamic platform color that resolves per trait environment, so all consumers
/// (root tint and direct readers) adapt to light/dark automatically.
var resolvedAccentColor: Color {
let light = resolvedLightAccentColor
guard useSeparateDarkAccentColor else { return light }
let dark = resolvedDarkAccentColor
#if os(macOS)
return Color(nsColor: NSColor(name: nil, dynamicProvider: { appearance in
let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
return NSColor(isDark ? dark : light)
}))
#else
return Color(uiColor: UIColor { traits in
UIColor(traits.userInterfaceStyle == .dark ? dark : light)
})
#endif
}
// MARK: - App Icon Settings // MARK: - App Icon Settings
var appIcon: AppIcon { var appIcon: AppIcon {

View File

@@ -29,6 +29,9 @@ final class SettingsManager {
var _theme: AppTheme? var _theme: AppTheme?
var _accentColor: AccentColor? var _accentColor: AccentColor?
var _customAccentColor: String? var _customAccentColor: String?
var _accentColorDark: AccentColor?
var _customAccentColorDark: String?
var _useSeparateDarkAccentColor: Bool?
var _showWatchedCheckmark: Bool? var _showWatchedCheckmark: Bool?
// Playback // Playback
@@ -419,6 +422,9 @@ final class SettingsManager {
_theme = nil _theme = nil
_accentColor = nil _accentColor = nil
_customAccentColor = nil _customAccentColor = nil
_accentColorDark = nil
_customAccentColorDark = nil
_useSeparateDarkAccentColor = nil
_showWatchedCheckmark = nil _showWatchedCheckmark = nil
_preferredQuality = nil _preferredQuality = nil
_cellularQuality = nil _cellularQuality = nil

View File

@@ -10363,6 +10363,16 @@
} }
} }
}, },
"settings.appearance.accentColor.dark" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Dark"
}
}
}
},
"settings.appearance.accentColor.default" : { "settings.appearance.accentColor.default" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@@ -10395,6 +10405,16 @@
}, },
"settings.appearance.accentColor.indigo" : { "settings.appearance.accentColor.indigo" : {
},
"settings.appearance.accentColor.light" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Light"
}
}
}
}, },
"settings.appearance.accentColor.orange" : { "settings.appearance.accentColor.orange" : {
"localizations" : { "localizations" : {
@@ -10436,6 +10456,16 @@
} }
} }
}, },
"settings.appearance.accentColor.separateColors" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Use separate light & dark colors"
}
}
}
},
"settings.appearance.accentColor.teal" : { "settings.appearance.accentColor.teal" : {
}, },

View File

@@ -143,29 +143,68 @@ private struct AppIconPickerView: View {
// MARK: - Accent Color Section // MARK: - Accent Color Section
private enum AccentColorTarget {
case light, dark
}
private struct AccentColorSection: View { private struct AccentColorSection: View {
@Bindable var settings: SettingsManager @Bindable var settings: SettingsManager
var body: some View { var body: some View {
SettingsFormSection("settings.appearance.accentColor.header") { SettingsFormSection("settings.appearance.accentColor.header") {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 50))], spacing: 16) { #if !os(tvOS)
ForEach(AccentColor.presets, id: \.self) { accentColor in Toggle(isOn: $settings.useSeparateDarkAccentColor) {
AccentColorButton( Text(String(localized: "settings.appearance.accentColor.separateColors"))
accentColor: accentColor, }
isSelected: settings.accentColor == accentColor, #endif
onSelect: { settings.accentColor = accentColor }
) if settings.useSeparateDarkAccentColor {
} Text(String(localized: "settings.appearance.accentColor.light"))
.font(.subheadline)
#if !os(tvOS) .foregroundStyle(.secondary)
CustomAccentColorButton(settings: settings) AccentColorGrid(settings: settings, target: .light)
#endif
Text(String(localized: "settings.appearance.accentColor.dark"))
.font(.subheadline)
.foregroundStyle(.secondary)
AccentColorGrid(settings: settings, target: .dark)
} else {
AccentColorGrid(settings: settings, target: .light)
} }
.padding(.vertical, 8)
} }
} }
} }
private struct AccentColorGrid: View {
@Bindable var settings: SettingsManager
let target: AccentColorTarget
private var selection: Binding<AccentColor> {
target == .light ? $settings.accentColor : $settings.accentColorDark
}
private var customColor: Binding<Color> {
target == .light ? $settings.customAccentColor : $settings.customAccentColorDark
}
var body: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 50))], spacing: 16) {
ForEach(AccentColor.presets, id: \.self) { accentColor in
AccentColorButton(
accentColor: accentColor,
isSelected: selection.wrappedValue == accentColor,
onSelect: { selection.wrappedValue = accentColor }
)
}
#if !os(tvOS)
CustomAccentColorButton(selection: selection, customColor: customColor)
#endif
}
.padding(.vertical, 8)
}
}
// MARK: - List Style Section // MARK: - List Style Section
private struct ListStyleSection: View { private struct ListStyleSection: View {
@@ -233,17 +272,18 @@ private struct AccentColorButton: View {
#if !os(tvOS) #if !os(tvOS)
private struct CustomAccentColorButton: View { private struct CustomAccentColorButton: View {
@Bindable var settings: SettingsManager @Binding var selection: AccentColor
@Binding var customColor: Color
private var isSelected: Bool { settings.accentColor == .custom } private var isSelected: Bool { selection == .custom }
#if os(macOS) #if os(macOS)
var body: some View { var body: some View {
Button { Button {
settings.accentColor = .custom selection = .custom
ColorPanelBridge.shared.open(with: NSColor(settings.customAccentColor)) { nsColor in ColorPanelBridge.shared.open(with: NSColor(customColor)) { nsColor in
settings.customAccentColor = Color(nsColor: nsColor) customColor = Color(nsColor: nsColor)
settings.accentColor = .custom selection = .custom
} }
} label: { } label: {
ZStack { ZStack {
@@ -258,7 +298,7 @@ private struct CustomAccentColorButton: View {
.frame(width: 40, height: 40) .frame(width: 40, height: 40)
Circle() Circle()
.fill(settings.customAccentColor) .fill(customColor)
.frame(width: 28, height: 28) .frame(width: 28, height: 28)
if isSelected { if isSelected {
@@ -274,10 +314,10 @@ private struct CustomAccentColorButton: View {
#else #else
private var pickedColor: Binding<Color> { private var pickedColor: Binding<Color> {
Binding( Binding(
get: { settings.customAccentColor }, get: { customColor },
set: { newColor in set: { newColor in
settings.customAccentColor = newColor customColor = newColor
settings.accentColor = .custom selection = .custom
} }
) )
} }
@@ -285,7 +325,7 @@ private struct CustomAccentColorButton: View {
var body: some View { var body: some View {
ZStack { ZStack {
Circle() Circle()
.fill(settings.customAccentColor) .fill(customColor)
.frame(width: 40, height: 40) .frame(width: 40, height: 40)
.opacity(isSelected ? 1 : 0) .opacity(isSelected ? 1 : 0)

View File

@@ -81,6 +81,82 @@ struct AccentColorTests {
} }
} }
// MARK: - Dark Accent Color Tests
@Suite("Dark Accent Color Tests", .serialized)
@MainActor
struct DarkAccentColorTests {
private static let touchedKeys = [
SettingsKey.accentColor,
.customAccentColor,
.accentColorDark,
.customAccentColorDark,
.useSeparateDarkAccentColor,
]
private func withCleanDefaults(_ body: (SettingsManager) throws -> Void) rethrows {
let removeAll = {
for key in Self.touchedKeys {
UserDefaults.standard.removeObject(forKey: key.rawValue)
}
}
removeAll()
defer { removeAll() }
try body(SettingsManager())
}
@Test("New keys are part of the settings key set and sync globally")
func keysSyncContract() {
for key in [SettingsKey.accentColorDark, .customAccentColorDark, .useSeparateDarkAccentColor] {
#expect(SettingsKey.allCases.contains(key))
#expect(!key.isLocalOnly)
#expect(!key.isPlatformSpecific)
}
}
@Test("Separate dark accent color is disabled by default")
func separateDarkDisabledByDefault() {
withCleanDefaults { settings in
#expect(settings.useSeparateDarkAccentColor == false)
}
}
@Test("Dark accent color falls back to light selection until set")
func darkFallsBackToLight() {
withCleanDefaults { settings in
settings.accentColor = .green
#expect(settings.accentColorDark == .green)
settings.accentColorDark = .purple
#expect(settings.accentColorDark == .purple)
#expect(settings.accentColor == .green)
}
}
@Test("Custom dark accent color falls back to light custom color until set")
func customDarkFallsBackToLight() {
withCleanDefaults { settings in
settings.customAccentColor = Color(hex: "#336699")!
#expect(settings.customAccentColorDark.toHexString() == "#336699")
settings.customAccentColorDark = Color(hex: "#993366")!
#expect(settings.customAccentColorDark.toHexString() == "#993366")
#expect(settings.customAccentColor.toHexString() == "#336699")
}
}
@Test("Resolved accent color equals light resolution when toggle is off")
func resolvedMatchesLightWhenToggleOff() {
withCleanDefaults { settings in
settings.accentColor = .teal
settings.accentColorDark = .red
settings.useSeparateDarkAccentColor = false
#expect(settings.resolvedAccentColor == AccentColor.teal.color)
}
}
}
// MARK: - VideoQuality Tests // MARK: - VideoQuality Tests
@Suite("VideoQuality Tests") @Suite("VideoQuality Tests")