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:
parent
cf1160b270
commit
80eb3708b4
12 changed files with 923 additions and 44 deletions
72
PLAN.md
72
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/<id>, 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
103
Sources/Core/Domain/Media.swift
Normal file
103
Sources/Core/Domain/Media.swift
Normal 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]
|
||||
}
|
||||
}
|
||||
73
Sources/Core/Sync/MediaCache.swift
Normal file
73
Sources/Core/Sync/MediaCache.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
15
Sources/Core/Sync/MediaEnvironment.swift
Normal file
15
Sources/Core/Sync/MediaEnvironment.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
147
Sources/Features/Editor/MaskEditorView.swift
Normal file
147
Sources/Features/Editor/MaskEditorView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
73
Sources/Features/Media/AudioPlayerButton.swift
Normal file
73
Sources/Features/Media/AudioPlayerButton.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
70
Sources/Features/Media/RemoteImage.swift
Normal file
70
Sources/Features/Media/RemoteImage.swift
Normal 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
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
79
Tests/UnitTests/MaskRegionsTests.swift
Normal file
79
Tests/UnitTests/MaskRegionsTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue