ManaPill(_:image:variant:size:action:) und Row(katalogSymbol:) für Apps mit eigenem Zeichensatz (seepuls). Rein additiv: bestehende Aufrufe mit systemImage/symbol laufen unverändert, kein Init wird mehrdeutig. Sitzung: 79964fc4 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0111BCGu1WXLkJzY5CGKXdC6
194 lines
6.4 KiB
Swift
194 lines
6.4 KiB
Swift
import ManaTokens
|
|
import SwiftUI
|
|
|
|
/// Erscheinungsbild einer ``ManaPill``.
|
|
public enum ManaPillVariant: Sendable {
|
|
/// Aktiv/ausgewählt bzw. prominente Aktion — gefüllt in der Akzentfarbe.
|
|
case selected
|
|
/// Inaktiv bzw. neutrale Aktion — Surface-Fläche mit Rahmen.
|
|
case neutral
|
|
/// Destruktive Aktion (z. B. Löschen) — in der Fehlerfarbe getönt.
|
|
case destructive
|
|
/// Hervorgehobener Hinweis (z. B. zeitlich/aufmerksamkeitsstark) — in der
|
|
/// Warnfarbe getönt. Für Info-Tags, nicht für Aktionen.
|
|
case warning
|
|
}
|
|
|
|
/// Größe einer ``ManaPill``.
|
|
public enum ManaPillSize: Sendable {
|
|
/// Standard — großzügiges Touch-Target für Filter & Aktionen
|
|
/// (18/11 Padding, `.callout`/semibold). Referenz: seepuls-Filterleiste.
|
|
case regular
|
|
/// Kompakt — kleineres Info-Tag/Chip (12/7 Padding, `.footnote`/medium),
|
|
/// z. B. für umbrechende Attribut-/Ausstattungs-Reihen.
|
|
case compact
|
|
}
|
|
|
|
/// Einheitliche Kapsel-Pille (Filter-Chip oder Bulk-Aktion) für alle nativen
|
|
/// mana-Apps. **Größe und Form sind hier zentral festgelegt** — großzügiges
|
|
/// Touch-Target und `Capsule`-Form (Referenz: die seepuls-Filterleiste), damit
|
|
/// Filter-Chips und Auswahl-Aktionen über die ganze Flotte gleich groß sind.
|
|
///
|
|
/// Die Farben kommen aus der aktiven Theme-Variant über
|
|
/// `@Environment(\.manaTheme)`; die App injiziert sie an der Root via
|
|
/// `.manaTheme(_:)`. Es gibt bewusst **keine** Farb-Parameter — so bleibt jede
|
|
/// App bei ihrer Theme-Variant, ohne dass die Komponente Farben rät.
|
|
///
|
|
/// ```swift
|
|
/// // Bulk-Aktionen:
|
|
/// ManaPill("Zusammenfassen", systemImage: "sparkles", variant: .selected) { summarize() }
|
|
/// ManaPill("Archivieren", systemImage: "archivebox") { archive() }
|
|
/// ManaPill("Löschen", systemImage: "trash", variant: .destructive) { confirmDelete() }
|
|
///
|
|
/// // Toggle-Filter (isOn → .selected / .neutral):
|
|
/// ManaPill("Lesend", isOn: filter == .reading) { filter = .reading }
|
|
/// ```
|
|
public struct ManaPill: View {
|
|
@Environment(\.manaTheme) private var theme
|
|
|
|
private let title: String
|
|
private let icon: Image?
|
|
private let variant: ManaPillVariant
|
|
private let size: ManaPillSize
|
|
private let action: (() -> Void)?
|
|
|
|
/// Erzeugt eine Pille mit explizitem `variant`.
|
|
///
|
|
/// - Parameters:
|
|
/// - title: Beschriftung.
|
|
/// - systemImage: optionales SF-Symbol vor der Beschriftung.
|
|
/// - variant: Erscheinungsbild (Default `.neutral`).
|
|
/// - size: Größe (Default `.regular`).
|
|
/// - action: optionaler Tap-Handler. `nil` → reines Label (nicht tappbar).
|
|
public init(
|
|
_ title: String,
|
|
systemImage: String? = nil,
|
|
variant: ManaPillVariant = .neutral,
|
|
size: ManaPillSize = .regular,
|
|
action: (() -> Void)? = nil
|
|
) {
|
|
self.title = title
|
|
self.icon = systemImage.map { Image(systemName: $0) }
|
|
self.variant = variant
|
|
self.size = size
|
|
self.action = action
|
|
}
|
|
|
|
/// Komfort-Init für Toggle-Filter: `isOn` mappt auf `.selected` bzw. `.neutral`.
|
|
///
|
|
/// - Parameters:
|
|
/// - title: Beschriftung.
|
|
/// - systemImage: optionales SF-Symbol vor der Beschriftung.
|
|
/// - isOn: ob der Filter aktiv ist.
|
|
/// - size: Größe (Default `.regular`).
|
|
/// - action: optionaler Tap-Handler.
|
|
public init(
|
|
_ title: String,
|
|
systemImage: String? = nil,
|
|
isOn: Bool,
|
|
size: ManaPillSize = .regular,
|
|
action: (() -> Void)? = nil
|
|
) {
|
|
self.init(
|
|
title,
|
|
systemImage: systemImage,
|
|
variant: isOn ? .selected : .neutral,
|
|
size: size,
|
|
action: action
|
|
)
|
|
}
|
|
|
|
/// Erzeugt eine Pille mit einem eigenen Bild statt eines SF-Symbols — für
|
|
/// Apps mit eigenem Zeichensatz (z. B. seepuls, `Image("zeichen.wlan")`).
|
|
/// Ein Symbol aus dem Asset-Katalog verhält sich dabei wie ein SF-Symbol:
|
|
/// es folgt Schriftgrösse und Farbe der Pille.
|
|
///
|
|
/// - Parameters:
|
|
/// - title: Beschriftung.
|
|
/// - image: Bild vor der Beschriftung.
|
|
/// - variant: Erscheinungsbild (Default `.neutral`).
|
|
/// - size: Größe (Default `.regular`).
|
|
/// - action: optionaler Tap-Handler. `nil` → reines Label (nicht tappbar).
|
|
public init(
|
|
_ title: String,
|
|
image: Image,
|
|
variant: ManaPillVariant = .neutral,
|
|
size: ManaPillSize = .regular,
|
|
action: (() -> Void)? = nil
|
|
) {
|
|
self.title = title
|
|
self.icon = image
|
|
self.variant = variant
|
|
self.size = size
|
|
self.action = action
|
|
}
|
|
|
|
public var body: some View {
|
|
if let action {
|
|
Button(action: action) { label }
|
|
.buttonStyle(.plain)
|
|
} else {
|
|
label
|
|
}
|
|
}
|
|
|
|
private var label: some View {
|
|
HStack(spacing: size == .compact ? Spacing.xxs : Spacing.xs) {
|
|
if let icon {
|
|
icon
|
|
}
|
|
Text(title)
|
|
}
|
|
.font(font)
|
|
.padding(.horizontal, size == .compact ? 12 : 18)
|
|
.padding(.vertical, size == .compact ? 7 : 11)
|
|
.foregroundStyle(foregroundColor)
|
|
.background(backgroundColor)
|
|
.clipShape(Capsule())
|
|
.overlay {
|
|
if showsBorder {
|
|
Capsule().strokeBorder(borderColor, lineWidth: 1)
|
|
}
|
|
}
|
|
.contentShape(Capsule())
|
|
}
|
|
|
|
private var colors: ManaThemeColors { theme.colors }
|
|
|
|
private var font: Font {
|
|
switch size {
|
|
case .regular: .callout.weight(.semibold)
|
|
case .compact: .footnote.weight(.medium)
|
|
}
|
|
}
|
|
|
|
private var backgroundColor: Color {
|
|
switch variant {
|
|
case .selected: colors.primary
|
|
case .neutral: colors.surface
|
|
case .destructive: colors.error.opacity(0.12)
|
|
case .warning: colors.warning.opacity(0.14)
|
|
}
|
|
}
|
|
|
|
private var foregroundColor: Color {
|
|
switch variant {
|
|
case .selected: colors.primaryForeground
|
|
case .neutral: colors.foreground
|
|
case .destructive: colors.error
|
|
case .warning: colors.warning
|
|
}
|
|
}
|
|
|
|
private var showsBorder: Bool {
|
|
variant != .selected
|
|
}
|
|
|
|
private var borderColor: Color {
|
|
switch variant {
|
|
case .destructive: colors.error.opacity(0.4)
|
|
case .warning: colors.warning.opacity(0.35)
|
|
default: colors.border
|
|
}
|
|
}
|
|
}
|