zitare-native/Sources/Core/Submit/PendingSubmissionModel.swift
Till JS 61927d27a3 ζ-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>
2026-05-19 16:22:52 +02:00

84 lines
2.9 KiB
Swift

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])
}
}