From c6127a2d3157f72fdc275520a27cc5ebf001bfb8 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 19 May 2026 16:49:17 +0200 Subject: [PATCH] =?UTF-8?q?=CE=B6-3.6:=20Drop-Notification-Banner=20f?= =?UTF-8?q?=C3=BCr=20Submission-Conflicts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Sources/Core/Submit/SubmissionQueue.swift | 32 +++++++++++++ Sources/Features/Submit/SubmitQuoteView.swift | 46 ++++++++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/Sources/Core/Submit/SubmissionQueue.swift b/Sources/Core/Submit/SubmissionQueue.swift index 7b498eb..d7ed8d5 100644 --- a/Sources/Core/Submit/SubmissionQueue.swift +++ b/Sources/Core/Submit/SubmissionQueue.swift @@ -21,10 +21,33 @@ final class SubmissionQueue { let container: ModelContainer private var inFlight: Bool = false + /// Sammelt Drop-Records vom letzten tryFlush — eine UI-Schicht kann + /// sie als "diese N Submissions wurden permanent verworfen"-Banner + /// anzeigen, statt dass der User stumm verliert. + struct DropRecord: Identifiable, Sendable { + let id = UUID() + let textPreview: String + let authorName: String? + let code: String? + let message: String + let droppedAt: Date + } + + /// Drop-Notifications der letzten Session — UI liest und konsumiert + /// via `consumeDropNotifications()`. + private(set) var dropNotifications: [DropRecord] = [] + init(container: ModelContainer) { self.container = container } + /// Leert die Liste — Caller hat sie gerade angezeigt. + func consumeDropNotifications() -> [DropRecord] { + let snapshot = dropNotifications + dropNotifications.removeAll() + return snapshot + } + /// Hängt einen Draft an die Queue. func deposit(_ draft: QuoteDraft, error: String? = nil) { let ctx = ModelContext(container) @@ -91,6 +114,15 @@ final class SubmissionQueue { } catch let error as ZitareAPIError where shouldDrop(error) { Log.app.warning("Dropping pending submission (permanent error: \(error.code ?? "?", privacy: .public))") entry.lastError = error.errorDescription + // Notify UI: dieser Draft wurde verworfen. Pre-Snapshot + // der Daten vor dem Delete machen. + dropNotifications.append(DropRecord( + textPreview: String(entry.text.prefix(120)), + authorName: entry.authorName, + code: error.code, + message: error.errorDescription ?? "Fehler", + droppedAt: Date() + )) ctx.delete(entry) } catch let error as LocalizedError { entry.lastError = error.errorDescription diff --git a/Sources/Features/Submit/SubmitQuoteView.swift b/Sources/Features/Submit/SubmitQuoteView.swift index 80d815e..fac2289 100644 --- a/Sources/Features/Submit/SubmitQuoteView.swift +++ b/Sources/Features/Submit/SubmitQuoteView.swift @@ -29,6 +29,7 @@ struct SubmitQuoteView: View { @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 { @@ -38,6 +39,9 @@ struct SubmitQuoteView: View { authorSection sourceSection licenseSection + if !droppedNotifications.isEmpty { + Section { droppedBanner } + } if pendingCount > 0 { Section { pendingBanner } } @@ -166,6 +170,35 @@ struct SubmitQuoteView: View { .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") @@ -294,19 +327,30 @@ struct SubmitQuoteView: View { @MainActor private func flushPending() async { - guard pendingCount > 0 else { return } + 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"