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 @FocusState private var textFocused: Bool var body: some View { NavigationStack { Form { quoteSection 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) } } 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 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 { 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" 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 ) }