Kundenwuensche

Nur angehakte Checkboxen im PDF
Fotos in Originalgröße per Antippen
Freitext zuerst in jedem Block
Akkordeon-Umbau (angefangen)
This commit is contained in:
2026-09-07 09:46:22 +02:00
parent cda0b4d3fe
commit 32d23f5d52
8 changed files with 322 additions and 122 deletions
@@ -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<Photo?>(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<DocumentationBlock>,
expandedBlockId: String?,
onToggleBlockExpanded: (blockStateId: String) -> Unit,
photos: List<Photo>,
signatures: List<Signature>,
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<Photo>,
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<Bitmap?>(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,
@@ -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) =
@@ -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)
}
}
}
}