mana-swift-ui/Sources/ManaThemeUI/ManaThemeGallery.swift
Till JS e2dc6024e6 ManaThemeGallery: freigeschaltete Themes zuerst + App-Brücke bleibt
- Reihenfolge: freigeschaltete Variants zuerst, gesperrte danach
  (stabile Partition über `unlocked`) — statt quer zwischen
  Schlössern verstreut.
- Der "<App> öffnen"-Button bleibt nach dem Freischalten sichtbar
  (Infotext "Signatur-Theme von <App>" statt Freischalt-Aufforderung)
  als dauerhafte Brücke zur Herkunfts-App: die anderen mana-Apps
  bleiben erreichbar, die Herkunft des Themes bleibt klar.
  Baseline-Variants ohne Signatur-App bekommen weiterhin keinen Button.
  `onLockedTap` feuert nun auch beim freigeschalteten Button-Druck.

Reine View-Änderung, keine API-Signatur-Änderung. swift build grün.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:32:32 +02:00

265 lines
12 KiB
Swift

import ManaTokens
import SwiftUI
/// Kompakte Theme-Auswahl als horizontaler Orb-Streifen mit glänzenden,
/// farbigen Orbs (je ``ManaTokens/ManaTheme``-Variant). Frei wischbar;
/// durch seitliches Padding startet die **aktive Variant zentriert**, beim
/// Antippen/Einrasten gleitet die Auswahl in die Mitte. Darunter Name +
/// Charakter der zentrierten Variant.
///
/// **Reihenfolge:** freigeschaltete Variants stehen **zuerst**, gesperrte
/// danach (stabile Partition über `unlocked`) — so liegen die nutzbaren
/// Themes zusammen am Anfang statt quer zwischen Schlössern verstreut.
///
/// **Theme-Pass:** Über `unlocked` lassen sich Variants als **gesperrt**
/// markieren (entsättigter Orb + Schloss). Tippen/Einrasten auf eine
/// gesperrte Variant wählt sie NICHT aus und öffnet auch **nichts
/// automatisch** — sie wird nur zentriert; darunter erscheint ein Hinweis
/// („Probier <App>"). Der **„<App> öffnen"-Button** zur eigenen Landingpage
/// der Signatur-App (`ManaTheme/signatureAppHomepage`, via
/// `@Environment(\.openURL)`) erscheint für **jedes** Signatur-Theme — bei
/// gesperrten zum Freischalten, bei freigeschalteten als dauerhafte Brücke
/// zur Herkunfts-App (die anderen mana-Apps bleiben erreichbar, die Herkunft
/// des Themes bleibt sichtbar). Baseline-Variants haben keine Signatur-App →
/// kein Button. `onLockedTap` ist ein **optionaler Analytics-Hook**, der beim
/// Button-Druck feuert (keine Navigation; die Galerie navigiert selbst).
/// Default: alles frei → keine Lock-UI, aber der App-Brücken-Button bleibt.
///
/// Pure View, bindet an eine app-gehaltene Auswahl (typisch `@AppStorage`).
/// Verein-Doktrin (`mana/docs/THEMING.md`): jede App bietet alle Variants an.
public struct ManaThemeGallery: View {
@Binding private var selection: ManaTheme
private let themes: [ManaTheme]
private let unlocked: Set<ManaTheme>
private let onLockedTap: ((ManaTheme) -> Void)?
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.openURL) private var openURL
@State private var centeredID: ManaTheme?
private let orbSize: CGFloat = 50
private let spacing: CGFloat = 22
/// - Parameters:
/// - selection: aktive (angewandte) Variant — immer freigeschaltet.
/// - themes: anzubietende Variants (Default: alle).
/// - unlocked: freigeschaltete Variants (Default: alle). Nicht
/// enthaltene werden gesperrt dargestellt und sind nicht wählbar.
/// - onLockedTap: optionaler Analytics-Hook, der feuert, wenn der User
/// den „<App> öffnen"-Button drückt (gesperrt **oder** freigeschaltet).
/// Die Navigation zur Landingpage übernimmt die Galerie selbst.
public init(
selection: Binding<ManaTheme>,
themes: [ManaTheme] = ManaTheme.allCases,
unlocked: Set<ManaTheme> = Set(ManaTheme.allCases),
onLockedTap: ((ManaTheme) -> Void)? = nil
) {
_selection = selection
self.themes = themes
self.unlocked = unlocked
self.onLockedTap = onLockedTap
}
private func isLocked(_ theme: ManaTheme) -> Bool {
!unlocked.contains(theme)
}
/// Anzeige-Reihenfolge: **freigeschaltete zuerst**, gesperrte danach —
/// jeweils in der übergebenen Original-Reihenfolge (stabile Partition).
/// So liegen die nutzbaren Themes zusammen am Anfang statt quer zwischen
/// Schlössern verstreut.
private var orderedThemes: [ManaTheme] {
themes.filter { !isLocked($0) } + themes.filter { isLocked($0) }
}
private var displayed: ManaTheme {
centeredID ?? selection
}
public var body: some View {
VStack(spacing: 14) {
GeometryReader { geo in
let side = max(0, geo.size.width / 2 - orbSize / 2)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: spacing) {
ForEach(orderedThemes, id: \.self) { theme in
ManaThemeOrb(
theme: theme,
size: orbSize,
isSelected: theme == selection,
isLocked: isLocked(theme)
)
.id(theme)
.contentShape(Circle())
.onTapGesture { choose(theme) }
.accessibilityElement()
.accessibilityLabel("\(theme.displayName) — \(theme.characterDescription)")
.accessibilityValue(isLocked(theme) ? "Gesperrt" : "")
.accessibilityAddTraits(theme == selection ? [.isButton, .isSelected] : .isButton)
}
}
.scrollTargetLayout()
.frame(maxHeight: .infinity)
}
.contentMargins(.horizontal, side, for: .scrollContent)
.scrollTargetBehavior(.viewAligned)
.scrollPosition(id: $centeredID, anchor: .center)
.onScrollPhaseChange { _, phase in
guard phase == .idle, let c = centeredID else { return }
// Gesperrt: nur zentriert lassen (Caption + Button zeigen),
// NICHT auswählen und nichts automatisch öffnen.
if isLocked(c) { return }
guard c != selection else { return }
if reduceMotion { selection = c }
else { withAnimation(.snappy(duration: 0.2)) { selection = c } }
feedback()
}
.onAppear { centeredID = selection }
.onChange(of: selection) { _, new in
guard centeredID != new else { return }
if reduceMotion { centeredID = new }
else { withAnimation(.snappy(duration: 0.35)) { centeredID = new } }
}
}
.frame(height: orbSize + 30)
VStack(spacing: 6) {
Text(displayed.displayName)
.font(.subheadline.weight(.semibold))
.foregroundStyle(isLocked(displayed) ? AnyShapeStyle(.secondary) : AnyShapeStyle(displayed.primary))
if isLocked(displayed) {
if let name = displayed.signatureAppName {
Text("Probier \(name) aus, um „\(displayed.displayName)“ freizuschalten")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Label("Durch Ausprobieren freischalten", systemImage: "lock.fill")
.font(.caption)
.foregroundStyle(.secondary)
}
} else {
Text(displayed.characterDescription)
.font(.caption)
.foregroundStyle(.secondary)
if let name = displayed.signatureAppName {
Text("Signatur-Theme von \(name)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
// App-Link bleibt in beiden Zuständen sichtbar: gesperrt führt er
// zum Freischalten, freigeschaltet als dauerhafte Brücke zur
// Herkunfts-App — so bleiben die anderen mana-Apps erreichbar und
// es bleibt klar, woher das Theme stammt. Baseline-Variants haben
// keine Signatur-App → kein Button.
if let name = displayed.signatureAppName, let url = displayed.signatureAppHomepage {
Button {
onLockedTap?(displayed)
openURL(url)
} label: {
Label("\(name) öffnen", systemImage: "arrow.up.right")
.font(.caption.weight(.semibold))
}
.buttonStyle(.bordered)
.tint(isLocked(displayed) ? displayed.primary : selection.primary)
.padding(.top, 2)
}
}
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
.animation(reduceMotion ? nil : .snappy(duration: 0.2), value: displayed)
}
.padding(.vertical, 8)
}
private func choose(_ theme: ManaTheme) {
if isLocked(theme) {
// Nur zentrieren → Caption blendet Hinweis + „<App> öffnen"-Button ein.
// Kein Auto-Open: der User entscheidet per Button.
if reduceMotion { centeredID = theme }
else { withAnimation(.snappy(duration: 0.3)) { centeredID = theme } }
return
}
guard theme != selection else { return }
if reduceMotion { selection = theme }
else { withAnimation(.snappy(duration: 0.3)) { selection = theme } }
feedback()
}
private func feedback() {
#if canImport(UIKit)
UISelectionFeedbackGenerator().selectionChanged()
#endif
}
}
/// Glänzender, voll gefüllter Orb in der Akzentfarbe der Variant. Ausgewählt:
/// Primary-Ring + volle Größe; unausgewählt kleiner + gedämpft. **Gesperrt:**
/// entsättigt (grayscale) + Schloss-Badge. Light/dark-adaptiv.
private struct ManaThemeOrb: View {
let theme: ManaTheme
let size: CGFloat
let isSelected: Bool
var isLocked: Bool = false
var body: some View {
Circle()
.fill(theme.primary)
.overlay(
LinearGradient(
colors: [.white.opacity(0.45), .white.opacity(0.06), .clear, .black.opacity(0.16)],
startPoint: .top,
endPoint: .bottom
)
.clipShape(Circle())
)
.overlay(
Circle()
.fill(.white.opacity(0.3))
.frame(width: size * 0.28, height: size * 0.28)
.blur(radius: size * 0.05)
.offset(x: -size * 0.18, y: -size * 0.18)
.clipShape(Circle())
)
.overlay(Circle().strokeBorder(theme.border.opacity(0.5), lineWidth: 0.75))
.overlay {
if isSelected, !isLocked {
Circle().stroke(theme.primary, lineWidth: 2.5).padding(-4)
}
}
.overlay {
if isLocked {
Image(systemName: "lock.fill")
.font(.system(size: size * 0.3, weight: .bold))
.foregroundStyle(.white)
.shadow(radius: 1)
}
}
.grayscale(isLocked ? 0.9 : 0)
.frame(width: size, height: size)
.scaleEffect(isSelected && !isLocked ? 1 : 0.78)
.opacity(isLocked ? 0.5 : (isSelected ? 1 : 0.6))
}
}
#if DEBUG
#Preview("Galerie — mit Lock") {
struct Demo: View {
@State private var theme: ManaTheme = .skylight
// Nur Baseline + skylight + bodensee frei; Rest gesperrt.
private let unlocked: Set<ManaTheme> = [.mana, .neutral, .monochrome, .forest, .skylight, .bodensee]
var body: some View {
Form {
Section("Thema") {
ManaThemeGallery(
selection: $theme,
unlocked: unlocked,
onLockedTap: { print("locked: \($0)") }
)
}
}
}
}
return Demo()
}
#endif