Beta 1.0 Release #11

Merged
niklas.ortmann merged 24 commits from dev into main 2026-10-03 00:37:20 +02:00
3 changed files with 140 additions and 15 deletions
Showing only changes of commit 8c05a513fe - Show all commits
@@ -0,0 +1,89 @@
package de.medapp.wunddoku.pdf
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import java.io.File
/**
* Öffnet eine bereits erzeugte PDF-Datei im auf dem Gerät verfügbaren Standard-PDF-Reader
* (PDF-005). Bewusst getrennt von [DocumentationPdfExporter]: PDF-Erzeugung (reine
* Datei-/Domain-Logik) und PDF-Anzeige (Android-Intent/Activity-Start) bleiben unterschiedliche
* Verantwortlichkeiten - der Exporter startet niemals eine Activity.
*
* VulneraDoc implementiert ausdrücklich KEINEN eigenen PDF-Viewer; welche konkrete App den
* `ACTION_VIEW`-Intent verarbeitet, entscheidet ausschließlich Android/der Nutzer.
*/
interface PdfOpener {
fun open(file: File): PdfOpenResult
}
sealed interface PdfOpenResult {
data object Success : PdfOpenResult
sealed interface Failure : PdfOpenResult {
/** Die referenzierte Datei existiert nicht mehr (z. B. zwischenzeitlich extern entfernt). */
data object FileNotFound : Failure
/** FileProvider konnte für die Datei keine content://-Uri erzeugen. */
data class UriResolutionFailed(val message: String) : Failure
/** Kein installierter Reader kann application/pdf verarbeiten. */
data object NoViewerAvailable : Failure
data class Unexpected(val message: String) : Failure
}
}
/** Fachliche, für die Anzeige (z. B. Snackbar) geeignete Übersetzung eines [PdfOpenResult.Failure]. */
fun PdfOpenResult.Failure.toUserMessage(): String = when (this) {
PdfOpenResult.Failure.FileNotFound ->
"Die PDF-Datei wurde nicht gefunden. Bitte die Dokumentation erneut exportieren."
is PdfOpenResult.Failure.UriResolutionFailed ->
"Die PDF-Datei konnte nicht zur Anzeige freigegeben werden."
PdfOpenResult.Failure.NoViewerAvailable ->
"Auf diesem Gerät ist keine App zum Anzeigen von PDF-Dateien installiert."
is PdfOpenResult.Failure.Unexpected ->
"Die PDF-Datei konnte nicht geöffnet werden."
}
class AndroidPdfOpener(private val context: Context) : PdfOpener {
override fun open(file: File): PdfOpenResult {
if (!file.exists()) {
return PdfOpenResult.Failure.FileNotFound
}
val authority = "${context.packageName}.fileprovider"
val uri = try {
FileProvider.getUriForFile(context, authority, file)
} catch (e: IllegalArgumentException) {
// Wird von FileProvider geworfen, wenn die Datei außerhalb der in file_paths.xml
// freigegebenen Verzeichnisse liegt oder die Authority nicht passt.
return PdfOpenResult.Failure.UriResolutionFailed(e.message ?: "Uri konnte nicht erzeugt werden.")
}
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/pdf")
// Ausschließlich Lese-, KEINE Schreibberechtigung für den externen Reader.
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
return try {
context.startActivity(intent)
PdfOpenResult.Success
} catch (e: ActivityNotFoundException) {
// Bewusst KEIN vorheriges intent.resolveActivity(...) - das würde auf Android 11+
// durch Package-Visibility-Einschränkungen ohne zusätzliche <queries>-Deklaration
// im Manifest fälschlich "kein Reader" melden können, auch wenn tatsächlich einer
// installiert ist. Der eigentliche startActivity()-Aufruf unterliegt dieser
// Einschränkung nicht - ActivityNotFoundException ist hier die zuverlässige Quelle.
PdfOpenResult.Failure.NoViewerAvailable
} catch (e: SecurityException) {
PdfOpenResult.Failure.Unexpected(e.message ?: "Zugriff auf die PDF-Datei wurde verweigert.")
} catch (e: Exception) {
PdfOpenResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler beim Öffnen der PDF-Datei.")
}
}
}
@@ -40,6 +40,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@@ -56,6 +57,7 @@ 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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
@@ -67,7 +69,9 @@ 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
import kotlinx.coroutines.launch
import de.medapp.wunddoku.pdf.AndroidPdfOpener
import de.medapp.wunddoku.pdf.PdfOpenResult
import de.medapp.wunddoku.pdf.toUserMessage
/**
* Bearbeitungsansicht für eine Wunddokumentation. Zeigt einen Block nach dem anderen mit
@@ -113,15 +117,30 @@ fun DocumentationEditScreen(
contract = ActivityResultContracts.PickMultipleVisualMedia(),
) { uris: List<Uri> -> viewModel.onPhotosPicked(uris) }
// Für die kurze Erfolgs-/Fehlermeldung nach einem PDF-Exportversuch (rein zum manuellen
// Testen von PDF-001/002 - Öffnen/Teilen der Datei folgt erst in PDF-005/006).
// Für die Erfolgs-/Fehlermeldung nach einem PDF-Exportversuch (PDF-001/002) inkl. optionaler
// "ANZEIGEN"-Aktion (PDF-005). PdfOpener wird bewusst hier in der Compose-Schicht instanziiert
// (mit dem Activity-Context aus LocalContext, analog zum Photo-Picker-Launcher oben) - das
// Starten einer externen Activity ist eine UI-/Plattform-Aktion, keine Repository-Aufgabe.
val context = LocalContext.current
val pdfOpener = remember(context) { AndroidPdfOpener(context) }
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(uiState.pdfExportMessage) {
val message = uiState.pdfExportMessage
if (message != null) {
snackbarHostState.showSnackbar(message)
viewModel.dismissPdfExportMessage()
val message = uiState.pdfExportMessage ?: return@LaunchedEffect
val exportedFile = uiState.lastExportedPdfFile
// "ANZEIGEN" wird ausschließlich angeboten, wenn tatsächlich eine erfolgreich erzeugte
// Datei vorliegt - bei einem fehlgeschlagenen Export ist exportedFile null und die
// Snackbar zeigt keine Aktion an.
val result = snackbarHostState.showSnackbar(
message = message,
actionLabel = if (exportedFile != null) "ANZEIGEN" else null,
)
if (result == SnackbarResult.ActionPerformed && exportedFile != null) {
when (val openResult = pdfOpener.open(exportedFile)) {
PdfOpenResult.Success -> Unit
is PdfOpenResult.Failure -> snackbarHostState.showSnackbar(openResult.toUserMessage())
}
}
viewModel.dismissPdfExportMessage()
}
Scaffold(
@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.File
data class DocumentationEditUiState(
val documentation: Documentation? = null,
@@ -39,6 +40,14 @@ data class DocumentationEditUiState(
/** Kurze, fachlich übersetzte Erfolgs-/Fehlermeldung nach einem Exportversuch - einmalig als
* Snackbar angezeigt (s. Screen), danach über [DocumentationEditViewModel.dismissPdfExportMessage] geleert. */
val pdfExportMessage: String? = null,
/**
* Die zuletzt erfolgreich erzeugte PDF-Datei dieser Dokumentation (PDF-005: "ANZEIGEN" in
* der Snackbar). `null` sowohl vor dem ersten Export als auch nach einem fehlgeschlagenen
* Export - "ANZEIGEN" wird nur angeboten, wenn hier tatsächlich eine Datei vorliegt. Wird
* bei jedem neuen Exportversuch zunächst zurückgesetzt, damit niemals eine veraltete Datei
* referenziert werden kann, während ein neuer Export läuft.
*/
val lastExportedPdfFile: File? = null,
) {
val blockCount: Int get() = documentation?.blocks?.size ?: 0
}
@@ -237,16 +246,24 @@ class DocumentationEditViewModel(
*/
fun exportPdf() {
viewModelScope.launch {
_uiState.update { it.copy(pdfExportInProgress = true, pdfExportMessage = null) }
_uiState.update {
it.copy(pdfExportInProgress = true, pdfExportMessage = null, lastExportedPdfFile = null)
}
val result = repository.exportPdf(documentationId)
_uiState.update {
it.copy(
pdfExportInProgress = false,
pdfExportMessage = when (result) {
is PdfExportResult.Success -> "PDF erzeugt: ${result.file.name}"
is PdfExportResult.Failure -> "PDF-Erzeugung fehlgeschlagen: ${result.error.toUserMessage()}"
},
)
when (result) {
is PdfExportResult.Success -> it.copy(
pdfExportInProgress = false,
pdfExportMessage = "PDF erfolgreich erstellt",
lastExportedPdfFile = result.file,
)
is PdfExportResult.Failure -> it.copy(
pdfExportInProgress = false,
pdfExportMessage = "PDF-Erzeugung fehlgeschlagen: ${result.error.toUserMessage()}",
lastExportedPdfFile = null,
)
}
}
}
}