diff --git a/Yattee/Core/Settings/SettingsKey.swift b/Yattee/Core/Settings/SettingsKey.swift index 982233c1..78a82e91 100644 --- a/Yattee/Core/Settings/SettingsKey.swift +++ b/Yattee/Core/Settings/SettingsKey.swift @@ -14,6 +14,9 @@ enum SettingsKey: String, CaseIterable { case theme case accentColor case customAccentColor + case accentColorDark + case customAccentColorDark + case useSeparateDarkAccentColor case showWatchedCheckmark // Playback diff --git a/Yattee/Core/Settings/SettingsManager+General.swift b/Yattee/Core/Settings/SettingsManager+General.swift index 16500a58..9d9eb730 100644 --- a/Yattee/Core/Settings/SettingsManager+General.swift +++ b/Yattee/Core/Settings/SettingsManager+General.swift @@ -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 { diff --git a/Yattee/Core/SettingsManager.swift b/Yattee/Core/SettingsManager.swift index f17b8db9..d59717bd 100644 --- a/Yattee/Core/SettingsManager.swift +++ b/Yattee/Core/SettingsManager.swift @@ -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 diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 7727472c..8fa92680 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -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" : { }, diff --git a/Yattee/Views/Settings/AppearanceSettingsView.swift b/Yattee/Views/Settings/AppearanceSettingsView.swift index f2373248..6a2d6228 100644 --- a/Yattee/Views/Settings/AppearanceSettingsView.swift +++ b/Yattee/Views/Settings/AppearanceSettingsView.swift @@ -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 { + target == .light ? $settings.accentColor : $settings.accentColorDark + } + + private var customColor: Binding { + 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 { 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) diff --git a/YatteeTests/SettingsTests.swift b/YatteeTests/SettingsTests.swift index 6cc6f2f4..ee5c2a38 100644 --- a/YatteeTests/SettingsTests.swift +++ b/YatteeTests/SettingsTests.swift @@ -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")