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>
This commit is contained in:
Till JS 2026-05-13 00:35:36 +02:00
parent cf1160b270
commit 80eb3708b4
12 changed files with 923 additions and 44 deletions

72
PLAN.md
View file

@ -1,9 +1,10 @@
# Plan — cards-native (SwiftUI Universal) # Plan — cards-native (SwiftUI Universal)
**Stand: 2026-05-13 — Phasen β-0 + β-1 + β-2 + β-3 abgeschlossen.** **Stand: 2026-05-13 — Phasen β-0 bis β-4 abgeschlossen.**
Login, Deck-Liste mit Cache, Study-Loop mit Offline-Grade-Queue, Alle 7 Card-Types werden gerendert und können erstellt werden,
voller Editor-Flow (Deck Create/Edit/Delete + Card Create für 5 inklusive image-occlusion (Touch-Drag-Mask-Editor) und audio-front
Types). 24 Unit-Tests + 1 UI-Test grün. (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 Pflicht-Check für β-2: Endurance-Test auf realem Gerät (200+ Karten
mit Flugmodus zwischendurch) steht aus — Aufgabe für Till. 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) - `LoginView` (Email/PW gegen mana-auth)
- 3 Unit-Tests (AppConfig) - 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`)** ✅ **β-3 — Editor (2026-05-13, Tag `v0.4.0`)**
- `DeckCreateBody`, `DeckUpdateBody`, `CardCreateBody`, `CardUpdateBody` - `DeckCreateBody`, `DeckUpdateBody`, `CardCreateBody`, `CardUpdateBody`
Encodable-Structs (snake_case via `CodingKeys`, nil-Felder werden 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 | | β-1 | ✅ 2026-05-13 | Decks lesen, SwiftData-Cache, Pull-to-Refresh |
| β-2 | ✅ 2026-05-13 | Study-Loop, Offline-Grade-Queue (Endurance-Test offen) | | β-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 | | β-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 | | β-5 | — | Marketplace, Universal-Links |
| β-6 | — | Native-Polish (Widgets, Notifications, Share-Extension) | | β-6 | — | Native-Polish (Widgets, Notifications, Share-Extension) |
| β-7 | — | App-Store-Submission | | β-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, 1. `ExploreView`: GET `/api/v1/marketplace/explore` — Featured/Trending
MinIO-Backend), PHPickerViewController für Foto-Auswahl 2. `BrowseView`: GET `/api/v1/marketplace/decks/browse` mit Filter-Bar
2. `audio-front`-Cards: AVAudioPlayer für Wiedergabe (Pattern aus 3. `PublicDeckView`: GET `/api/v1/marketplace/decks/:slug` — Detail mit
memoro-native) Subscribe-Button (= POST `/subscribe/:slug`, Auto-Fork)
3. `image-occlusion`-Renderer: SVG-Mask-Overlay über AsyncImage, 4. Subscribed-Decks-Liste als zweite Section in `DeckListView`
Tap auf Mask → Reveal 5. **Universal-Links**: `cardecky.mana.how/d/:slug` öffnet App direkt
4. iPad-PencilKit-Editor für Image-Occlusion-Masks
5. `MediaCache` im FileManager (Caches/cards-media/<id>, LRU 200 MB)
6. `CardEditorView` um image-occlusion + audio-front erweitern
**Erfolgskriterium:** Karten mit Bildern und Audio aus Web-erstellten **Erfolgskriterium:** Drei Live-Decks (geografie-welt-top30, english-a2,
Decks funktionieren in Native. Image-Occlusion in beide Richtungen periodensystem-elemente) sichtbar, subscribebar, lernbar.
(Native↔Web) sichtbar.
**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 ## Verschoben auf β-3-Extension oder später

View file

@ -6,6 +6,7 @@ import SwiftUI
struct CardsNativeApp: App { struct CardsNativeApp: App {
let container: ModelContainer let container: ModelContainer
@State private var auth: AuthClient @State private var auth: AuthClient
private let mediaCache: MediaCache
init() { init() {
do { do {
@ -16,6 +17,7 @@ struct CardsNativeApp: App {
let auth = AuthClient(config: AppConfig.manaAppConfig) let auth = AuthClient(config: AppConfig.manaAppConfig)
auth.bootstrap() auth.bootstrap()
_auth = State(initialValue: auth) _auth = State(initialValue: auth)
mediaCache = MediaCache(api: CardsAPI(auth: auth))
Log.app.info("Cards starting — auth status: \(String(describing: auth.status), privacy: .public)") Log.app.info("Cards starting — auth status: \(String(describing: auth.status), privacy: .public)")
} }
@ -23,6 +25,7 @@ struct CardsNativeApp: App {
WindowGroup { WindowGroup {
RootView() RootView()
.environment(auth) .environment(auth)
.environment(\.mediaCache, mediaCache)
.tint(CardsTheme.primary) .tint(CardsTheme.primary)
} }
.modelContainer(container) .modelContainer(container)

View file

@ -54,6 +54,44 @@ actor CardsAPI {
return try decoder.decode(DueReviewsResponse.self, from: data).total 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 // MARK: - Deck-Mutations
/// `POST /api/v1/decks` Deck anlegen. /// `POST /api/v1/decks` Deck anlegen.
@ -172,6 +210,28 @@ actor CardsAPI {
return try encoder.encode(value) 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 // MARK: - Helpers
private func ensureOK(_ http: HTTPURLResponse, data: Data) throws { private func ensureOK(_ http: HTTPURLResponse, data: Data) throws {

View file

@ -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<string,string>`).
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]
}
}

