Alle UI-Strings der Feature-/App-Schicht ziehen aus mana/i18n (Listen/Detail/Marker/Feed/Rückblick/Zusammenfassen/Settings/Account + Archiv/Bulk/Note-Sheet/Palette); Interpolation als generierte Funktionen. Verwaisten-Check grün (0 hartkodierte UI-Strings). Onboarding bleibt seine eigene SOT (mana/content/onboarding). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
249 lines
8.4 KiB
Swift
249 lines
8.4 KiB
Swift
import ManaAuthUI
|
|
import ManaCore
|
|
import ManaThemeUI
|
|
import ManaTokens
|
|
import SwiftUI
|
|
|
|
/// Top-Level-Container. Platform-specific:
|
|
/// - **iOS**: TabView am unteren Rand (iPhone-Standard, gut für Daumen).
|
|
/// - **macOS**: NavigationSplitView mit Sidebar + Detail (Desktop-
|
|
/// Standard, nutzt die größere Fläche).
|
|
struct RootView: View {
|
|
#if os(iOS)
|
|
@Environment(AuthClient.self) private var auth
|
|
|
|
/// Konto-Sheet (hinter dem Avatar oben rechts) — ersetzt den
|
|
/// früheren Konto-Tab. Apple-Muster: Konto ist selten benutzt und
|
|
/// gehört nicht in die Tab-Bar. Siehe
|
|
/// `../mana/docs/playbooks/ACCOUNT_AVATAR_MIGRATION.md`.
|
|
///
|
|
/// macOS bleibt unangetastet: das Konto liegt dort weiterhin in der
|
|
/// Settings-Scene (⌘,), nicht hinter einem Avatar.
|
|
@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?
|
|
// Theme + Modus auch hier, um sie am Konto-Sheet zu wiederholen: ein
|
|
// offenes Sheet erbt `.preferredColorScheme`/`.manaTheme` NICHT reaktiv vom
|
|
// App-Root, sonst griffe ein Hell/Dunkel-/Variant-Wechsel erst nach Reopen.
|
|
@Environment(\.manaTheme) private var theme
|
|
@AppStorage(ManaAppearanceMode.storageKey) private var appearanceRaw = ManaAppearanceMode.system
|
|
.rawValue
|
|
private var appearance: ManaAppearanceMode {
|
|
ManaAppearanceMode(rawValue: appearanceRaw) ?? .system
|
|
}
|
|
#endif
|
|
|
|
#if os(macOS)
|
|
@SceneStorage("pageta.sidebar.section") private var rawSection: String = SidebarSection.liste
|
|
.rawValue
|
|
|
|
/// Konto-Section ist auf macOS bewusst NICHT in der Sidebar —
|
|
/// Konto/Darstellung/Benachrichtigungen leben in der Settings-
|
|
/// Scene (⌘,), wie es macOS-Standard ist.
|
|
///
|
|
/// `.feed` ist seit 2026-06-01 NICHT mehr gelistet (siehe
|
|
/// SidebarSection.feed-Kommentar + ../pageta/docs/FEED.md).
|
|
private static let macSidebar: [SidebarSection] = [.liste, .markierungen, .rueckblick]
|
|
|
|
private var selection: Binding<SidebarSection> {
|
|
Binding(
|
|
get: {
|
|
let saved = SidebarSection(rawValue: rawSection) ?? .liste
|
|
return Self.macSidebar.contains(saved) ? saved : .liste
|
|
},
|
|
set: { rawSection = $0.rawValue }
|
|
)
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationSplitView {
|
|
List(Self.macSidebar, id: \.self, selection: selection) { section in
|
|
Label(section.label, systemImage: section.icon)
|
|
.tag(section)
|
|
}
|
|
.listStyle(.sidebar)
|
|
.navigationTitle("Pageta")
|
|
.navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 320)
|
|
} detail: {
|
|
detailView(for: selection.wrappedValue)
|
|
}
|
|
.navigationSplitViewStyle(.balanced)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func detailView(for section: SidebarSection) -> some View {
|
|
switch section {
|
|
case .liste: LeseListeTabView()
|
|
case .markierungen: MarkierungenTabView()
|
|
case .rueckblick: RueckblickTabView()
|
|
case .feed: FeedTabView()
|
|
case .konto: LeseListeTabView() // Fallback — Konto ist über ⌘, erreichbar.
|
|
case .search: LeseListeTabView() // macOS-Suche liegt in der Lese-Liste-Toolbar, nicht in
|
|
// der Sidebar.
|
|
}
|
|
}
|
|
#else
|
|
@SceneStorage("pageta.tab.section") private var rawSection: String = SidebarSection.liste
|
|
.rawValue
|
|
|
|
/// iOS-Tabs ohne `.feed` (dormant) und ohne `.konto` (liegt seit der
|
|
/// Avatar-Migration hinter dem Avatar oben rechts → Sheet). Ein in
|
|
/// SceneStorage persistierter `.feed`- oder `.konto`-Zustand (User
|
|
/// war zuletzt im alten Feed-/Konto-Tab) fällt auf `.liste` zurück —
|
|
/// sonst landet die App auf einem nicht mehr existierenden Tab.
|
|
private static let iosTabs: [SidebarSection] = [.liste, .markierungen, .rueckblick]
|
|
|
|
private var selection: Binding<SidebarSection> {
|
|
Binding(
|
|
get: {
|
|
let saved = SidebarSection(rawValue: rawSection) ?? .liste
|
|
return Self.iosTabs.contains(saved) ? saved : .liste
|
|
},
|
|
set: { rawSection = $0.rawValue }
|
|
)
|
|
}
|
|
|
|
// Tab-Reihenfolge: Lese-Liste links (= default-Tab beim Cold-
|
|
// Launch), Markierungen, Rückblick. Pageta ist primär eine
|
|
// Reader-App — die persönliche Liste ist die wichtigste Surface,
|
|
// also der erste Tab.
|
|
//
|
|
// Der Feed-Tab ist seit 2026-06-01 raus (Werte-Spannung zum
|
|
// Engagement-Feed-Verbot + commodity-Charakter). FeedTabView +
|
|
// PagetaAPI.fetchFeed bleiben dormant im Repo — Begründung und
|
|
// Revival-Bedingung in ../pageta/docs/FEED.md.
|
|
//
|
|
// Der Konto-Tab ist seit der Avatar-Migration raus: das Konto liegt
|
|
// hinter `ManaAccountAvatarButton` oben rechts (in jedem Tab via
|
|
// `.manaAccountToolbar`) und öffnet das Konto als Sheet. Siehe
|
|
// ../mana/docs/playbooks/ACCOUNT_AVATAR_MIGRATION.md.
|
|
var body: some View {
|
|
TabView(selection: selection) {
|
|
Tab(PagetaStrings.tabArticles, systemImage: "books.vertical", value: SidebarSection.liste) {
|
|
LeseListeTabView(
|
|
accountProfile: accountProfile,
|
|
onAccountTap: { showAccount = true }
|
|
)
|
|
}
|
|
Tab(PagetaStrings.tabHighlights, systemImage: "highlighter", value: SidebarSection.markierungen) {
|
|
MarkierungenTabView(
|
|
accountProfile: accountProfile,
|
|
onAccountTap: { showAccount = true }
|
|
)
|
|
}
|
|
Tab(
|
|
PagetaStrings.tabReflection,
|
|
systemImage: "clock.arrow.circlepath",
|
|
value: SidebarSection.rueckblick
|
|
) {
|
|
RueckblickTabView(
|
|
accountProfile: accountProfile,
|
|
onAccountTap: { showAccount = true }
|
|
)
|
|
}
|
|
// iOS-26-Such-Tab: lokale Artikel-Suche statt der bisherigen
|
|
// Inline-`.searchable` in der Lese-Liste.
|
|
Tab(
|
|
PagetaStrings.actionSearch,
|
|
systemImage: "magnifyingglass",
|
|
value: SidebarSection.search,
|
|
role: .search
|
|
) {
|
|
PagetaSearchView(
|
|
accountProfile: accountProfile,
|
|
onAccountTap: { showAccount = true }
|
|
)
|
|
}
|
|
}
|
|
.sheet(isPresented: $showAccount) {
|
|
NavigationStack {
|
|
AccountTabView()
|
|
.toolbar {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button(PagetaStrings.actionDone) { showAccount = false }
|
|
}
|
|
}
|
|
}
|
|
// Theme/Modus am Sheet-Inhalt wiederholen: ein offenes Sheet erbt
|
|
// `.preferredColorScheme`/`.manaTheme` NICHT reaktiv vom App-Root —
|
|
// sonst griffe ein Hell/Dunkel-/Variant-Wechsel erst nach Reopen.
|
|
.preferredColorScheme(appearance.colorScheme)
|
|
.manaTheme(theme)
|
|
.manaBrand(PagetaBrand.config(for: theme))
|
|
}
|
|
.task(id: signedInId) { await refreshAccountProfile() }
|
|
}
|
|
|
|
/// Identität für `task(id:)` — feuert `refreshAccountProfile` initial
|
|
/// und erneut bei jeder Sign-In/Sign-Out-Transition (der Avatar
|
|
/// braucht nach dem Login den Namen für die Initialen).
|
|
private var signedInId: String? {
|
|
if case let .signedIn(email) = auth.status { email } else { nil }
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
/// Top-Level-Surfaces der App. iOS rendert als TabView-Items,
|
|
/// macOS als Sidebar-Liste. `rawValue` ist persistent über
|
|
/// SceneStorage — User landet beim Re-Open im letzten Tab/Sidebar.
|
|
enum SidebarSection: String, CaseIterable, Identifiable, Hashable {
|
|
// Reihenfolge bestimmt sowohl macOS-Sidebar-Listing als auch
|
|
// impliziert die Tab-Reihenfolge auf iOS. `liste` ist bewusst
|
|
// zuerst — siehe Kommentar in RootView.body.
|
|
//
|
|
// `feed` ist seit 2026-06-01 dormant: der Case bleibt, damit
|
|
// FeedTabView weiter kompiliert und ein Wiedereinbau billig ist,
|
|
// wird aber weder von `iosTabs` noch von `macSidebar` gelistet.
|
|
// Siehe ../pageta/docs/FEED.md.
|
|
case liste
|
|
case markierungen
|
|
case rueckblick
|
|
case feed
|
|
case konto
|
|
/// iOS-26-Such-Tab (`Tab(role: .search)`) — lokale Artikel-Suche, auf
|
|
/// iOS 26 als unten schwebendes Liquid-Glass-Suchfeld. Nur iOS; macOS
|
|
/// hat die Suche in der Lese-Liste-Toolbar, nicht in der Sidebar.
|
|
case search
|
|
|
|
var id: String {
|
|
rawValue
|
|
}
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .liste: PagetaStrings.tabArticles
|
|
case .markierungen: PagetaStrings.tabHighlights
|
|
case .rueckblick: PagetaStrings.tabReflection
|
|
case .feed: PagetaStrings.tabFeed
|
|
case .konto: PagetaStrings.tabAccount
|
|
case .search: PagetaStrings.actionSearch
|
|
}
|
|
}
|
|
|
|
var icon: String {
|
|
switch self {
|
|
case .liste: "books.vertical"
|
|
case .markierungen: "highlighter"
|
|
case .rueckblick: "clock.arrow.circlepath"
|
|
case .feed: "newspaper"
|
|
case .konto: "person.crop.circle"
|
|
case .search: "magnifyingglass"
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
RootView()
|
|
}
|