ζ-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>
This commit is contained in:
Till JS 2026-05-19 16:49:17 +02:00
parent 53f8043a2d
commit c6127a2d31
2 changed files with 77 additions and 1 deletions

View file

@ -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

View file

@ -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"