View file

@ -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<URLResourceKey> = [.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)
}
}

View file

@ -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 }
}
}

View file

@ -1,9 +1,13 @@
import ManaCore import ManaCore
import PhotosUI
import SwiftUI import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
/// Card-Create-View. Type-Picker oben, type-spezifische Felder unten. /// Card-Create-View. Type-Picker oben, type-spezifische Felder unten.
/// Deckt basic, basic-reverse, cloze, typing, multiple-choice ab /// Deckt alle 7 Card-Types ab.
/// image-occlusion und audio-front kommen in β-4 (brauchen Media).
struct CardEditorView: View { struct CardEditorView: View {
let deckId: String let deckId: String
let onCreated: (Card) -> Void let onCreated: (Card) -> Void
@ -20,9 +24,21 @@ struct CardEditorView: View {
@State private var isSubmitting = false @State private var isSubmitting = false
@State private var errorMessage: String? @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] = [ private static let supportedTypes: [CardType] = [
.basic, .basicReverse, .cloze, .typing, .multipleChoice, .basic, .basicReverse, .cloze, .typing, .multipleChoice,
.imageOcclusion, .audioFront,
] ]
var body: some View { var body: some View {
@ -129,11 +145,123 @@ struct CardEditorView: View {
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(CardsTheme.mutedForeground)
} }
case .imageOcclusion, .audioFront: case .imageOcclusion:
Section { imageOcclusionFields
Label("Dieser Typ kommt in Phase β-4 (Media)", systemImage: "clock")
.foregroundStyle(CardsTheme.mutedForeground) 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 !front.trimmed.isEmpty && !typingAnswer.trimmed.isEmpty
case .multipleChoice: case .multipleChoice:
!front.trimmed.isEmpty && !multipleChoiceAnswer.trimmed.isEmpty !front.trimmed.isEmpty && !multipleChoiceAnswer.trimmed.isEmpty
case .imageOcclusion, .audioFront: case .imageOcclusion:
false occlusionImageData != nil && !occlusionRegions.isEmpty
case .audioFront:
audioFileURL != nil && !back.trimmed.isEmpty
} }
} }
@ -158,22 +288,46 @@ struct CardEditorView: View {
defer { isSubmitting = false } defer { isSubmitting = false }
let api = CardsAPI(auth: auth) 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 { 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) let card = try await api.createCard(body)
onCreated(card) onCreated(card)
dismiss() dismiss()

View file

@ -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)
}
}

View file

@ -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
}
}
}

View file

@ -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

View file

@ -24,7 +24,11 @@ struct CardRenderer: View {
} }
case .cloze: case .cloze:
clozeView clozeView
case .imageOcclusion, .audioFront, .typing, .multipleChoice: case .imageOcclusion:
imageOcclusionView
case .audioFront:
audioFrontView
case .typing, .multipleChoice:
placeholderView 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 @ViewBuilder
private var placeholderView: some View { private var placeholderView: some View {
VStack(spacing: 8) { VStack(spacing: 8) {

View file

@ -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)
}
}