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 accentColor
|
||||
case customAccentColor
|
||||
case accentColorDark
|
||||
case customAccentColorDark
|
||||
case useSeparateDarkAccentColor
|
||||
case showWatchedCheckmark
|
||||
|
||||
// Playback
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
@@ -50,11 +50,78 @@ extension SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// The effective accent color, resolving `.custom` to the user-picked value.
|
||||
var resolvedAccentColor: Color {
|
||||
/// Whether the user picked a separate accent color for dark mode.
|
||||
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
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
var appIcon: AppIcon {
|
||||
|
||||
@@ -29,6 +29,9 @@ final class SettingsManager {
|
||||
var _theme: AppTheme?
|
||||
var _accentColor: AccentColor?
|
||||
var _customAccentColor: String?
|
||||
var _accentColorDark: AccentColor?
|
||||
var _customAccentColorDark: String?
|
||||
var _useSeparateDarkAccentColor: Bool?
|
||||
var _showWatchedCheckmark: Bool?
|
||||
|
||||
// Playback
|
||||
@@ -419,6 +422,9 @@ final class SettingsManager {
|
||||
_theme = nil
|
||||
_accentColor = nil
|
||||
_customAccentColor = nil
|
||||
_accentColorDark = nil
|
||||
_customAccentColorDark = nil
|
||||
_useSeparateDarkAccentColor = nil
|
||||
_showWatchedCheckmark = nil
|
||||
_preferredQuality = nil
|
||||
_cellularQuality = nil
|
||||
|
||||
@@ -10363,6 +10363,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.appearance.accentColor.dark" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Dark"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.appearance.accentColor.default" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@@ -10395,6 +10405,16 @@
|
||||
},
|
||||
"settings.appearance.accentColor.indigo" : {
|
||||
|
||||
},
|
||||
"settings.appearance.accentColor.light" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Light"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.appearance.accentColor.orange" : {
|
||||
"localizations" : {
|
||||
@@ -10436,6 +10456,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.appearance.accentColor.separateColors" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Use separate light & dark colors"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.appearance.accentColor.teal" : {
|
||||
|
||||
},
|
||||
|
||||
@@ -143,29 +143,68 @@ private struct AppIconPickerView: View {
|
||||
|
||||
// MARK: - Accent Color Section
|
||||
|
||||
private enum AccentColorTarget {
|
||||
case light, dark
|
||||
}
|
||||
|
||||
private struct AccentColorSection: View {
|
||||
@Bindable var settings: SettingsManager
|
||||
|
||||
var body: some View {
|
||||
SettingsFormSection("settings.appearance.accentColor.header") {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 50))], spacing: 16) {
|
||||
ForEach(AccentColor.presets, id: \.self) { accentColor in
|
||||
AccentColorButton(
|
||||
accentColor: accentColor,
|
||||
isSelected: settings.accentColor == accentColor,
|
||||
onSelect: { settings.accentColor = accentColor }
|
||||
)
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
CustomAccentColorButton(settings: settings)
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
Toggle(isOn: $settings.useSeparateDarkAccentColor) {
|
||||
Text(String(localized: "settings.appearance.accentColor.separateColors"))
|
||||
}
|
||||
#endif
|
||||
|
||||
if settings.useSeparateDarkAccentColor {
|
||||
Text(String(localized: "settings.appearance.accentColor.light"))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
AccentColorGrid(settings: settings, target: .light)
|
||||
|
||||
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
|
||||
|
||||
private struct ListStyleSection: View {
|
||||
@@ -233,17 +272,18 @@ private struct AccentColorButton: View {
|
||||
|
||||
#if !os(tvOS)
|
||||
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)
|
||||
var body: some View {
|
||||
Button {
|
||||
settings.accentColor = .custom
|
||||
ColorPanelBridge.shared.open(with: NSColor(settings.customAccentColor)) { nsColor in
|
||||
settings.customAccentColor = Color(nsColor: nsColor)
|
||||
settings.accentColor = .custom
|
||||
selection = .custom
|
||||
ColorPanelBridge.shared.open(with: NSColor(customColor)) { nsColor in
|
||||
customColor = Color(nsColor: nsColor)
|
||||
selection = .custom
|
||||
}
|
||||
} label: {
|
||||
ZStack {
|
||||
@@ -258,7 +298,7 @@ private struct CustomAccentColorButton: View {
|
||||
.frame(width: 40, height: 40)
|
||||
|
||||
Circle()
|
||||
.fill(settings.customAccentColor)
|
||||
.fill(customColor)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
if isSelected {
|
||||
@@ -274,10 +314,10 @@ private struct CustomAccentColorButton: View {
|
||||
#else
|
||||
private var pickedColor: Binding<Color> {
|
||||
Binding(
|
||||
get: { settings.customAccentColor },
|
||||
get: { customColor },
|
||||
set: { newColor in
|
||||
settings.customAccentColor = newColor
|
||||
settings.accentColor = .custom
|
||||
customColor = newColor
|
||||
selection = .custom
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -285,7 +325,7 @@ private struct CustomAccentColorButton: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(settings.customAccentColor)
|
||||
.fill(customColor)
|
||||
.frame(width: 40, height: 40)
|
||||
.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
|
||||
|
||||
@Suite("VideoQuality Tests")
|
||||
|
||||
Reference in New Issue
Block a user