- 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>
265 lines
12 KiB
Swift
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
|