Pending-Queue-UI: - AccountView.pendingQueueCard listet alle wartenden Submissions mit Text-Preview (120c), Author, createdAt, retryCount, lastError - "Jetzt versuchen"-Button triggert tryFlush(api:) - Trash-Icon pro Row löscht einzeln aus der Queue - Pull-to-Refresh aktualisiert beide Listen - Card-View nur sichtbar wenn Queue-Tiefe > 0 ReachabilityWatcher: - NWPathMonitor erkennt Reconnect-Flanke (offline → online) - Bei Reconnect: SubmissionQueue.tryFlush auf @MainActor - Filtert reine Wifi↔Mobil-Switches raus (nur "wieder erreichbar" zählt) - Lebt im App-Root, startet nach Launch via .task Files: - Sources/Core/Submit/ReachabilityWatcher.swift (neu) - Sources/App/ZitareNativeApp.swift: reachability.start in .task - Sources/Features/Account/AccountView.swift: pendingQueueCard iOS + macOS BUILD SUCCEEDED. 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 ζ-3 erweitert um Submission-History-Link (via WebShell auf
|
|
/// `zitare.mana.how/me`). 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 — Setup")
|
|
.font(.caption)
|
|
.foregroundStyle(ZitareTheme.mutedForeground)
|
|
Text(
|
|
"Diese App ist noch im Aufbau. Web-App live auf "
|
|
+ "zitare.com und zitare.mana.how. "
|
|
+ "Plan in mana/docs/playbooks/ZITARE_NATIVE_GREENFIELD.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"
|
|
}
|
|
}
|
|
}
|