MEDIA-001 & MEDIA-002

This commit is contained in:
2026-08-23 22:45:13 +02:00
parent 84f3ba40e6
commit 9cb49dda5c
6 changed files with 646 additions and 72 deletions
@@ -1,44 +1,81 @@
package de.medapp.wunddoku.ui.documentation.edit
import android.graphics.Bitmap
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
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.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.Close
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import de.medapp.wunddoku.R
import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.domain.model.DocumentationBlock
import de.medapp.wunddoku.domain.model.Photo
/**
* Bearbeitungsansicht für eine Wunddokumentation (Aufgabenstellung Abschnitt 7 Schritt 2,
* Abschnitt 8). Zeigt in Phase 2 einen Block nach dem anderen mit Vor-/Zurück-Buttons und
* Positionsanzeige ("7 / 20"). Die von der Aufgabenstellung bevorzugte Swipe-Geste zwischen
* Blöcken sowie Fotos/Signatur/PDF-Export folgen in den Phasen 3–5.
* Bearbeitungsansicht für eine Wunddokumentation. Zeigt einen Block nach dem anderen mit
* Vor-/Zurück-Buttons und Positionsanzeige ("7 / 20"), darunter dokumentationsweit (nicht
* blockbezogen) den Fotobereich (MEDIA-001/002). Die von der Aufgabenstellung bevorzugte
* Swipe-Geste zwischen Blöcken sowie Signatur/PDF-Export folgen in späteren Phasen.
*
* Der Inhalt ist als [LazyColumn] aufgebaut (statt eines einzelnen `Column` mit
* `verticalScroll`), damit Foto-Vorschaubilder nur decodiert werden, wenn ihre Zeile tatsächlich
* sichtbar ist/wird - wichtig, da die Anzahl der Fotos einer Dokumentation nicht künstlich
* begrenzt werden soll (Aufgabenstellung MEDIA-002) und jedes zusätzliche gleichzeitig
* gehaltene Vorschaubild RAM kostet.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -49,9 +86,8 @@ fun DocumentationEditScreen(
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// Best-effort-Flush ausstehender Freitext-Änderungen, wenn der Screen den sichtbaren
// Bereich verlässt (App gewechselt, Bildschirm gesperrt, ...). Siehe Architektur-Dokument
// Abschnitt 9.2: KEINE Garantie bei hartem Prozessabbruch, aber sinnvolle zusätzliche
// Absicherung für den häufigen Fall.
// Bereich verlässt (App gewechselt, Bildschirm gesperrt, ...). KEINE Garantie bei hartem
// Prozessabbruch, aber sinnvolle zusätzliche Absicherung für den häufigen Fall.
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
@@ -63,6 +99,16 @@ fun DocumentationEditScreen(
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
// Android Photo Picker (MEDIA-001): benötigt KEINE READ_MEDIA_IMAGES-/
// READ_EXTERNAL_STORAGE-Berechtigung, da das System die Auswahl übernimmt und der App nur
// Zugriff auf die konkret gewählten Bilder gewährt. `PickMultipleVisualMedia` erlaubt
// Mehrfachauswahl, `ImageOnly` beschränkt die Auswahl auf Bilder. Bricht der Nutzer den
// Picker ohne Auswahl ab, liefert der Callback eine leere Liste - das wird im ViewModel
// (onPhotosPicked) als No-Op behandelt, nicht als Fehler.
val photoPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickMultipleVisualMedia(),
) { uris: List<Uri> -> viewModel.onPhotosPicked(uris) }
Scaffold(
topBar = {
TopAppBar(
@@ -80,8 +126,8 @@ fun DocumentationEditScreen(
}
},
actions = {
// PDF-Export/Unterschrift folgen in Phase 4/5. "Fertig" beendet die
// Bearbeitung für V1 bereits jetzt (Inhalte sind ohnehin per Autosave
// PDF-Export/Unterschrift folgen in einer späteren Phase. "Fertig" beendet
// die Bearbeitung bereits jetzt (Inhalte sind ohnehin per Autosave
// gesichert, kein expliziter Speichern-Button nötig).
Button(onClick = onFinished) { Text("Fertig") }
},
@@ -107,42 +153,86 @@ fun DocumentationEditScreen(
Text("Diese Dokumentation enthält keine Blöcke.")
}
} else {
DocumentationBlockContent(
DocumentationEditContent(
modifier = Modifier.fillMaxSize().padding(padding),
block = currentBlock,
photos = documentation.photos,
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,
onAddPhotosClick = {
photoPickerLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
},
onDeletePhotoRequested = viewModel::requestDeletePhoto,
loadThumbnail = viewModel::loadPhotoThumbnail,
)
}
}
}
}
val pendingDeletePhotoId = uiState.pendingDeletePhotoId
if (pendingDeletePhotoId != null) {
AlertDialog(
onDismissRequest = viewModel::dismissDeletePhoto,
title = { Text(stringResource(R.string.documentation_edit_delete_photo_confirm_title)) },
text = { Text(stringResource(R.string.documentation_edit_delete_photo_confirm_text)) },
confirmButton = {
TextButton(onClick = viewModel::confirmDeletePhoto) {
Text(stringResource(R.string.action_confirm))
}
},
dismissButton = {
TextButton(onClick = viewModel::dismissDeletePhoto) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
}
/**
* Gesamter scrollbarer Inhalt der Dokumentationsbearbeitung: aktueller Block (Titel, Optionen,
* Freitext-Option, Vor-/Zurück-Navigation) gefolgt vom dokumentationsweiten Fotobereich. Als
* einzelne [LazyColumn] umgesetzt, damit Foto-Zeilen erst decodiert werden, wenn sie sichtbar
* sind/werden.
*/
@Composable
private fun DocumentationBlockContent(
private fun DocumentationEditContent(
modifier: Modifier = Modifier,
block: DocumentationBlock,
photos: List<Photo>,
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?,
) {
Column(
modifier = modifier
.verticalScroll(rememberScrollState())
.padding(16.dp),
LazyColumn(
modifier = modifier,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(block.title, style = MaterialTheme.typography.headlineSmall)
item(key = "block_title") {
Text(block.title, style = MaterialTheme.typography.headlineSmall)
}
block.items.forEach { item ->
items(block.items, key = { it.checkItemStateId }) { item ->
if (item.isFreeText) {
FreeTextCheckItemRow(
item = item,
@@ -154,16 +244,154 @@ private fun DocumentationBlockContent(
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
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))
}
}
}
item(key = "photos_header") {
Column {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(stringResource(R.string.documentation_edit_photos_title), style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (photoImportInProgress) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
}
OutlinedButton(onClick = onAddPhotosClick, enabled = !photoImportInProgress) {
Icon(Icons.Filled.Add, contentDescription = null)
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.documentation_edit_add_photos))
}
}
}
}
}
if (photoImportError != null) {
item(key = "photo_import_error") {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
photoImportError,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onDismissPhotoImportError) { Text("OK") }
}
}
}
if (photos.isEmpty()) {
item(key = "photos_empty") {
Text(
stringResource(R.string.documentation_edit_no_photos),
style = MaterialTheme.typography.bodySmall,
)
}
} else {
items(photos.chunked(PHOTO_GRID_COLUMNS), key = { row -> row.joinToString(separator = "_") { it.id } }) { row ->
PhotoRow(photos = row, loadThumbnail = loadThumbnail, onDeleteRequested = onDeletePhotoRequested)
}
}
}
}
private const val PHOTO_GRID_COLUMNS = 3
@Composable
private fun PhotoRow(
photos: List<Photo>,
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
onDeleteRequested: (photoId: String) -> Unit,
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
photos.forEach { photo ->
PhotoThumbnail(
photo = photo,
loadThumbnail = loadThumbnail,
onDeleteRequested = { onDeleteRequested(photo.id) },
)
}
}
}
/**
* Eine einzelne Foto-Kachel im Fotogitter. Lädt ihre Vorschauversion selbst, transient über
* [LaunchedEffect]/`remember` (an `photo.id` gebunden) - das entstehende [Bitmap] wird
* ausschließlich lokal in dieser Composable gehalten, nicht im zentralen ViewModel-Zustand
* (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.
*/
@Composable
private fun PhotoThumbnail(
photo: Photo,
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
onDeleteRequested: () -> Unit,
) {
var bitmap by remember(photo.id) { mutableStateOf<Bitmap?>(null) }
var loadFailed by remember(photo.id) { mutableStateOf(false) }
LaunchedEffect(photo.id) {
val result = runCatching { loadThumbnail(photo.documentationId, photo.fileName) }.getOrNull()
bitmap = result
loadFailed = result == null
}
Box(
modifier = Modifier
.size(96.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
) {
val currentBitmap = bitmap
when {
currentBitmap != null -> Image(
bitmap = currentBitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
loadFailed -> Icon(
Icons.Filled.BrokenImage,
contentDescription = null,
modifier = Modifier.align(Alignment.Center),
)
else -> CircularProgressIndicator(
modifier = Modifier.size(24.dp).align(Alignment.Center),
strokeWidth = 2.dp,
)
}
IconButton(
onClick = onDeleteRequested,
modifier = Modifier.align(Alignment.TopEnd).size(28.dp),
) {
OutlinedButton(onClick = onPrevious, enabled = canGoBack) {
Text(stringResource(R.string.documentation_edit_previous))
}
Button(onClick = onNext, enabled = canGoNext) {
Text(stringResource(R.string.documentation_edit_next))
}
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.documentation_action_delete),
tint = Color.White,
)
}
}
}
@@ -183,10 +411,9 @@ private fun CheckItemRow(
}
/**
* Die Freitext-Option eines Blocks (Änderungswunsch: Freitext als weitere auswählbare Option
* statt eigenes Feld am Block). Das Textfeld ist deaktiviert, solange die zugehörige Checkbox
* nicht angehakt ist - so kann kein Freitext eingegeben werden, ohne dass die Option auch als
* "bearbeitet" gilt (siehe BlockEditedPolicy: `items.any { it.checked }`).
* Die Freitext-Option eines Blocks. Das Textfeld ist deaktiviert, solange die zugehörige
* Checkbox nicht angehakt ist - so kann kein Freitext eingegeben werden, ohne dass die Option
* auch als "bearbeitet" gilt (siehe BlockEditedPolicy: `items.any { it.checked }`).
*/
@Composable
private fun FreeTextCheckItemRow(
@@ -1,5 +1,6 @@
package de.medapp.wunddoku.ui.documentation.edit
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewModelScope
@@ -24,6 +25,13 @@ data class DocumentationEditUiState(
val currentBlockIndex: Int = 0,
val isLoading: Boolean = true,
val error: String? = null,
/** true, während ausgewählte Fotos importiert werden (z. B. für einen Ladeindikator). */
val photoImportInProgress: Boolean = false,
/** Zusammenfassende Fehlermeldung, falls beim letzten Importvorgang einzelne Bilder
* fehlgeschlagen sind - erfolgreich importierbare Bilder werden trotzdem übernommen. */
val photoImportError: String? = null,
/** != null, solange die Löschbestätigung für ein Foto angezeigt werden soll. */
val pendingDeletePhotoId: String? = null,
) {
val blockCount: Int get() = documentation?.blocks?.size ?: 0
}
@@ -141,6 +149,84 @@ class DocumentationEditViewModel(
}
}
/**
* Importiert die vom Android Photo Picker ausgewählten Bilder (MEDIA-001). Ein leerer
* [uris] (Nutzer hat den Picker ohne Auswahl verlassen) ist kein Fehler und wird
* stillschweigend ignoriert.
*
* Jedes Bild wird EINZELN abgefangen: Schlägt der Import eines Bildes fehl (defektes
* Format, nicht mehr lesbare Uri o. Ä.), werden die übrigen, erfolgreich importierbaren
* Bilder trotzdem übernommen - ein einzelnes fehlerhaftes Bild darf einen Mehrfachimport
* nicht komplett verhindern. Am Ende wird die Dokumentation einmalig neu geladen, damit die
* UI konsistent den tatsächlichen Datenbankstand zeigt.
*/
fun onPhotosPicked(uris: List<Uri>) {
if (uris.isEmpty()) return
viewModelScope.launch {
_uiState.update { it.copy(photoImportInProgress = true, photoImportError = null) }
var failureCount = 0
for (uri in uris) {
try {
repository.addPhoto(documentationId, uri)
} catch (e: Exception) {
failureCount++
}
}
val refreshed = repository.getFullDocumentation(documentationId)
_uiState.update { state ->
state.copy(
documentation = refreshed ?: state.documentation,
photoImportInProgress = false,
photoImportError = if (failureCount > 0) {
"$failureCount von ${uris.size} Bild(ern) konnten nicht importiert werden."
} else {
null
},
)
}
}
}
fun dismissPhotoImportError() {
_uiState.update { it.copy(photoImportError = null) }
}
/** Öffnet die Löschbestätigung für ein Foto (Aufgabenstellung: "vor dem endgültigen Löschen
* soll eine Bestätigung erfolgen"). */
fun requestDeletePhoto(photoId: String) {
_uiState.update { it.copy(pendingDeletePhotoId = photoId) }
}
fun dismissDeletePhoto() {
_uiState.update { it.copy(pendingDeletePhotoId = null) }
}
fun confirmDeletePhoto() {
val photoId = _uiState.value.pendingDeletePhotoId ?: return
viewModelScope.launch {
repository.removePhoto(photoId)
val refreshed = repository.getFullDocumentation(documentationId)
_uiState.update { state ->
state.copy(
documentation = refreshed ?: state.documentation,
pendingDeletePhotoId = null,
)
}
}
}
/**
* Lädt eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter.
* Bewusst NICHT im zentralen UiState gehalten (kein `Bitmap`-Feld in
* [DocumentationEditUiState]): Bitmaps sind vergleichsweise schwergewichtig und sollen nur
* transient in der jeweiligen Compose-Kachel gehalten werden, nicht dauerhaft im
* ViewModel-Zustand. Die eigentliche Datei-/Decodier-Logik bleibt vollständig in
* PhotoStorage (über das Repository) - Compose ruft ausschließlich diese suspend-Funktion
* auf und führt selbst keinen Datei-/ContentResolver-Zugriff aus.
*/
suspend fun loadPhotoThumbnail(documentationId: String, fileName: String) =
repository.loadPhotoThumbnail(documentationId, fileName)
/**
* Best-effort-Flush ausstehender, noch gedebouncter Freitext-Änderungen. Aufgerufen vom
* Screen bei `Lifecycle.Event.ON_STOP` – siehe Klassenkommentar zur Nicht-Garantie bei