zitare-native/Sources/Features/Submit/SubmitQuoteView.swift
Till JS 61927d27a3 ζ-3.5: Offline-Submit-Queue mit SwiftData + Auto-Retry
Bei Network-Failure landet der Quote-Draft jetzt in einer persistenten
SwiftData-Queue (\`PendingSubmission\`) statt im Error-Banner. Beim
nächsten App-Launch ODER beim Wechsel auf scenePhase.active wird der
Flush automatisch versucht.

Retry-Policy:
- 5xx oder Transport-Failure (NSURLErrorDomain) → in Queue, Retry
- 4xx mit code (validation_failed, duplicate, unauthorized) →
  permanenter Fehler, kein Retry (User-Aktion nötig)
- Hard-Limit 50 Retries pro Entry, danach pausiert

App-Group-Store \`submissions.store\` (parallel zu snapshot.store) im
\`group.ev.mana.zitare\`-Container. Fallback auf In-Memory falls
Disk-Init scheitert (App-Group noch nicht aktiviert im Apple-Dev-Portal).

UI-Pieces:
- Pending-Banner zeigt Queue-Tiefe wenn > 0
- Queued-Banner nach erfolgreichem Enqueue
- Form-Reset nach Enqueue (User sieht: "weg, kommt nach")
- onChange(scenePhase) → Auto-Flush bei Foreground
- ZitareNativeApp.task: Flush am Launch

Files:
- Sources/Core/Submit/PendingSubmissionModel.swift (neu, @Model)
- Sources/Core/Submit/SubmissionQueue.swift (neu, @Observable @MainActor)
- Sources/App/ZitareNativeApp.swift: Container-Init + environment-Wiring
- Sources/Features/Submit/SubmitQuoteView.swift: enqueue + flush + banners

iOS + macOS BUILD SUCCEEDED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:22:52 +02:00

333 lines
12 KiB
Swift

import ManaAuthUI
import ManaCore
import SwiftUI
/// SwiftUI-Form für Quote-Submission. Sendet via `POST /api/v1/quotes`
/// gegen zitare-api; landet als `status='draft'` zur Moderation.
///
/// **Flow:**
/// 1. User füllt Text + Author + (optional) Source + Lizenz-Zustimmung
/// 2. Tap "Einreichen" `authGate.require(reason: "submit")`
/// 3. Bei `.guest`/`.signedOut` öffnet ManaLoginView. Nach Login feuert
/// die merged-Action automatisch.
/// 4. Erfolg: Toast + Form-Reset; bei Network-Failure Fehler-Banner mit
/// lokalisierter API-Error-Message.
///
/// **Offline-Queue (ζ-3-extended):** noch nicht implementiert bei
/// Network-Failure bleibt der Draft im Form-State, User kann manuell
/// retry. PendingSubmission/SwiftData-Queue kommt mit ζ-3.5.
struct SubmitQuoteView: View {
@Environment(AuthClient.self) private var auth
@Environment(ManaAuthGate.self) private var authGate
@Environment(SubmissionQueue.self) private var submissionQueue
@Environment(\.scenePhase) private var scenePhase
@State private var draft = QuoteDraft.empty
@State private var includesSource = false
@State private var submitting = false
@State private var lastError: String?
@State private var lastSuccessSlug: String?
@State private var queuedForRetry: Bool = false
@State private var pendingCount: Int = 0
@FocusState private var textFocused: Bool
var body: some View {
NavigationStack {
Form {
quoteSection
authorSection
sourceSection
licenseSection
if pendingCount > 0 {
Section { pendingBanner }
}
if let lastError {
Section { errorBanner(lastError) }
}
if queuedForRetry {
Section { queuedBanner }
}
if let lastSuccessSlug {
Section { successBanner(slug: lastSuccessSlug) }
}
Section {
Button {
triggerSubmit()
} label: {
if submitting {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Label("Einreichen", systemImage: "paperplane.fill")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.tint(ZitareTheme.primary)
.disabled(!canSubmit || submitting)
}
}
.scrollContentBackground(.hidden)
.background(ZitareTheme.background)
.navigationTitle("Quote vorschlagen")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.task {
refreshPendingCount()
}
.onChange(of: scenePhase) { _, phase in
if phase == .active {
Task { await flushPending() }
}
}
}
}
// MARK: - Sections
private var quoteSection: some View {
Section("Zitat") {
TextEditor(text: $draft.text)
.font(.body)
.frame(minHeight: 120)
.focused($textFocused)
HStack {
Text("\(draft.text.count)/1000")
.font(.caption)
.foregroundStyle(textCountColor)
Spacer()
Picker("Sprache", selection: $draft.language) {
ForEach(supportedLanguages, id: \.self) { code in
Text(code.uppercased()).tag(code)
}
}
.pickerStyle(.menu)
}
}
}
private var authorSection: some View {
Section("Wer hat das gesagt?") {
TextField("Name (z.B. Mark Twain)", text: Binding(
get: { draft.authorName ?? "" },
set: { draft.authorName = $0.isEmpty ? nil : $0 }
))
#if os(iOS)
.textInputAutocapitalization(.words)
#endif
}
}
@ViewBuilder
private var sourceSection: some View {
Section {
Toggle("Quelle angeben (optional)", isOn: $includesSource)
if includesSource {
TextField("Titel (Werk, Vortrag, …)", text: Binding(
get: { draft.sourceTitle ?? "" },
set: { draft.sourceTitle = $0.isEmpty ? nil : $0 }
))
Picker("Art", selection: Binding(
get: { draft.sourceKind ?? .book },
set: { draft.sourceKind = $0 }
)) {
ForEach(QuoteDraft.SourceKind.allCases) { kind in
Text(label(for: kind)).tag(kind)
}
}
TextField("Jahr (z.B. 1885)", value: $draft.sourceYear, format: .number.grouping(.never))
#if os(iOS)
.keyboardType(.numberPad)
#endif
}
}
}
private var licenseSection: some View {
Section {
Toggle(isOn: $draft.acceptedTos) {
VStack(alignment: .leading, spacing: 4) {
Text("CC-BY-SA-4.0 zustimmen")
.fontWeight(.medium)
Text("Mit Einreichen veröffentlichst du das Zitat unter CC-BY-SA-4.0. Andere dürfen es teilen und remixen, solange sie dich nennen und das Ergebnis ebenfalls frei teilen.")
.font(.caption)
.foregroundStyle(ZitareTheme.mutedForeground)
}
}
} header: {
Text("Lizenz")
}
}
private func errorBanner(_ message: String) -> some View {
Label(message, systemImage: "exclamationmark.triangle")
.foregroundStyle(ZitareTheme.error)
.font(.callout)
}
private var pendingBanner: some View {
VStack(alignment: .leading, spacing: 4) {
Label("\(pendingCount) Submission(s) in der Warteschlange", systemImage: "tray.and.arrow.up")
.foregroundStyle(ZitareTheme.mutedForeground)
.font(.callout)
Text("Beim nächsten Verbindungsaufbau werden sie automatisch nachgereicht.")
.font(.caption)
.foregroundStyle(ZitareTheme.mutedForeground)
}
}
private var queuedBanner: some View {
VStack(alignment: .leading, spacing: 4) {
Label("Offline gespeichert — wird automatisch nachgereicht", systemImage: "tray.and.arrow.up.fill")
.foregroundStyle(ZitareTheme.primary)
.font(.callout)
.fontWeight(.medium)
Text("Sobald wieder Netzwerk verfügbar ist, fliegt der Draft an die Moderation. Du musst nichts mehr tun.")
.font(.caption)
.foregroundStyle(ZitareTheme.mutedForeground)
}
}
private func successBanner(slug: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Label("Eingereicht — wartet auf Moderation", systemImage: "checkmark.seal.fill")
.foregroundStyle(ZitareTheme.success)
.font(.callout)
.fontWeight(.medium)
Text("Slug: \(slug)")
.font(.caption.monospaced())
.foregroundStyle(ZitareTheme.mutedForeground)
}
}
// MARK: - State
private var trimmedText: String {
draft.text.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var hasAuthor: Bool {
let name = draft.authorName?.trimmingCharacters(in: .whitespaces) ?? ""
let slug = draft.authorSlug?.trimmingCharacters(in: .whitespaces) ?? ""
return !name.isEmpty || !slug.isEmpty
}
private var canSubmit: Bool {
trimmedText.count >= 10
&& trimmedText.count <= 1000
&& hasAuthor
&& draft.acceptedTos
}
private var textCountColor: Color {
let n = draft.text.count
if n < 10 { return ZitareTheme.mutedForeground }
if n > 1000 { return ZitareTheme.error }
return ZitareTheme.foreground
}
private let supportedLanguages = ["de", "en", "fr", "es", "it"]
// MARK: - Submit
private func triggerSubmit() {
textFocused = false
lastError = nil
lastSuccessSlug = nil
authGate.require(reason: "submit") {
Task { await self.performSubmit() }
}
}
@MainActor
private func performSubmit() async {
submitting = true
defer { submitting = false }
var payload = draft
payload.text = trimmedText
if !includesSource {
payload.sourceTitle = nil
payload.sourceKind = nil
payload.sourceYear = nil
}
let api = ZitareAPI(auth: auth)
do {
let result = try await api.submitQuote(payload)
Log.app.info("Submit erfolgreich: slug=\(result.slug, privacy: .public)")
lastSuccessSlug = result.slug
draft = .empty
includesSource = false
queuedForRetry = false
} catch let apiError as ZitareAPIError {
// 4xx mit Code (validation_failed, duplicate, unauthorized)
// permanenter Fehler, kein Retry. 5xx oder kein Code Queue.
if apiError.status >= 500 || apiError.code == nil {
enqueue(payload, error: apiError.errorDescription)
} else {
lastError = apiError.errorDescription ?? "Fehler"
}
} catch {
// URLError / Transport-Failure ebenfalls in die Queue.
if isTransportError(error) {
enqueue(payload, error: String(describing: error))
} else {
lastError = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
}
}
}
private func enqueue(_ payload: QuoteDraft, error: String?) {
submissionQueue.deposit(payload, error: error)
queuedForRetry = true
lastError = nil
draft = .empty
includesSource = false
refreshPendingCount()
Log.app.info("Submit fehlgeschlagen — in Queue gelegt (\(self.pendingCount) wartend)")
}
private func isTransportError(_ error: Error) -> Bool {
let ns = error as NSError
return ns.domain == NSURLErrorDomain
}
@MainActor
private func flushPending() async {
guard pendingCount > 0 else { return }
let api = ZitareAPI(auth: auth)
let sent = await submissionQueue.tryFlush(api: api)
if sent > 0 {
Log.app.info("Foreground-Flush: \(sent) Submission(s) nachgereicht")
}
refreshPendingCount()
}
private func refreshPendingCount() {
pendingCount = submissionQueue.queueDepth()
}
private func label(for kind: QuoteDraft.SourceKind) -> LocalizedStringKey {
switch kind {
case .book: "Buch"
case .article: "Artikel"
case .talk: "Vortrag"
case .film: "Film"
case .other: "Anderes"
}
}
}
extension QuoteDraft {
static let empty = QuoteDraft(
text: "",
language: "de",
authorName: nil,
authorSlug: nil,
sourceTitle: nil,
sourceKind: nil,
sourceYear: nil,
editReason: nil,
acceptedTos: false
)
}