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
172 lines
6.1 KiB
Swift
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)")
|
|
}
|
|
}
|