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>
297 lines
9.4 KiB
Swift
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
|
|
}
|
|
}
|