cards-native/Sources/Features/Editor/CardEditorView.swift
Till JS 80eb3708b4 v0.5.0 — Phase β-4 Media + Advanced Card-Types
Alle 7 Card-Types werden gerendert und können erstellt werden.
image-occlusion mit Touch-Drag-Mask-Editor (kein PencilKit — Server-
Schema erlaubt nur Rechtecke), audio-front mit AVAudioPlayer und
File-Picker.

- MediaUploadResponse-DTO, MaskRegion-Codable mit 0..1-Coordinates
- MaskRegions.parse/encode (1:1-Port aus cards-domain, Sortierung
  nach ID lexikographisch)
- CardFieldsBuilder.imageOcclusion mit stringified-JSON-mask_regions
  + audioFront
- CardsAPI.uploadMedia (Multipart, 25 MiB) + fetchMedia (streamed)
- MediaCache actor mit LRU 200 MB (contentModificationDate-Eviction)
- mediaCache Environment-Key
- RemoteImage + AudioPlayerButton SwiftUI-Views
- CardRenderer: imageOcclusion (Mask-Overlay über RemoteImage) +
  audioFront (AudioPlayerButton + back-Text auf Flip)
- MaskEditorView: Touch-Drag-Rechteck, Label-Edit, Delete
- CardEditorView erweitert: PhotosPicker für Image, fileImporter
  für Audio, Magic-Byte-MIME-Detection
- 6 neue Tests für MaskRegions (30 Total grün)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 00:35:36 +02:00

356 lines
13 KiB
Swift

