moodlit-native/Sources/App/RootView.swift
till 50202ed34d feat: geteilten ManaOnboardingUI-Einstiegsflow einbauen
Zwei Seiten (Werte, dann Features) im geteilten Apple-Stil-Onboarding, themed via manaTheme. Keine Berechtigungs-Abfrage; Konto nur als Fussnote. Einmalig beim ersten Start via @AppStorage-Gate.

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

297 lines
9.4 KiB
Swift

import CoreSpotlight
import ManaAuthUI
import ManaCore
import ManaOnboardingUI
import SwiftUI
/// Die zwei Top-Level-Tabs. Konto (Profil + Einstellungen) ist kein
/// Tab mehr es liegt hinter dem Avatar oben rechts (Sheet), siehe
/// `RootView` + `mana/docs/playbooks/ACCOUNT_AVATAR_MIGRATION.md`.
enum AppTab: Hashable {
case moods
case sequences
}
/// Top-Level-View. iOS: 3-Tab-TabView; macOS: NavigationSplitView mit
/// Sidebar. AuthGate + Brand-Theme + Reset-Password-Universal-Link
/// laufen über alle Plattformen.
struct RootView: View {
@Environment(AuthClient.self) private var auth
@Environment(ManaAuthGate.self) private var authGate
@Environment(MoodStore.self) private var store
@Environment(PresenceClient.self) private var presence
@Environment(\.scenePhase) private var scenePhase
@State private var selectedTab: AppTab = .moods
@State private var resetPasswordToken: String?
@State private var deepLinkMoodId: String?
/// Konto-Sheet (hinter dem Avatar oben rechts) ersetzt den früheren
/// Profil- **und** Einstellungen-Tab. Apple-Muster: Konto + App-
/// Settings sind selten und gehören nicht in die Tab-Bar. Siehe
/// `mana/docs/playbooks/ACCOUNT_AVATAR_MIGRATION.md`.
@State private var showAccount = false
/// Profil für den Avatar (treibt die Initialen). Lazy via
/// `getProfile()` geladen `AuthClient.Status` trägt nur die E-Mail.
@State private var accountProfile: ProfileInfo?
private let resetUniversalLink = URL(string: "https://moodlit.mana.how/auth/reset")!
var body: some View {
platformBody
.manaOnboarding(
storageKey: MoodlitOnboarding.storageKey,
pages: MoodlitOnboarding.pages
)
.onOpenURL { url in handle(url: url) }
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
if let url = activity.webpageURL { handle(url: url) }
}
#if os(iOS)
.onContinueUserActivity(CSSearchableItemActionType) { activity in
// Spotlight liefert die `uniqueIdentifier` aus dem
// CSSearchableItem zurück (Format: `mood:<id>` oder
// `sequence:<id>`).
guard let unique = activity.userInfo?[CSSearchableItemActivityIdentifier] as? String
else { return }
if unique.hasPrefix("mood:") {
deepLinkMoodId = String(unique.dropFirst("mood:".count))
selectedTab = .moods
} else if unique.hasPrefix("sequence:") {
// Sequence-Spotlight in den Sequenzen-Tab springen.
// Direkter Play kommt mit μ-7.8 (Sequence-Auto-Play-
// Parameter durch RootView ist ein weiterer State).
selectedTab = .sequences
}
}
#endif
.manaBrand(MoodlitBrand.manaBrand)
.manaAuthGate(authGate) {
gateSignInContent
}
.sheet(isPresented: $showAccount) {
accountSheet
}
.sheet(item: Binding(
get: { resetPasswordToken.map(IdentifiedString.init) },
set: { resetPasswordToken = $0?.value }
)) { token in
ManaResetPasswordView(
token: token.value,
auth: auth,
onDone: { resetPasswordToken = nil }
)
.manaBrand(MoodlitBrand.manaBrand)
}
.fullScreenCoverIfAvailable(item: Binding<Mood?>(
get: { deepLinkMoodId.flatMap { store.moodById($0) ?? DefaultMoods.byId($0) } },
set: { _ in deepLinkMoodId = nil }
)) { mood in
MoodPlayerView(
mood: mood,
isFavorite: store.isFavorite(mood.id),
brightness: store.playerBrightness,
speedMultiplier: store.playerSpeedMultiplier,
onClose: { deepLinkMoodId = nil },
onFavoriteToggle: {
Task { await store.toggleFavorite(moodId: mood.id) }
}
)
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
Task {
await store.loadAll()
if case .signedIn = auth.status {
await presence.connect()
}
}
} else if newPhase == .background {
presence.disconnect()
}
}
.onChange(of: presence.remote?.payload.moodId) { _, newMoodId in
// Auto-Switch im Deep-Link-Player (z.B. via Widget oder
// Universal-Link gestartet). Symmetrisch zur MoodListView-
// Logik wenn ein anderes Gerät umschaltet, folgt dieses
// auch hier mit.
guard let currentId = deepLinkMoodId,
let newMoodId,
currentId != newMoodId,
store.moodById(newMoodId) != nil || DefaultMoods.byId(newMoodId) != nil
else { return }
deepLinkMoodId = newMoodId
}
.onChange(of: authStatusKey(auth.status)) { _, newKey in
// 0 = signedIn, 1 = signedOut, 2 = guest. Beim Wechsel
// auf signedOut Spotlight-Index für moodlit-Daten leeren
// kein Eintrag soll auf das abgemeldete Konto verweisen.
// + Presence-Session beenden, damit andere Geräte den
// Logout sehen.
if newKey == 1 {
store.clearSpotlightIndex()
Task {
await presence.endSession()
presence.disconnect()
}
} else if newKey == 0 {
Task { await presence.connect() }
}
// Avatar-Initialen bei jedem Auth-Wechsel nachziehen
// (Login füllt sie, Logout leert sie wieder).
Task { await refreshAccountProfile() }
}
.task {
// Presets sofort indizieren, damit Spotlight sie auch
// ohne Login findet. Custom-Moods + Sequenzen folgen
// in `loadAll`.
store.refreshSpotlightIndex()
await store.loadAll()
await refreshAccountProfile()
if case .signedIn = auth.status {
await presence.connect()
}
}
}
/// Lädt das Profil für den Avatar (Initialen), wenn signed-in; sonst
/// nil. Fehler werden geschluckt der Avatar fällt dann auf den
/// Glyph zurück.
private func refreshAccountProfile() async {
if case .signedIn = auth.status {
accountProfile = try? await auth.getProfile()
} else {
accountProfile = nil
}
}
/// Konto-Sheet hinter dem Avatar: ``ProfileView`` (Identität/Login)
/// als Wurzel, die Einstellungen sind darin per `NavigationLink`
/// erreichbar (Apple-Muster: Settings liegen beim Konto). Eigener
/// `NavigationStack` hier, weil ProfileView keinen mehr mitbringt.
private var accountSheet: some View {
NavigationStack {
ProfileView()
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Fertig") { showAccount = false }
}
}
}
}
/// Kompakter Discriminator für `auth.status` ohne assoziierte
/// Werte, damit `onChange` Equatable-kompatibel feuert.
/// 0 = signedIn · 1 = signedOut · 2 = guest · 3 = transient
/// (unknown/signingIn/2FA/error).
private func authStatusKey(_ status: AuthClient.Status) -> Int {
switch status {
case .signedIn: return 0
case .signedOut: return 1
case .guest: return 2
default: return 3
}
}
@ViewBuilder
private var platformBody: some View {
#if os(iOS)
TabView(selection: $selectedTab) {
NavigationStack {
MoodListView()
.manaAccountToolbar(auth: auth, profile: accountProfile) { showAccount = true }
}
.tabItem { Label("Moods", systemImage: "sparkles") }
.tag(AppTab.moods)
NavigationStack {
SequenceListView()
.manaAccountToolbar(auth: auth, profile: accountProfile) { showAccount = true }
}
.tabItem { Label("Sequenzen", systemImage: "list.triangle") }
.tag(AppTab.sequences)
}
#else
NavigationSplitView {
List(selection: $selectedTab) {
Label("Moods", systemImage: "sparkles").tag(AppTab.moods)
Label("Sequenzen", systemImage: "list.triangle").tag(AppTab.sequences)
}
.navigationTitle("Moodlit")
} detail: {
NavigationStack {
switch selectedTab {
case .moods: MoodListView()
case .sequences: SequenceListView()
}
}
// macOS versteckt die Nav-Bar nicht der Avatar sitzt für
// ALLE Tabs in der Detail-Toolbar.
.manaAccountToolbar(auth: auth, profile: accountProfile) { showAccount = true }
}
#endif
}
@ViewBuilder
private var gateSignInContent: some View {
NavigationStack {
ManaLoginView(
auth: auth,
onSignUpTapped: {},
onForgotTapped: {}
)
.manaBrand(MoodlitBrand.manaBrand)
}
}
private func handle(url: URL) {
// Reset-Password über Universal-Link.
if url.host == resetUniversalLink.host && url.path.hasPrefix("/auth/reset") {
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
if let token = components?.queryItems?.first(where: { $0.name == "token" })?.value {
resetPasswordToken = token
}
return
}
// Widget-Deep-Link: `moodlit://play/<id>` (Custom-Scheme,
// `url.host == "play"`, `pathComponents == ["/", "<id>"]`)
// oder `https://moodlit.mana.how/play/<id>` (Universal-Link,
// AASA, `pathComponents == ["/", "play", "<id>"]`).
if url.scheme == "moodlit", url.host == "play" {
let id = url.pathComponents.filter { $0 != "/" }.first
if let id, !id.isEmpty {
deepLinkMoodId = id
selectedTab = .moods
}
return
}
if url.host == "moodlit.mana.how" {
let segments = url.pathComponents.filter { $0 != "/" }
if segments.count >= 2, segments[0] == "play" {
deepLinkMoodId = segments[1]
selectedTab = .moods
}
}
}
}
/// Wrapper-Type damit ein `String?` als `Identifiable`-Sheet-Item dient.
struct IdentifiedString: Identifiable {
let value: String
var id: String { value }
init(_ value: String) { self.value = value }
}
private extension View {
/// macOS hat kein `fullScreenCover` auf macOS via `sheet`.
@ViewBuilder
func fullScreenCoverIfAvailable<Item: Identifiable, Content: View>(
item: Binding<Item?>,
@ViewBuilder content: @escaping (Item) -> Content
) -> some View {
#if os(iOS)
self.fullScreenCover(item: item, content: content)
#else
self.sheet(item: item, content: content)
#endif
}
}