Add custom accent color with system color picker

Add a Custom swatch to the appearance settings accent color grid,
backed by a hex value stored in settings (synced via iCloud like the
rest). iOS uses the native ColorPicker wheel; macOS uses a circular
swatch that opens NSColorPanel, since the SwiftUI color well looks out
of place among the circles. All accent consumers now read the resolved
color through SettingsManager.resolvedAccentColor.

The indigo preset is retired from the grid but kept in the enum, so
users who selected it keep their color until they pick another one.

Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o
This commit is contained in:
Arkadiusz Fal
2026-07-04 23:57:59 +02:00
parent f5b86effd3
commit b47888fe04
25 changed files with 188 additions and 21 deletions

View File

@@ -6,6 +6,9 @@
//
import SwiftUI
#if os(macOS)
import AppKit
#endif
struct AppearanceSettingsView: View {
@Environment(\.appEnvironment) private var appEnvironment
@@ -146,13 +149,17 @@ private struct AccentColorSection: View {
var body: some View {
SettingsFormSection("settings.appearance.accentColor.header") {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 50))], spacing: 16) {
ForEach(AccentColor.allCases, id: \.self) { accentColor in
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
}
.padding(.vertical, 8)
}
@@ -222,6 +229,110 @@ private struct AccentColorButton: View {
}
}
// MARK: - Custom Accent Color Button
#if !os(tvOS)
private struct CustomAccentColorButton: View {
@Bindable var settings: SettingsManager
private var isSelected: Bool { settings.accentColor == .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
}
} label: {
ZStack {
Circle()
.strokeBorder(
AngularGradient(
colors: [.red, .yellow, .green, .cyan, .blue, .purple, .red],
center: .center
),
lineWidth: 4
)
.frame(width: 40, height: 40)
Circle()
.fill(settings.customAccentColor)
.frame(width: 28, height: 28)
if isSelected {
Image(systemName: "checkmark")
.font(.caption.bold())
.foregroundStyle(.white)
}
}
}
.buttonStyle(.plain)
.accessibilityLabel(String(localized: "settings.appearance.accentColor.custom"))
}
#else
private var pickedColor: Binding<Color> {
Binding(
get: { settings.customAccentColor },
set: { newColor in
settings.customAccentColor = newColor
settings.accentColor = .custom
}
)
}
var body: some View {
ZStack {
Circle()
.fill(settings.customAccentColor)
.frame(width: 40, height: 40)
.opacity(isSelected ? 1 : 0)
ColorPicker(
String(localized: "settings.appearance.accentColor.custom"),
selection: pickedColor,
supportsOpacity: false
)
.labelsHidden()
if isSelected {
Image(systemName: "checkmark")
.font(.caption.bold())
.foregroundStyle(.white)
.allowsHitTesting(false)
}
}
.accessibilityLabel(String(localized: "settings.appearance.accentColor.custom"))
}
#endif
}
#if os(macOS)
/// Routes NSColorPanel target/action callbacks to the settings binding.
/// NSColorPanel keeps only a weak target, so this must outlive the view.
@MainActor
private final class ColorPanelBridge: NSObject {
static let shared = ColorPanelBridge()
private var onChange: ((NSColor) -> Void)?
func open(with color: NSColor, onChange: @escaping (NSColor) -> Void) {
self.onChange = onChange
let panel = NSColorPanel.shared
panel.showsAlpha = false
panel.color = color
panel.setTarget(self)
panel.setAction(#selector(colorChanged(_:)))
panel.makeKeyAndOrderFront(nil)
}
@objc private func colorChanged(_ sender: NSColorPanel) {
onChange?(sender.color)
}
}
#endif
#endif
// MARK: - Theme Display Names
extension AppTheme {
@@ -251,6 +362,7 @@ extension AccentColor {
case .blue: return String(localized: "settings.appearance.accentColor.blue")
case .purple: return String(localized: "settings.appearance.accentColor.purple")
case .indigo: return String(localized: "settings.appearance.accentColor.indigo")
case .custom: return String(localized: "settings.appearance.accentColor.custom")
}
}
}