ζ-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>
This commit is contained in:
parent
ef8364f05d
commit
61927d27a3
4 changed files with 308 additions and 3 deletions
|
|
@ -8,6 +8,7 @@ import WidgetKit
|
|||
struct ZitareNativeApp: App {
|
||||
@State private var auth: AuthClient
|
||||
@State private var authGate: ManaAuthGate
|
||||
@State private var submissionQueue: SubmissionQueue
|
||||
private let snapshotContainer: ModelContainer?
|
||||
|
||||
init() {
|
||||
|
|
@ -23,6 +24,18 @@ struct ZitareNativeApp: App {
|
|||
)
|
||||
snapshotContainer = nil
|
||||
}
|
||||
let pending: ModelContainer
|
||||
do {
|
||||
pending = try PendingSubmissionContainer.make()
|
||||
} catch {
|
||||
Log.app.error(
|
||||
"PendingSubmissionContainer-Disk init fehlgeschlagen, falle auf in-memory zurück: \(String(describing: error), privacy: .public)"
|
||||
)
|
||||
// In-memory-Fallback statt nil. Submissions sind dann nur
|
||||
// für die aktuelle Session persistiert — besser als Crash.
|
||||
pending = try! PendingSubmissionContainer.make(inMemory: true)
|
||||
}
|
||||
_submissionQueue = State(initialValue: SubmissionQueue(container: pending))
|
||||
Log.app.info(
|
||||
"Zitare starting — auth status: \(String(describing: auth.status), privacy: .public)"
|
||||
)
|
||||
|
|
@ -33,13 +46,23 @@ struct ZitareNativeApp: App {
|
|||
RootView()
|
||||
.environment(auth)
|
||||
.environment(authGate)
|
||||
.environment(submissionQueue)
|
||||
.tint(ZitareTheme.primary)
|
||||
.task {
|
||||
await refreshSnapshot()
|
||||
await flushPending()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func flushPending() async {
|
||||
let api = ZitareAPI(auth: auth)
|
||||
let sent = await submissionQueue.tryFlush(api: api)
|
||||
if sent > 0 {
|
||||
Log.app.info("Auto-flushed \(sent) pending submission(s) at launch")
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshSnapshot() async {
|
||||
guard let container = snapshotContainer else { return }
|
||||
let sync = SnapshotSync(container: container)
|
||||
|
|
|
|||
84
Sources/Core/Submit/PendingSubmissionModel.swift
Normal file
84
Sources/Core/Submit/PendingSubmissionModel.swift
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Pending Quote-Submission, die wegen Network-Fehler nicht durchging.
|
||||
/// Wird beim nächsten Foreground/Reconnect via `SubmissionQueue.tryFlush`
|
||||
/// nachgereicht.
|
||||
///
|
||||
/// Eigener Store (`submissions.store`) im App-Group-Container, damit
|
||||
/// Snapshot-Sync und Pending-Submissions sich nicht in den Quere kommen.
|
||||
@Model
|
||||
final class PendingSubmission {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var createdAt: Date
|
||||
var lastTriedAt: Date?
|
||||
var retryCount: Int
|
||||
var lastError: String?
|
||||
|
||||
// QuoteDraft-Felder, flach gespeichert für SwiftData-Kompatibilität.
|
||||
var text: String
|
||||
var language: String
|
||||
var authorName: String?
|
||||
var authorSlug: String?
|
||||
var sourceTitle: String?
|
||||
var sourceKindRaw: String?
|
||||
var sourceYear: Int?
|
||||
var editReason: String?
|
||||
var acceptedTos: Bool
|
||||
|
||||
init(draft: QuoteDraft, id: UUID = UUID(), createdAt: Date = Date()) {
|
||||
self.id = id
|
||||
self.createdAt = createdAt
|
||||
self.retryCount = 0
|
||||
self.text = draft.text
|
||||
self.language = draft.language
|
||||
self.authorName = draft.authorName
|
||||
self.authorSlug = draft.authorSlug
|
||||
self.sourceTitle = draft.sourceTitle
|
||||
self.sourceKindRaw = draft.sourceKind?.rawValue
|
||||
self.sourceYear = draft.sourceYear
|
||||
self.editReason = draft.editReason
|
||||
self.acceptedTos = draft.acceptedTos
|
||||
}
|
||||
|
||||
func toDraft() -> QuoteDraft {
|
||||
QuoteDraft(
|
||||
text: text,
|
||||
language: language,
|
||||
authorName: authorName,
|
||||
authorSlug: authorSlug,
|
||||
sourceTitle: sourceTitle,
|
||||
sourceKind: sourceKindRaw.flatMap(QuoteDraft.SourceKind.init(rawValue:)),
|
||||
sourceYear: sourceYear,
|
||||
editReason: editReason,
|
||||
acceptedTos: acceptedTos
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper für den ModelContainer. Eigener Store, damit das
|
||||
/// Snapshot-Schema nicht mit-migriert wird, wenn wir Submission-
|
||||
/// Felder ändern.
|
||||
enum PendingSubmissionContainer {
|
||||
static let appGroup = "group.ev.mana.zitare"
|
||||
|
||||
static func defaultStoreURL() -> URL {
|
||||
let fm = FileManager.default
|
||||
if let groupURL = fm.containerURL(forSecurityApplicationGroupIdentifier: appGroup) {
|
||||
return groupURL.appendingPathComponent("submissions.store")
|
||||
}
|
||||
let docs = fm.urls(for: .documentDirectory, in: .userDomainMask).first
|
||||
?? URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
return docs.appendingPathComponent("submissions.store")
|
||||
}
|
||||
|
||||
static func make(inMemory: Bool = false) throws -> ModelContainer {
|
||||
let schema = Schema([PendingSubmission.self])
|
||||
let config: ModelConfiguration = if inMemory {
|
||||
ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
} else {
|
||||
ModelConfiguration("submissions", schema: schema, url: defaultStoreURL())
|
||||
}
|
||||
return try ModelContainer(for: schema, configurations: [config])
|
||||
}
|
||||
}
|
||||
115
Sources/Core/Submit/SubmissionQueue.swift
Normal file
115
Sources/Core/Submit/SubmissionQueue.swift
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import Foundation
|
||||
import ManaCore
|
||||
import SwiftData
|
||||
|
||||
/// Verwaltet die `PendingSubmission`-Queue. Hauptverantwortung: Drafts
|
||||
/// persistieren bei Network-Failure, beim Foreground/Reconnect via
|
||||
/// `tryFlush(api:)` einen Retry-Run starten.
|
||||
///
|
||||
/// **Retry-Strategie:** alle wartenden Drafts werden sequentiell gesendet.
|
||||
/// Bei `400/409/401` (Server-Code) wird der Eintrag **gelöscht** — das
|
||||
/// sind permanente Fehler (validation_failed, duplicate, unauthorized),
|
||||
/// die ein Retry nicht rettet. Bei Network-/5xx-Fehler bleibt der Draft
|
||||
/// in der Queue mit `lastError` für Diagnose; `retryCount++`.
|
||||
///
|
||||
/// **Hartes Limit:** 50 Retries pro Eintrag, danach wird er als
|
||||
/// "permanent failed" markiert (`retryCount > 50`) und nicht mehr
|
||||
/// versucht — User muss manuell löschen oder neu eintragen.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class SubmissionQueue {
|
||||
let container: ModelContainer
|
||||
private var inFlight: Bool = false
|
||||
|
||||
init(container: ModelContainer) {
|
||||
self.container = container
|
||||
}
|
||||
|
||||
/// Hängt einen Draft an die Queue.
|
||||
func deposit(_ draft: QuoteDraft, error: String? = nil) {
|
||||
let ctx = ModelContext(container)
|
||||
let entry = PendingSubmission(draft: draft)
|
||||
entry.lastError = error
|
||||
ctx.insert(entry)
|
||||
try? ctx.save()
|
||||
Log.app.info("Submission deposited (queue depth: \(self.queueDepth()))")
|
||||
}
|
||||
|
||||
/// Anzahl der wartenden Submissions.
|
||||
func queueDepth() -> Int {
|
||||
let ctx = ModelContext(container)
|
||||
let descriptor = FetchDescriptor<PendingSubmission>()
|
||||
return (try? ctx.fetch(descriptor).count) ?? 0
|
||||
}
|
||||
|
||||
/// Holt alle pending Drafts (für UI).
|
||||
func loadAll() -> [PendingSubmission] {
|
||||
let ctx = ModelContext(container)
|
||||
let descriptor = FetchDescriptor<PendingSubmission>(
|
||||
sortBy: [SortDescriptor(\.createdAt)]
|
||||
)
|
||||
return (try? ctx.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
/// Löscht eine Submission manuell aus der Queue.
|
||||
func delete(id: UUID) {
|
||||
let ctx = ModelContext(container)
|
||||
let descriptor = FetchDescriptor<PendingSubmission>(
|
||||
predicate: #Predicate { $0.id == id }
|
||||
)
|
||||
if let row = try? ctx.fetch(descriptor).first {
|
||||
ctx.delete(row)
|
||||
try? ctx.save()
|
||||
}
|
||||
}
|
||||
|
||||
/// Versucht alle wartenden Submissions zu senden. Returns count of
|
||||
/// erfolgreich gesendeter Items.
|
||||
@discardableResult
|
||||
func tryFlush(api: ZitareAPI) async -> Int {
|
||||
guard !inFlight else { return 0 }
|
||||
inFlight = true
|
||||
defer { inFlight = false }
|
||||
|
||||
let ctx = ModelContext(container)
|
||||
let descriptor = FetchDescriptor<PendingSubmission>(
|
||||
predicate: #Predicate { $0.retryCount <= 50 },
|
||||
sortBy: [SortDescriptor(\.createdAt)]
|
||||
)
|
||||
guard let entries = try? ctx.fetch(descriptor), !entries.isEmpty else {
|
||||
return 0
|
||||
}
|
||||
|
||||
Log.app.info("Flushing \(entries.count) pending submission(s)")
|
||||
var sent = 0
|
||||
for entry in entries {
|
||||
let draft = entry.toDraft()
|
||||
do {
|
||||
_ = try await api.submitQuote(draft)
|
||||
ctx.delete(entry)
|
||||
sent += 1
|
||||
} catch let error as ZitareAPIError where shouldDrop(error) {
|
||||
Log.app.warning("Dropping pending submission (permanent error: \(error.code ?? "?", privacy: .public))")
|
||||
entry.lastError = error.errorDescription
|
||||
ctx.delete(entry)
|
||||
} catch let error as LocalizedError {
|
||||
entry.lastError = error.errorDescription
|
||||
entry.lastTriedAt = Date()
|
||||
entry.retryCount += 1
|
||||
} catch {
|
||||
entry.lastError = String(describing: error)
|
||||
entry.lastTriedAt = Date()
|
||||
entry.retryCount += 1
|
||||
}
|
||||
}
|
||||
try? ctx.save()
|
||||
Log.app.info("Flush done — sent \(sent)/\(entries.count)")
|
||||
return sent
|
||||
}
|
||||
|
||||
/// 4xx-Errors sind permanent (validation, auth, duplicate) → Drop.
|
||||
/// 5xx und Transport-Failure → Retry.
|
||||
private func shouldDrop(_ error: ZitareAPIError) -> Bool {
|
||||
error.status >= 400 && error.status < 500
|
||||
}
|
||||
}
|
||||
|
|
@ -19,12 +19,16 @@ import SwiftUI
|
|||
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 {
|
||||
|
|
@ -34,9 +38,15 @@ struct SubmitQuoteView: View {
|
|||
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) }
|
||||
}
|
||||
|
|
@ -63,6 +73,14 @@ struct SubmitQuoteView: View {
|
|||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.task {
|
||||
refreshPendingCount()
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
if phase == .active {
|
||||
Task { await flushPending() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -148,6 +166,29 @@ struct SubmitQuoteView: View {
|
|||
.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")
|
||||
|
|
@ -217,13 +258,55 @@ struct SubmitQuoteView: View {
|
|||
lastSuccessSlug = result.slug
|
||||
draft = .empty
|
||||
includesSource = false
|
||||
} catch let error as LocalizedError {
|
||||
lastError = error.errorDescription ?? "Fehler"
|
||||
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 {
|
||||
lastError = String(describing: error)
|
||||
// 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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue