Kundenwuensche #6

Merged
niklas.ortmann merged 1 commits from dev-klock into dev 2026-09-07 11:42:07 +02:00
8 changed files with 322 additions and 122 deletions
@@ -292,6 +292,19 @@ class DocumentationRepository(
suspend fun loadPhotoThumbnail(documentationId: String, fileName: String): Bitmap? = suspend fun loadPhotoThumbnail(documentationId: String, fileName: String): Bitmap? =
photoStorage.decodeThumbnail(documentationId, fileName, ImageDownsampler.THUMBNAIL_MAX_DIMENSION_PX) photoStorage.decodeThumbnail(documentationId, fileName, ImageDownsampler.THUMBNAIL_MAX_DIMENSION_PX)
/**
* Lädt ein Foto in Originalgröße für die Vollbildansicht beim Antippen einer Foto-Kachel
* (Änderungswunsch). Verwendet bewusst [de.medapp.wunddoku.media.PhotoStorage.decodeFitted]
* statt eines unskalierten Rohzugriffs: Fotos werden bereits beim Import auf eine sinnvolle
* Maximalauflösung begrenzt (s. PhotoStorage-Importlogik), ein zusätzliches Herunterrechnen
* auf höchstens die tatsächliche Displaygröße ([maxWidthPx]/[maxHeightPx], vom Aufrufer aus
* der Bildschirmauflösung ermittelt) vermeidet unnötig hohen Speicherbedarf, ohne das Bild
* sichtbar zu verkleinern - der Nutzer sieht es exakt so groß wie der Bildschirm es ohnehin
* darstellen könnte.
*/
suspend fun loadPhotoFullSize(documentationId: String, fileName: String, maxWidthPx: Int, maxHeightPx: Int): Bitmap? =
photoStorage.decodeFitted(documentationId, fileName, maxWidthPx, maxHeightPx)
/** /**
* Lädt eine Signatur für die Vorschau in der Dokumentationsbearbeitung. Kein separates * Lädt eine Signatur für die Vorschau in der Dokumentationsbearbeitung. Kein separates
* Thumbnail-Downsampling nötig - das logische Signaturformat (1000x400px) ist bereits klein * Thumbnail-Downsampling nötig - das logische Signaturformat (1000x400px) ist bereits klein
@@ -364,8 +377,15 @@ class DocumentationRepository(
/** /**
* Legt für jeden Katalog-Block einen BlockState an, sowie je ein CheckItemState pro * Legt für jeden Katalog-Block einen BlockState an, sowie je ein CheckItemState pro
* Katalog-Checkbox PLUS eine zusätzliche, automatisch angehängte Freitext-Option * Katalog-Checkbox PLUS eine zusätzliche, automatisch angehängte Freitext-Option
* (isFreeText = true, letzte Position im Block) - siehe DocumentationCatalog.FREE_TEXT_LABEL * (isFreeText = true) - siehe DocumentationCatalog.FREE_TEXT_LABEL und
* und BlockDefinition.freeTextDefinitionId. * BlockDefinition.freeTextDefinitionId.
*
* Änderungswunsch: Die Freitext-Option erscheint als ERSTES Element im Block (itemOrder = 0,
* niedriger als jede Katalog-Checkbox, deren `order` bei 1 beginnt) - Vorbereitung dafür,
* dass echte Multiple-Choice-Varianten später als weitere Optionen NACH dem Freitext folgen
* können. Betrifft ausschließlich NEU erstellte Dokumentationen: bereits bestehende
* BlockState-/CheckItemState-Zeilen behalten ihre historisch gespeicherte itemOrder
* unverändert (Snapshot-Prinzip, s. Klassendokumentation zu titleSnapshot/labelSnapshot).
*/ */
private suspend fun insertBlocksFromCatalog(documentationId: String, now: Long) { private suspend fun insertBlocksFromCatalog(documentationId: String, now: Long) {
for (blockDef in DocumentationCatalog.blocks) { for (blockDef in DocumentationCatalog.blocks) {
@@ -400,7 +420,7 @@ class DocumentationRepository(
blockStateId = blockStateId, blockStateId = blockStateId,
checkItemDefinitionId = blockDef.freeTextDefinitionId, checkItemDefinitionId = blockDef.freeTextDefinitionId,
labelSnapshot = DocumentationCatalog.FREE_TEXT_LABEL, labelSnapshot = DocumentationCatalog.FREE_TEXT_LABEL,
itemOrder = blockDef.items.size + 1, itemOrder = 0,
checked = false, checked = false,
isFreeText = true, isFreeText = true,
freeTextValue = "", freeTextValue = "",
@@ -2,7 +2,6 @@ package de.medapp.wunddoku.pdf
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.Paint import android.graphics.Paint
import android.graphics.pdf.PdfDocument import android.graphics.pdf.PdfDocument
@@ -84,10 +83,9 @@ class AndroidPdfDocumentExporter(
private const val SIGNATURE_SPACING_AFTER_BLOCK = 16f private const val SIGNATURE_SPACING_AFTER_BLOCK = 16f
private const val SIGNATURE_SECTION_TITLE_SPACING_AFTER = 8f private const val SIGNATURE_SECTION_TITLE_SPACING_AFTER = 8f
// Vektor-Checkbox: Kantenlänge des gezeichneten Kästchens und Abstand zum Label-Text. // Änderungswunsch: kein Checkbox-Symbol mehr im PDF - nur noch eine schlichte Einrückung
private const val CHECKBOX_SIZE = 10f // der (bereits ausschließlich angehakten) Optionen unter dem Blocktitel.
private const val CHECKBOX_GAP = 6f private const val ITEM_INDENT = 10f
private const val CHECKBOX_INDENT = CHECKBOX_SIZE + CHECKBOX_GAP
/** /**
* Zielauflösung für den Fotoanhang, bewusst als Kompromiss zwischen Bildqualität und * Zielauflösung für den Fotoanhang, bewusst als Kompromiss zwischen Bildqualität und
@@ -244,21 +242,6 @@ class AndroidPdfDocumentExporter(
color = Color.DKGRAY color = Color.DKGRAY
} }
// Reine Vektor-Checkbox-Paints - keine Textschrift/Glyphen-Abhängigkeit.
val checkboxOutline = Paint().apply {
isAntiAlias = true
style = Paint.Style.STROKE
strokeWidth = 1.2f
color = Color.BLACK
}
val checkboxMark = Paint().apply {
isAntiAlias = true
style = Paint.Style.STROKE
strokeWidth = 1.5f
strokeCap = Paint.Cap.ROUND
color = Color.BLACK
}
/** Für das Zeichnen der Fotoanhang-Bitmaps (PDF-004). `isFilterBitmap` sorgt für /** Für das Zeichnen der Fotoanhang-Bitmaps (PDF-004). `isFilterBitmap` sorgt für
* weiche statt treppenartige Kanten beim Canvas-seitigen Skalieren. */ * weiche statt treppenartige Kanten beim Canvas-seitigen Skalieren. */
val photo = Paint().apply { val photo = Paint().apply {
@@ -294,24 +277,23 @@ class AndroidPdfDocumentExporter(
cursor.drawDivider(spacingBefore = 4f, spacingAfter = 12f) cursor.drawDivider(spacingBefore = 4f, spacingAfter = 12f)
} }
/** Änderungswunsch: KEIN Checkbox-Symbol mehr im PDF. Da [PdfExportBlock.items] bereits nur
* angehakte Optionen enthält (s. Mapper), wäre ein "immer angekreuztes" Kästchen ohnehin
* redundant - stattdessen ein einfacher, leicht eingerückter Label-Text je Option. */
private fun drawItem(cursor: PdfPageCursor, item: PdfExportItem, paints: ExportPaints) { private fun drawItem(cursor: PdfPageCursor, item: PdfExportItem, paints: ExportPaints) {
// Die Freitext-Option bekommt einen abschließenden Doppelpunkt, normale Optionen nicht. // Die Freitext-Option bekommt einen abschließenden Doppelpunkt, normale Optionen nicht.
val labelText = if (item.isFreeText) "${item.label}:" else item.label val labelText = if (item.isFreeText) "${item.label}:" else item.label
val checked = item.checked
cursor.drawText( cursor.drawText(
text = labelText, text = labelText,
paint = paints.item, paint = paints.item,
indent = CHECKBOX_INDENT, indent = ITEM_INDENT,
spacingAfter = 3f, spacingAfter = 3f,
leadingMark = { canvas, left, top, bottom ->
drawCheckboxGlyph(canvas, left, top, bottom, checked, paints)
},
) )
// Freitext nur zeichnen, wenn die Option angehakt UND tatsächlich Text vorhanden ist - // Freitext nur zeichnen, wenn tatsächlich Text vorhanden ist - ein angehakter, aber
// ein angehakter, aber leerer Freitext erzeugt keinen leeren Bereich im PDF. // leerer Freitext erzeugt keinen leeren Bereich im PDF.
if (item.isFreeText && checked && item.freeTextValue.isNotBlank()) { if (item.isFreeText && item.freeTextValue.isNotBlank()) {
cursor.drawText( cursor.drawText(
text = item.freeTextValue, text = item.freeTextValue,
paint = paints.freeText, paint = paints.freeText,
@@ -321,33 +303,6 @@ class AndroidPdfDocumentExporter(
} }
} }
/**
* Zeichnet ein Checkbox-Symbol rein per Canvas-Vektorprimitiven: ein Quadrat-Umriss, bei
* `checked = true` zusätzlich ein Kreuz darin. [top]/[bottom] sind die Geräte-Y-Koordinaten
* der zugehörigen ersten Textzeile - das Kästchen wird vertikal darauf zentriert.
*/
private fun drawCheckboxGlyph(
canvas: Canvas,
left: Float,
top: Float,
bottom: Float,
checked: Boolean,
paints: ExportPaints,
) {
val centerY = (top + bottom) / 2f
val boxTop = centerY - CHECKBOX_SIZE / 2f
val boxBottom = centerY + CHECKBOX_SIZE / 2f
val boxRight = left + CHECKBOX_SIZE
canvas.drawRect(left, boxTop, boxRight, boxBottom, paints.checkboxOutline)
if (checked) {
val inset = CHECKBOX_SIZE * 0.2f
canvas.drawLine(left + inset, boxTop + inset, boxRight - inset, boxBottom - inset, paints.checkboxMark)
canvas.drawLine(boxRight - inset, boxTop + inset, left + inset, boxBottom - inset, paints.checkboxMark)
}
}
/** /**
* Zeichnet den kompletten Signaturbereich (PDF-003): eine einmalige Überschrift * Zeichnet den kompletten Signaturbereich (PDF-003): eine einmalige Überschrift
* "Unterschriften", gefolgt von einem atomaren Block je Signatur (bereits vom Mapper stabil * "Unterschriften", gefolgt von einem atomaren Block je Signatur (bereits vom Mapper stabil
@@ -28,12 +28,16 @@ object DocumentationToPdfExportModelMapper {
.map { block -> .map { block ->
PdfExportBlock( PdfExportBlock(
title = block.title, title = block.title,
// Änderungswunsch: nur angehakte Optionen erscheinen im PDF - nicht
// angehakte Checkboxen werden komplett weggelassen (nicht nur unausgefüllt
// dargestellt). Ein leeres Ergebnis ist hier ausgeschlossen: BlockEditedPolicy
// (Filter oben) lässt nur Blöcke mit mindestens einer angehakten Option durch.
items = block.items items = block.items
.filter { it.checked }
.sortedBy { it.order } .sortedBy { it.order }
.map { item -> .map { item ->
PdfExportItem( PdfExportItem(
label = item.label, label = item.label,
checked = item.checked,
isFreeText = item.isFreeText, isFreeText = item.isFreeText,
freeTextValue = item.freeTextValue, freeTextValue = item.freeTextValue,
) )
@@ -28,14 +28,15 @@ data class PdfExportModel(
data class PdfExportBlock( data class PdfExportBlock(
val title: String, val title: String,
/** Bereits sortiert nach itemOrder. Enthält ALLE Optionen des Blocks (angehakt und nicht /** Bereits gefiltert (NUR angehakte Optionen, s. Mapper - Änderungswunsch) und sortiert
* angehakt) - siehe Architekturentscheidung zur Checkbox-Darstellung. */ * (itemOrder). Nicht angehakte Optionen erscheinen im PDF überhaupt nicht mehr. */
val items: List<PdfExportItem>, val items: List<PdfExportItem>,
) )
/** Kein `checked`-Feld mehr: Da [PdfExportBlock.items] bereits ausschließlich angehakte
* Optionen enthält, wäre es immer `true` und damit bedeutungslos - s. Mapper-Filter. */
data class PdfExportItem( data class PdfExportItem(
val label: String, val label: String,
val checked: Boolean,
val isFreeText: Boolean, val isFreeText: Boolean,
val freeTextValue: String, val freeTextValue: String,
) )
@@ -7,6 +7,7 @@ import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -24,7 +25,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.BrokenImage import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.PictureAsPdf import androidx.compose.material.icons.filled.PictureAsPdf
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -95,6 +99,13 @@ fun DocumentationEditScreen(
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// Vollbildansicht eines Fotos beim Antippen einer Kachel (Änderungswunsch) - rein lokaler
// UI-Zustand, bewusst nicht im ViewModel gehalten (analog zu den transient geladenen
// Thumbnail-/Signatur-Bitmaps, s. Kommentare an PhotoThumbnail/loadPhotoThumbnail): welches
// Foto gerade großformatig angezeigt wird, ist reine Präsentationslogik ohne fachliche
// Bedeutung, die einen Prozesstod überdauern müsste.
var viewedPhoto by remember { mutableStateOf<Photo?>(null) }
// Best-effort-Flush ausstehender Freitext-Änderungen, wenn der Screen den sichtbaren // Best-effort-Flush ausstehender Freitext-Änderungen, wenn der Screen den sichtbaren
// Bereich verlässt (App gewechselt, Bildschirm gesperrt, ...). KEINE Garantie bei hartem // Bereich verlässt (App gewechselt, Bildschirm gesperrt, ...). KEINE Garantie bei hartem
// Prozessabbruch, aber sinnvolle zusätzliche Absicherung für den häufigen Fall. // Prozessabbruch, aber sinnvolle zusätzliche Absicherung für den häufigen Fall.
@@ -153,8 +164,8 @@ fun DocumentationEditScreen(
if (uiState.blockCount > 0) { if (uiState.blockCount > 0) {
Text( Text(
stringResource( stringResource(
R.string.documentation_edit_position, R.string.documentation_edit_progress,
uiState.currentBlockIndex + 1, uiState.editedBlockCount,
uiState.blockCount, uiState.blockCount,
) )
) )
@@ -193,23 +204,20 @@ fun DocumentationEditScreen(
else -> { else -> {
val documentation = uiState.documentation val documentation = uiState.documentation
val currentBlock = documentation?.blocks?.getOrNull(uiState.currentBlockIndex) if (documentation == null || documentation.blocks.isEmpty()) {
if (documentation == null || currentBlock == null) {
Column(modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp)) { Column(modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp)) {
Text("Diese Dokumentation enthält keine Blöcke.") Text("Diese Dokumentation enthält keine Blöcke.")
} }
} else { } else {
DocumentationEditContent( DocumentationEditContent(
modifier = Modifier.fillMaxSize().padding(padding), modifier = Modifier.fillMaxSize().padding(padding),
block = currentBlock, blocks = documentation.blocks,
expandedBlockId = uiState.expandedBlockId,
onToggleBlockExpanded = viewModel::toggleBlockExpanded,
photos = documentation.photos, photos = documentation.photos,
signatures = documentation.signatures, signatures = documentation.signatures,
onCheckedChanged = viewModel::onCheckedChanged, onCheckedChanged = viewModel::onCheckedChanged,
onFreeTextChanged = viewModel::onFreeTextChanged, onFreeTextChanged = viewModel::onFreeTextChanged,
canGoBack = uiState.currentBlockIndex > 0,
canGoNext = uiState.currentBlockIndex < uiState.blockCount - 1,
onPrevious = viewModel::onPreviousBlock,
onNext = viewModel::onNextBlock,
photoImportInProgress = uiState.photoImportInProgress, photoImportInProgress = uiState.photoImportInProgress,
photoImportError = uiState.photoImportError, photoImportError = uiState.photoImportError,
onDismissPhotoImportError = viewModel::dismissPhotoImportError, onDismissPhotoImportError = viewModel::dismissPhotoImportError,
@@ -220,6 +228,7 @@ fun DocumentationEditScreen(
}, },
onDeletePhotoRequested = viewModel::requestDeletePhoto, onDeletePhotoRequested = viewModel::requestDeletePhoto,
loadThumbnail = viewModel::loadPhotoThumbnail, loadThumbnail = viewModel::loadPhotoThumbnail,
onPhotoClick = { viewedPhoto = it },
onAddSignatureClick = viewModel::requestAddSignature, onAddSignatureClick = viewModel::requestAddSignature,
onDeleteSignatureRequested = viewModel::requestDeleteSignature, onDeleteSignatureRequested = viewModel::requestDeleteSignature,
loadSignatureBitmap = viewModel::loadSignatureBitmap, loadSignatureBitmap = viewModel::loadSignatureBitmap,
@@ -295,32 +304,44 @@ fun DocumentationEditScreen(
}, },
) )
} }
// Änderungswunsch: Foto in Originalgröße anzeigen.
val photoToView = viewedPhoto
if (photoToView != null) {
PhotoViewerDialog(
photo = photoToView,
loadFullSize = viewModel::loadPhotoFullSize,
onDismiss = { viewedPhoto = null },
)
}
} }
/** /**
* Gesamter scrollbarer Inhalt der Dokumentationsbearbeitung: aktueller Block (Titel, Optionen, * Gesamter scrollbarer Inhalt der Dokumentationsbearbeitung: Akkordeon aller Blöcke
* Freitext-Option, Vor-/Zurück-Navigation) gefolgt vom dokumentationsweiten Fotobereich. Als * (Änderungswunsch) - zunächst stehen nur die Überschriften untereinander, Antippen einer
* Überschrift klappt genau diesen Block auf und schließt dabei automatisch einen zuvor
* aufgeklappten (s. [DocumentationEditViewModel.toggleBlockExpanded] - IMMER höchstens ein
* Block gleichzeitig offen) - gefolgt vom dokumentationsweiten Signatur- und Fotobereich. Als
* einzelne [LazyColumn] umgesetzt, damit Foto-Zeilen erst decodiert werden, wenn sie sichtbar * einzelne [LazyColumn] umgesetzt, damit Foto-Zeilen erst decodiert werden, wenn sie sichtbar
* sind/werden. * sind/werden.
*/ */
@Composable @Composable
private fun DocumentationEditContent( private fun DocumentationEditContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
block: DocumentationBlock, blocks: List<DocumentationBlock>,
expandedBlockId: String?,
onToggleBlockExpanded: (blockStateId: String) -> Unit,
photos: List<Photo>, photos: List<Photo>,
signatures: List<Signature>, signatures: List<Signature>,
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit, onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit, onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit,
canGoBack: Boolean,
canGoNext: Boolean,
onPrevious: () -> Unit,
onNext: () -> Unit,
photoImportInProgress: Boolean, photoImportInProgress: Boolean,
photoImportError: String?, photoImportError: String?,
onDismissPhotoImportError: () -> Unit, onDismissPhotoImportError: () -> Unit,
onAddPhotosClick: () -> Unit, onAddPhotosClick: () -> Unit,
onDeletePhotoRequested: (photoId: String) -> Unit, onDeletePhotoRequested: (photoId: String) -> Unit,
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?, loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
onPhotoClick: (Photo) -> Unit,
onAddSignatureClick: () -> Unit, onAddSignatureClick: () -> Unit,
onDeleteSignatureRequested: (signatureId: String) -> Unit, onDeleteSignatureRequested: (signatureId: String) -> Unit,
loadSignatureBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?, loadSignatureBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?,
@@ -330,34 +351,14 @@ private fun DocumentationEditContent(
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
item(key = "block_title") { items(blocks, key = { it.blockStateId }) { block ->
Text(block.title, style = MaterialTheme.typography.headlineSmall) BlockAccordionItem(
} block = block,
expanded = block.blockStateId == expandedBlockId,
items(block.items, key = { it.checkItemStateId }) { item -> onHeaderClick = { onToggleBlockExpanded(block.blockStateId) },
if (item.isFreeText) {
FreeTextCheckItemRow(
item = item,
onCheckedChanged = onCheckedChanged, onCheckedChanged = onCheckedChanged,
onFreeTextChanged = onFreeTextChanged, onFreeTextChanged = onFreeTextChanged,
) )
} else {
CheckItemRow(item = item, onCheckedChanged = onCheckedChanged)
}
}
item(key = "block_navigation") {
Row(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
OutlinedButton(onClick = onPrevious, enabled = canGoBack) {
Text(stringResource(R.string.documentation_edit_previous))
}
Button(onClick = onNext, enabled = canGoNext) {
Text(stringResource(R.string.documentation_edit_next))
}
}
} }
// MEDIA-003/004: dokumentationsweiter Signaturbereich, analog zum Fotobereich direkt // MEDIA-003/004: dokumentationsweiter Signaturbereich, analog zum Fotobereich direkt
@@ -451,7 +452,12 @@ private fun DocumentationEditContent(
} }
} else { } else {
items(photos.chunked(PHOTO_GRID_COLUMNS), key = { row -> row.joinToString(separator = "_") { it.id } }) { row -> items(photos.chunked(PHOTO_GRID_COLUMNS), key = { row -> row.joinToString(separator = "_") { it.id } }) { row ->
PhotoRow(photos = row, loadThumbnail = loadThumbnail, onDeleteRequested = onDeletePhotoRequested) PhotoRow(
photos = row,
loadThumbnail = loadThumbnail,
onDeleteRequested = onDeletePhotoRequested,
onPhotoClick = onPhotoClick,
)
} }
} }
} }
@@ -464,6 +470,7 @@ private fun PhotoRow(
photos: List<Photo>, photos: List<Photo>,
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?, loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
onDeleteRequested: (photoId: String) -> Unit, onDeleteRequested: (photoId: String) -> Unit,
onPhotoClick: (Photo) -> Unit,
) { ) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
photos.forEach { photo -> photos.forEach { photo ->
@@ -471,6 +478,7 @@ private fun PhotoRow(
photo = photo, photo = photo,
loadThumbnail = loadThumbnail, loadThumbnail = loadThumbnail,
onDeleteRequested = { onDeleteRequested(photo.id) }, onDeleteRequested = { onDeleteRequested(photo.id) },
onClick = { onPhotoClick(photo) },
) )
} }
} }
@@ -483,12 +491,17 @@ private fun PhotoRow(
* (siehe Kommentar an [DocumentationEditViewModel.loadPhotoThumbnail]). Verlässt die Kachel die * (siehe Kommentar an [DocumentationEditViewModel.loadPhotoThumbnail]). Verlässt die Kachel die
* Komposition (z. B. weil die Dokumentation viele Fotos hat und diese Zeile aktuell nicht * Komposition (z. B. weil die Dokumentation viele Fotos hat und diese Zeile aktuell nicht
* sichtbar ist), wird der Zustand automatisch freigegeben. * sichtbar ist), wird der Zustand automatisch freigegeben.
*
* Ein Antippen der Kachel (außerhalb des Löschen-Buttons) öffnet die Vollbildansicht
* (Änderungswunsch) - [onClick] statt eines eigenen Ladevorgangs hier, da die Kachel selbst nur
* das kleine, bereits geladene Thumbnail kennt.
*/ */
@Composable @Composable
private fun PhotoThumbnail( private fun PhotoThumbnail(
photo: Photo, photo: Photo,
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?, loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
onDeleteRequested: () -> Unit, onDeleteRequested: () -> Unit,
onClick: () -> Unit,
) { ) {
var bitmap by remember(photo.id) { mutableStateOf<Bitmap?>(null) } var bitmap by remember(photo.id) { mutableStateOf<Bitmap?>(null) }
var loadFailed by remember(photo.id) { mutableStateOf(false) } var loadFailed by remember(photo.id) { mutableStateOf(false) }
@@ -503,7 +516,8 @@ private fun PhotoThumbnail(
modifier = Modifier modifier = Modifier
.size(96.dp) .size(96.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant), .background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(enabled = bitmap != null, onClick = onClick),
) { ) {
val currentBitmap = bitmap val currentBitmap = bitmap
when { when {
@@ -611,6 +625,73 @@ private fun SignatureRow(
} }
} }
/**
* Ein einzelner Block im Akkordeon (Änderungswunsch): eine antippbare Kopfzeile mit Titel,
* einem kleinen "bearbeitet"-Indikator (grüner Haken, s. [DocumentationBlock.isEdited]) und
* einem Pfeil-Icon, das den Auf-/Zugeklappt-Zustand anzeigt. Der eigentliche Inhalt (Checkboxen
* inkl. Freitext-Option) wird ausschließlich gerendert, wenn [expanded] `true` ist - das
* entspricht fachlich einem Dropdown/Expansion-Panel, technisch einfach bedingtes Komponieren
* ohne eigene Lazy-Liste (ein Block hat nur wenige Optionen).
*/
@Composable
private fun BlockAccordionItem(
block: DocumentationBlock,
expanded: Boolean,
onHeaderClick: () -> Unit,
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit,
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onHeaderClick)
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (block.isEdited) {
Icon(
Icons.Filled.CheckCircle,
contentDescription = stringResource(R.string.documentation_edit_block_edited),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(8.dp))
}
Text(
block.title,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
Icon(
if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = null,
)
}
if (expanded) {
Column(
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
for (item in block.items) {
if (item.isFreeText) {
FreeTextCheckItemRow(
item = item,
onCheckedChanged = onCheckedChanged,
onFreeTextChanged = onFreeTextChanged,
)
} else {
CheckItemRow(item = item, onCheckedChanged = onCheckedChanged)
}
}
}
}
HorizontalDivider()
}
}
@Composable @Composable
private fun CheckItemRow( private fun CheckItemRow(
item: CheckItem, item: CheckItem,
@@ -28,7 +28,11 @@ import java.io.File
data class DocumentationEditUiState( data class DocumentationEditUiState(
val documentation: Documentation? = null, val documentation: Documentation? = null,
val currentBlockIndex: Int = 0, /** ID des BlockState, der gerade im Akkordeon aufgeklappt ist - `null`, wenn keiner
* aufgeklappt ist (Änderungswunsch: zunächst nur alle Überschriften untereinander, Antippen
* einer Überschrift klappt sie auf). Es ist IMMER höchstens ein Block gleichzeitig
* aufgeklappt, s. [DocumentationEditViewModel.toggleBlockExpanded]. */
val expandedBlockId: String? = null,
val isLoading: Boolean = true, val isLoading: Boolean = true,
val error: String? = null, val error: String? = null,
/** true, während ausgewählte Fotos importiert werden (z. B. für einen Ladeindikator). */ /** true, während ausgewählte Fotos importiert werden (z. B. für einen Ladeindikator). */
@@ -61,6 +65,13 @@ data class DocumentationEditUiState(
val lastExportedPdfFile: File? = null, val lastExportedPdfFile: File? = null,
) { ) {
val blockCount: Int get() = documentation?.blocks?.size ?: 0 val blockCount: Int get() = documentation?.blocks?.size ?: 0
/** Anzahl bereits bearbeiteter Blöcke (s. [de.medapp.wunddoku.domain.usecase.BlockEditedPolicy])
* - ersetzt die frühere "X / Y"-Positionsanzeige, die sich auf den einzelnen, gerade
* angezeigten Block bezog; im Akkordeon-Layout sind ALLE Blöcke gleichzeitig sichtbar
* (nur der Inhalt ist ein-/ausklappbar), daher ist ein Fortschritt sinnvoller als eine
* Position. */
val editedBlockCount: Int get() = documentation?.blocks?.count { it.isEdited } ?: 0
} }
/** /**
@@ -163,17 +174,17 @@ class DocumentationEditViewModel(
viewModelScope.launch { repository.setChecked(checkItemStateId, checked) } viewModelScope.launch { repository.setChecked(checkItemStateId, checked) }
} }
fun onNextBlock() { /**
* Klappt den Block mit [blockStateId] auf bzw. zu (Änderungswunsch: Akkordeon statt
* Vor-/Zurück-Navigation). Antippen eines bereits aufgeklappten Blocks klappt ihn zu;
* Antippen eines anderen Blocks klappt diesen auf und ersetzt dabei automatisch einen zuvor
* aufgeklappten Block - es ist also IMMER höchstens ein Block gleichzeitig aufgeklappt,
* ganz ohne zusätzliche Schließen-Logik für den vorherigen.
*/
fun toggleBlockExpanded(blockStateId: String) {
_uiState.update { state -> _uiState.update { state ->
val lastIndex = state.blockCount - 1 val newExpandedId = if (state.expandedBlockId == blockStateId) null else blockStateId
if (lastIndex < 0) return@update state state.copy(expandedBlockId = newExpandedId)
state.copy(currentBlockIndex = (state.currentBlockIndex + 1).coerceAtMost(lastIndex))
}
}
fun onPreviousBlock() {
_uiState.update { state ->
state.copy(currentBlockIndex = (state.currentBlockIndex - 1).coerceAtLeast(0))
} }
} }
@@ -365,6 +376,10 @@ class DocumentationEditViewModel(
suspend fun loadPhotoThumbnail(documentationId: String, fileName: String) = suspend fun loadPhotoThumbnail(documentationId: String, fileName: String) =
repository.loadPhotoThumbnail(documentationId, fileName) repository.loadPhotoThumbnail(documentationId, fileName)
/** Für die Vollbildansicht beim Antippen einer Foto-Kachel (Änderungswunsch). */
suspend fun loadPhotoFullSize(documentationId: String, fileName: String, maxWidthPx: Int, maxHeightPx: Int) =
repository.loadPhotoFullSize(documentationId, fileName, maxWidthPx, maxHeightPx)
/** Lädt eine Signatur für die Vorschau in der Dokumentationsbearbeitung - analog /** Lädt eine Signatur für die Vorschau in der Dokumentationsbearbeitung - analog
* [loadPhotoThumbnail], siehe dortigen Kommentar zur bewussten Nicht-Haltung im UiState. */ * [loadPhotoThumbnail], siehe dortigen Kommentar zur bewussten Nicht-Haltung im UiState. */
suspend fun loadSignatureBitmap(documentationId: String, fileName: String) = suspend fun loadSignatureBitmap(documentationId: String, fileName: String) =
@@ -0,0 +1,124 @@
package de.medapp.wunddoku.ui.documentation.edit
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import de.medapp.wunddoku.R
import de.medapp.wunddoku.domain.model.Photo
/**
* Vollbild-Ansicht eines einzelnen Fotos in Originalgröße (Änderungswunsch: "Fotos in der App
* durch Antippen in Originalgröße angezeigt werden"). Lädt das Foto über [loadFullSize] separat
* von der kleinen Grid-Vorschau ([DocumentationEditViewModel.loadPhotoThumbnail]) - dieselbe
* Trennung wie bei den Signatur-Vorschauen: die kleine Kachel und die Vollbildansicht sind
* unabhängige Ladevorgänge mit unterschiedlicher Zielauflösung.
*
* "Originalgröße" bedeutet hier: so groß wie der Bildschirm es zulässt, ohne unnötig über die
* tatsächliche Displayauflösung hinaus zu decodieren (s. Kommentar an
* [de.medapp.wunddoku.data.repository.DocumentationRepository.loadPhotoFullSize]) - eine echte
* 1:1-Pixel-Darstellung würde bei modernen Fotoauflösungen ohnehin über die Bildschirmgröße
* hinausgehen und wäre nur per Zoom/Scroll sichtbar, was hier nicht gefordert ist.
*
* Volle Bildschirmbreite/-höhe via [DialogProperties.usePlatformDefaultWidth] = false, da der
* Standard-Dialog-Rahmen von Compose sonst deutlich schmaler als der Bildschirm wäre. Schließen
* über den Schließen-Button ODER Antippen des dunklen Hintergrunds.
*/
@Composable
fun PhotoViewerDialog(
photo: Photo,
loadFullSize: suspend (documentationId: String, fileName: String, maxWidthPx: Int, maxHeightPx: Int) -> Bitmap?,
onDismiss: () -> Unit,
) {
var bitmap by remember(photo.id) { mutableStateOf<Bitmap?>(null) }
var loadFailed by remember(photo.id) { mutableStateOf(false) }
val configuration = LocalConfiguration.current
val density = LocalDensity.current
LaunchedEffect(photo.id) {
val maxWidthPx = with(density) { configuration.screenWidthDp.dp.roundToPx() }
val maxHeightPx = with(density) { configuration.screenHeightDp.dp.roundToPx() }
val result = runCatching {
loadFullSize(photo.documentationId, photo.fileName, maxWidthPx, maxHeightPx)
}.getOrNull()
bitmap = result
loadFailed = result == null
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.clickable(onClick = onDismiss),
) {
val currentBitmap = bitmap
when {
currentBitmap != null -> Image(
bitmap = currentBitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize().padding(16.dp),
contentScale = ContentScale.Fit,
)
loadFailed -> Box(modifier = Modifier.fillMaxSize()) {
Icon(
Icons.Filled.BrokenImage,
contentDescription = null,
tint = Color.White,
modifier = Modifier.align(Alignment.Center).size(48.dp),
)
Text(
stringResource(R.string.documentation_edit_photo_load_failed),
color = Color.White,
modifier = Modifier.align(Alignment.BottomCenter).padding(24.dp),
)
}
else -> CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center),
color = Color.White,
)
}
IconButton(
onClick = onDismiss,
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
) {
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.action_cancel), tint = Color.White)
}
}
}
}
+3 -3
View File
@@ -32,14 +32,14 @@
<string name="patient_form_phone3">Telefonnummer 3 (optional)</string> <string name="patient_form_phone3">Telefonnummer 3 (optional)</string>
<string name="patient_form_save">Speichern</string> <string name="patient_form_save">Speichern</string>
<string name="documentation_edit_position">%1$d / %2$d</string> <string name="documentation_edit_progress">%1$d / %2$d bearbeitet</string>
<string name="documentation_edit_previous">Zurück</string> <string name="documentation_edit_block_edited">Block bearbeitet</string>
<string name="documentation_edit_next">Weiter</string>
<string name="documentation_edit_free_text_label">Freitext</string> <string name="documentation_edit_free_text_label">Freitext</string>
<string name="documentation_edit_photos_title">Fotos</string> <string name="documentation_edit_photos_title">Fotos</string>
<string name="documentation_edit_add_photos">Hinzufügen</string> <string name="documentation_edit_add_photos">Hinzufügen</string>
<string name="documentation_edit_no_photos">Noch keine Fotos vorhanden.</string> <string name="documentation_edit_no_photos">Noch keine Fotos vorhanden.</string>
<string name="documentation_edit_photo_load_failed">Foto konnte nicht geladen werden.</string>
<string name="documentation_edit_delete_photo_confirm_title">Foto löschen?</string> <string name="documentation_edit_delete_photo_confirm_title">Foto löschen?</string>
<string name="documentation_edit_delete_photo_confirm_text">Dieses Foto wird endgültig aus der Dokumentation entfernt. Diese Aktion kann nicht rückgängig gemacht werden.</string> <string name="documentation_edit_delete_photo_confirm_text">Dieses Foto wird endgültig aus der Dokumentation entfernt. Diese Aktion kann nicht rückgängig gemacht werden.</string>