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>
250 lines
8.3 KiB
Swift
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
|
|
)
|
|
}
|