Compare commits

...

5 Commits

7 changed files with 661 additions and 74 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ local.properties
.DS_Store
Thumbs.db
# Android profiling
# Android proFiling
*.hprof
# Signing keys
@@ -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) } }
}
@@ -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)
}
}
@@ -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/
* └── <documentationId>/
* ├── <uuid>.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()
}
}
@@ -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,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<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++
// 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
+6
View File
@@ -37,6 +37,12 @@
<string name="documentation_edit_next">Weiter</string>
<string name="documentation_edit_free_text_label">Freitext</string>
<string name="documentation_edit_photos_title">Fotos</string>
<string name="documentation_edit_add_photos">Hinzufügen</string>
<string name="documentation_edit_no_photos">Noch keine Fotos vorhanden.</string>
<string name="documentation_edit_delete_photo_confirm_title">Foto löschen?</string>
<string name="documentation_edit_delete_photo_confirm_text">Dieses Foto wird endgültig aus der Dokumentation entfernt. Diese Aktion kann nicht rückgängig gemacht werden.</string>
<string name="action_cancel">Abbrechen</string>
<string name="action_confirm">Bestätigen</string>
</resources>