mirror of
https://github.com/yattee/yattee.git
synced 2026-07-19 22:02:10 +00:00
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
378 lines
12 KiB
Swift
378 lines
12 KiB
Swift
//
|
|
// AppearanceSettingsView.swift
|
|
// Yattee
|
|
//
|
|
// Appearance settings with theme and accent color selection.
|
|
//
|
|
|
|
import SwiftUI
|
|
#if os(macOS)
|
|
import AppKit
|
|
#endif
|
|
|
|
struct AppearanceSettingsView: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
var body: some View {
|
|
SettingsFormContainer {
|
|
if let settings = appEnvironment?.settingsManager {
|
|
// Theme section
|
|
#if !os(tvOS)
|
|
ThemeSection(settings: settings)
|
|
#endif
|
|
|
|
// App icon section
|
|
#if !os(tvOS)
|
|
AppIconSection(settings: settings)
|
|
#endif
|
|
|
|
// Accent color section
|
|
#if !os(tvOS)
|
|
AccentColorSection(settings: settings)
|
|
#endif
|
|
|
|
// List style section
|
|
#if !os(tvOS)
|
|
ListStyleSection(settings: settings)
|
|
#endif
|
|
|
|
// Thumbnail section
|
|
ThumbnailSection(settings: settings)
|
|
}
|
|
}
|
|
#if !os(tvOS)
|
|
.navigationTitle(String(localized: "settings.appearance.title"))
|
|
#endif
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
}
|
|
}
|
|
|
|
// MARK: - Theme Section
|
|
|
|
private struct ThemeSection: View {
|
|
@Bindable var settings: SettingsManager
|
|
|
|
var body: some View {
|
|
SettingsFormSection("settings.appearance.theme.header") {
|
|
Picker(
|
|
String(localized: "settings.appearance.theme"),
|
|
selection: $settings.theme
|
|
) {
|
|
ForEach(AppTheme.allCases, id: \.self) { theme in
|
|
Text(theme.displayName).tag(theme)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - App Icon Section
|
|
|
|
#if !os(tvOS)
|
|
private struct AppIconSection: View {
|
|
@Bindable var settings: SettingsManager
|
|
|
|
var body: some View {
|
|
SettingsFormSection("settings.appearance.appIcon.header") {
|
|
NavigationLink {
|
|
AppIconPickerView(settings: settings)
|
|
} label: {
|
|
HStack {
|
|
Image(settings.appIcon.previewImageName)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 44, height: 44)
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
|
|
Text(settings.appIcon.displayName)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct AppIconPickerView: View {
|
|
@Bindable var settings: SettingsManager
|
|
|
|
var body: some View {
|
|
List {
|
|
ForEach(AppIcon.allCases, id: \.self) { appIcon in
|
|
Button {
|
|
settings.appIcon = appIcon
|
|
} label: {
|
|
HStack {
|
|
Image(appIcon.previewImageName)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 60, height: 60)
|
|
.clipShape(RoundedRectangle(cornerRadius: 13.5))
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(appIcon.displayName)
|
|
.foregroundStyle(.primary)
|
|
|
|
if let author = appIcon.author {
|
|
Text(author)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if settings.appIcon == appIcon {
|
|
Image(systemName: "checkmark")
|
|
.foregroundStyle(.blue)
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.navigationTitle(String(localized: "settings.appearance.appIcon.header"))
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// MARK: - Accent Color Section
|
|
|
|
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
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - List Style Section
|
|
|
|
private struct ListStyleSection: View {
|
|
@Bindable var settings: SettingsManager
|
|
|
|
var body: some View {
|
|
SettingsFormSection("settings.appearance.listStyle.header") {
|
|
Picker(selection: $settings.listStyle) {
|
|
ForEach(VideoListStyle.allCases, id: \.self) { style in
|
|
Text(style.displayName).tag(style)
|
|
}
|
|
} label: {
|
|
Label(String(localized: "settings.appearance.listStyle"), systemImage: "list.bullet")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Thumbnail Section
|
|
|
|
private struct ThumbnailSection: View {
|
|
@Bindable var settings: SettingsManager
|
|
|
|
var body: some View {
|
|
SettingsFormSection("settings.appearance.thumbnails.header") {
|
|
Toggle(isOn: $settings.showWatchedCheckmark) {
|
|
Label(String(localized: "settings.appearance.showWatchedCheckmark"),
|
|
systemImage: "checkmark.circle.fill")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Accent Color Button
|
|
|
|
private struct AccentColorButton: View {
|
|
let accentColor: AccentColor
|
|
let isSelected: Bool
|
|
let onSelect: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: onSelect) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(accentColor.color)
|
|
.frame(width: 40, height: 40)
|
|
|
|
if isSelected {
|
|
Circle()
|
|
.strokeBorder(.white, lineWidth: 3)
|
|
.frame(width: 40, height: 40)
|
|
|
|
Image(systemName: "checkmark")
|
|
.font(.caption.bold())
|
|
.foregroundStyle(.white)
|
|
}
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(accentColor.displayName)
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
var displayName: String {
|
|
switch self {
|
|
case .system: return String(localized: "settings.appearance.theme.system")
|
|
case .light: return String(localized: "settings.appearance.theme.light")
|
|
case .dark: return String(localized: "settings.appearance.theme.dark")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Ambient Glow Section
|
|
|
|
// MARK: - Accent Color Display Names
|
|
|
|
extension AccentColor {
|
|
var displayName: String {
|
|
switch self {
|
|
case .default: return String(localized: "settings.appearance.accentColor.default")
|
|
case .red: return String(localized: "settings.appearance.accentColor.red")
|
|
case .pink: return String(localized: "settings.appearance.accentColor.pink")
|
|
case .orange: return String(localized: "settings.appearance.accentColor.orange")
|
|
case .yellow: return String(localized: "settings.appearance.accentColor.yellow")
|
|
case .green: return String(localized: "settings.appearance.accentColor.green")
|
|
case .teal: return String(localized: "settings.appearance.accentColor.teal")
|
|
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")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview {
|
|
NavigationStack {
|
|
AppearanceSettingsView()
|
|
}
|
|
.appEnvironment(.preview)
|
|
}
|