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>
377 lines
13 KiB
Swift
377 lines
13 KiB
Swift
import ManaAuthUI
|
|
import ManaCore
|
|
import SwiftUI
|
|
|
|
/// SwiftUI-Form für Quote-Submission. Sendet via `POST /api/v1/quotes`
|
|
/// gegen zitare-api; landet als `status='draft'` zur Moderation.
|
|
///
|
|
/// **Flow:**
|
|
/// 1. User füllt Text + Author + (optional) Source + Lizenz-Zustimmung
|
|
/// 2. Tap "Einreichen" → `authGate.require(reason: "submit")`
|
|
/// 3. Bei `.guest`/`.signedOut` öffnet ManaLoginView. Nach Login feuert
|
|
/// die merged-Action automatisch.
|
|
/// 4. Erfolg: Toast + Form-Reset; bei Network-Failure Fehler-Banner mit
|
|
/// lokalisierter API-Error-Message.
|
|
///
|
|
/// **Offline-Queue (ζ-3-extended):** noch nicht implementiert — bei
|
|
/// Network-Failure bleibt der Draft im Form-State, User kann manuell
|
|
/// retry. PendingSubmission/SwiftData-Queue kommt mit ζ-3.5.
|
|
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
|
|
@State private var droppedNotifications: [SubmissionQueue.DropRecord] = []
|
|
@FocusState private var textFocused: Bool
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
quoteSection
|
|
authorSection
|
|
sourceSection
|
|
licenseSection
|
|
if !droppedNotifications.isEmpty {
|
|
Section { droppedBanner }
|
|
}
|
|
if pendingCount > 0 {
|
|
Section { pendingBanner }
|
|
}
|
|
if let lastError {
|
|
Section { errorBanner(lastError) }
|
|
}
|
|
if queuedForRetry {
|
|
Section { queuedBanner }
|
|
}
|
|
if let lastSuccessSlug {
|
|
Section { successBanner(slug: lastSuccessSlug) }
|
|
}
|
|
Section {
|
|
Button {
|
|
triggerSubmit()
|
|
} label: {
|
|
if submitting {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity)
|
|
} else {
|
|
Label("Einreichen", systemImage: "paperplane.fill")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(ZitareTheme.primary)
|
|
.disabled(!canSubmit || submitting)
|
|
}
|
|
}
|
|
.scrollContentBackground(.hidden)
|
|
.background(ZitareTheme.background)
|
|
.navigationTitle("Quote vorschlagen")
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
.task {
|
|
refreshPendingCount()
|
|
}
|
|
.onChange(of: scenePhase) { _, phase in
|
|
if phase == .active {
|
|
Task { await flushPending() }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Sections
|
|
|
|
private var quoteSection: some View {
|
|
Section("Zitat") {
|
|
TextEditor(text: $draft.text)
|
|
.font(.body)
|
|
.frame(minHeight: 120)
|
|
.focused($textFocused)
|
|
HStack {
|
|
Text("\(draft.text.count)/1000")
|
|
.font(.caption)
|
|
.foregroundStyle(textCountColor)
|
|
Spacer()
|
|
Picker("Sprache", selection: $draft.language) {
|
|
ForEach(supportedLanguages, id: \.self) { code in
|
|
Text(code.uppercased()).tag(code)
|
|
}
|
|
}
|
|
.pickerStyle(.menu)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var authorSection: some View {
|
|
Section("Wer hat das gesagt?") {
|
|
TextField("Name (z.B. Mark Twain)", text: Binding(
|
|
get: { draft.authorName ?? "" },
|
|
set: { draft.authorName = $0.isEmpty ? nil : $0 }
|
|
))
|
|
#if os(iOS)
|
|
.textInputAutocapitalization(.words)
|
|
#endif
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var sourceSection: some View {
|
|
Section {
|
|
Toggle("Quelle angeben (optional)", isOn: $includesSource)
|
|
if includesSource {
|
|
TextField("Titel (Werk, Vortrag, …)", text: Binding(
|
|
get: { draft.sourceTitle ?? "" },
|
|
set: { draft.sourceTitle = $0.isEmpty ? nil : $0 }
|
|
))
|
|
Picker("Art", selection: Binding(
|
|
get: { draft.sourceKind ?? .book },
|
|
set: { draft.sourceKind = $0 }
|
|
)) {
|
|
ForEach(QuoteDraft.SourceKind.allCases) { kind in
|
|
Text(label(for: kind)).tag(kind)
|
|
}
|
|
}
|
|
TextField("Jahr (z.B. 1885)", value: $draft.sourceYear, format: .number.grouping(.never))
|
|
#if os(iOS)
|
|
.keyboardType(.numberPad)
|
|
#endif
|
|
}
|
|
}
|
|
}
|
|
|
|
private var licenseSection: some View {
|
|
Section {
|
|
Toggle(isOn: $draft.acceptedTos) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("CC-BY-SA-4.0 zustimmen")
|
|
.fontWeight(.medium)
|
|
Text("Mit Einreichen veröffentlichst du das Zitat unter CC-BY-SA-4.0. Andere dürfen es teilen und remixen, solange sie dich nennen und das Ergebnis ebenfalls frei teilen.")
|
|
.font(.caption)
|
|
.foregroundStyle(ZitareTheme.mutedForeground)
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Lizenz")
|
|
}
|
|
}
|
|
|
|
private func errorBanner(_ message: String) -> some View {
|
|
Label(message, systemImage: "exclamationmark.triangle")
|
|
.foregroundStyle(ZitareTheme.error)
|
|
.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")
|
|
.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")
|
|
.foregroundStyle(ZitareTheme.success)
|
|
.font(.callout)
|
|
.fontWeight(.medium)
|
|
Text("Slug: \(slug)")
|
|
.font(.caption.monospaced())
|
|
.foregroundStyle(ZitareTheme.mutedForeground)
|
|
}
|
|
}
|
|
|
|
// MARK: - State
|
|
|
|
private var trimmedText: String {
|
|
draft.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
private var hasAuthor: Bool {
|
|
let name = draft.authorName?.trimmingCharacters(in: .whitespaces) ?? ""
|
|
let slug = draft.authorSlug?.trimmingCharacters(in: .whitespaces) ?? ""
|
|
return !name.isEmpty || !slug.isEmpty
|
|
}
|
|
|
|
private var canSubmit: Bool {
|
|
trimmedText.count >= 10
|
|
&& trimmedText.count <= 1000
|
|
&& hasAuthor
|
|
&& draft.acceptedTos
|
|
}
|
|
|
|
private var textCountColor: Color {
|
|
let n = draft.text.count
|
|
if n < 10 { return ZitareTheme.mutedForeground }
|
|
if n > 1000 { return ZitareTheme.error }
|
|
return ZitareTheme.foreground
|
|
}
|
|
|
|
private let supportedLanguages = ["de", "en", "fr", "es", "it"]
|
|
|
|
// MARK: - Submit
|
|
|
|
private func triggerSubmit() {
|
|
textFocused = false
|
|
lastError = nil
|
|
lastSuccessSlug = nil
|
|
authGate.require(reason: "submit") {
|
|
Task { await self.performSubmit() }
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func performSubmit() async {
|
|
submitting = true
|
|
defer { submitting = false }
|
|
var payload = draft
|
|
payload.text = trimmedText
|
|
if !includesSource {
|
|
payload.sourceTitle = nil
|
|
payload.sourceKind = nil
|
|
payload.sourceYear = nil
|
|
}
|
|
let api = ZitareAPI(auth: auth)
|
|
do {
|
|
let result = try await api.submitQuote(payload)
|
|
Log.app.info("Submit erfolgreich: slug=\(result.slug, privacy: .public)")
|
|
lastSuccessSlug = result.slug
|
|
draft = .empty
|
|
includesSource = false
|
|
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 {
|
|
// 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 {
|
|
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"
|
|
case .article: "Artikel"
|
|
case .talk: "Vortrag"
|
|
case .film: "Film"
|
|
case .other: "Anderes"
|
|
}
|
|
}
|
|
}
|
|
|
|
extension QuoteDraft {
|
|
static let empty = QuoteDraft(
|
|
text: "",
|
|
language: "de",
|
|
authorName: nil,
|
|
authorSlug: nil,
|
|
sourceTitle: nil,
|
|
sourceKind: nil,
|
|
sourceYear: nil,
|
|
editReason: nil,
|
|
acceptedTos: false
|
|
)
|
|
}
|