mana-swift-ui/Sources/ManaOnboardingUI/ManaOnboardingView.swift
Till JS 6d1eae1589 ManaOnboardingUI: Zeilen-Karten, Swipe, Indikator unten, größere Icons
Feinschliff nach Geräte-Feedback:
- Zeilen jetzt in eingefasste .manaCard-Container.
- Zwischen Seiten wischbar (iOS Page-Style; macOS Cross-Fade).
- Seiten-Indikator wandert nach unten (über den Button).
- Icons größer (28pt) + kleinerer Icon-zu-Text-Abstand.

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

218 lines
7.2 KiB
Swift

import ManaTokens
import ManaThemeUI
import SwiftUI
/// Einstiegs-Flow im Stil von Apples „Neue Funktionen“-Sheet — der
/// geteilte Unterbau, damit Onboarding über alle nativen Apps **gleich
/// aussieht und sich gleich anfühlt**, aber im Signatur-Theme der App.
///
/// Aufbau pro Seite: großer Titel oben → optionaler Vorspann → eine Liste
/// von **eingefassten Zeilen-Karten** (Icon + Titel + Text) → optionale
/// Fußnote. Darunter persistent: der Seiten-Indikator und genau **ein**
/// Primär-Button. Zwischen den Seiten kann **gewischt** werden (iOS).
///
/// Wird üblicherweise nicht direkt verwendet, sondern über
/// ``SwiftUICore/View/manaOnboarding(storageKey:pages:onFinish:)``, das
/// als `.sheet` präsentiert und den „nur beim ersten Start“-Teil per
/// `@AppStorage` erledigt.
@MainActor
public struct ManaOnboardingView: View {
private let pages: [ManaOnboardingPage]
private let onFinish: () -> Void
@Environment(\.manaTheme) private var theme
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.dismiss) private var dismiss
@State private var index = 0
public init(pages: [ManaOnboardingPage], onFinish: @escaping () -> Void) {
self.pages = pages
self.onFinish = onFinish
}
private var isLast: Bool { index >= pages.count - 1 }
public var body: some View {
VStack(spacing: 0) {
pager
footer
}
.background(theme.background.ignoresSafeArea())
.onAppear { if pages.isEmpty { finish() } }
}
// MARK: - Seiten (wischbar auf iOS)
@ViewBuilder
private var pager: some View {
#if os(iOS)
TabView(selection: $index) {
ForEach(Array(pages.enumerated()), id: \.offset) { i, page in
pageScroll(page).tag(i)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.animation(reduceMotion ? nil : .easeInOut, value: index)
#else
// macOS hat keinen Page-Style — Button-getriebener Cross-Fade.
Group {
if pages.indices.contains(index) {
pageScroll(pages[index])
}
}
.id(index)
.transition(reduceMotion ? .identity : .opacity)
#endif
}
private func pageScroll(_ page: ManaOnboardingPage) -> some View {
ScrollView {
PageContent(page: page)
.padding(.horizontal, Spacing.lg)
.padding(.top, Spacing.xl)
.padding(.bottom, Spacing.md)
.frame(maxWidth: 540)
.frame(maxWidth: .infinity)
}
}
// MARK: - Fuß: Indikator (unten) + ein Button
private var footer: some View {
VStack(spacing: Spacing.md) {
if pages.count > 1 {
ProgressDots(count: pages.count, index: index, theme: theme)
}
Button {
advance()
} label: {
Text(isLast ? "Los geht's" : "Weiter")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 15)
.background(theme.primary, in: RoundedRectangle(cornerRadius: Radius.lg, style: .continuous))
.foregroundStyle(theme.primaryForeground)
}
.buttonStyle(.plain)
.frame(maxWidth: 540)
}
.padding(.horizontal, Spacing.lg)
.padding(.top, Spacing.md)
.padding(.bottom, Spacing.lg)
}
private func advance() {
if isLast { finish(); return }
if reduceMotion {
index += 1
} else {
withAnimation(.easeInOut(duration: 0.25)) { index += 1 }
}
}
private func finish() {
onFinish()
dismiss()
}
}
// MARK: - Seiteninhalt
/// Titel + Vorspann + Zeilen-Karten + optionale Fußnote einer Seite.
private struct PageContent: View {
let page: ManaOnboardingPage
@Environment(\.manaTheme) private var theme
var body: some View {
VStack(alignment: .leading, spacing: Spacing.lg) {
VStack(spacing: Spacing.sm) {
Text(page.title)
.font(.largeTitle.bold())
.multilineTextAlignment(.center)
.foregroundStyle(theme.foreground)
.frame(maxWidth: .infinity)
if let intro = page.intro {
Text(intro)
.font(.body)
.multilineTextAlignment(.center)
.foregroundStyle(theme.mutedForeground)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity)
}
}
.padding(.bottom, Spacing.xs)
VStack(spacing: Spacing.sm) {
ForEach(page.rows) { row in
RowView(row: row)
.manaCard(surface: theme.surface, border: theme.border, padding: Spacing.md)
}
}
if let note = page.footnote {
Text(note)
.font(.footnote)
.multilineTextAlignment(.center)
.foregroundStyle(theme.mutedForeground)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity)
.padding(.top, Spacing.xs)
}
}
}
}
/// Eine Zeile: brand-getöntes Icon (groß) in fester Spalte, dicht daneben
/// Titel + Text.
private struct RowView: View {
let row: ManaOnboardingPage.Row
@Environment(\.manaTheme) private var theme
var body: some View {
HStack(alignment: .top, spacing: Spacing.sm) {
Image(systemName: row.symbol)
.font(.system(size: 28))
.foregroundStyle(theme.primary)
.frame(width: 38, alignment: .center)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 3) {
Text(row.title)
.font(.headline)
.foregroundStyle(theme.foreground)
Text(row.text)
.font(.subheadline)
.foregroundStyle(theme.mutedForeground)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(row.title). \(row.text)")
}
}
// MARK: - Fortschritts-Punkte
/// Brand-getönte Punkte (statt der System-`PageTabViewStyle`-Dots, die es
/// auf macOS nicht gibt und die sich nicht thementen lassen).
private struct ProgressDots: View {
let count: Int
let index: Int
let theme: ManaTheme
var body: some View {
HStack(spacing: Spacing.xs) {
ForEach(0 ..< count, id: \.self) { i in
Capsule()
.fill(i == index ? theme.primary : theme.border)
.frame(width: i == index ? 20 : 7, height: 7)
}
}
.animation(.easeInOut(duration: 0.2), value: index)
.accessibilityLabel("Seite \(index + 1) von \(count)")
}
}