Add card style option for Home shortcut cards

Adds a Style picker (Plain / Accent / Colorful) to the Customize Home
sheet, shown when the shortcuts layout is Cards. Defaults to Plain.

- Plain: current look (light accent tint + accent border)
- Accent: solid accent fill, white icon/text
- Colorful: solid fill with a fixed color per shortcut type

The filled styles use a Reminders-style layout (icon top-leading, count
top-trailing, title anchored to the bottom) with a subtle gradient sheen
over the fill color. Cards without a meaningful count (Open Link, Remote,
Subscriptions, instance content, media sources) hide the number; Remote
shows its status dot in the top-right slot instead.

Persisted via SettingsKey.homeShortcutCardStyle, mirroring the existing
homeShortcutLayout accessor.
This commit is contained in:
Arkadiusz Fal
2026-05-26 06:43:34 +02:00
parent 5f49f2d022
commit 7a1af02418
8 changed files with 294 additions and 25 deletions

View File

@@ -22,6 +22,11 @@ struct HomeShortcutCardView<StatusIndicator: View>: View {
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
/// Fixed color used when the "colorful" card style is active.
var colorfulColor: Color
var statusIndicator: StatusIndicator?
init(
@@ -29,15 +34,49 @@ struct HomeShortcutCardView<StatusIndicator: View>: View {
title: String,
count: Int,
subtitle: String,
showsCount: Bool = true,
colorfulColor: Color = .accentColor,
statusIndicator: StatusIndicator?
) {
self.icon = icon
self.title = title
self.count = count
self.subtitle = subtitle
self.showsCount = showsCount
self.colorfulColor = colorfulColor
self.statusIndicator = statusIndicator
}
// MARK: - Card Style
private var cardStyle: HomeShortcutCardStyle {
#if os(tvOS)
return .plain
#else
return appEnvironment?.settingsManager.homeShortcutCardStyle ?? .plain
#endif
}
private var isFilled: Bool {
cardStyle != .plain
}
private var fillColor: Color {
cardStyle == .colorful ? colorfulColor : accentColor
}
private var iconColor: Color {
isFilled ? .white : accentColor
}
private var titleColor: Color {
isFilled ? .white : .primary
}
private var subtitleColor: Color {
isFilled ? Color.white.opacity(0.85) : .secondary
}
// Platform-specific styling
#if os(tvOS)
private let iconSize: CGFloat = 36
@@ -70,18 +109,30 @@ struct HomeShortcutCardView<StatusIndicator: View>: View {
#endif
}
/// Filled styles (accent/colorful) use a Reminders-style layout:
/// icon top-leading, count top-trailing, title at the bottom.
private var useRemindersLayout: Bool {
#if os(tvOS)
return false
#else
return isFilled
#endif
}
private var hasSubtitle: Bool {
!subtitle.isEmpty
}
var body: some View {
Group {
if needsVerticalLayout {
if useRemindersLayout {
remindersLayout
} else if needsVerticalLayout {
// Vertical layout for tvOS and accessibility sizes
VStack(alignment: .leading, spacing: 8) {
Image(systemName: icon)
.font(.title3)
.foregroundStyle(accentColor)
.foregroundStyle(iconColor)
.frame(width: iconSize)
VStack(alignment: .leading, spacing: 2) {
@@ -89,7 +140,7 @@ struct HomeShortcutCardView<StatusIndicator: View>: View {
Text(title)
.font(titleFont)
.fontWeight(.semibold)
.foregroundStyle(.primary)
.foregroundStyle(titleColor)
if let statusIndicator {
statusIndicator.padding(.leading, 4)
}
@@ -97,7 +148,7 @@ struct HomeShortcutCardView<StatusIndicator: View>: View {
Text(hasSubtitle ? subtitle : " ")
.font(subtitleFont)
.foregroundStyle(.secondary)
.foregroundStyle(subtitleColor)
.opacity(hasSubtitle ? 1 : 0)
}
}
@@ -107,7 +158,7 @@ struct HomeShortcutCardView<StatusIndicator: View>: View {
HStack(alignment: .center, spacing: 8) {
Image(systemName: icon)
.font(.title3)
.foregroundStyle(accentColor)
.foregroundStyle(iconColor)
.frame(width: iconSize)
VStack(alignment: .leading, spacing: 2) {
@@ -115,7 +166,7 @@ struct HomeShortcutCardView<StatusIndicator: View>: View {
Text(title)
.font(titleFont)
.fontWeight(.semibold)
.foregroundStyle(.primary)
.foregroundStyle(titleColor)
.allowsTightening(true)
.lineLimit(1)
if let statusIndicator {
@@ -126,7 +177,7 @@ struct HomeShortcutCardView<StatusIndicator: View>: View {
if hasSubtitle {
Text(subtitle)
.font(subtitleFont)
.foregroundStyle(.secondary)
.foregroundStyle(subtitleColor)
.lineLimit(1)
}
}
@@ -141,11 +192,48 @@ struct HomeShortcutCardView<StatusIndicator: View>: View {
.background(cardBackground)
.overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(accentColor.opacity(0.3), lineWidth: 1)
.strokeBorder(borderColor, lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
}
/// Reminders-style layout used by the accent/colorful filled styles:
/// 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)
@@ -160,11 +248,41 @@ struct HomeShortcutCardView<StatusIndicator: View>: View {
#endif
}
private var cardBackground: some ShapeStyle {
#if os(tvOS)
isFocused ? Color.white.opacity(0.2) : Color.gray.opacity(0.3)
/// 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
accentColor.opacity(0.1)
60
#endif
}
@ViewBuilder
private var cardBackground: some View {
#if os(tvOS)
(isFocused ? Color.white.opacity(0.2) : Color.gray.opacity(0.3))
#else
if isFilled {
// Solid fill plus a subtle top-to-bottom sheen for a gentle gradient.
fillColor.overlay(
LinearGradient(
colors: [Color.white.opacity(0.28), Color.clear, Color.black.opacity(0.10)],
startPoint: .top,
endPoint: .bottom
)
)
} else {
accentColor.opacity(0.1)
}
#endif
}
private var borderColor: Color {
#if os(tvOS)
return accentColor.opacity(0.3)
#else
return isFilled ? .clear : accentColor.opacity(0.3)
#endif
}
}
@@ -176,12 +294,16 @@ extension HomeShortcutCardView where StatusIndicator == EmptyView {
icon: String,
title: String,
count: Int,
subtitle: String
subtitle: String,
showsCount: Bool = true,
colorfulColor: Color = .accentColor
) {
self.icon = icon
self.title = title
self.count = count
self.subtitle = subtitle
self.showsCount = showsCount
self.colorfulColor = colorfulColor
self.statusIndicator = nil
}
}