mana-swift-ui/Sources/ManaComponentsUI/ManaPill.swift
Till JS fe92bffc59 ManaPill und Onboarding-Zeile: auch eigene Bilder statt nur SF-Symbole
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
2026-09-14 19:47:02 +02:00

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
}
}
}