Files
yattee/Yattee/Views/Home/HomeShortcutCardView.swift
Arkadiusz Fal b47888fe04 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
2026-07-04 23:57:59 +02:00

337 lines
11 KiB
Swift

//
// HomeShortcutCardView.swift
// Yattee
//
// Card component for home shortcuts.
//
import SwiftUI
struct HomeShortcutCardView<StatusIndicator: View>: View {
@Environment(\.appEnvironment) private var appEnvironment
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
/// Position-resolved colorful color; when set it overrides `colorfulColor`.
@Environment(\.homeShortcutColorfulColor) private var positionColorfulColor
#if os(tvOS)
@Environment(\.isFocused) private var isFocused
#endif
private var accentColor: Color {
appEnvironment?.settingsManager.resolvedAccentColor ?? .accentColor
}
let icon: String
let title: String
let count: Int
let subtitle: String
/// Whether to show the count number in the filled (Reminders) layout.
/// Cards without a meaningful count (Open Link, Remote, Subscriptions) hide it.
var showsCount: Bool
/// Palette-resolved color for this card (from position/palette). Used as the
/// tint in Soft and the solid fill in Vibrant.
var colorfulColor: Color
/// Explicit layout override. When set, takes precedence over the global
/// setting used to render live previews of a not-yet-saved layout.
var styleOverride: HomeShortcutCardStyle?
/// Explicit color override. When set, takes precedence over the global
/// setting used to render live previews of a not-yet-saved color.
var colorOverride: HomeShortcutCardColor?
var statusIndicator: StatusIndicator?
init(
icon: String,
title: String,
count: Int,
subtitle: String,
showsCount: Bool = true,
colorfulColor: Color = .accentColor,
styleOverride: HomeShortcutCardStyle? = nil,
colorOverride: HomeShortcutCardColor? = nil,
statusIndicator: StatusIndicator?
) {
self.icon = icon
self.title = title
self.count = count
self.subtitle = subtitle
self.showsCount = showsCount
self.colorfulColor = colorfulColor
self.styleOverride = styleOverride
self.colorOverride = colorOverride
self.statusIndicator = statusIndicator
}
// MARK: - Card Style
private var cardStyle: HomeShortcutCardStyle {
#if os(tvOS)
return .compact
#else
return styleOverride ?? (appEnvironment?.settingsManager.homeShortcutCardStyle ?? .regular)
#endif
}
private var cardColor: HomeShortcutCardColor {
#if os(tvOS)
return .soft
#else
return colorOverride ?? (appEnvironment?.settingsManager.homeShortcutCardColor ?? .soft)
#endif
}
private var isVibrant: Bool {
cardColor == .vibrant
}
/// Palette-resolved color injected via the environment (the Accent palette
/// injects the accent color). Used as the tint in Soft and the fill in Vibrant.
private var paletteColor: Color {
positionColorfulColor ?? colorfulColor
}
private var iconColor: Color {
isVibrant ? .white : paletteColor
}
private var titleColor: Color {
isVibrant ? .white : .primary
}
private var subtitleColor: Color {
isVibrant ? Color.white.opacity(0.85) : .secondary
}
// Platform-specific styling
#if os(tvOS)
private let iconSize: CGFloat = 36
private let titleFont: Font = .headline
private let subtitleFont: Font = .subheadline.monospacedDigit()
private let hPadding: CGFloat = 20
private let vPadding: CGFloat = 20
private let cornerRadius: CGFloat = 20
#elseif os(macOS)
private let iconSize: CGFloat = 28
private let titleFont: Font = .body
private let subtitleFont: Font = .subheadline.monospacedDigit()
private let hPadding: CGFloat = 12
private let vPadding: CGFloat = 12
private let cornerRadius: CGFloat = 16
#else
private let iconSize: CGFloat = 28
private let titleFont: Font = .subheadline
private let subtitleFont: Font = .caption.monospacedDigit()
private let hPadding: CGFloat = 12
private let vPadding: CGFloat = 12
private let cornerRadius: CGFloat = 16
#endif
private var needsVerticalLayout: Bool {
#if os(tvOS)
return true // Always vertical on tvOS for better readability
#else
return dynamicTypeSize >= .xxxLarge
#endif
}
/// The Regular layout is the Reminders-style card:
/// icon top-leading, count top-trailing, title at the bottom.
private var useRemindersLayout: Bool {
#if os(tvOS)
return false
#else
return cardStyle == .regular
#endif
}
private var hasSubtitle: Bool {
!subtitle.isEmpty
}
var body: some View {
Group {
if useRemindersLayout {
remindersLayout
} else if needsVerticalLayout {
// Vertical layout for tvOS and accessibility sizes
VStack(alignment: .leading, spacing: 8) {
Image(systemName: icon)
.font(.title3)
.foregroundStyle(iconColor)
.frame(width: iconSize)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Text(title)
.font(titleFont)
.fontWeight(.semibold)
.foregroundStyle(titleColor)
if let statusIndicator {
statusIndicator.padding(.leading, 4)
}
}
Text(hasSubtitle ? subtitle : " ")
.font(subtitleFont)
.foregroundStyle(subtitleColor)
.opacity(hasSubtitle ? 1 : 0)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
} else {
// Horizontal layout for standard sizes
HStack(alignment: .center, spacing: 8) {
Image(systemName: icon)
.font(.title3)
.foregroundStyle(iconColor)
.frame(width: iconSize)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Text(title)
.font(titleFont)
.fontWeight(.semibold)
.foregroundStyle(titleColor)
.allowsTightening(true)
.lineLimit(1)
if let statusIndicator {
statusIndicator.padding(.leading, 4)
}
}
if hasSubtitle {
Text(subtitle)
.font(subtitleFont)
.foregroundStyle(subtitleColor)
.lineLimit(1)
}
}
Spacer(minLength: 0)
}
.frame(minHeight: subtitleMinHeight)
}
}
.padding(.horizontal, hPadding)
.padding(.vertical, vPadding)
.background(cardBackground)
.overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(borderColor, lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
}
/// Reminders-style layout used by the Regular layout:
/// icon top-leading, count top-trailing, title anchored to the bottom.
private var remindersLayout: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top, spacing: 4) {
Image(systemName: icon)
.font(.title2)
.foregroundStyle(iconColor)
Spacer(minLength: 4)
if let statusIndicator {
statusIndicator
}
if showsCount {
Text(count, format: .number)
.font(.title2)
.fontWeight(.bold)
.monospacedDigit()
.foregroundStyle(titleColor)
.lineLimit(1)
}
}
Spacer(minLength: 8)
Text(title)
.font(titleFont)
.fontWeight(.semibold)
.foregroundStyle(titleColor)
.lineLimit(1)
.allowsTightening(true)
}
.frame(maxWidth: .infinity, minHeight: remindersMinHeight, alignment: .leading)
}
/// Minimum height for the text content area to ensure consistent card heights
private var subtitleMinHeight: CGFloat {
#if os(tvOS)
// headline + subheadline + spacing
44
#elseif os(macOS)
// body + subheadline + spacing
38
#else
// subheadline + caption + spacing
34
#endif
}
/// Minimum content height for the Reminders-style layout so the title sits
/// well below the icon/count row (matches Apple's roomier card proportions).
private var remindersMinHeight: CGFloat {
#if os(macOS)
64
#else
60
#endif
}
@ViewBuilder
private var cardBackground: some View {
#if os(tvOS)
(isFocused ? Color.white.opacity(0.2) : Color.gray.opacity(0.3))
#else
if isVibrant {
// Solid fill plus a subtle top-to-bottom sheen for a gentle gradient.
paletteColor.overlay(
LinearGradient(
colors: [Color.white.opacity(0.28), Color.clear, Color.black.opacity(0.10)],
startPoint: .top,
endPoint: .bottom
)
)
} else {
paletteColor.opacity(0.1)
}
#endif
}
private var borderColor: Color {
#if os(tvOS)
return accentColor.opacity(0.3)
#else
return isVibrant ? .clear : paletteColor.opacity(0.3)
#endif
}
}
// MARK: - Convenience Initializer (no status indicator)
extension HomeShortcutCardView where StatusIndicator == EmptyView {
init(
icon: String,
title: String,
count: Int,
subtitle: String,
showsCount: Bool = true,
colorfulColor: Color = .accentColor,
styleOverride: HomeShortcutCardStyle? = nil,
colorOverride: HomeShortcutCardColor? = nil
) {
self.icon = icon
self.title = title
self.count = count
self.subtitle = subtitle
self.showsCount = showsCount
self.colorfulColor = colorfulColor
self.styleOverride = styleOverride
self.colorOverride = colorOverride
self.statusIndicator = nil
}
}