MEDIA-001 & MEDIA-002
This commit is contained in:
+260
-33
@@ -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(
|
||||
|
||||
+86
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user