mirror of
https://github.com/yattee/yattee.git
synced 2026-07-19 05:42:04 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user