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>
218 lines
7.2 KiB
Swift
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)")
|
|
}
|
|
}
|