From 32d23f5d52c35cb2e4e42ac954251a0bba4fd2ce Mon Sep 17 00:00:00 2001 From: NOrtmann1 Date: Mon, 7 Sep 2026 09:46:22 +0200 Subject: [PATCH] Kundenwuensche MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nur angehakte Checkboxen im PDF Fotos in Originalgröße per Antippen Freitext zuerst in jedem Block Akkordeon-Umbau (angefangen) --- .../repository/DocumentationRepository.kt | 26 ++- .../pdf/AndroidPdfDocumentExporter.kt | 65 +------ .../DocumentationToPdfExportModelMapper.kt | 6 +- .../de/medapp/wunddoku/pdf/PdfExportModel.kt | 7 +- .../edit/DocumentationEditScreen.kt | 173 +++++++++++++----- .../edit/DocumentationEditViewModel.kt | 37 ++-- .../documentation/edit/PhotoViewerDialog.kt | 124 +++++++++++++ app/src/main/res/values/strings.xml | 6 +- 8 files changed, 322 insertions(+), 122 deletions(-) create mode 100644 app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/PhotoViewerDialog.kt diff --git a/app/src/main/java/de/medapp/wunddoku/data/repository/DocumentationRepository.kt b/app/src/main/java/de/medapp/wunddoku/data/repository/DocumentationRepository.kt index c12f160..e051892 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/repository/DocumentationRepository.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/repository/DocumentationRepository.kt @@ -292,6 +292,19 @@ class DocumentationRepository( suspend fun loadPhotoThumbnail(documentationId: String, fileName: String): Bitmap? = 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 * 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 * Katalog-Checkbox PLUS eine zusätzliche, automatisch angehängte Freitext-Option - * (isFreeText = true, letzte Position im Block) - siehe DocumentationCatalog.FREE_TEXT_LABEL - * und BlockDefinition.freeTextDefinitionId. + * (isFreeText = true) - siehe DocumentationCatalog.FREE_TEXT_LABEL und + * 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) { for (blockDef in DocumentationCatalog.blocks) { @@ -400,7 +420,7 @@ class DocumentationRepository( blockStateId = blockStateId, checkItemDefinitionId = blockDef.freeTextDefinitionId, labelSnapshot = DocumentationCatalog.FREE_TEXT_LABEL, - itemOrder = blockDef.items.size + 1, + itemOrder = 0, checked = false, isFreeText = true, freeTextValue = "", diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt b/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt index 64f7b79..a32f2b1 100644 --- a/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt +++ b/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt @@ -2,7 +2,6 @@ package de.medapp.wunddoku.pdf import android.content.Context import android.graphics.Bitmap -import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.pdf.PdfDocument @@ -84,10 +83,9 @@ class AndroidPdfDocumentExporter( private const val SIGNATURE_SPACING_AFTER_BLOCK = 16f private const val SIGNATURE_SECTION_TITLE_SPACING_AFTER = 8f - // Vektor-Checkbox: Kantenlänge des gezeichneten Kästchens und Abstand zum Label-Text. - private const val CHECKBOX_SIZE = 10f - private const val CHECKBOX_GAP = 6f - private const val CHECKBOX_INDENT = CHECKBOX_SIZE + CHECKBOX_GAP + // Änderungswunsch: kein Checkbox-Symbol mehr im PDF - nur noch eine schlichte Einrückung + // der (bereits ausschließlich angehakten) Optionen unter dem Blocktitel. + private const val ITEM_INDENT = 10f /** * Zielauflösung für den Fotoanhang, bewusst als Kompromiss zwischen Bildqualität und @@ -244,21 +242,6 @@ class AndroidPdfDocumentExporter( 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 * weiche statt treppenartige Kanten beim Canvas-seitigen Skalieren. */ val photo = Paint().apply { @@ -294,24 +277,23 @@ class AndroidPdfDocumentExporter( 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) { // Die Freitext-Option bekommt einen abschließenden Doppelpunkt, normale Optionen nicht. val labelText = if (item.isFreeText) "${item.label}:" else item.label - val checked = item.checked cursor.drawText( text = labelText, paint = paints.item, - indent = CHECKBOX_INDENT, + indent = ITEM_INDENT, 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 - - // ein angehakter, aber leerer Freitext erzeugt keinen leeren Bereich im PDF. - if (item.isFreeText && checked && item.freeTextValue.isNotBlank()) { + // Freitext nur zeichnen, wenn tatsächlich Text vorhanden ist - ein angehakter, aber + // leerer Freitext erzeugt keinen leeren Bereich im PDF. + if (item.isFreeText && item.freeTextValue.isNotBlank()) { cursor.drawText( text = item.freeTextValue, 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 * "Unterschriften", gefolgt von einem atomaren Block je Signatur (bereits vom Mapper stabil diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapper.kt b/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapper.kt index e9f02b6..ea91483 100644 --- a/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapper.kt +++ b/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapper.kt @@ -28,12 +28,16 @@ object DocumentationToPdfExportModelMapper { .map { block -> PdfExportBlock( 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 + .filter { it.checked } .sortedBy { it.order } .map { item -> PdfExportItem( label = item.label, - checked = item.checked, isFreeText = item.isFreeText, freeTextValue = item.freeTextValue, ) diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/PdfExportModel.kt b/app/src/main/java/de/medapp/wunddoku/pdf/PdfExportModel.kt index 0a82e71..4dadbbf 100644 --- a/app/src/main/java/de/medapp/wunddoku/pdf/PdfExportModel.kt +++ b/app/src/main/java/de/medapp/wunddoku/pdf/PdfExportModel.kt @@ -28,14 +28,15 @@ data class PdfExportModel( data class PdfExportBlock( val title: String, - /** Bereits sortiert nach itemOrder. Enthält ALLE Optionen des Blocks (angehakt und nicht - * angehakt) - siehe Architekturentscheidung zur Checkbox-Darstellung. */ + /** Bereits gefiltert (NUR angehakte Optionen, s. Mapper - Änderungswunsch) und sortiert + * (itemOrder). Nicht angehakte Optionen erscheinen im PDF überhaupt nicht mehr. */ val items: List, ) +/** 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( val label: String, - val checked: Boolean, val isFreeText: Boolean, val freeTextValue: String, ) diff --git a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt index 9acf19a..b1a2e72 100644 --- a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt +++ b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt @@ -7,6 +7,7 @@ import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.filled.Add 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.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.PictureAsPdf import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -95,6 +99,13 @@ fun DocumentationEditScreen( ) { 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(null) } + // Best-effort-Flush ausstehender Freitext-Änderungen, wenn der Screen den sichtbaren // Bereich verlässt (App gewechselt, Bildschirm gesperrt, ...). KEINE Garantie bei hartem // Prozessabbruch, aber sinnvolle zusätzliche Absicherung für den häufigen Fall. @@ -153,8 +164,8 @@ fun DocumentationEditScreen( if (uiState.blockCount > 0) { Text( stringResource( - R.string.documentation_edit_position, - uiState.currentBlockIndex + 1, + R.string.documentation_edit_progress, + uiState.editedBlockCount, uiState.blockCount, ) ) @@ -193,23 +204,20 @@ fun DocumentationEditScreen( else -> { val documentation = uiState.documentation - val currentBlock = documentation?.blocks?.getOrNull(uiState.currentBlockIndex) - if (documentation == null || currentBlock == null) { + if (documentation == null || documentation.blocks.isEmpty()) { Column(modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp)) { Text("Diese Dokumentation enthält keine Blöcke.") } } else { DocumentationEditContent( modifier = Modifier.fillMaxSize().padding(padding), - block = currentBlock, + blocks = documentation.blocks, + expandedBlockId = uiState.expandedBlockId, + onToggleBlockExpanded = viewModel::toggleBlockExpanded, photos = documentation.photos, signatures = documentation.signatures, onCheckedChanged = viewModel::onCheckedChanged, onFreeTextChanged = viewModel::onFreeTextChanged, - canGoBack = uiState.currentBlockIndex > 0, - canGoNext = uiState.currentBlockIndex < uiState.blockCount - 1, - onPrevious = viewModel::onPreviousBlock, - onNext = viewModel::onNextBlock, photoImportInProgress = uiState.photoImportInProgress, photoImportError = uiState.photoImportError, onDismissPhotoImportError = viewModel::dismissPhotoImportError, @@ -220,6 +228,7 @@ fun DocumentationEditScreen( }, onDeletePhotoRequested = viewModel::requestDeletePhoto, loadThumbnail = viewModel::loadPhotoThumbnail, + onPhotoClick = { viewedPhoto = it }, onAddSignatureClick = viewModel::requestAddSignature, onDeleteSignatureRequested = viewModel::requestDeleteSignature, 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, - * Freitext-Option, Vor-/Zurück-Navigation) gefolgt vom dokumentationsweiten Fotobereich. Als + * Gesamter scrollbarer Inhalt der Dokumentationsbearbeitung: Akkordeon aller Blöcke + * (Ä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 * sind/werden. */ @Composable private fun DocumentationEditContent( modifier: Modifier = Modifier, - block: DocumentationBlock, + blocks: List, + expandedBlockId: String?, + onToggleBlockExpanded: (blockStateId: String) -> Unit, photos: List, signatures: List, onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit, onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit, - canGoBack: Boolean, - canGoNext: Boolean, - onPrevious: () -> Unit, - onNext: () -> Unit, photoImportInProgress: Boolean, photoImportError: String?, onDismissPhotoImportError: () -> Unit, onAddPhotosClick: () -> Unit, onDeletePhotoRequested: (photoId: String) -> Unit, loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?, + onPhotoClick: (Photo) -> Unit, onAddSignatureClick: () -> Unit, onDeleteSignatureRequested: (signatureId: String) -> Unit, loadSignatureBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?, @@ -330,34 +351,14 @@ private fun DocumentationEditContent( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - item(key = "block_title") { - Text(block.title, style = MaterialTheme.typography.headlineSmall) - } - - items(block.items, key = { it.checkItemStateId }) { item -> - if (item.isFreeText) { - FreeTextCheckItemRow( - item = item, - onCheckedChanged = onCheckedChanged, - 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)) - } - } + items(blocks, key = { it.blockStateId }) { block -> + BlockAccordionItem( + block = block, + expanded = block.blockStateId == expandedBlockId, + onHeaderClick = { onToggleBlockExpanded(block.blockStateId) }, + onCheckedChanged = onCheckedChanged, + onFreeTextChanged = onFreeTextChanged, + ) } // MEDIA-003/004: dokumentationsweiter Signaturbereich, analog zum Fotobereich direkt @@ -451,7 +452,12 @@ private fun DocumentationEditContent( } } else { 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, loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?, onDeleteRequested: (photoId: String) -> Unit, + onPhotoClick: (Photo) -> Unit, ) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { photos.forEach { photo -> @@ -471,6 +478,7 @@ private fun PhotoRow( photo = photo, loadThumbnail = loadThumbnail, onDeleteRequested = { onDeleteRequested(photo.id) }, + onClick = { onPhotoClick(photo) }, ) } } @@ -483,12 +491,17 @@ private fun PhotoRow( * (siehe Kommentar an [DocumentationEditViewModel.loadPhotoThumbnail]). Verlässt die Kachel die * Komposition (z. B. weil die Dokumentation viele Fotos hat und diese Zeile aktuell nicht * 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 private fun PhotoThumbnail( photo: Photo, loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?, onDeleteRequested: () -> Unit, + onClick: () -> Unit, ) { var bitmap by remember(photo.id) { mutableStateOf(null) } var loadFailed by remember(photo.id) { mutableStateOf(false) } @@ -503,7 +516,8 @@ private fun PhotoThumbnail( modifier = Modifier .size(96.dp) .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant), + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(enabled = bitmap != null, onClick = onClick), ) { val currentBitmap = bitmap 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 private fun CheckItemRow( item: CheckItem, diff --git a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt index 326c4f3..5ed53fd 100644 --- a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt +++ b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt @@ -28,7 +28,11 @@ import java.io.File data class DocumentationEditUiState( 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 error: String? = null, /** 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 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) } } - 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 -> - val lastIndex = state.blockCount - 1 - if (lastIndex < 0) return@update state - state.copy(currentBlockIndex = (state.currentBlockIndex + 1).coerceAtMost(lastIndex)) - } - } - - fun onPreviousBlock() { - _uiState.update { state -> - state.copy(currentBlockIndex = (state.currentBlockIndex - 1).coerceAtLeast(0)) + val newExpandedId = if (state.expandedBlockId == blockStateId) null else blockStateId + state.copy(expandedBlockId = newExpandedId) } } @@ -365,6 +376,10 @@ class DocumentationEditViewModel( suspend fun loadPhotoThumbnail(documentationId: String, fileName: String) = 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 * [loadPhotoThumbnail], siehe dortigen Kommentar zur bewussten Nicht-Haltung im UiState. */ suspend fun loadSignatureBitmap(documentationId: String, fileName: String) = diff --git a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/PhotoViewerDialog.kt b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/PhotoViewerDialog.kt new file mode 100644 index 0000000..3f66fc5 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/PhotoViewerDialog.kt @@ -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(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) + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9634c39..569d7a1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -32,14 +32,14 @@ Telefonnummer 3 (optional) Speichern - %1$d / %2$d - Zurück - Weiter + %1$d / %2$d bearbeitet + Block bearbeitet Freitext Fotos Hinzufügen Noch keine Fotos vorhanden. + Foto konnte nicht geladen werden. Foto löschen? Dieses Foto wird endgültig aus der Dokumentation entfernt. Diese Aktion kann nicht rückgängig gemacht werden. -- 2.47.3