zitare-native/Sources/Features/Submit/SubmitQuoteView.swift
Till JS c6127a2d31 ζ-3.6: Drop-Notification-Banner für Submission-Conflicts
Auto-Flush hat 4xx-Errors (duplicate, validation_failed, unauthorized)
bisher stillschweigend gedroppt — User offline einreichen, im Web
denselben Text posten, Online gehen → die Native-Submission war weg
ohne Hinweis.

SubmissionQueue:
- struct DropRecord (textPreview, authorName, code, message, droppedAt)
- private(set) var dropNotifications: [DropRecord]
- tryFlush sammelt jetzt einen Pre-Delete-Snapshot in dropNotifications
- consumeDropNotifications() leert die Liste — UI ruft beim
  Banner-Quittieren auf

SubmitQuoteView:
- droppedBanner zeigt alle gedroppten Drafts mit Text-Preview +
  lokalisierter Error-Message
- "Quittieren"-Button leert nur die UI-State (Server-Drop ist final)
- harvestDropNotifications() läuft nach jedem flushPending

iOS + macOS BUILD SUCCEEDED.

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

377 lines
13 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
@State private var droppedNotifications: [SubmissionQueue.DropRecord] = []
@FocusState private var textFocused: Bool
var body: some View {
NavigationStack {
Form {
quoteSection
authorSection
sourceSection
licenseSection
if !droppedNotifications.isEmpty {
Section { droppedBanner }
}
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 droppedBanner: some View {
VStack(alignment: .leading, spacing: 8) {
Label(
"\(droppedNotifications.count) Submission(s) wurden verworfen",
systemImage: "exclamationmark.octagon"
)
.foregroundStyle(ZitareTheme.error)
.font(.callout)
.fontWeight(.medium)
ForEach(droppedNotifications) { record in
VStack(alignment: .leading, spacing: 2) {
Text("\(record.textPreview)\"")
.font(.caption)
.foregroundStyle(ZitareTheme.foreground)
.lineLimit(2)
Text(record.message)
.font(.caption2)
.foregroundStyle(ZitareTheme.mutedForeground)
}
}
Button {
droppedNotifications = []
} label: {
Label("Quittieren", systemImage: "checkmark")
.font(.caption)
}
}
}
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 {
harvestDropNotifications()
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()
harvestDropNotifications()
}
private func refreshPendingCount() {
pendingCount = submissionQueue.queueDepth()
}
private func harvestDropNotifications() {
let drops = submissionQueue.consumeDropNotifications()
if !drops.isEmpty {
droppedNotifications.append(contentsOf: drops)
}
}
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
)
}