mana-swift-ui/Sources/ManaOnboardingUI/ManaOnboardingView.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

172 lines
6.1 KiB
Swift

import ManaTokens
import ManaThemeUI
import SwiftUI
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
/// 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.
///
/// **Eine einzige, scrollbare Seite mit genau einem Titel** (Design
/// 2026-06-09): ganz oben das App-Icon neben **einem** Titel (dem der
/// ersten `ManaOnboardingPage`), darunter ausschließlich die
/// **Info-Karten** — alle Zeilen aller übergebenen Seiten flach
/// untereinander, ohne Zwischen-Titel. Kein Wischen, keine
/// Seiten-Indikatoren, kein freier Text. Unten genau **ein**
/// Primär-Button.
///
/// 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(\.dismiss) private var dismiss
public init(pages: [ManaOnboardingPage], onFinish: @escaping () -> Void) {
self.pages = pages
self.onFinish = onFinish
}
/// Der eine Titel ganz oben (Titel der ersten Seite).
private var title: String { pages.first?.title ?? "" }
/// Alle Karten aller Seiten, flach — es gibt nur noch eine Seite.
private var rows: [ManaOnboardingPage.Row] { pages.flatMap(\.rows) }
public var body: some View {
VStack(spacing: 0) {
scroller
footer
}
.background(theme.background.ignoresSafeArea())
.onAppear { if pages.isEmpty { finish() } }
}
// MARK: - Inhalt (ein Titel + Karten, scrollbar)
private var scroller: some View {
ScrollView {
VStack(alignment: .leading, spacing: Spacing.lg) {
HStack(alignment: .center, spacing: Spacing.md) {
if let appIcon = Self.bundleAppIcon() {
appIcon
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 40, height: 40)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.accessibilityHidden(true)
}
Text(title)
.font(.title2.bold())
.foregroundStyle(theme.foreground)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
.padding(.bottom, Spacing.xs)
VStack(spacing: Spacing.sm) {
ForEach(rows) { row in
RowView(row: row)
.manaCard(surface: theme.surface, border: theme.border, padding: Spacing.md)
}
}
}
.padding(.horizontal, Spacing.lg)
.padding(.top, Spacing.xxl)
.padding(.bottom, Spacing.md)
.frame(maxWidth: 540)
.frame(maxWidth: .infinity)
}
}
/// Lädt das App-Icon aus dem Bundle der konsumierenden App, damit der
/// geteilte Flow ohne Zutun jeder App das richtige Icon zeigt.
static func bundleAppIcon() -> Image? {
#if canImport(UIKit)
// Bevorzugt den im Info.plist registrierten Primär-Icon-Namen;
// fällt auf den Asset-Catalog-Namen „AppIcon“ zurück (viele
// Apps liefern ihr Icon nur über den Asset-Catalog).
if let icons = Bundle.main.infoDictionary?["CFBundleIcons"] as? [String: Any],
let primary = icons["CFBundlePrimaryIcon"] as? [String: Any],
let files = primary["CFBundleIconFiles"] as? [String],
let name = files.last,
let image = UIImage(named: name) {
return Image(uiImage: image)
}
if let image = UIImage(named: "AppIcon") {
return Image(uiImage: image)
}
return nil
#elseif canImport(AppKit)
return Image(nsImage: NSApplication.shared.applicationIconImage)
#else
return nil
#endif
}
// MARK: - Fuß: genau ein Button
private var footer: some View {
Button {
finish()
} label: {
Text("Los geht's")
.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 finish() {
onFinish()
dismiss()
}
}
// MARK: - Karte
/// Eine Info-Karte: brand-getöntes Icon 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) {
row.bild
.font(.system(size: 24))
.foregroundStyle(theme.primary)
.frame(width: 34, 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)")
}
}