ζ-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:
parent
53f8043a2d
commit
c6127a2d31
2 changed files with 77 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue