diff --git a/PLAN.md b/PLAN.md index 09f4c09..7ffe6f0 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,9 +1,10 @@ # Plan — cards-native (SwiftUI Universal) -**Stand: 2026-05-13 — Phasen β-0 + β-1 + β-2 + β-3 abgeschlossen.** -Login, Deck-Liste mit Cache, Study-Loop mit Offline-Grade-Queue, -voller Editor-Flow (Deck Create/Edit/Delete + Card Create für 5 -Types). 24 Unit-Tests + 1 UI-Test grün. +**Stand: 2026-05-13 — Phasen β-0 bis β-4 abgeschlossen.** +Alle 7 Card-Types werden gerendert und können erstellt werden, +inklusive image-occlusion (Touch-Drag-Mask-Editor) und audio-front +(File-Picker + AVAudioPlayer). MediaCache mit LRU 200 MB. +30 Unit-Tests + 1 UI-Test grün. Pflicht-Check für β-2: Endurance-Test auf realem Gerät (200+ Karten mit Flugmodus zwischendurch) steht aus — Aufgabe für Till. @@ -27,6 +28,30 @@ mit Flugmodus zwischendurch) steht aus — Aufgabe für Till. - `LoginView` (Email/PW gegen mana-auth) - 3 Unit-Tests (AppConfig) +✅ **β-4 — Media + Advanced Card-Types (2026-05-13, Tag `v0.5.0`)** +- `MediaUploadResponse` DTO + `MediaKind`-Enum +- `MaskRegion` Codable mit 0..1-Coordinates, `MaskRegions.parse/encode`- + Helpers (1:1-Port aus `cards-domain/image-occlusion.ts` — Sortierung + nach ID lexikographisch) +- `CardFieldsBuilder.imageOcclusion`, `.audioFront` mit korrekter + `mask_regions`-Serialisierung als stringified JSON-Array +- `CardsAPI.uploadMedia(data, filename, mimeType)` mit Multipart + (25 MiB max), `.fetchMedia(id)` für streamed bytes +- `MediaCache` actor mit LRU 200 MB (sortiert nach `contentModificationDate`) +- `mediaCache`-Environment-Key, im App-Entrypoint instantiiert +- `RemoteImage` View — authentifiziertes Image-Loading mit ProgressView + + Failure-State +- `AudioPlayerButton` — AVAudioPlayer-Wrapper mit Play/Pause-Toggle, + AVAudioSession-Setup für iOS +- `CardRenderer.imageOcclusionView`: AsyncImage + opake Maske über aktiver + Region (Frontside), Label-Reveal auf Backside +- `CardRenderer.audioFrontView`: AudioPlayerButton + back-Text auf Flip +- `MaskEditorView`: Touch-Drag-to-Create-Rectangle, Liste mit Label-Edit + + Delete, 0..1-Normalisierung beim Commit +- `CardEditorView` erweitert: PhotosPicker für Image, fileImporter für + Audio, Magic-Byte-MIME-Detection (JPEG/PNG/GIF/WebP) +- 6 neue Tests für MaskRegions-Parse/Encode + Field-Builder (30 Total) + ✅ **β-3 — Editor (2026-05-13, Tag `v0.4.0`)** - `DeckCreateBody`, `DeckUpdateBody`, `CardCreateBody`, `CardUpdateBody` Encodable-Structs (snake_case via `CodingKeys`, nil-Felder werden @@ -86,28 +111,37 @@ mit Flugmodus zwischendurch) steht aus — Aufgabe für Till. | β-1 | ✅ 2026-05-13 | Decks lesen, SwiftData-Cache, Pull-to-Refresh | | β-2 | ✅ 2026-05-13 | Study-Loop, Offline-Grade-Queue (Endurance-Test offen) | | β-3 | ✅ 2026-05-13 | Editor: Deck-CRUD + Card-Create (5 Types); Anki-Import auf β-3-ext verschoben | -| β-4 | — | Media, image-occlusion (PencilKit), audio-front | +| β-4 | ✅ 2026-05-13 | Media-Upload, image-occlusion (Touch-Mask-Editor), audio-front (AVAudioPlayer) | | β-5 | — | Marketplace, Universal-Links | | β-6 | — | Native-Polish (Widgets, Notifications, Share-Extension) | | β-7 | — | App-Store-Submission | -## Nächste Schritte für β-4 (Media + Advanced Card-Types) +## Nächste Schritte für β-5 (Marketplace) -Aus Greenfield-Plan-Sektion "Phase β-4": +Aus Greenfield-Plan-Sektion "Phase β-5": -1. Media-Upload via `POST /api/v1/media` (Multipart, 25 MiB max, - MinIO-Backend), PHPickerViewController für Foto-Auswahl -2. `audio-front`-Cards: AVAudioPlayer für Wiedergabe (Pattern aus - memoro-native) -3. `image-occlusion`-Renderer: SVG-Mask-Overlay über AsyncImage, - Tap auf Mask → Reveal -4. iPad-PencilKit-Editor für Image-Occlusion-Masks -5. `MediaCache` im FileManager (Caches/cards-media/, LRU 200 MB) -6. `CardEditorView` um image-occlusion + audio-front erweitern +1. `ExploreView`: GET `/api/v1/marketplace/explore` — Featured/Trending +2. `BrowseView`: GET `/api/v1/marketplace/decks/browse` mit Filter-Bar +3. `PublicDeckView`: GET `/api/v1/marketplace/decks/:slug` — Detail mit + Subscribe-Button (= POST `/subscribe/:slug`, Auto-Fork) +4. Subscribed-Decks-Liste als zweite Section in `DeckListView` +5. **Universal-Links**: `cardecky.mana.how/d/:slug` öffnet App direkt -**Erfolgskriterium:** Karten mit Bildern und Audio aus Web-erstellten -Decks funktionieren in Native. Image-Occlusion in beide Richtungen -(Native↔Web) sichtbar. +**Erfolgskriterium:** Drei Live-Decks (geografie-welt-top30, english-a2, +periodensystem-elemente) sichtbar, subscribebar, lernbar. + +**Vorbedingung:** AASA auf `cardecky.mana.how/.well-known/apple-app-site-association` +muss aufgesetzt werden — heute 404. Aufgabe ans Cards-Web-Repo. + +## Notizen aus β-4 + +- **PencilKit für Mask-Editor explizit nicht genutzt.** Web macht + Image-Occlusion-Masks per Touch-Drag-Rechteck (kein Freihand). Server- + Schema (`MaskRegion = {id, x, y, w, h, label}`) erlaubt nur Rechtecke, + PencilKit-Strokes wären dafür übersteigert. Wenn später Polygon-Masks + oder Freihand-Skizzen dazu kommen, kann PencilKit nachgereicht werden. +- **Apple-Pencil-Support** trotzdem grundsätzlich da: SwiftUI's + `DragGesture` reagiert auf Pencil-Eingaben genauso wie auf Finger. ## Verschoben auf β-3-Extension oder später diff --git a/Sources/App/CardsNativeApp.swift b/Sources/App/CardsNativeApp.swift index daaaf93..7cdc542 100644 --- a/Sources/App/CardsNativeApp.swift +++ b/Sources/App/CardsNativeApp.swift @@ -6,6 +6,7 @@ import SwiftUI struct CardsNativeApp: App { let container: ModelContainer @State private var auth: AuthClient + private let mediaCache: MediaCache init() { do { @@ -16,6 +17,7 @@ struct CardsNativeApp: App { let auth = AuthClient(config: AppConfig.manaAppConfig) auth.bootstrap() _auth = State(initialValue: auth) + mediaCache = MediaCache(api: CardsAPI(auth: auth)) Log.app.info("Cards starting — auth status: \(String(describing: auth.status), privacy: .public)") } @@ -23,6 +25,7 @@ struct CardsNativeApp: App { WindowGroup { RootView() .environment(auth) + .environment(\.mediaCache, mediaCache) .tint(CardsTheme.primary) } .modelContainer(container) diff --git a/Sources/Core/API/CardsAPI.swift b/Sources/Core/API/CardsAPI.swift index 943d5d8..f40a18c 100644 --- a/Sources/Core/API/CardsAPI.swift +++ b/Sources/Core/API/CardsAPI.swift @@ -54,6 +54,44 @@ actor CardsAPI { return try decoder.decode(DueReviewsResponse.self, from: data).total } + // MARK: - Media + + /// `POST /api/v1/media/upload` — Multipart-Upload. Max 25 MiB. + /// Erlaubte MIMEs: image/*, audio/*, video/*. + func uploadMedia(data: Data, filename: String, mimeType: String) async throws -> MediaUploadResponse { + let boundary = "cards-native-\(UUID().uuidString)" + let body = makeMultipartBody( + file: data, + filename: filename, + mimeType: mimeType, + boundary: boundary + ) + let (response, http) = try await transport.request( + path: "/api/v1/media/upload", + method: "POST", + body: body, + contentType: "multipart/form-data; boundary=\(boundary)" + ) + try ensureOK(http, data: response) + return try decoder.decode(MediaUploadResponse.self, from: response) + } + + /// `GET /api/v1/media/:id` — streamt das Media-File. Antwortet mit + /// raw bytes (kein JSON), Caller schreibt das auf Disk via MediaCache. + func fetchMedia(id: String) async throws -> Data { + let (data, http) = try await transport.request(path: "/api/v1/media/\(id)") + guard (200 ..< 300).contains(http.statusCode) else { + throw AuthError.serverError(status: http.statusCode, message: "media fetch failed") + } + return data + } + + /// `DELETE /api/v1/media/:id` — Soft-Forget. (Endpoint heute nicht + /// implementiert serverseitig; Stub bleibt für späteren Use.) + func deleteMedia(id _: String) async throws { + throw AuthError.serverError(status: 501, message: "media delete not implemented on server") + } + // MARK: - Deck-Mutations /// `POST /api/v1/decks` — Deck anlegen. @@ -172,6 +210,28 @@ actor CardsAPI { return try encoder.encode(value) } + // MARK: - Multipart + + private func makeMultipartBody( + file: Data, + filename: String, + mimeType: String, + boundary: String + ) -> Data { + var body = Data() + let lineBreak = "\r\n" + let header = """ + --\(boundary)\(lineBreak)\ + Content-Disposition: form-data; name="file"; filename="\(filename)"\(lineBreak)\ + Content-Type: \(mimeType)\(lineBreak)\(lineBreak) + """ + body.append(header.data(using: .utf8) ?? Data()) + body.append(file) + body.append(lineBreak.data(using: .utf8) ?? Data()) + body.append("--\(boundary)--\(lineBreak)".data(using: .utf8) ?? Data()) + return body + } + // MARK: - Helpers private func ensureOK(_ http: HTTPURLResponse, data: Data) throws { diff --git a/Sources/Core/Domain/Media.swift b/Sources/Core/Domain/Media.swift new file mode 100644 index 0000000..a20e498 --- /dev/null +++ b/Sources/Core/Domain/Media.swift @@ -0,0 +1,103 @@ +import Foundation + +/// Response von `POST /api/v1/media/upload`. +struct MediaUploadResponse: Decodable, Sendable { + let id: String + let url: String + let mimeType: String + let kind: MediaKind + let sizeBytes: Int + let originalFilename: String? + + enum CodingKeys: String, CodingKey { + case id + case url + case mimeType = "mime_type" + case kind + case sizeBytes = "size_bytes" + case originalFilename = "original_filename" + } +} + +enum MediaKind: String, Codable, Sendable { + case image + case audio + case video + case other +} + +/// Image-Occlusion-Mask-Region. +/// `mask_regions`-Feld ist ein JSON-Array-**String** in `fields`, +/// nicht ein Object — Server-Schema-Constraint (`fields: Record`). +struct MaskRegion: Codable, Hashable, Sendable, Identifiable { + let id: String + let x: Double // 0..1 relativ + let y: Double + let w: Double + let h: Double + let label: String? + + init(id: String, x: Double, y: Double, w: Double, h: Double, label: String? = nil) { + self.id = id + self.x = x + self.y = y + self.w = w + self.h = h + self.label = label + } +} + +/// Helpers zum Parsen/Serialisieren von `mask_regions` als JSON-String. +enum MaskRegions { + /// 1:1-Port aus `cards-domain/image-occlusion.ts:parseMaskRegions`. + /// Bei Parse- oder Schema-Fehler: leere Liste. Sortiert nach ID + /// (lexikographisch, gleich wie Server-Sortierung). + static func parse(_ json: String) -> [MaskRegion] { + guard let data = json.data(using: .utf8) else { return [] } + guard let regions = try? JSONDecoder().decode([MaskRegion].self, from: data) else { return [] } + return regions.sorted { $0.id < $1.id } + } + + /// Sub-Index → Region (Sortier-Reihenfolge). + static func region(for json: String, subIndex: Int) -> MaskRegion? { + let all = parse(json) + return all.indices.contains(subIndex) ? all[subIndex] : nil + } + + /// Anzahl Regionen → Anzahl Sub-Index-Reviews. + static func count(_ json: String) -> Int { + parse(json).count + } + + /// Serialisiert eine Liste zu einem JSON-Array-String fürs `fields`-Feld. + static func encode(_ regions: [MaskRegion]) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let data = try? encoder.encode(regions) else { return "[]" } + return String(decoding: data, as: UTF8.self) + } +} + +extension CardFieldsBuilder { + /// `image-occlusion`-Fields: `image_ref` (media_id) + + /// `mask_regions` (stringified JSON-Array) + optional `note`. + static func imageOcclusion( + imageRef: String, + regions: [MaskRegion], + note: String? = nil + ) -> [String: String] { + var fields: [String: String] = [ + "image_ref": imageRef, + "mask_regions": MaskRegions.encode(regions), + ] + if let note, !note.isEmpty { + fields["note"] = note + } + return fields + } + + /// `audio-front`-Fields: `audio_ref` (media_id) + `back` (Antwort-Text). + static func audioFront(audioRef: String, back: String) -> [String: String] { + ["audio_ref": audioRef, "back": back] + } +} diff --git a/Sources/Core/Sync/MediaCache.swift b/Sources/Core/Sync/MediaCache.swift new file mode 100644 index 0000000..b636072 --- /dev/null +++ b/Sources/Core/Sync/MediaCache.swift @@ -0,0 +1,73 @@ +import Foundation +import ManaCore + +/// Persistenter Disk-Cache für Cards-Media-Files. Bilder/Audio werden +/// einmal vom Server geladen und danach lokal serviert — der Server +/// setzt `Cache-Control: private, immutable`, das honorieren wir hier. +/// +/// LRU-Verdrängung mit Soft-Limit (Default 200 MB). +actor MediaCache { + private let root: URL + private let api: CardsAPI + private let maxBytes: Int + + init(api: CardsAPI, maxBytes: Int = 200 * 1024 * 1024) { + self.api = api + self.maxBytes = maxBytes + let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + root = caches.appendingPathComponent("cards-media", isDirectory: true) + try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } + + /// Liefert die lokale URL eines Media-Files. Lädt vom Server, falls + /// nicht im Cache. Wirft `AuthError`, wenn der Download scheitert. + func localURL(for mediaId: String) async throws -> URL { + let target = root.appendingPathComponent(mediaId) + if FileManager.default.fileExists(atPath: target.path) { + try? FileManager.default.setAttributes([.modificationDate: Date.now], ofItemAtPath: target.path) + return target + } + let data = try await api.fetchMedia(id: mediaId) + try data.write(to: target, options: .atomic) + try? await pruneIfNeeded() + return target + } + + /// Direktes Lesen — für UI-Komponenten, die `Data` brauchen (z.B. AVAudioPlayer). + func data(for mediaId: String) async throws -> Data { + try Data(contentsOf: try await localURL(for: mediaId)) + } + + /// LRU-Eviction: bei Überschreitung des Limits ältesten zuerst löschen. + private func pruneIfNeeded() async throws { + let resourceKeys: Set = [.fileSizeKey, .contentModificationDateKey] + guard let items = try? FileManager.default.contentsOfDirectory( + at: root, + includingPropertiesForKeys: Array(resourceKeys) + ) else { return } + + let withMeta = items.compactMap { url -> (url: URL, size: Int, date: Date)? in + let values = try? url.resourceValues(forKeys: resourceKeys) + guard let size = values?.fileSize, let date = values?.contentModificationDate else { return nil } + return (url, size, date) + } + + let totalBytes = withMeta.reduce(0) { $0 + $1.size } + guard totalBytes > maxBytes else { return } + + let sortedOldestFirst = withMeta.sorted { $0.date < $1.date } + var remaining = totalBytes + for item in sortedOldestFirst { + if remaining <= maxBytes { break } + try? FileManager.default.removeItem(at: item.url) + remaining -= item.size + Log.sync.info("MediaCache evicted \(item.url.lastPathComponent, privacy: .public) (\(item.size, privacy: .public)B)") + } + } + + /// Wipe — für Sign-out o.ä. + func clear() { + try? FileManager.default.removeItem(at: root) + try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } +} diff --git a/Sources/Core/Sync/MediaEnvironment.swift b/Sources/Core/Sync/MediaEnvironment.swift new file mode 100644 index 0000000..bd158c0 --- /dev/null +++ b/Sources/Core/Sync/MediaEnvironment.swift @@ -0,0 +1,15 @@ +import SwiftUI + +/// Environment-Key, der den shared `MediaCache` durch die View-Hierarchie +/// reicht. App-Entrypoint setzt den Wert; Views lesen via +/// `@Environment(\.mediaCache)`. +private struct MediaCacheKey: EnvironmentKey { + static let defaultValue: MediaCache? = nil +} + +extension EnvironmentValues { + var mediaCache: MediaCache? { + get { self[MediaCacheKey.self] } + set { self[MediaCacheKey.self] = newValue } + } +} diff --git a/Sources/Features/Editor/CardEditorView.swift b/Sources/Features/Editor/CardEditorView.swift index ad4379f..d5bc324 100644 --- a/Sources/Features/Editor/CardEditorView.swift +++ b/Sources/Features/Editor/CardEditorView.swift @@ -1,9 +1,13 @@ import ManaCore +import PhotosUI import SwiftUI +#if canImport(UIKit) +import UIKit +#endif + /// Card-Create-View. Type-Picker oben, type-spezifische Felder unten. -/// Deckt basic, basic-reverse, cloze, typing, multiple-choice ab — -/// image-occlusion und audio-front kommen in β-4 (brauchen Media). +/// Deckt alle 7 Card-Types ab. struct CardEditorView: View { let deckId: String let onCreated: (Card) -> Void @@ -20,9 +24,21 @@ struct CardEditorView: View { @State private var isSubmitting = false @State private var errorMessage: String? - /// β-3-Card-Types (β-4 ergänzt image-occlusion + audio-front). + // 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 { @@ -129,11 +145,123 @@ struct CardEditorView: View { .foregroundStyle(CardsTheme.mutedForeground) } - case .imageOcclusion, .audioFront: - Section { - Label("Dieser Typ kommt in Phase β-4 (Media)", systemImage: "clock") - .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" } } @@ -147,8 +275,10 @@ struct CardEditorView: View { !front.trimmed.isEmpty && !typingAnswer.trimmed.isEmpty case .multipleChoice: !front.trimmed.isEmpty && !multipleChoiceAnswer.trimmed.isEmpty - case .imageOcclusion, .audioFront: - false + case .imageOcclusion: + occlusionImageData != nil && !occlusionRegions.isEmpty + case .audioFront: + audioFileURL != nil && !back.trimmed.isEmpty } } @@ -158,22 +288,46 @@ struct CardEditorView: View { defer { isSubmitting = false } let api = CardsAPI(auth: auth) - let fields: [String: String] - 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, .audioFront: - return // disabled - } - - let body = CardCreateBody(deckId: deckId, type: type, fields: fields, mediaRefs: nil) 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() diff --git a/Sources/Features/Editor/MaskEditorView.swift b/Sources/Features/Editor/MaskEditorView.swift new file mode 100644 index 0000000..ba04c75 --- /dev/null +++ b/Sources/Features/Editor/MaskEditorView.swift @@ -0,0 +1,147 @@ +import SwiftUI + +#if canImport(UIKit) +import UIKit +#endif + +/// Mask-Editor: Bild anzeigen, mit Drag-Gesten Rechtecke zeichnen, jede +/// Region mit Label versehen. Coordinaten 0..1 relativ zur Bild-Größe. +/// +/// Output binding ist `regions`. Caller serialisiert via `MaskRegions.encode()`. +struct MaskEditorView: View { + let image: PlatformImage + @Binding var regions: [MaskRegion] + + @State private var dragStart: CGPoint? + @State private var dragEnd: CGPoint? + @State private var nextIdCounter: Int = 0 + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Tippe und ziehe auf das Bild, um eine Maske zu erstellen.") + .font(.caption) + .foregroundStyle(CardsTheme.mutedForeground) + + imageCanvas + .aspectRatio(image.size.width / max(image.size.height, 1), contentMode: .fit) + .frame(maxWidth: .infinity) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + if regions.isEmpty { + Text("Noch keine Maske") + .font(.caption) + .foregroundStyle(CardsTheme.mutedForeground) + } else { + ForEach(regions) { region in + maskRow(region: region) + } + } + } + } + + @ViewBuilder + private var imageCanvas: some View { + GeometryReader { geo in + ZStack(alignment: .topLeading) { + #if canImport(UIKit) + Image(uiImage: image).resizable().aspectRatio(contentMode: .fit) + #else + Image(nsImage: image).resizable().aspectRatio(contentMode: .fit) + #endif + + ForEach(regions) { region in + overlayRect(for: region, in: geo.size) + } + + if let dragStart, let dragEnd { + let rect = normalizedRect(from: dragStart, to: dragEnd) + Rectangle() + .stroke(CardsTheme.warning, lineWidth: 2) + .background(Rectangle().fill(CardsTheme.warning.opacity(0.2))) + .frame(width: rect.width, height: rect.height) + .offset(x: rect.minX, y: rect.minY) + } + } + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 4) + .onChanged { value in + if dragStart == nil { dragStart = value.startLocation } + dragEnd = value.location + } + .onEnded { value in + commitDrag(start: value.startLocation, end: value.location, in: geo.size) + } + ) + } + } + + private func overlayRect(for region: MaskRegion, in size: CGSize) -> some View { + Rectangle() + .fill(CardsTheme.primary.opacity(0.6)) + .frame(width: region.w * size.width, height: region.h * size.height) + .offset(x: region.x * size.width, y: region.y * size.height) + .overlay( + Text(region.label?.isEmpty == false ? region.label! : region.id) + .font(.caption2.weight(.bold)) + .foregroundStyle(CardsTheme.primaryForeground) + .padding(2) + .offset(x: region.x * size.width + 2, y: region.y * size.height + 2), + alignment: .topLeading + ) + } + + private func maskRow(region: MaskRegion) -> some View { + HStack(spacing: 8) { + Image(systemName: "square.dashed") + .foregroundStyle(CardsTheme.primary) + TextField("Label (optional)", text: Binding( + get: { region.label ?? "" }, + set: { newValue in updateLabel(for: region.id, to: newValue) } + )) + .textFieldStyle(.roundedBorder) + Button(role: .destructive) { + regions.removeAll { $0.id == region.id } + } label: { + Image(systemName: "trash") + .foregroundStyle(CardsTheme.error) + } + .buttonStyle(.plain) + } + } + + private func updateLabel(for id: String, to value: String) { + guard let idx = regions.firstIndex(where: { $0.id == id }) else { return } + let old = regions[idx] + regions[idx] = MaskRegion(id: old.id, x: old.x, y: old.y, w: old.w, h: old.h, label: value) + } + + private func normalizedRect(from start: CGPoint, to end: CGPoint) -> CGRect { + let x = min(start.x, end.x) + let y = min(start.y, end.y) + let w = abs(end.x - start.x) + let h = abs(end.y - start.y) + return CGRect(x: x, y: y, width: w, height: h) + } + + private func commitDrag(start: CGPoint, end: CGPoint, in size: CGSize) { + defer { + dragStart = nil + dragEnd = nil + } + let rect = normalizedRect(from: start, to: end) + // Mindestgröße 1% der Bildkante — Tap-Klicks ignorieren + guard rect.width > size.width * 0.01, rect.height > size.height * 0.01 else { return } + nextIdCounter += 1 + let id = String(format: "m%03d", nextIdCounter) + let normalized = MaskRegion( + id: id, + x: rect.minX / size.width, + y: rect.minY / size.height, + w: rect.width / size.width, + h: rect.height / size.height, + label: nil + ) + regions.append(normalized) + } +} diff --git a/Sources/Features/Media/AudioPlayerButton.swift b/Sources/Features/Media/AudioPlayerButton.swift new file mode 100644 index 0000000..99bc12f --- /dev/null +++ b/Sources/Features/Media/AudioPlayerButton.swift @@ -0,0 +1,73 @@ +import AVFoundation +import SwiftUI + +/// Audio-Wiedergabe-Button für `audio-front`-Karten. Lädt das File einmal +/// per MediaCache, spielt mit AVAudioPlayer ab. +struct AudioPlayerButton: View { + let mediaId: String + + @Environment(\.mediaCache) private var mediaCache + @State private var player: AVAudioPlayer? + @State private var isPlaying = false + @State private var failed = false + + var body: some View { + Button { + togglePlayback() + } label: { + HStack(spacing: 12) { + Image(systemName: failed + ? "speaker.slash.fill" + : (isPlaying ? "pause.circle.fill" : "play.circle.fill")) + .font(.system(size: 48)) + .foregroundStyle(failed ? CardsTheme.error : CardsTheme.primary) + Text(failed ? "Audio nicht verfügbar" : (isPlaying ? "Wiedergabe läuft" : "Anhören")) + .font(.headline) + .foregroundStyle(CardsTheme.foreground) + } + .frame(maxWidth: .infinity) + .padding(20) + .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(CardsTheme.border, lineWidth: 1) + ) + } + .buttonStyle(.plain) + .disabled(failed) + .task(id: mediaId) { + await load() + } + .onDisappear { + player?.stop() + isPlaying = false + } + } + + private func load() async { + guard let cache = mediaCache else { failed = true; return } + do { + let data = try await cache.data(for: mediaId) + #if canImport(UIKit) + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) + try AVAudioSession.sharedInstance().setActive(true) + #endif + player = try AVAudioPlayer(data: data) + player?.prepareToPlay() + } catch { + failed = true + } + } + + private func togglePlayback() { + guard let player else { return } + if player.isPlaying { + player.pause() + isPlaying = false + } else { + player.currentTime = 0 + player.play() + isPlaying = true + } + } +} diff --git a/Sources/Features/Media/RemoteImage.swift b/Sources/Features/Media/RemoteImage.swift new file mode 100644 index 0000000..4db3842 --- /dev/null +++ b/Sources/Features/Media/RemoteImage.swift @@ -0,0 +1,70 @@ +import SwiftUI + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +/// Lädt ein authentifiziertes Image vom Cardecky-Media-Endpoint und +/// rendert es. Streamt erst beim ersten Mal, danach aus dem +/// MediaCache (LRU 200 MB). +struct RemoteImage: View { + let mediaId: String + let contentMode: ContentMode + + @Environment(\.mediaCache) private var mediaCache + @State private var image: PlatformImage? + @State private var failed = false + + init(mediaId: String, contentMode: ContentMode = .fit) { + self.mediaId = mediaId + self.contentMode = contentMode + } + + var body: some View { + Group { + if let image { + imageView(image) + } else if failed { + ContentUnavailableView("Bild konnte nicht geladen werden", systemImage: "photo.badge.exclamationmark") + .foregroundStyle(CardsTheme.mutedForeground) + } else { + ProgressView() + .tint(CardsTheme.primary) + } + } + .task(id: mediaId) { + await load() + } + } + + @ViewBuilder + private func imageView(_ image: PlatformImage) -> some View { + #if canImport(UIKit) + Image(uiImage: image).resizable().aspectRatio(contentMode: contentMode) + #elseif canImport(AppKit) + Image(nsImage: image).resizable().aspectRatio(contentMode: contentMode) + #endif + } + + private func load() async { + guard let cache = mediaCache else { failed = true; return } + do { + let data = try await cache.data(for: mediaId) + if let img = PlatformImage(data: data) { + image = img + } else { + failed = true + } + } catch { + failed = true + } + } +} + +#if canImport(UIKit) +typealias PlatformImage = UIImage +#elseif canImport(AppKit) +typealias PlatformImage = NSImage +#endif diff --git a/Sources/Features/Study/CardRenderer.swift b/Sources/Features/Study/CardRenderer.swift index 9c258b0..ebf3908 100644 --- a/Sources/Features/Study/CardRenderer.swift +++ b/Sources/Features/Study/CardRenderer.swift @@ -24,7 +24,11 @@ struct CardRenderer: View { } case .cloze: clozeView - case .imageOcclusion, .audioFront, .typing, .multipleChoice: + case .imageOcclusion: + imageOcclusionView + case .audioFront: + audioFrontView + case .typing, .multipleChoice: placeholderView } } @@ -61,6 +65,70 @@ struct CardRenderer: View { } } + @ViewBuilder + private var imageOcclusionView: some View { + let imageRef = card.fields["image_ref"] ?? "" + let maskJSON = card.fields["mask_regions"] ?? "[]" + let regions = MaskRegions.parse(maskJSON) + let activeRegion = regions.indices.contains(subIndex) ? regions[subIndex] : nil + + VStack(spacing: 12) { + GeometryReader { geo in + ZStack(alignment: .topLeading) { + RemoteImage(mediaId: imageRef, contentMode: .fit) + .frame(width: geo.size.width, height: geo.size.height) + ForEach(regions) { region in + let isActive = region.id == activeRegion?.id + // Front: aktive Maske opak, andere transparent. + // Back: alle Masken transparent (Bild komplett sichtbar). + if !isFlipped, isActive { + Rectangle() + .fill(CardsTheme.primary.opacity(0.92)) + .frame( + width: region.w * geo.size.width, + height: region.h * geo.size.height + ) + .offset(x: region.x * geo.size.width, y: region.y * geo.size.height) + .overlay( + Text(region.label?.isEmpty == false ? region.label! : "?") + .font(.caption.weight(.bold)) + .foregroundStyle(CardsTheme.primaryForeground) + .offset(x: region.x * geo.size.width, y: region.y * geo.size.height), + alignment: .topLeading + ) + } + } + } + } + .aspectRatio(4 / 3, contentMode: .fit) + + if isFlipped, let label = activeRegion?.label, !label.isEmpty { + Text(label) + .font(.title3.weight(.semibold)) + .foregroundStyle(CardsTheme.primary) + } + if let note = card.fields["note"], !note.isEmpty { + Text(note) + .font(.caption) + .foregroundStyle(CardsTheme.mutedForeground) + } + } + } + + @ViewBuilder + private var audioFrontView: some View { + let audioRef = card.fields["audio_ref"] ?? "" + VStack(spacing: 16) { + AudioPlayerButton(mediaId: audioRef) + if isFlipped { + Divider().background(CardsTheme.border) + text(card.fields["back"] ?? "") + .font(.title3) + .foregroundStyle(CardsTheme.foreground) + } + } + } + @ViewBuilder private var placeholderView: some View { VStack(spacing: 8) { diff --git a/Tests/UnitTests/MaskRegionsTests.swift b/Tests/UnitTests/MaskRegionsTests.swift new file mode 100644 index 0000000..d11fb9e --- /dev/null +++ b/Tests/UnitTests/MaskRegionsTests.swift @@ -0,0 +1,79 @@ +import Foundation +import Testing +@testable import CardsNative + +@Suite("MaskRegions") +struct MaskRegionsTests { + @Test("Parsed Liste sortiert nach ID lexikographisch") + func parseSortsByIdLexically() { + let json = """ + [ + {"id":"m003","x":0.1,"y":0.1,"w":0.2,"h":0.2,"label":"C"}, + {"id":"m001","x":0,"y":0,"w":0.1,"h":0.1,"label":"A"}, + {"id":"m002","x":0.5,"y":0.5,"w":0.3,"h":0.3} + ] + """ + let regions = MaskRegions.parse(json) + #expect(regions.count == 3) + #expect(regions[0].id == "m001") + #expect(regions[1].id == "m002") + #expect(regions[2].id == "m003") + #expect(regions[2].label == "C") + #expect(regions[1].label == nil) + } + + @Test("Bei Parse-Fehler → leere Liste") + func parseInvalidReturnsEmpty() { + #expect(MaskRegions.parse("[}").isEmpty) + #expect(MaskRegions.parse("{}").isEmpty) + #expect(MaskRegions.parse("").isEmpty) + } + + @Test("region(forSubIndex:) mappt aufsteigend") + func subIndexLookup() { + let json = """ + [{"id":"b","x":0,"y":0,"w":0.1,"h":0.1}, + {"id":"a","x":0,"y":0,"w":0.2,"h":0.2}] + """ + #expect(MaskRegions.region(for: json, subIndex: 0)?.id == "a") + #expect(MaskRegions.region(for: json, subIndex: 1)?.id == "b") + #expect(MaskRegions.region(for: json, subIndex: 2) == nil) + } + + @Test("Encode-Roundtrip") + func encodeRoundtrip() { + let original = [ + MaskRegion(id: "m1", x: 0.1, y: 0.2, w: 0.3, h: 0.4, label: "test"), + MaskRegion(id: "m2", x: 0.5, y: 0.6, w: 0.2, h: 0.2, label: nil), + ] + let encoded = MaskRegions.encode(original) + let parsed = MaskRegions.parse(encoded) + #expect(parsed.count == 2) + #expect(parsed[0].id == "m1") + #expect(parsed[0].label == "test") + #expect(parsed[1].label == nil) + } + + @Test("CardFieldsBuilder.imageOcclusion produziert korrekte Felder") + func builderImageOcclusion() { + let regions = [MaskRegion(id: "m1", x: 0, y: 0, w: 0.5, h: 0.5, label: "x")] + let fields = CardFieldsBuilder.imageOcclusion( + imageRef: "media_123", + regions: regions, + note: "Hinweis" + ) + #expect(fields["image_ref"] == "media_123") + #expect(fields["note"] == "Hinweis") + let reparsed = MaskRegions.parse(fields["mask_regions"] ?? "") + #expect(reparsed.count == 1) + #expect(reparsed[0].id == "m1") + } + + @Test("CardFieldsBuilder.audioFront produziert korrekte Felder") + func builderAudioFront() { + let fields = CardFieldsBuilder.audioFront(audioRef: "audio_456", back: "Antwort") + #expect(fields["audio_ref"] == "audio_456") + #expect(fields["back"] == "Antwort") + #expect(fields.count == 2) + } +}