Files
Termind/TFI/UI/Managed/MobileCards.swift
T

200 lines
6.3 KiB
Swift

import SwiftUI
import UIKit
struct MobileCardPage<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 24) { content }
.padding(16)
}
.background(Color(uiColor: .systemGroupedBackground))
}
}
struct MobileCardGrid<Content: View>: View {
@Environment(\.dynamicTypeSize) private var typeSize
@State private var width: CGFloat = 0
@ViewBuilder var content: Content
var body: some View {
LazyVGrid(
columns: Array(
repeating: GridItem(.flexible(), spacing: 12, alignment: .top),
count: MobileCardLayout.columns(
width: width,
accessibilitySize: typeSize.isAccessibilitySize)),
alignment: .leading, spacing: 12
) { content }
.onGeometryChange(for: CGFloat.self) {
$0.size.width
} action: {
width = $0
}
}
}
struct MobileCardSection<Content: View>: View {
let title: LocalizedStringKey
var count: Int? = nil
@ViewBuilder var content: Content
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
Text(title).font(.headline)
if let count {
Text(count, format: .number).font(.caption.monospacedDigit())
.foregroundStyle(.secondary).padding(.horizontal, 8).padding(.vertical, 2)
.background(.quaternary, in: .capsule)
}
}.accessibilityAddTraits(.isHeader)
content
}
}
}
private struct MobileCardSurface: ViewModifier {
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
@Environment(\.colorSchemeContrast) private var contrast
@ViewBuilder
func body(content: Content) -> some View {
if #available(iOS 26, *) {
surface(
content,
shape: ConcentricRectangle(corners: .concentric(minimum: 20), isUniform: true),
containerRadius: 20
)
} else {
surface(
content, shape: RoundedRectangle(cornerRadius: 10, style: .continuous),
containerRadius: 10)
}
}
private func surface<S: Shape>(
_ content: Content, shape: S, containerRadius: CGFloat
) -> some View {
filledSurface(content, shape: shape)
.overlay {
// The final clip keeps the inner half, preserving the existing one-point border.
shape
.stroke(Color.primary.opacity(contrast == .increased ? 0.5 : 0.12), lineWidth: 2)
.allowsHitTesting(false)
}
.clipShape(shape)
.containerShape(
RoundedRectangle(cornerRadius: containerRadius, style: .continuous)
)
.contentShape([.interaction, .contextMenuPreview], shape)
}
@ViewBuilder private func filledSurface<S: Shape>(_ content: Content, shape: S) -> some View {
if #available(iOS 26, *), !reduceTransparency, contrast != .increased {
// Keep dense text legible while retaining the system's glass edge and lighting.
content
.background(
Color(uiColor: .secondarySystemGroupedBackground).opacity(0.9), in: shape
)
.glassEffect(.regular, in: shape)
} else {
content.background(Color(uiColor: .secondarySystemGroupedBackground), in: shape)
}
}
}
extension View {
func mobileCardSurface() -> some View { modifier(MobileCardSurface()) }
}
struct MobileCard<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
content.frame(maxWidth: .infinity, alignment: .leading).multilineTextAlignment(.leading)
.padding(16)
.frame(minHeight: MobileCardLayout.minimumHeight, alignment: .topLeading)
.mobileCardSurface()
}
}
/// The primary action fills the card. Secondary actions are available from the card's context menu.
struct MobileActionCard<Content: View, Actions: View>: View {
let action: () -> Void
@ViewBuilder var content: Content
@ViewBuilder var actions: Actions
init(
action: @escaping () -> Void,
@ViewBuilder content: () -> Content,
@ViewBuilder actions: () -> Actions
) {
self.action = action
self.content = content()
self.actions = actions()
}
private var cardSurface: some View {
content.frame(maxWidth: .infinity, alignment: .leading).multilineTextAlignment(.leading)
.padding(16)
.frame(
maxWidth: .infinity, minHeight: MobileCardLayout.minimumHeight,
alignment: .topLeading
)
.mobileCardSurface()
}
var body: some View {
Button(action: action) {
cardSurface
.contextMenu {
actions
}
}
.buttonStyle(.plain)
.accessibilityElement(children: .contain)
}
}
struct MobileCardIcon: View {
let symbol: String
var tint: Color = .accentColor
var body: some View {
Image(systemName: symbol).font(.system(size: 17, weight: .medium)).foregroundStyle(tint)
.frame(width: 36, height: 36)
.background(tint.opacity(0.16), in: .rect(cornerRadius: 8))
.accessibilityHidden(true)
}
}
struct MobileCardChip: View {
private let text: Text
private let symbol: String?
private let tint: Color
init(text: String, symbol: String? = nil, tint: Color = .secondary) {
self.text = Text(verbatim: text)
self.symbol = symbol
self.tint = tint
}
init(title: LocalizedStringKey, symbol: String? = nil, tint: Color = .secondary) {
text = Text(title)
self.symbol = symbol
self.tint = tint
}
var body: some View {
HStack(spacing: 4) {
if let symbol { Image(systemName: symbol) }
text
}
.font(.caption).foregroundStyle(tint)
.padding(.horizontal, 8).padding(.vertical, 4)
.background(tint.opacity(0.12), in: .capsule)
.lineLimit(1)
.truncationMode(.tail)
}
}