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)
|
# 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
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 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()
|
||||||
|
|
|
||||||
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:
|
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) {
|
||||||
|
|
|
||||||
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