diff --git a/.gitignore b/.gitignore index 798292f..27e9ba9 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,7 @@ local.properties .DS_Store Thumbs.db -# Android profiling +# Android proFiling *.hprof # Signing keys 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 1fee058..70393f1 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 @@ -23,6 +23,7 @@ import de.medapp.wunddoku.domain.model.DocumentationStatus import de.medapp.wunddoku.domain.model.DocumentationSummary import de.medapp.wunddoku.domain.model.Photo import de.medapp.wunddoku.domain.model.Signature +import de.medapp.wunddoku.media.ImageDownsampler import de.medapp.wunddoku.media.PhotoStorage import de.medapp.wunddoku.signature.SignatureFileStorage import kotlinx.coroutines.flow.Flow @@ -117,7 +118,7 @@ class DocumentationRepository( */ suspend fun continueDocumentation( sourceDocumentationId: String, - copyPhotos: Boolean = false, + copyPhotos: Boolean = true, ): Documentation = db.withTransaction { val source = documentationDao.getFullDocumentation(sourceDocumentationId) ?: error("Quell-Dokumentation $sourceDocumentationId nicht gefunden") @@ -181,7 +182,11 @@ class DocumentationRepository( if (copyPhotos) { for (sourcePhoto in source.photos) { - val copiedFileName = photoStorage.duplicate(sourcePhoto.fileName) + val copiedFileName = photoStorage.duplicate( + sourceDocumentationId = source.documentation.id, + sourceFileName = sourcePhoto.fileName, + targetDocumentationId = newDocumentationId, + ) photoDao.insert( PhotoEntity( id = UUID.randomUUID().toString(), @@ -224,7 +229,7 @@ class DocumentationRepository( } suspend fun addPhoto(documentationId: String, sourceUri: Uri): Photo { - val fileName = photoStorage.copyFrom(sourceUri) + val fileName = photoStorage.importFrom(documentationId, sourceUri) val nextOrder = photoDao.getMaxOrderIndex(documentationId) + 1 val now = Instant.now() val entity = PhotoEntity( @@ -247,9 +252,18 @@ class DocumentationRepository( suspend fun removePhoto(photoId: String) { val photo = photoDao.getById(photoId) ?: return photoDao.delete(photo) - runCatching { photoStorage.delete(photo.fileName) } + runCatching { photoStorage.delete(photo.documentationId, photo.fileName) } } + /** + * Liefert eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter + * der Dokumentationsbearbeitung (kein Laden der vollen gespeicherten Auflösung). Wird von + * Compose ausschließlich über das ViewModel aufgerufen, nie direkt - siehe Architektur: + * Compose führt keine Dateizugriffe selbst durch. + */ + suspend fun loadPhotoThumbnail(documentationId: String, fileName: String): Bitmap? = + photoStorage.decodeThumbnail(documentationId, fileName, ImageDownsampler.THUMBNAIL_MAX_DIMENSION_PX) + suspend fun saveSignature(documentationId: String, signerName: String?, bitmap: Bitmap): Signature { val fileName = signatureFileStorage.save(bitmap) val now = Instant.now() @@ -273,10 +287,13 @@ class DocumentationRepository( suspend fun delete(documentationId: String) { val entity = documentationDao.getById(documentationId) ?: return - val photos = photoDao.getForDocumentation(documentationId) val signatures = signatureDao.getForDocumentation(documentationId) documentationDao.delete(entity) - photos.forEach { runCatching { photoStorage.delete(it.fileName) } } + // Ein einziger Verzeichnis-Löschvorgang statt Einzeldateien - siehe PhotoStorage- + // Kommentar zur dokumentationsbezogenen Ordnerstruktur. Room-Cascade entfernt zwar die + // PhotoEntity-Zeilen automatisch, aber keine Dateien aus filesDir - das übernimmt + // ausschließlich diese Methode. + runCatching { photoStorage.deleteAll(documentationId) } signatures.forEach { runCatching { signatureFileStorage.delete(it.fileName) } } } diff --git a/app/src/main/java/de/medapp/wunddoku/media/ImageDownsampler.kt b/app/src/main/java/de/medapp/wunddoku/media/ImageDownsampler.kt new file mode 100644 index 0000000..1d0c53a --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/media/ImageDownsampler.kt @@ -0,0 +1,95 @@ +package de.medapp.wunddoku.media + +import android.graphics.Bitmap +import android.graphics.Matrix +import androidx.exifinterface.media.ExifInterface + +/** + * Reine, von Android-Kontext (Storage/ContentResolver) unabhängige Bildverarbeitungs- + * Hilfsfunktionen - bewusst getrennt von [PhotoStorage], das für die Dateisystem-/ + * ContentResolver-Koordination zuständig ist. Dadurch einzeln testbar (Unit-Test ohne + * Instrumentierung möglich, da nur `android.graphics`-Typen ohne Kontext verwendet werden). + */ +object ImageDownsampler { + + /** + * Maximale längste Kante gespeicherter Fotos in Pixeln. + * + * Begründung: 2000px ist deutlich mehr, als für die Bildschirmanzeige auf Smartphone/Tablet + * nötig ist, aber klein genug, um RAM-/Speicherverbrauch bei modernen 12-48-Megapixel- + * Smartphone-Fotos drastisch zu reduzieren (ein 48-MP-Foto hätte z. B. ~8000x6000px - mehr + * als 60x so viele Pixel wie 2000px auf der langen Kante). 2000px reicht außerdem für eine + * spätere PDF-Einbettung in guter Druck-/Bildschirmqualität, ohne Rohaufnahmen dauerhaft in + * voller Auflösung vorzuhalten. + */ + const val MAX_STORED_DIMENSION_PX = 2000 + + /** + * Zielgröße für Vorschaubilder in der Fotoübersicht der Dokumentationsbearbeitung. Deutlich + * kleiner als die gespeicherte Datei, damit beim Rendern eines Fotogitters nicht + * versehentlich für jede Kachel die volle 2000px-Version decodiert wird. + */ + const val THUMBNAIL_MAX_DIMENSION_PX = 300 + + /** + * JPEG-Qualität beim Speichern (0-100). + * + * Begründung: 90 ist bewusst konservativ hoch gewählt. Die Bilder werden später Bestandteil + * medizinischer Dokumentations-PDFs (Wundränder, Verfärbungen, Größenverhältnisse müssen + * erkennbar bleiben) - eine aggressivere Kompression (z. B. 70-80) würde solche feinen + * Details sichtbar beeinträchtigen. 90 reduziert die Dateigröße gegenüber verlustfreier + * Speicherung trotzdem spürbar. + */ + const val JPEG_QUALITY = 90 + + /** + * Berechnet den `inSampleSize`-Wert für `BitmapFactory.Options`, mit dem ein Bild beim + * Decodieren bereits speicherschonend heruntergerechnet gelesen wird (Zweierpotenz- + * Downsampling direkt im Decoder), statt es vollständig in Originalauflösung zu laden und + * erst danach zu skalieren - das ist der von Android empfohlene Weg, um + * OutOfMemoryError bei großen Fotos zu vermeiden. + */ + fun computeInSampleSize(sourceWidth: Int, sourceHeight: Int, targetMaxDimension: Int): Int { + if (sourceWidth <= 0 || sourceHeight <= 0 || targetMaxDimension <= 0) return 1 + var inSampleSize = 1 + val largestSourceDimension = maxOf(sourceWidth, sourceHeight) + if (largestSourceDimension > targetMaxDimension) { + val halfDimension = largestSourceDimension / 2 + while (halfDimension / inSampleSize >= targetMaxDimension) { + inSampleSize *= 2 + } + } + return inSampleSize + } + + /** + * Wendet die EXIF-Ausrichtung [exifOrientation] auf [bitmap] an, sodass das Ergebnis bereits + * in der visuell korrekten Ausrichtung vorliegt (viele Smartphone-Kameras schreiben Fotos + * sensor-nativ und speichern die tatsächliche Ausrichtung nur als EXIF-Metadatum). + * + * Liefert bei `ORIENTATION_NORMAL`/`ORIENTATION_UNDEFINED` (inkl. fehlender EXIF-Daten, + * s. [PhotoStorage]) dieselbe Bitmap-Instanz unverändert zurück - kein unnötiges Neu- + * Rendern. Die aufrufende Stelle ist dafür verantwortlich, das Original zu recyceln, falls + * eine neue, rotierte Instanz zurückgegeben wurde und das Original nicht mehr gebraucht wird. + */ + fun applyExifOrientation(bitmap: Bitmap, exifOrientation: Int): Bitmap { + val matrix = Matrix() + when (exifOrientation) { + ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f) + ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f) + ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f) + ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f) + ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f) + ExifInterface.ORIENTATION_TRANSPOSE -> { + matrix.postRotate(90f) + matrix.postScale(-1f, 1f) + } + ExifInterface.ORIENTATION_TRANSVERSE -> { + matrix.postRotate(270f) + matrix.postScale(-1f, 1f) + } + else -> return bitmap // NORMAL oder UNDEFINED - keine Korrektur nötig + } + return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt b/app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt index 961a38e..43db5cc 100644 --- a/app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt +++ b/app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt @@ -1,65 +1,215 @@ package de.medapp.wunddoku.media import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.net.Uri +import androidx.exifinterface.media.ExifInterface +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.io.File import java.util.UUID /** - * Verwaltet die physischen Bilddateien im internen App-Speicher (context.filesDir/photos/). + * Verwaltet die physischen Bilddateien im internen App-Speicher, organisiert pro Dokumentation: * - * WICHTIG (Phase-Hinweis): Diese Implementierung kopiert Bilder in Phase 2 zunächst - * unverändert (1:1-Kopie der ausgewählten Datei). Downsampling großer Smartphone-Fotos und - * EXIF-Rotation (Aufgabenstellung Abschnitt 10) werden erst in Phase 4 ergänzt, wenn der - * Photo Picker tatsächlich angebunden wird – das Interface ist aber bereits so geschnitten, - * dass diese Erweiterung [copyFrom] intern betrifft, ohne die Repository-Schicht zu ändern. + * ``` + * filesDir/ + * └── photos/ + * └── / + * ├── .jpg + * └── ... + * ``` * - * Bewusst als Interface + Implementierung: erlaubt, in Unit-Tests eine Fake-Implementierung - * einzusetzen, ohne echtes Dateisystem anzufassen (Aufgabenstellung Abschnitt 21). + * Ein eigenes Unterverzeichnis pro Dokumentation (statt eines einzigen flachen Ordners für alle + * Fotos) hat zwei konkrete Vorteile: (1) Löschen einer kompletten Dokumentation kann das + * gesamte Verzeichnis in einem Schritt entfernen ([deleteAll]), statt Einzeldateien + * durchzugehen; (2) beim Fortsetzen einer Dokumentation mit kopierten Fotos landen die Kopien + * physisch getrennt vom Original in einem eigenen Verzeichnis - Löschen eines Fotos in der + * einen Dokumentation kann das Foto der anderen dadurch strukturell nicht berühren. + * + * Dateinamen sind durchgehend zufällige UUIDs, unabhängig vom ursprünglichen Dateinamen des + * importierten Fotos - vermeidet Kollisionen und verhindert, dass ein möglicherweise + * sensibler/persönlicher Originaldateiname ins interne Dateisystem übernommen wird. + * + * Alle Ein-/Ausgabeoperationen laufen über [Dispatchers.IO], nie auf dem Hauptthread. */ interface PhotoStorage { - /** Kopiert das Bild von [sourceUri] in den internen Speicher und liefert den relativen Dateinamen. */ - suspend fun copyFrom(sourceUri: Uri): String /** - * Erstellt eine physisch eigenständige Kopie einer bereits gespeicherten Datei und liefert - * deren neuen Dateinamen. Wird beim "Fortsetzen" mit `copyPhotos = true` benötigt: Eine neue - * DB-Zeile, die auf dieselbe Datei wie das Original zeigt, wäre falsch – sonst würde das - * Löschen des Fotos in einer Dokumentation es auch in der anderen entfernen. + * Importiert ein vom Android Photo Picker ausgewähltes Bild: liest es speicherschonend + * (Downsampling auf [ImageDownsampler.MAX_STORED_DIMENSION_PX] längster Kante über + * `BitmapFactory.Options.inSampleSize`, ohne das Originalbild vollständig in den Speicher + * zu laden), korrigiert die EXIF-Ausrichtung und speichert das Ergebnis als neue, + * eigenständige JPEG-Datei im internen Speicher. Liefert den relativen Dateinamen + * (ausschließlich der documentationId, s. [resolve]). + * + * Wirft eine Exception, wenn das Bild nicht gelesen/decodiert werden kann. Aufrufer sind + * dafür verantwortlich, das pro Bild abzufangen, damit ein einzelnes fehlerhaftes Bild bei + * Mehrfachauswahl nicht den gesamten Importvorgang abbricht. */ - suspend fun duplicate(fileName: String): String + suspend fun importFrom(documentationId: String, sourceUri: Uri): String - /** Löscht die physische Datei. Wirft KEINE Exception, wenn die Datei nicht (mehr) existiert. */ - suspend fun delete(fileName: String) + /** + * Erstellt eine physisch eigenständige Kopie einer bereits gespeicherten Datei in einer + * ANDEREN Dokumentation ([targetDocumentationId]). Wird bei "Dokumentation fortsetzen" mit + * `copyPhotos = true` benötigt: Eine neue DB-Zeile, die auf dieselbe Datei wie das Original + * zeigt, wäre falsch - sonst würde Löschen des Fotos in einer Dokumentation es auch in der + * anderen entfernen. + */ + suspend fun duplicate(sourceDocumentationId: String, sourceFileName: String, targetDocumentationId: String): String - /** Liefert die absolute [File] für einen gespeicherten relativen Dateinamen (z. B. zur Anzeige). */ - fun resolve(fileName: String): File + /** Löscht eine einzelne physische Datei. Wirft KEINE Exception, wenn die Datei nicht (mehr) existiert. */ + suspend fun delete(documentationId: String, fileName: String) + + /** + * Löscht das gesamte Fotoverzeichnis einer Dokumentation samt aller enthaltenen Dateien in + * einem Schritt. Verwendet, wenn die komplette Dokumentation gelöscht wird - Room-Cascade + * entfernt zwar die `PhotoEntity`-Zeilen, aber keine Dateien aus `filesDir`, das übernimmt + * ausschließlich diese Methode. + */ + suspend fun deleteAll(documentationId: String) + + /** + * Liefert die absolute [File] für einen gespeicherten relativen Dateinamen. Wird von + * MEDIA-001/002 selbst nicht für den Zugriff von außen benötigt (Compose greift nicht + * direkt auf Dateien zu, s. [decodeThumbnail]), steht aber für die spätere PDF- + * Fotoanhang-Funktionalität bereit, ohne diese hier vorwegzunehmen. + */ + fun resolve(documentationId: String, fileName: String): File + + /** + * Decodiert eine speicherschonende Vorschauversion (max. [maxDimensionPx] längste Kante) + * der gespeicherten Datei, statt die volle gespeicherte Auflösung zu laden - wichtig, damit + * ein Fotogitter mit vielen Bildern nicht für jede Kachel unnötig viel Speicher belegt. + * Liefert `null`, wenn die Datei fehlt oder nicht decodiert werden kann (kein Absturz, z. B. + * wenn eine Datei nachträglich außerhalb der App entfernt wurde). + */ + suspend fun decodeThumbnail(documentationId: String, fileName: String, maxDimensionPx: Int): Bitmap? } class InternalPhotoStorage(private val context: Context) : PhotoStorage { - private val photosDir: File by lazy { + private val photosRootDir: File by lazy { File(context.filesDir, "photos").apply { mkdirs() } } - override suspend fun copyFrom(sourceUri: Uri): String { - val fileName = "${UUID.randomUUID()}.jpg" - val targetFile = File(photosDir, fileName) - context.contentResolver.openInputStream(sourceUri)?.use { input -> - targetFile.outputStream().use { output -> input.copyTo(output) } - } ?: error("Konnte ausgewähltes Bild nicht öffnen (Uri nicht mehr gültig?): $sourceUri") - return fileName + private fun documentationDirPath(documentationId: String): File = + File(photosRootDir, documentationId) + + private fun ensureDocumentationDir(documentationId: String): File = + documentationDirPath(documentationId).apply { mkdirs() } + + override fun resolve(documentationId: String, fileName: String): File = + File(documentationDirPath(documentationId), fileName) + + override suspend fun importFrom(documentationId: String, sourceUri: Uri): String = + withContext(Dispatchers.IO) { + val resolver = context.contentResolver + + // 1) Nur die Abmessungen ermitteln (inJustDecodeBounds), ohne Pixel zu laden. + // + // WICHTIG: BitmapFactory.decodeStream(...) liefert bei inJustDecodeBounds = true + // IMMER null zurück (es befüllt nur boundsOptions, gibt aber nie ein Bitmap zurück). + // Die Null-Prüfung muss sich deshalb auf den STREAM selbst beziehen, nicht auf das + // (stets null-wertige) Ergebnis von decodeStream() - sonst würde jeder Import- + // Versuch fälschlich als "Bild konnte nicht geöffnet werden" fehlschlagen, komplett + // unabhängig vom tatsächlichen Bild. + val boundsOptions = BitmapFactory.Options().apply { inJustDecodeBounds = true } + val boundsStream = resolver.openInputStream(sourceUri) + ?: error("Bild konnte nicht geöffnet werden (Uri nicht mehr gültig?).") + boundsStream.use { input -> BitmapFactory.decodeStream(input, null, boundsOptions) } + + if (boundsOptions.outWidth <= 0 || boundsOptions.outHeight <= 0) { + error("Bildabmessungen konnten nicht ermittelt werden - vermutlich kein unterstütztes Bildformat.") + } + + val sampleSize = ImageDownsampler.computeInSampleSize( + boundsOptions.outWidth, + boundsOptions.outHeight, + ImageDownsampler.MAX_STORED_DIMENSION_PX, + ) + + // 2) Tatsächlich decodieren - bereits mit inSampleSize speicherschonend heruntergerechnet. + // Ein content://-InputStream lässt sich i. d. R. nicht zurückspulen, daher wird die + // Quelle für jeden Schritt separat neu geöffnet. + val decodeOptions = BitmapFactory.Options().apply { inSampleSize = sampleSize } + val decoded = resolver.openInputStream(sourceUri)?.use { input -> + BitmapFactory.decodeStream(input, null, decodeOptions) + } ?: error("Bild konnte nicht dekodiert werden.") + + // 3) EXIF-Ausrichtung auslesen. Fehlende/nicht lesbare EXIF-Daten führen bewusst zu + // ORIENTATION_NORMAL (keine Korrektur) statt zu einem Fehler - ein Bild ohne EXIF + // ist kein Importfehler. + val orientation = resolver.openInputStream(sourceUri)?.use { input -> + runCatching { + ExifInterface(input).getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL, + ) + }.getOrDefault(ExifInterface.ORIENTATION_NORMAL) + } ?: ExifInterface.ORIENTATION_NORMAL + + val corrected = ImageDownsampler.applyExifOrientation(decoded, orientation) + + // 4) Als neue, eigenständige JPEG-Datei im dokumentationsspezifischen Verzeichnis + // speichern. Erneutes Kodieren als JPEG verwirft dabei nebenbei die ursprünglichen + // EXIF-Metadaten (z. B. GPS-Position) des Originalfotos, statt sie 1:1 zu + // übernehmen - für sensible medizinische Dokumentationsfotos ein willkommener + // Nebeneffekt, keine bewusste Anforderung dieser Aufgabe. + val targetDir = ensureDocumentationDir(documentationId) + val fileName = "${UUID.randomUUID()}.jpg" + File(targetDir, fileName).outputStream().use { out -> + corrected.compress(Bitmap.CompressFormat.JPEG, ImageDownsampler.JPEG_QUALITY, out) + } + + if (corrected !== decoded) decoded.recycle() + corrected.recycle() + + fileName + } + + override suspend fun duplicate( + sourceDocumentationId: String, + sourceFileName: String, + targetDocumentationId: String, + ): String = withContext(Dispatchers.IO) { + val sourceFile = resolve(sourceDocumentationId, sourceFileName) + val targetFileName = "${UUID.randomUUID()}.jpg" + val targetFile = File(ensureDocumentationDir(targetDocumentationId), targetFileName) + sourceFile.copyTo(targetFile, overwrite = false) + targetFileName } - override suspend fun duplicate(fileName: String): String { - val newFileName = "${UUID.randomUUID()}.jpg" - resolve(fileName).copyTo(File(photosDir, newFileName), overwrite = false) - return newFileName + override suspend fun delete(documentationId: String, fileName: String) = withContext(Dispatchers.IO) { + resolve(documentationId, fileName).delete() + Unit } - override suspend fun delete(fileName: String) { - resolve(fileName).delete() + override suspend fun deleteAll(documentationId: String) = withContext(Dispatchers.IO) { + documentationDirPath(documentationId).deleteRecursively() + Unit } - override fun resolve(fileName: String): File = File(photosDir, fileName) + override suspend fun decodeThumbnail( + documentationId: String, + fileName: String, + maxDimensionPx: Int, + ): Bitmap? = withContext(Dispatchers.IO) { + val file = resolve(documentationId, fileName) + if (!file.exists()) return@withContext null + runCatching { + val boundsOptions = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(file.absolutePath, boundsOptions) + val sampleSize = ImageDownsampler.computeInSampleSize( + boundsOptions.outWidth, + boundsOptions.outHeight, + maxDimensionPx, + ) + BitmapFactory.decodeFile( + file.absolutePath, + BitmapFactory.Options().apply { inSampleSize = sampleSize }, + ) + }.getOrNull() + } } 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 d36d496..ca57317 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 @@ -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 -> 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, 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, + 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(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( 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 b18bc75..8c1af9a 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 @@ -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,90 @@ 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) { + 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++ + // Bewusst OHNE Uri/Dateipfad geloggt (Datenschutz-Vorgabe: keine Bild-Uris/ + // Dateipfade in Logs) - die Exception-Nachricht selbst genügt zur Diagnose. + android.util.Log.w( + "DocumentationEdit", + "Fotoimport fehlgeschlagen: ${e::class.simpleName}: ${e.message}", + ) + } + } + 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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cbeda3e..9c8932e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -37,6 +37,12 @@ Weiter Freitext + Fotos + Hinzufügen + Noch keine Fotos vorhanden. + Foto löschen? + Dieses Foto wird endgültig aus der Dokumentation entfernt. Diese Aktion kann nicht rückgängig gemacht werden. + Abbrechen Bestätigen