Beta 1.0 Release #11
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
-7
@@ -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(
|
||||
|
||||
+25
-8
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user