import ManaCore
import PhotosUI
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
/// Card-Create-View. Type-Picker oben, type-spezifische Felder unten.
/// Deckt alle 7 Card-Types ab.
struct CardEditorView: View {
let deckId: String
let onCreated: (Card) -> Void
@Environment(AuthClient.self) private var auth
@Environment(\.dismiss) private var dismiss
@State private var type: CardType = .basic
@State private var front: String = ""
@State private var back: String = ""
@State private var clozeText: String = ""
@State private var typingAnswer: String = ""
@State private var multipleChoiceAnswer: String = ""
@State private var isSubmitting = false
@State private var errorMessage: String?
// Image-Occlusion-State
@State private var imagePickerItem: PhotosPickerItem?
@State private var occlusionImage: PlatformImage?
@State private var occlusionImageData: Data?
@State private var occlusionMimeType: String = "image/jpeg"
@State private var occlusionRegions: [MaskRegion] = []
@State private var occlusionNote: String = ""
// Audio-Front-State
@State private var audioFileURL: URL?
@State private var showAudioPicker = false
private static let supportedTypes: [CardType] = [
.basic, .basicReverse, .cloze, .typing, .multipleChoice,
.imageOcclusion, .audioFront,
]
var body: some View {
Form {
Section("Card-Type") {
Picker("Typ", selection: $type) {
ForEach(Self.supportedTypes, id: \.self) { t in
Text(label(for: t)).tag(t)
}
}
.pickerStyle(.menu)
}
typeFields
if let errorMessage {
Section {
Text(errorMessage)
.font(.footnote)
.foregroundStyle(CardsTheme.error)
}
}
}
.navigationTitle("Neue Karte")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Erstellen") { Task { await submit() } }
.disabled(!canSubmit || isSubmitting)
}
}
}
@ViewBuilder
private var typeFields: some View {
switch type {
case .basic, .basicReverse:
Section("Vorderseite") {
TextField("Front", text: $front, axis: .vertical)
.lineLimit(2 ... 6)
}
Section("Rückseite") {
TextField("Back", text: $back, axis: .vertical)
.lineLimit(2 ... 6)
}
if type == .basicReverse {
Section {
Text("Beide Richtungen werden gelernt — front→back und back→front.")
.font(.caption)
.foregroundStyle(CardsTheme.mutedForeground)
}
}
case .cloze:
Section("Cloze-Text") {
TextField("Beispiel: Die Hauptstadt von {{c1::Frankreich}} ist {{c2::Paris}}.",
text: $clozeText, axis: .vertical)
.lineLimit(3 ... 8)
.autocorrectionDisabled()
.textInputAutocapitalization(.sentences)
.monospaced()
}
Section {
let count = Cloze.subIndexCount(clozeText)
if count > 0 {
Label("\(count) Lücken erkannt → \(count) Reviews", systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(CardsTheme.success)
} else {
Label("Mindestens ein Cluster `{{c1::...}}` erforderlich", systemImage: "exclamationmark.circle")
.font(.caption)
.foregroundStyle(CardsTheme.warning)
}
Text("Mit Hint: `{{c1::Berlin::Hauptstadt von DE}}`")
.font(.caption2)
.foregroundStyle(CardsTheme.mutedForeground)
}
case .typing:
Section("Frage") {
TextField("Front", text: $front, axis: .vertical)
.lineLimit(2 ... 4)
}
Section("Erwartete Antwort") {
TextField("Answer", text: $typingAnswer)
}
case .multipleChoice:
Section("Frage") {
TextField("Front", text: $front, axis: .vertical)
.lineLimit(2 ... 4)
}
Section("Richtige Antwort") {
TextField("Answer", text: $multipleChoiceAnswer)
}
Section {
Text("Distractor-Optionen werden zur Lernzeit automatisch aus anderen Karten desselben Decks gezogen.")
.font(.caption)
.foregroundStyle(CardsTheme.mutedForeground)
}
case .imageOcclusion:
imageOcclusionFields
case .audioFront:
audioFrontFields
}
}
@ViewBuilder
private var imageOcclusionFields: some View {
Section("Bild") {
PhotosPicker(selection: $imagePickerItem, matching: .images) {
if occlusionImage == nil {
Label("Bild auswählen", systemImage: "photo")
} else {
Label("Bild ersetzen", systemImage: "arrow.triangle.2.circlepath")
}
}
.onChange(of: imagePickerItem) { _, newItem in
Task { await loadPickedImage(newItem) }
}
}
if let image = occlusionImage {
Section("Masken") {
MaskEditorView(image: image, regions: $occlusionRegions)
}
}
Section("Hinweis (optional)") {
TextField("z.B. Kurz-Erklärung", text: $occlusionNote, axis: .vertical)
.lineLimit(1 ... 3)
}
Section {
if occlusionImage == nil {
Label("Erst Bild wählen", systemImage: "info.circle")
.font(.caption)
.foregroundStyle(CardsTheme.mutedForeground)
} else if occlusionRegions.isEmpty {
Label("Mindestens eine Maske nötig", systemImage: "exclamationmark.circle")
.font(.caption)
.foregroundStyle(CardsTheme.warning)
} else {
Label("\(occlusionRegions.count) Masken → \(occlusionRegions.count) Reviews",
systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(CardsTheme.success)
}
}
}
@ViewBuilder
private var audioFrontFields: some View {
Section("Audio-Datei") {
Button {
showAudioPicker = true
} label: {
if let audioFileURL {
Label(audioFileURL.lastPathComponent, systemImage: "waveform")
} else {
Label("Audio auswählen", systemImage: "waveform.badge.plus")
}
}
.fileImporter(
isPresented: $showAudioPicker,
allowedContentTypes: [.audio, .mp3, .wav, .mpeg4Audio],
allowsMultipleSelection: false
) { result in
if case let .success(urls) = result, let first = urls.first {
audioFileURL = first
}
}
}
Section("Antwort") {
TextField("Was zu hören ist", text: $back, axis: .vertical)
.lineLimit(2 ... 4)
}
}
private func loadPickedImage(_ item: PhotosPickerItem?) async {
guard let item else { return }
do {
guard let data = try await item.loadTransferable(type: Data.self) else { return }
occlusionImageData = data
occlusionMimeType = inferMimeType(from: data)
if let img = PlatformImage(data: data) {
occlusionImage = img
occlusionRegions = [] // neue Bildauswahl resetet Masken
}
} catch {
errorMessage = "Bild konnte nicht geladen werden: \(error.localizedDescription)"
}
}
private func inferMimeType(from data: Data) -> String {
// Schneller Magic-Byte-Check für die häufigsten Formate
guard data.count > 4 else { return "image/jpeg" }
let bytes = Array(data.prefix(8))
if bytes.starts(with: [0xFF, 0xD8, 0xFF]) { return "image/jpeg" }
if bytes.starts(with: [0x89, 0x50, 0x4E, 0x47]) { return "image/png" }
if bytes.starts(with: [0x47, 0x49, 0x46, 0x38]) { return "image/gif" }
// WebP: starts with "RIFF" + 4 bytes size + "WEBP"
if bytes.count >= 8,
bytes[0 ... 3] == [0x52, 0x49, 0x46, 0x46] {
return "image/webp"
}
return "image/jpeg"
}
private func audioMimeType(for url: URL) -> String {
switch url.pathExtension.lowercased() {
case "mp3": "audio/mpeg"
case "wav": "audio/wav"
case "m4a", "mp4": "audio/mp4"
case "ogg", "oga": "audio/ogg"
default: "audio/mpeg"
}
}
private var canSubmit: Bool {
switch type {
case .basic, .basicReverse:
!front.trimmed.isEmpty && !back.trimmed.isEmpty
case .cloze:
Cloze.subIndexCount(clozeText) > 0
case .typing:
!front.trimmed.isEmpty && !typingAnswer.trimmed.isEmpty
case .multipleChoice:
!front.trimmed.isEmpty && !multipleChoiceAnswer.trimmed.isEmpty
case .imageOcclusion:
occlusionImageData != nil && !occlusionRegions.isEmpty
case .audioFront:
audioFileURL != nil && !back.trimmed.isEmpty
}
}
private func submit() async {
isSubmitting = true
errorMessage = nil
defer { isSubmitting = false }
let api = CardsAPI(auth: auth)
do {
let fields: [String: String]
var mediaRefs: [String]? = nil
switch type {
case .basic, .basicReverse:
fields = CardFieldsBuilder.basic(front: front.trimmed, back: back.trimmed)
case .cloze:
fields = CardFieldsBuilder.cloze(text: clozeText.trimmed)
case .typing:
fields = CardFieldsBuilder.typing(front: front.trimmed, answer: typingAnswer.trimmed)
case .multipleChoice:
fields = CardFieldsBuilder.multipleChoice(front: front.trimmed, answer: multipleChoiceAnswer.trimmed)
case .imageOcclusion:
guard let data = occlusionImageData else { return }
let media = try await api.uploadMedia(
data: data,
filename: "occlusion.\(occlusionMimeType.contains("png") ? "png" : "jpg")",
mimeType: occlusionMimeType
)
fields = CardFieldsBuilder.imageOcclusion(
imageRef: media.id,
regions: occlusionRegions,
note: occlusionNote.trimmed.isEmpty ? nil : occlusionNote.trimmed
)
mediaRefs = [media.id]
case .audioFront:
guard let url = audioFileURL else { return }
let didStart = url.startAccessingSecurityScopedResource()
defer { if didStart { url.stopAccessingSecurityScopedResource() } }
let data = try Data(contentsOf: url)
let media = try await api.uploadMedia(
data: data,
filename: url.lastPathComponent,
mimeType: audioMimeType(for: url)
)
fields = CardFieldsBuilder.audioFront(audioRef: media.id, back: back.trimmed)
mediaRefs = [media.id]
}
let body = CardCreateBody(deckId: deckId, type: type, fields: fields, mediaRefs: mediaRefs)
let card = try await api.createCard(body)
onCreated(card)
dismiss()
} catch {
errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
}
}
private func label(for type: CardType) -> String {
switch type {
case .basic: "Einfach (Vorder/Rück)"
case .basicReverse: "Beidseitig"
case .cloze: "Lückentext"
case .typing: "Eintippen"
case .multipleChoice: "Multiple Choice"
case .imageOcclusion: "Bild-Verdeckung"
case .audioFront: "Audio"
}
}
}
private extension String {
var trimmed: String {
trimmingCharacters(in: .whitespacesAndNewlines)
}
}