ImagePickerLabel als private View-Struct extrahiert. SwiftUIs
PhotosPicker(label:)-Closure ist @Sendable, aber View-Konstruktor-
Calls werden zur Build-Zeit MainActor-isoliert evaluiert — im
Gegensatz zu direktem @State-Zugriff im Closure-Body.
Vorher: pickerLabel als computed property → Warning blieb.
Jetzt: ImagePickerLabel(hasImage: occlusionImage != nil) →
Warning weg, Swift-Build clean.
Archive grün, Build grün, keine Swift-Warnings mehr (nur eine
AppIntents-Framework-Hinweis-Note ohne Auswirkung).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
369 lines
13 KiB
Swift
369 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) {
|
|
ImagePickerLabel(hasImage: occlusionImage != nil)
|
|
}
|
|
.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)
|
|
}
|
|
}
|
|
|
|
/// Wird als Sub-View aus dem PhotosPicker-Label-Closure aufgerufen.
|
|
/// Eigene `View`-Struct vermeidet die Swift-6-Strict-Concurrency-
|
|
/// Warning: SwiftUIs `PhotosPicker(label:)`-Closure ist `@Sendable`,
|
|
/// aber View-Konstruktor-Calls werden zur Build-Zeit MainActor-isoliert
|
|
/// evaluiert (im Gegensatz zu direktem @State-Zugriff im Closure-Body).
|
|
private struct ImagePickerLabel: View {
|
|
let hasImage: Bool
|
|
|
|
var body: some View {
|
|
if hasImage {
|
|
Label("Bild ersetzen", systemImage: "arrow.triangle.2.circlepath")
|
|
} else {
|
|
Label("Bild auswählen", systemImage: "photo")
|
|
}
|
|
}
|
|
}
|