Lift von Hybrid (WKWebView für Lesen/Erkunden) auf fully-native ist beschlossen. Diese Phase entfernt die WebShell-Infrastruktur; das volle native Read-Surface folgt in η-2..η-5 nach docs/NATIVE_LIFT_PLAN.md. - ManaWebShell-Dep raus aus project.yml - Sources/Core/WebShell/CookieBridge.swift gelöscht - RootView auf vier native Tabs (Lesen + Erkunden = Platzhalter, Submit + Konto unverändert nativ) - DocComments in DeepLinkRouter / AppConfig / Account / Settings von WebView-Verweisen befreit - CLAUDE.md Invarianten von Hybrid auf η umgestellt (13 Invarianten, pure SwiftUI + Offline-first + SafariView-Ausnahme für Legal) - PLAN.md auf η-0 + Phasenübersicht η-0..η-10 - AppConfigTests.test_keychainService_matchesSharedGroup auf ManaSharedKeychainGroup aktualisiert (war drift seit Cross-App-SSO) Verifikation: - xcodebuild iOS-Simulator iPhone 16e: BUILD SUCCEEDED - nm ZitareNative | grep WKWebView: 0 Referenzen - otool -L: kein WebKit-Framework-Link - 20/20 Tests grün Cross-Repo-Follow-up (η-1 Blocker): - zitare/apps/zitare/ muss index-full.json + 7 Stammdaten-JSONs liefern - zitare/apps/api/ Volltext-Search-Endpoint bestätigen/ergänzen Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
233 lines
7.5 KiB
Swift
233 lines
7.5 KiB
Swift
import ManaAuthUI
|
|
import ManaCore
|
|
import SwiftUI
|
|
|
|
/// Phase ζ-0 minimal: zeigt Auth-Status und Healthz-Probe-Ergebnis.
|
|
/// Phase η-6 erweitert um native Submission-History (eigene
|
|
/// Submissions-Liste via `GET /api/v1/me/submissions`) + Link auf
|
|
/// Web-Konto-Verwaltung via SafariView. Login-Sheet schon hier, damit
|
|
/// Guests einen Anmelden-Button finden.
|
|
struct AccountView: View {
|
|
@Environment(AuthClient.self) private var auth
|
|
@Environment(ManaAuthGate.self) private var authGate
|
|
@Environment(SubmissionQueue.self) private var submissionQueue
|
|
let healthStatus: HealthStatus
|
|
|
|
@State private var pendingEntries: [PendingSubmission] = []
|
|
@State private var refreshTick: Int = 0
|
|
|
|
private var isSignedIn: Bool {
|
|
if case .signedIn = auth.status { return true }
|
|
return false
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 24) {
|
|
header
|
|
|
|
statusCard
|
|
|
|
authActionCard
|
|
|
|
if !pendingEntries.isEmpty {
|
|
pendingQueueCard
|
|
}
|
|
|
|
Spacer(minLength: 32)
|
|
|
|
aboutCard
|
|
}
|
|
.padding()
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(ZitareTheme.background)
|
|
.task {
|
|
reload()
|
|
}
|
|
.refreshable {
|
|
reload()
|
|
}
|
|
}
|
|
|
|
private var pendingQueueCard: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
Label("Offline-Warteschlange", systemImage: "tray.and.arrow.up")
|
|
.font(.headline)
|
|
Spacer()
|
|
Button {
|
|
Task {
|
|
let api = ZitareAPI(auth: auth)
|
|
_ = await submissionQueue.tryFlush(api: api)
|
|
reload()
|
|
}
|
|
} label: {
|
|
Label("Jetzt versuchen", systemImage: "arrow.clockwise")
|
|
.font(.callout)
|
|
}
|
|
.tint(ZitareTheme.primary)
|
|
}
|
|
ForEach(pendingEntries) { entry in
|
|
pendingRow(entry)
|
|
if entry.id != pendingEntries.last?.id {
|
|
Divider()
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
.background(ZitareTheme.surface)
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.stroke(ZitareTheme.border, lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
private func pendingRow(_ entry: PendingSubmission) -> some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(entry.text.prefix(120) + (entry.text.count > 120 ? "…" : ""))
|
|
.font(.callout)
|
|
.foregroundStyle(ZitareTheme.foreground)
|
|
HStack(spacing: 8) {
|
|
if let author = entry.authorName {
|
|
Text(author)
|
|
.font(.caption.weight(.medium))
|
|
}
|
|
Text(entry.createdAt, format: .dateTime.day().month().hour().minute())
|
|
.font(.caption)
|
|
if entry.retryCount > 0 {
|
|
Text("· \(entry.retryCount) Versuch(e)")
|
|
.font(.caption)
|
|
}
|
|
Spacer()
|
|
Button(role: .destructive) {
|
|
submissionQueue.delete(id: entry.id)
|
|
reload()
|
|
} label: {
|
|
Image(systemName: "trash")
|
|
.foregroundStyle(ZitareTheme.mutedForeground)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.foregroundStyle(ZitareTheme.mutedForeground)
|
|
if let err = entry.lastError {
|
|
Text(err)
|
|
.font(.caption)
|
|
.foregroundStyle(ZitareTheme.error)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func reload() {
|
|
pendingEntries = submissionQueue.loadAll()
|
|
refreshTick &+= 1
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var authActionCard: some View {
|
|
if isSignedIn {
|
|
Button(role: .destructive) {
|
|
Task { await auth.signOut(keepGuestMode: true) }
|
|
} label: {
|
|
Label("Abmelden", systemImage: "arrow.left.square")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.large)
|
|
} else {
|
|
Button {
|
|
authGate.isPresentingSignIn = true
|
|
} label: {
|
|
Label("Mit mana-Konto anmelden", systemImage: "arrow.right.square")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.large)
|
|
.tint(ZitareTheme.primary)
|
|
}
|
|
}
|
|
|
|
private var header: some View {
|
|
VStack(spacing: 12) {
|
|
// Eigenes Anführungszeichen-Glyph in der gleichen Variante
|
|
// wie das App-Icon — sienna auf transparentem Hintergrund,
|
|
// serif. SF-Symbol "quote.opening" rendert zwei Glyphen
|
|
// asymmetrisch nebeneinander, das wirkt unsauber.
|
|
Text(verbatim: "\u{201C}")
|
|
.font(.custom("Georgia-Bold", size: 96))
|
|
.foregroundStyle(ZitareTheme.primary)
|
|
.frame(height: 64, alignment: .top)
|
|
Text("Zitare")
|
|
.font(.largeTitle)
|
|
.fontWeight(.semibold)
|
|
Text("Öffentlicher Zitat-Korpus von mana e.V.")
|
|
.font(.callout)
|
|
.foregroundStyle(ZitareTheme.mutedForeground)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.padding(.top, 32)
|
|
}
|
|
|
|
private var statusCard: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
row("Auth", value: authStatusLabel)
|
|
Divider()
|
|
row("API", value: healthLabel)
|
|
}
|
|
.padding()
|
|
.background(ZitareTheme.surface)
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.stroke(ZitareTheme.border, lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
private var aboutCard: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Phase η-0 — De-Hybrid")
|
|
.font(.caption)
|
|
.foregroundStyle(ZitareTheme.mutedForeground)
|
|
Text(
|
|
"Diese App wird nativ ausgebaut. Web-App weiter live auf "
|
|
+ "zitare.com. Plan in zitare-native/docs/NATIVE_LIFT_PLAN.md."
|
|
)
|
|
.font(.footnote)
|
|
.foregroundStyle(ZitareTheme.foreground)
|
|
}
|
|
.padding()
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
private func row(_ label: String, value: String) -> some View {
|
|
HStack {
|
|
Text(label)
|
|
.foregroundStyle(ZitareTheme.mutedForeground)
|
|
Spacer()
|
|
Text(value)
|
|
.foregroundStyle(ZitareTheme.foreground)
|
|
.fontWeight(.medium)
|
|
}
|
|
}
|
|
|
|
private var authStatusLabel: String {
|
|
switch auth.status {
|
|
case .unknown: "—"
|
|
case .signedOut: "Nicht eingeloggt"
|
|
case .guest: "Gast"
|
|
case .signingIn: "Login läuft …"
|
|
case .twoFactorRequired: "2FA erforderlich"
|
|
case let .signedIn(email): email
|
|
case .error: "Fehler"
|
|
}
|
|
}
|
|
|
|
private var healthLabel: String {
|
|
switch healthStatus {
|
|
case .unknown: "—"
|
|
case .ok: "OK"
|
|
case .down: "nicht erreichbar"
|
|
}
|
|
}
|
|
}
|