Files
yattee/Yattee/Views/Home/HomeShortcutStyleView.swift
Arkadiusz Fal 9e2b0fa095 Extend opaque background to settings and other pushed pages on macOS
The NavigationStack-level background wasn't enough: macOS draws its own
translucent material behind pushed navigation pages, and Form/List
containers draw a translucent scroll background on top of anything
placed behind them, so several pages kept the wallpaper-tinted look.

Bake the opaque background into SettingsFormContainer (covers all pages
built on it, pushed or not) and add opaqueSettingsFormBackground(),
which hides the container's scroll background before applying the
opaque one, to every macOS-reachable Form/List settings page: player
controls and its sub-editors, sidebar settings, edit source, legacy
data import, subscription/playlist import, customize home and its
shortcut style page, and the log viewer. Contributors, translators and
the remote control device page get the plain opaque background.

Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o
2026-07-07 22:16:09 +02:00

353 lines
13 KiB
Swift

//
// HomeShortcutStyleView.swift
// Yattee
//
// Dedicated page for choosing the Home shortcut card style, with a live
// non-interactive preview of every shortcut type in the selected style.
//
#if !os(tvOS)
import SwiftUI
struct HomeShortcutStyleView: View {
@Environment(\.appEnvironment) private var appEnvironment
@Binding var style: HomeShortcutCardStyle
@Binding var color: HomeShortcutCardColor
@Binding var palette: HomeShortcutColorfulPalette
@Binding var customColors: [String]
/// The user's accent color, used by the Accent palette (uniform fill).
private var accentColor: Color {
appEnvironment?.settingsManager.resolvedAccentColor ?? .accentColor
}
/// Called after any value changes so the owner persists immediately. The
/// owner sits covered in the navigation stack where its own onChange never
/// fires, and swipe-dismissing the sheet from here skips its lifecycle.
var onSave: (() -> Void)? = nil
/// Editing mode for the custom palette.
private enum CustomEditMode: String, CaseIterable {
case list
case text
var displayName: LocalizedStringKey {
switch self {
case .list: return "home.shortcuts.palette.custom.mode.list"
case .text: return "home.shortcuts.palette.custom.mode.text"
}
}
}
@State private var customEditMode: CustomEditMode = .list
private let previewColumns = [GridItem(.adaptive(minimum: 150), spacing: 8)]
/// All static shortcut types, previewed regardless of the user's current
/// Home configuration so every style variation is visible.
private let previewItems = HomeShortcutItem.defaultOrder
var body: some View {
List {
Section {
Picker(String(localized: "home.settings.shortcuts.layout"), selection: $style) {
ForEach(HomeShortcutCardStyle.allCases, id: \.self) { style in
Text(style.displayName).tag(style)
}
}
#if os(iOS)
.pickerStyle(.segmented)
#endif
} header: {
Text(String(localized: "home.settings.shortcuts.layout"))
}
Section {
Picker(String(localized: "home.settings.shortcuts.color"), selection: $color) {
ForEach(HomeShortcutCardColor.allCases, id: \.self) { color in
Text(color.displayName).tag(color)
}
}
#if os(iOS)
.pickerStyle(.segmented)
#endif
} header: {
Text(String(localized: "home.settings.shortcuts.color"))
}
paletteSection
if palette == .custom {
customColorsSection
}
Section {
LazyVGrid(columns: previewColumns, spacing: 8) {
ForEach(Array(previewItems.enumerated()), id: \.element.id) { index, item in
HomeShortcutCardView(
icon: item.icon,
title: item.localizedTitle,
count: sampleCount(for: item),
subtitle: sampleSubtitle(for: item),
showsCount: showsCount(for: item),
colorfulColor: item.cardColor,
styleOverride: style,
colorOverride: color
)
.environment(
\.homeShortcutColorfulColor,
HomeShortcutColorfulPalette.color(forPosition: index, palette: palette, customHex: customColors, accentColor: accentColor)
)
}
}
.padding(.vertical, 4)
.allowsHitTesting(false)
} header: {
Text(String(localized: "home.settings.shortcuts.style.preview"))
}
}
#if os(macOS)
.listStyle(.inset)
#endif
.opaqueSettingsFormBackground()
.navigationTitle(String(localized: "home.settings.shortcuts.style"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.onChange(of: style) { onSave?() }
.onChange(of: color) { onSave?() }
.onChange(of: palette) { onSave?() }
.onChange(of: customColors) { onSave?() }
}
// MARK: - Palette
private var paletteSection: some View {
Section {
Picker(String(localized: "home.settings.shortcuts.palette"), selection: $palette) {
ForEach(HomeShortcutColorfulPalette.allCases, id: \.self) { palette in
Text(palette.displayName).tag(palette)
}
}
// Swatch strip for the selected palette.
swatchStrip(for: paletteSwatchColors)
.padding(.vertical, 4)
} header: {
Text(String(localized: "home.settings.shortcuts.palette"))
}
}
/// Colors shown in the selected-palette swatch strip.
private var paletteSwatchColors: [Color] {
if palette == .accent {
return [accentColor]
}
if palette == .custom {
let parsed = customColors.compactMap { Color(hex: $0) }
return parsed.isEmpty ? (HomeShortcutColorfulPalette.classic.builtInColors ?? []) : parsed
}
return palette.builtInColors ?? []
}
private func swatchStrip(for colors: [Color]) -> some View {
HStack(spacing: 6) {
ForEach(Array(colors.enumerated()), id: \.offset) { _, color in
RoundedRectangle(cornerRadius: 5)
.fill(color)
.frame(height: 22)
}
}
}
// MARK: - Custom Colors
private var customColorsSection: some View {
Section {
Picker(String(localized: "home.shortcuts.palette.custom.mode"), selection: $customEditMode) {
ForEach(CustomEditMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode)
}
}
.pickerStyle(.segmented)
switch customEditMode {
case .list:
ForEach(customColors.indices, id: \.self) { index in
customColorRow(index: index)
}
.onDelete { offsets in
customColors.remove(atOffsets: offsets)
}
Button {
let seed = HomeShortcutColorfulPalette.customStarterColors.first ?? "#4D96FF"
customColors.append(seed)
} label: {
Label(String(localized: "home.shortcuts.palette.custom.add"), systemImage: "plus.circle.fill")
}
case .text:
TextField(
String(localized: "home.shortcuts.palette.custom.placeholder"),
text: customColorsText,
axis: .vertical
)
.lineLimit(3 ... 6)
#if os(iOS)
.textInputAutocapitalization(.characters)
.autocorrectionDisabled()
#endif
.font(.body.monospaced())
}
} header: {
Text(String(localized: "home.shortcuts.palette.custom.header"))
} footer: {
Text(String(localized: "home.shortcuts.palette.custom.footer"))
}
}
private func customColorRow(index: Int) -> some View {
HStack(spacing: 12) {
ColorPicker("", selection: colorBinding(at: index), supportsOpacity: false)
.labelsHidden()
TextField("#RRGGBB", text: hexBinding(at: index))
#if os(iOS)
.textInputAutocapitalization(.characters)
.autocorrectionDisabled()
#endif
.font(.body.monospaced())
Spacer(minLength: 0)
Button(role: .destructive) {
guard customColors.indices.contains(index) else { return }
customColors.remove(at: index)
} label: {
Image(systemName: "minus.circle.fill")
.foregroundStyle(.red)
}
.buttonStyle(.plain)
}
}
// MARK: - Bindings
/// Two-way binding between a stored hex string and a SwiftUI Color for the
/// native ColorPicker.
private func colorBinding(at index: Int) -> Binding<Color> {
Binding(
get: {
guard customColors.indices.contains(index) else { return .gray }
return Color(hex: customColors[index]) ?? .gray
},
set: { newColor in
guard customColors.indices.contains(index) else { return }
customColors[index] = newColor.toHexString()
}
)
}
/// Editable hex text for a single custom color.
private func hexBinding(at index: Int) -> Binding<String> {
Binding(
get: {
guard customColors.indices.contains(index) else { return "" }
return customColors[index]
},
set: { newValue in
guard customColors.indices.contains(index) else { return }
customColors[index] = newValue
}
)
}
/// Comma/newline separated hex list for the text-editing mode.
private var customColorsText: Binding<String> {
Binding(
get: { customColors.joined(separator: ", ") },
set: { newValue in
customColors = newValue
.split(whereSeparator: { $0 == "," || $0 == "\n" })
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
}
)
}
// MARK: - Preview Helpers
/// Shortcuts without a meaningful count hide it in the filled layout,
/// mirroring `HomeView`'s real card configuration.
private func showsCount(for item: HomeShortcutItem) -> Bool {
switch item {
case .openURL, .remoteControl, .subscriptions:
return false
default:
return true
}
}
/// Representative count so the Regular (Reminders-style) layout looks
/// realistic in the preview.
private func sampleCount(for item: HomeShortcutItem) -> Int {
switch item {
case .playlists: return 8
case .bookmarks: return 12
case .continueWatching: return 3
case .history: return 42
case .downloads: return 5
case .channels: return 24
case .mediaSources: return 2
default: return 0
}
}
/// The count subtitle shown on real Compact cards, mirroring `HomeView`'s
/// per-item subtitles so the Compact preview shows its second line.
private func sampleSubtitle(for item: HomeShortcutItem) -> String {
switch item {
case .openURL, .remoteControl:
return ""
case .playlists:
return formatCount(sampleCount(for: item), singular: "home.count.playlist", plural: "home.count.playlists")
case .bookmarks:
return formatCount(sampleCount(for: item), singular: "home.count.bookmark", plural: "home.count.bookmarks")
case .continueWatching, .history:
return formatCount(sampleCount(for: item), singular: "home.count.video", plural: "home.count.videos")
case .downloads:
return formatCount(sampleCount(for: item), singular: "home.count.video", plural: "home.count.videos")
case .channels:
return formatCount(sampleCount(for: item), singular: "home.count.channel", plural: "home.count.channels")
case .subscriptions:
return String(localized: "home.subscriptions.subtitle")
case .mediaSources:
return sampleCount(for: item) == 1 ? "1 source" : "\(sampleCount(for: item)) sources"
case .instanceContent, .mediaSource:
return ""
}
}
/// Localized "N items" string, mirroring `HomeView.formatCount`.
private func formatCount(_ count: Int, singular: String.LocalizationValue, plural: String.LocalizationValue) -> String {
let formattedCount = CountFormatter.compact(count)
let key = count == 1 ? singular : plural
return String(localized: "\(formattedCount) \(String(localized: key))")
}
}
#Preview {
NavigationStack {
HomeShortcutStyleView(
style: .constant(.regular),
color: .constant(.vibrant),
palette: .constant(.classic),
customColors: .constant(HomeShortcutColorfulPalette.customStarterColors)
)
.appEnvironment(.preview)
}
}
#endif