MEDIA-001 & MEDIA-002 #4
+1
-1
@@ -25,7 +25,7 @@ local.properties
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Android profiling
|
# Android proFiling
|
||||||
*.hprof
|
*.hprof
|
||||||
|
|
||||||
# Signing keys
|
# 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.DocumentationSummary
|
||||||
import de.medapp.wunddoku.domain.model.Photo
|
import de.medapp.wunddoku.domain.model.Photo
|
||||||
import de.medapp.wunddoku.domain.model.Signature
|
import de.medapp.wunddoku.domain.model.Signature
|
||||||
|
import de.medapp.wunddoku.media.ImageDownsampler
|
||||||
import de.medapp.wunddoku.media.PhotoStorage
|
import de.medapp.wunddoku.media.PhotoStorage
|
||||||
import de.medapp.wunddoku.signature.SignatureFileStorage
|
import de.medapp.wunddoku.signature.SignatureFileStorage
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -117,7 +118,7 @@ class DocumentationRepository(
|
|||||||
*/
|
*/
|
||||||
suspend fun continueDocumentation(
|
suspend fun continueDocumentation(
|
||||||
sourceDocumentationId: String,
|
sourceDocumentationId: String,
|
||||||
copyPhotos: Boolean = false,
|
copyPhotos: Boolean = true,
|
||||||
): Documentation = db.withTransaction {
|
): Documentation = db.withTransaction {
|
||||||
val source = documentationDao.getFullDocumentation(sourceDocumentationId)
|
val source = documentationDao.getFullDocumentation(sourceDocumentationId)
|
||||||
?: error("Quell-Dokumentation $sourceDocumentationId nicht gefunden")
|
?: error("Quell-Dokumentation $sourceDocumentationId nicht gefunden")
|
||||||
@@ -181,7 +182,11 @@ class DocumentationRepository(
|
|||||||
|
|
||||||
if (copyPhotos) {
|
if (copyPhotos) {
|
||||||
for (sourcePhoto in source.photos) {
|
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(
|
photoDao.insert(
|
||||||
PhotoEntity(
|
PhotoEntity(
|
||||||
id = UUID.randomUUID().toString(),
|
id = UUID.randomUUID().toString(),
|
||||||
@@ -224,7 +229,7 @@ class DocumentationRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun addPhoto(documentationId: String, sourceUri: Uri): Photo {
|
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 nextOrder = photoDao.getMaxOrderIndex(documentationId) + 1
|
||||||
val now = Instant.now()
|
val now = Instant.now()
|
||||||
val entity = PhotoEntity(
|
val entity = PhotoEntity(
|
||||||
@@ -247,9 +252,18 @@ class DocumentationRepository(
|
|||||||
suspend fun removePhoto(photoId: String) {
|
suspend fun removePhoto(photoId: String) {
|
||||||
val photo = photoDao.getById(photoId) ?: return
|
val photo = photoDao.getById(photoId) ?: return
|
||||||
photoDao.delete(photo)
|
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 {
|
suspend fun saveSignature(documentationId: String, signerName: String?, bitmap: Bitmap): Signature {
|
||||||
val fileName = signatureFileStorage.save(bitmap)
|
val fileName = signatureFileStorage.save(bitmap)
|
||||||
val now = Instant.now()
|
val now = Instant.now()
|
||||||
@@ -273,10 +287,13 @@ class DocumentationRepository(
|
|||||||
|
|
||||||
suspend fun delete(documentationId: String) {
|
suspend fun delete(documentationId: String) {
|
||||||
val entity = documentationDao.getById(documentationId) ?: return
|
val entity = documentationDao.getById(documentationId) ?: return
|
||||||
val photos = photoDao.getForDocumentation(documentationId)
|
|
||||||
val signatures = signatureDao.getForDocumentation(documentationId)
|
val signatures = signatureDao.getForDocumentation(documentationId)
|
||||||
documentationDao.delete(entity)
|
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) } }
|
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
|
package de.medapp.wunddoku.media
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.exifinterface.media.ExifInterface
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.UUID
|
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
|
* filesDir/
|
||||||
* EXIF-Rotation (Aufgabenstellung Abschnitt 10) werden erst in Phase 4 ergänzt, wenn der
|
* └── photos/
|
||||||
* Photo Picker tatsächlich angebunden wird – das Interface ist aber bereits so geschnitten,
|
* └── <documentationId>/
|
||||||
* dass diese Erweiterung [copyFrom] intern betrifft, ohne die Repository-Schicht zu ändern.
|
* ├── <uuid>.jpg
|
||||||
|
* └── ...
|
||||||
|
* ```
|
||||||
*
|
*
|
||||||
* Bewusst als Interface + Implementierung: erlaubt, in Unit-Tests eine Fake-Implementierung
|
* Ein eigenes Unterverzeichnis pro Dokumentation (statt eines einzigen flachen Ordners für alle
|
||||||
* einzusetzen, ohne echtes Dateisystem anzufassen (Aufgabenstellung Abschnitt 21).
|
* 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 {
|
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
|
* Importiert ein vom Android Photo Picker ausgewähltes Bild: liest es speicherschonend
|
||||||
* deren neuen Dateinamen. Wird beim "Fortsetzen" mit `copyPhotos = true` benötigt: Eine neue
|
* (Downsampling auf [ImageDownsampler.MAX_STORED_DIMENSION_PX] längster Kante über
|
||||||
* DB-Zeile, die auf dieselbe Datei wie das Original zeigt, wäre falsch – sonst würde das
|
* `BitmapFactory.Options.inSampleSize`, ohne das Originalbild vollständig in den Speicher
|
||||||
* Löschen des Fotos in einer Dokumentation es auch in der anderen entfernen.
|
* 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). */
|
/** Löscht eine einzelne physische Datei. Wirft KEINE Exception, wenn die Datei nicht (mehr) existiert. */
|
||||||
fun resolve(fileName: String): File
|
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 {
|
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() }
|
File(context.filesDir, "photos").apply { mkdirs() }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun copyFrom(sourceUri: Uri): String {
|
private fun documentationDirPath(documentationId: String): File =
|
||||||
val fileName = "${UUID.randomUUID()}.jpg"
|
File(photosRootDir, documentationId)
|
||||||
val targetFile = File(photosDir, fileName)
|
|
||||||
context.contentResolver.openInputStream(sourceUri)?.use { input ->
|
private fun ensureDocumentationDir(documentationId: String): File =
|
||||||
targetFile.outputStream().use { output -> input.copyTo(output) }
|
documentationDirPath(documentationId).apply { mkdirs() }
|
||||||
} ?: error("Konnte ausgewähltes Bild nicht öffnen (Uri nicht mehr gültig?): $sourceUri")
|
|
||||||
return fileName
|
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 {
|
override suspend fun delete(documentationId: String, fileName: String) = withContext(Dispatchers.IO) {
|
||||||
val newFileName = "${UUID.randomUUID()}.jpg"
|
resolve(documentationId, fileName).delete()
|
||||||
resolve(fileName).copyTo(File(photosDir, newFileName), overwrite = false)
|
Unit
|
||||||
return newFileName
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun delete(fileName: String) {
|
override suspend fun deleteAll(documentationId: String) = withContext(Dispatchers.IO) {
|
||||||
resolve(fileName).delete()
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+260
-33
@@ -1,44 +1,81 @@
|
|||||||
package de.medapp.wunddoku.ui.documentation.edit
|
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.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.verticalScroll
|
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.Button
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import de.medapp.wunddoku.R
|
import de.medapp.wunddoku.R
|
||||||
import de.medapp.wunddoku.domain.model.CheckItem
|
import de.medapp.wunddoku.domain.model.CheckItem
|
||||||
import de.medapp.wunddoku.domain.model.DocumentationBlock
|
import de.medapp.wunddoku.domain.model.DocumentationBlock
|
||||||
|
import de.medapp.wunddoku.domain.model.Photo
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bearbeitungsansicht für eine Wunddokumentation (Aufgabenstellung Abschnitt 7 Schritt 2,
|
* Bearbeitungsansicht für eine Wunddokumentation. Zeigt einen Block nach dem anderen mit
|
||||||
* Abschnitt 8). Zeigt in Phase 2 einen Block nach dem anderen mit Vor-/Zurück-Buttons und
|
* Vor-/Zurück-Buttons und Positionsanzeige ("7 / 20"), darunter dokumentationsweit (nicht
|
||||||
* Positionsanzeige ("7 / 20"). Die von der Aufgabenstellung bevorzugte Swipe-Geste zwischen
|
* blockbezogen) den Fotobereich (MEDIA-001/002). Die von der Aufgabenstellung bevorzugte
|
||||||
* Blöcken sowie Fotos/Signatur/PDF-Export folgen in den Phasen 3–5.
|
* 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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -49,9 +86,8 @@ fun DocumentationEditScreen(
|
|||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Best-effort-Flush ausstehender Freitext-Änderungen, wenn der Screen den sichtbaren
|
// Best-effort-Flush ausstehender Freitext-Änderungen, wenn der Screen den sichtbaren
|
||||||
// Bereich verlässt (App gewechselt, Bildschirm gesperrt, ...). Siehe Architektur-Dokument
|
// Bereich verlässt (App gewechselt, Bildschirm gesperrt, ...). KEINE Garantie bei hartem
|
||||||
// Abschnitt 9.2: KEINE Garantie bei hartem Prozessabbruch, aber sinnvolle zusätzliche
|
// Prozessabbruch, aber sinnvolle zusätzliche Absicherung für den häufigen Fall.
|
||||||
// Absicherung für den häufigen Fall.
|
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
DisposableEffect(lifecycleOwner) {
|
DisposableEffect(lifecycleOwner) {
|
||||||
val observer = LifecycleEventObserver { _, event ->
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
@@ -63,6 +99,16 @@ fun DocumentationEditScreen(
|
|||||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -80,8 +126,8 @@ fun DocumentationEditScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
// PDF-Export/Unterschrift folgen in Phase 4/5. "Fertig" beendet die
|
// PDF-Export/Unterschrift folgen in einer späteren Phase. "Fertig" beendet
|
||||||
// Bearbeitung für V1 bereits jetzt (Inhalte sind ohnehin per Autosave
|
// die Bearbeitung bereits jetzt (Inhalte sind ohnehin per Autosave
|
||||||
// gesichert, kein expliziter Speichern-Button nötig).
|
// gesichert, kein expliziter Speichern-Button nötig).
|
||||||
Button(onClick = onFinished) { Text("Fertig") }
|
Button(onClick = onFinished) { Text("Fertig") }
|
||||||
},
|
},
|
||||||
@@ -107,42 +153,86 @@ fun DocumentationEditScreen(
|
|||||||
Text("Diese Dokumentation enthält keine Blöcke.")
|
Text("Diese Dokumentation enthält keine Blöcke.")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
DocumentationBlockContent(
|
DocumentationEditContent(
|
||||||
modifier = Modifier.fillMaxSize().padding(padding),
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
block = currentBlock,
|
block = currentBlock,
|
||||||
|
photos = documentation.photos,
|
||||||
onCheckedChanged = viewModel::onCheckedChanged,
|
onCheckedChanged = viewModel::onCheckedChanged,
|
||||||
onFreeTextChanged = viewModel::onFreeTextChanged,
|
onFreeTextChanged = viewModel::onFreeTextChanged,
|
||||||
canGoBack = uiState.currentBlockIndex > 0,
|
canGoBack = uiState.currentBlockIndex > 0,
|
||||||
canGoNext = uiState.currentBlockIndex < uiState.blockCount - 1,
|
canGoNext = uiState.currentBlockIndex < uiState.blockCount - 1,
|
||||||
onPrevious = viewModel::onPreviousBlock,
|
onPrevious = viewModel::onPreviousBlock,
|
||||||
onNext = viewModel::onNextBlock,
|
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
|
@Composable
|
||||||
private fun DocumentationBlockContent(
|
private fun DocumentationEditContent(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
block: DocumentationBlock,
|
block: DocumentationBlock,
|
||||||
|
photos: List<Photo>,
|
||||||
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
|
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
|
||||||
onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit,
|
onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit,
|
||||||
canGoBack: Boolean,
|
canGoBack: Boolean,
|
||||||
canGoNext: Boolean,
|
canGoNext: Boolean,
|
||||||
onPrevious: () -> Unit,
|
onPrevious: () -> Unit,
|
||||||
onNext: () -> Unit,
|
onNext: () -> Unit,
|
||||||
|
photoImportInProgress: Boolean,
|
||||||
|
photoImportError: String?,
|
||||||
|
onDismissPhotoImportError: () -> Unit,
|
||||||
|
onAddPhotosClick: () -> Unit,
|
||||||
|
onDeletePhotoRequested: (photoId: String) -> Unit,
|
||||||
|
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
|
||||||
) {
|
) {
|
||||||
Column(
|
LazyColumn(
|
||||||
modifier = modifier
|
modifier = modifier,
|
||||||
.verticalScroll(rememberScrollState())
|
contentPadding = PaddingValues(16.dp),
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.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) {
|
if (item.isFreeText) {
|
||||||
FreeTextCheckItemRow(
|
FreeTextCheckItemRow(
|
||||||
item = item,
|
item = item,
|
||||||
@@ -154,16 +244,154 @@ private fun DocumentationBlockContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
item(key = "block_navigation") {
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
|
Row(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
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) {
|
Icon(
|
||||||
Text(stringResource(R.string.documentation_edit_previous))
|
Icons.Filled.Close,
|
||||||
}
|
contentDescription = stringResource(R.string.documentation_action_delete),
|
||||||
Button(onClick = onNext, enabled = canGoNext) {
|
tint = Color.White,
|
||||||
Text(stringResource(R.string.documentation_edit_next))
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,10 +411,9 @@ private fun CheckItemRow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die Freitext-Option eines Blocks (Änderungswunsch: Freitext als weitere auswählbare Option
|
* Die Freitext-Option eines Blocks. Das Textfeld ist deaktiviert, solange die zugehörige
|
||||||
* statt eigenes Feld am Block). Das Textfeld ist deaktiviert, solange die zugehörige Checkbox
|
* Checkbox nicht angehakt ist - so kann kein Freitext eingegeben werden, ohne dass die Option
|
||||||
* nicht angehakt ist - so kann kein Freitext eingegeben werden, ohne dass die Option auch als
|
* auch als "bearbeitet" gilt (siehe BlockEditedPolicy: `items.any { it.checked }`).
|
||||||
* "bearbeitet" gilt (siehe BlockEditedPolicy: `items.any { it.checked }`).
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun FreeTextCheckItemRow(
|
private fun FreeTextCheckItemRow(
|
||||||
|
|||||||
+92
@@ -1,5 +1,6 @@
|
|||||||
package de.medapp.wunddoku.ui.documentation.edit
|
package de.medapp.wunddoku.ui.documentation.edit
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.createSavedStateHandle
|
import androidx.lifecycle.createSavedStateHandle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
@@ -24,6 +25,13 @@ data class DocumentationEditUiState(
|
|||||||
val currentBlockIndex: Int = 0,
|
val currentBlockIndex: Int = 0,
|
||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
val error: String? = null,
|
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
|
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
|
* Best-effort-Flush ausstehender, noch gedebouncter Freitext-Änderungen. Aufgerufen vom
|
||||||
* Screen bei `Lifecycle.Event.ON_STOP` – siehe Klassenkommentar zur Nicht-Garantie bei
|
* Screen bei `Lifecycle.Event.ON_STOP` – siehe Klassenkommentar zur Nicht-Garantie bei
|
||||||
|
|||||||
@@ -37,6 +37,12 @@
|
|||||||
<string name="documentation_edit_next">Weiter</string>
|
<string name="documentation_edit_next">Weiter</string>
|
||||||
<string name="documentation_edit_free_text_label">Freitext</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_cancel">Abbrechen</string>
|
||||||
<string name="action_confirm">Bestätigen</string>
|
<string name="action_confirm">Bestätigen</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user