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
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue