import ManaCore import PhotosUI import SwiftUI /// Bild + Masken-Editor + Hinweis-Feld + Status für `image-occlusion`- /// Cards. Owned-State: `imagePickerItem` (PhotosPicker-Bridge). Alles /// andere lebt im Parent als `@State` und kommt hier als `@Binding` an. /// /// Beim Mount im Edit-Modus wird das bestehende Bild via `MediaCache` /// nachgeladen, damit der User die existierenden Masken sieht. struct ImageOcclusionFields: View { @Binding var image: PlatformImage? @Binding var imageData: Data? @Binding var mimeType: String @Binding var regions: [MaskRegion] @Binding var note: String @Binding var existingImageRef: String? let onLoadError: (String) -> Void @Environment(\.mediaCache) private var mediaCache @State private var pickerItem: PhotosPickerItem? var body: some View { Section("Bild") { PhotosPicker(selection: $pickerItem, matching: .images) { ImagePickerLabel(hasImage: image != nil) } .onChange(of: pickerItem) { _, newItem in Task { await loadPickedImage(newItem) } } } if let image { Section("Masken") { MaskEditorView(image: image, regions: $regions) } } Section("Hinweis (optional)") { TextField("z.B. Kurz-Erklärung", text: $note, axis: .vertical) .lineLimit(1 ... 3) } Section { statusLabel } .task(id: existingImageRef) { await loadExistingImageIfNeeded() } } @ViewBuilder private var statusLabel: some View { if image == nil { Label("Erst Bild wählen", systemImage: "info.circle") .font(.caption) .foregroundStyle(CardsTheme.mutedForeground) } else if regions.isEmpty { Label("Mindestens eine Maske nötig", systemImage: "exclamationmark.circle") .font(.caption) .foregroundStyle(CardsTheme.warning) } else { Label( "\(regions.count) Masken → \(regions.count) Reviews", systemImage: "checkmark.circle.fill" ) .font(.caption) .foregroundStyle(CardsTheme.success) } } private func loadExistingImageIfNeeded() async { guard image == nil, let ref = existingImageRef, let cache = mediaCache else { return } do { let data = try await cache.data(for: ref) if let img = PlatformImage(data: data) { image = img } } catch { onLoadError("Bestehendes Bild konnte nicht geladen werden: \(error.localizedDescription)") } } private func loadPickedImage(_ item: PhotosPickerItem?) async { guard let item else { return } do { guard let data = try await item.loadTransferable(type: Data.self) else { return } imageData = data mimeType = inferImageMimeType(from: data) if let img = PlatformImage(data: data) { image = img regions = [] // neue Bildauswahl resetet Masken existingImageRef = nil // bestehender Ref wird ersetzt } } catch { onLoadError("Bild konnte nicht geladen werden: \(error.localizedDescription)") } } private func inferImageMimeType(from data: Data) -> String { 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" } if bytes.count >= 4, bytes[0 ... 3] == [0x52, 0x49, 0x46, 0x46] { return "image/webp" } return "image/jpeg" } } /// Datei-Picker + Antwort-Feld für `audio-front`-Cards. Owned-State: /// `showAudioPicker`. URL und Antwort kommen als `@Binding` aus dem /// Parent. struct AudioFrontFields: View { @Binding var audioFileURL: URL? @Binding var back: String let existingAudioRef: String? @State private var showPicker = false var body: some View { Section("Audio-Datei") { Button { showPicker = true } label: { pickerLabel } .fileImporter( isPresented: $showPicker, 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) } } @ViewBuilder private var pickerLabel: some View { if let audioFileURL { Label(audioFileURL.lastPathComponent, systemImage: "waveform") } else if existingAudioRef != nil { Label("Audio ersetzen", systemImage: "waveform.badge.plus") } else { Label("Audio auswählen", systemImage: "waveform.badge.plus") } } } /// PhotosPicker-Label als eigene View, damit Swift-6-Strict-Concurrency /// nicht über den `@Sendable`-Closure meckert (View-Konstruktor-Calls /// werden zur Build-Zeit MainActor-isoliert evaluiert). 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") } } }