mirror of
https://github.com/yattee/yattee.git
synced 2026-07-20 14:22:02 +00:00
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.
337 lines
11 KiB
Swift
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
|
|
}
|
|
}
|