Files
yattee/Yattee/Views/Home/HomeShortcutCardView.swift
Arkadiusz Fal 8d556abd0b Split home shortcut card style into Layout, Color, and Palette
Decouple the two concerns that were entangled in a single card-style setting:

- Layout (Compact / Regular) now only controls positioning of the
  icon / title / subtitle / counter.
- Color (Soft / Vibrant) is a new independent setting: Soft = tinted
  background with palette-colored icon and neutral text; Vibrant =
  solid palette fill with white icon/text.
- Palette (Accent first, then Classic/Sunset/Meadow/Berry/Grape/Custom)
  now applies to both layouts and both colors, and is always shown.

Adds section headers (Layout / Color / Palette) to the style page and
fixes the Compact preview to show the count subtitle line. Default is
Regular + Soft + Accent.
2026-06-11 19:03:52 +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.accentColor.color ?? .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
}
}