zitare-native/Sources/Features/Submit/SubmitQuoteView.swift
Till JS 127c81b74c ζ-3: SubmitQuoteView nativ (Form + authGate + POST /quotes)
Native Submit-Flow gegen zitare-api. SwiftUI-Form mit:
- TextEditor mit 10-1000-Zeichen-Validation + Counter
- Sprache (de/en/fr/es/it Picker)
- Author-Name (mandatory)
- Optional Source-Section (Toggle): Titel + Art (book/article/talk/film/other) + Jahr
- CC-BY-SA-4.0-Zustimmung als Pflicht-Toggle
- Submit-Button erst aktiv wenn alle 3 Bedingungen erfüllt
- authGate.require(reason: "submit") öffnet Login-Sheet wenn nötig;
  Submit feuert auto nach signedIn
- Error-Banner mit lokalisiertem API-Code (api.error.<code> wird
  in xcstrings nachgeschlagen)
- Success-Banner mit Slug + "wartet auf Moderation"-Hinweis

Neu in Submit-Tab als 4. Tab (Lesen / Erkunden / Einreichen / Konto).

- ZitareAPI: submitQuote(_:), QuoteDraft, SubmittedQuote, ZitareAPIError
- SubmitQuoteView ersetzt Placeholder-Stub
- RootView: AppTab.submit ergänzt

Offen: Offline-Queue (PendingSubmission via SwiftData) — bei Network-
Failure bleibt der Draft im Form-State und User retried manuell.
Nicht in ζ-3 abgeschlossen, gehört in ζ-3.5.

Offen: api.error.*-Keys in zitare-native Localizable.xcstrings —
aktuell nur DE-Source. EN/FR/ES/IT folgen separat.

iOS + macOS BUILD SUCCEEDED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:49:51 +02:00

250 lines
8.3 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
@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?
@FocusState private var textFocused: Bool
var body: some View {
NavigationStack {
Form {
quoteSection
authorSection
sourceSection
licenseSection
if let lastError {
Section { errorBanner(lastError) }
}
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
}
}
// 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 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
} catch let error as LocalizedError {
lastError = error.errorDescription ?? "Fehler"
} catch {
lastError = String(describing: error)
}
}
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
)
}