PDF/MEDIA

MEDIA-003
MEDIA-004
PDF-003
This commit is contained in:
2026-09-06 21:19:58 +02:00
parent 91e68c827a
commit c52229b7f7
25 changed files with 2684 additions and 51 deletions
@@ -69,9 +69,11 @@ 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 de.medapp.wunddoku.domain.model.Signature
import de.medapp.wunddoku.pdf.AndroidPdfOpener
import de.medapp.wunddoku.pdf.PdfOpenResult
import de.medapp.wunddoku.pdf.toUserMessage
import de.medapp.wunddoku.ui.documentation.edit.signature.AddSignatureDialog
/**
* Bearbeitungsansicht für eine Wunddokumentation. Zeigt einen Block nach dem anderen mit
@@ -201,6 +203,7 @@ fun DocumentationEditScreen(
modifier = Modifier.fillMaxSize().padding(padding),
block = currentBlock,
photos = documentation.photos,
signatures = documentation.signatures,
onCheckedChanged = viewModel::onCheckedChanged,
onFreeTextChanged = viewModel::onFreeTextChanged,
canGoBack = uiState.currentBlockIndex > 0,
@@ -217,6 +220,9 @@ fun DocumentationEditScreen(
},
onDeletePhotoRequested = viewModel::requestDeletePhoto,
loadThumbnail = viewModel::loadPhotoThumbnail,
onAddSignatureClick = viewModel::requestAddSignature,
onDeleteSignatureRequested = viewModel::requestDeleteSignature,
loadSignatureBitmap = viewModel::loadSignatureBitmap,
)
}
}
@@ -241,6 +247,54 @@ fun DocumentationEditScreen(
},
)
}
// MEDIA-003: Erfassungsdialog, ausschließlich innerhalb dieses Screens (keine eigene
// Navigationsroute, s. Aufgabenstellung Abschnitt 7).
if (uiState.showAddSignatureDialog) {
AddSignatureDialog(
onDismiss = viewModel::dismissAddSignature,
onSave = viewModel::saveSignature,
loadDefaultWoundCarer = viewModel::getDefaultWoundCarer,
searchWoundCarers = viewModel::searchWoundCarers,
loadWoundCarerSignatureBitmap = viewModel::loadWoundCarerSignatureBitmap,
)
}
// Zweite, von der UI-Button-Deaktivierung unabhängige Absicherungsebene (Aufgabenstellung
// Abschnitt 12) - greift in der Praxis kaum, da der Dialog "Speichern" bereits vorher
// deaktiviert, solange Name oder Zeichnung leer sind.
val signatureError = uiState.signatureError
if (signatureError != null) {
AlertDialog(
onDismissRequest = viewModel::dismissSignatureError,
title = { Text(stringResource(R.string.documentation_edit_signatures_title)) },
text = { Text(signatureError) },
confirmButton = {
TextButton(onClick = viewModel::dismissSignatureError) {
Text(stringResource(R.string.action_confirm))
}
},
)
}
val pendingDeleteSignatureId = uiState.pendingDeleteSignatureId
if (pendingDeleteSignatureId != null) {
AlertDialog(
onDismissRequest = viewModel::dismissDeleteSignature,
title = { Text(stringResource(R.string.documentation_edit_delete_signature_confirm_title)) },
text = { Text(stringResource(R.string.documentation_edit_delete_signature_confirm_text)) },
confirmButton = {
TextButton(onClick = viewModel::confirmDeleteSignature) {
Text(stringResource(R.string.action_confirm))
}
},
dismissButton = {
TextButton(onClick = viewModel::dismissDeleteSignature) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
}
/**
@@ -254,6 +308,7 @@ private fun DocumentationEditContent(
modifier: Modifier = Modifier,
block: DocumentationBlock,
photos: List<Photo>,
signatures: List<Signature>,
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit,
canGoBack: Boolean,
@@ -266,6 +321,9 @@ private fun DocumentationEditContent(
onAddPhotosClick: () -> Unit,
onDeletePhotoRequested: (photoId: String) -> Unit,
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
onAddSignatureClick: () -> Unit,
onDeleteSignatureRequested: (signatureId: String) -> Unit,
loadSignatureBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?,
) {
LazyColumn(
modifier = modifier,
@@ -302,6 +360,47 @@ private fun DocumentationEditContent(
}
}
// MEDIA-003/004: dokumentationsweiter Signaturbereich, analog zum Fotobereich direkt
// darunter. Bewusst VOR den Fotos platziert - spiegelt dieselbe Reihenfolge wie im
// späteren PDF-Export (Dokumentationsblöcke -> Signaturen -> Fotoanhang, PDF-003).
item(key = "signatures_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_signatures_title),
style = MaterialTheme.typography.titleMedium,
)
OutlinedButton(onClick = onAddSignatureClick) {
Icon(Icons.Filled.Add, contentDescription = null)
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.documentation_edit_add_signature))
}
}
}
}
if (signatures.isEmpty()) {
item(key = "signatures_empty") {
Text(
stringResource(R.string.documentation_edit_no_signatures),
style = MaterialTheme.typography.bodySmall,
)
}
} else {
items(signatures, key = { it.id }) { signature ->
SignatureRow(
signature = signature,
loadBitmap = loadSignatureBitmap,
onDeleteRequested = { onDeleteSignatureRequested(signature.id) },
)
}
}
item(key = "photos_header") {
Column {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
@@ -440,6 +539,78 @@ private fun PhotoThumbnail(
}
}
/**
* Eine Zeile je gespeicherter Signatur: Name des Wundversorgers, Vorschaubild und
* Löschen-Button - sinngemäß wie in der Aufgabenstellung (Abschnitt 16) skizziert. Lädt ihre
* Vorschau selbst, transient über [LaunchedEffect]/`remember` (an `signature.id` gebunden) -
* analog [PhotoThumbnail], siehe dortigen Kommentar zur bewussten Nicht-Haltung im ViewModel.
* Da Signaturen nach dem Speichern unveränderlich sind (Aufgabenstellung Abschnitt 17), gibt es
* hier bewusst KEINE Bearbeiten-Aktion - nur Anzeigen und Löschen.
*/
@Composable
private fun SignatureRow(
signature: Signature,
loadBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?,
onDeleteRequested: () -> Unit,
) {
var bitmap by remember(signature.id) { mutableStateOf<Bitmap?>(null) }
var loadFailed by remember(signature.id) { mutableStateOf(false) }
LaunchedEffect(signature.id) {
val result = runCatching { loadBitmap(signature.documentationId, signature.fileName) }.getOrNull()
bitmap = result
loadFailed = result == null
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.width(96.dp)
.size(96.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.White),
) {
val currentBitmap = bitmap
when {
currentBitmap != null -> Image(
bitmap = currentBitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
)
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,
)
}
}
Text(
text = signature.signerName,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onDeleteRequested) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.documentation_action_delete),
)
}
}
}
@Composable
private fun CheckItemRow(
item: CheckItem,
@@ -1,5 +1,6 @@
package de.medapp.wunddoku.ui.documentation.edit
import android.graphics.Bitmap
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.createSavedStateHandle
@@ -8,8 +9,10 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import androidx.navigation.toRoute
import de.medapp.wunddoku.data.repository.DocumentationRepository
import de.medapp.wunddoku.data.repository.WoundCarerRepository
import de.medapp.wunddoku.di.wunddokuApplication
import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.model.WoundCarer
import de.medapp.wunddoku.pdf.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.ui.navigation.Screen
@@ -35,6 +38,14 @@ data class DocumentationEditUiState(
val photoImportError: String? = null,
/** != null, solange die Löschbestätigung für ein Foto angezeigt werden soll. */
val pendingDeletePhotoId: String? = null,
/** true, solange der "Signatur hinzufügen"-Dialog angezeigt wird (MEDIA-003). */
val showAddSignatureDialog: Boolean = false,
/** Fehlermeldung aus einer fehlgeschlagenen Signatur-Validierung (zweite, von der UI
* unabhängige Absicherungsebene in [DocumentationRepository.saveSignature] - im Normalfall
* verhindert bereits der deaktivierte "Speichern"-Button im Dialog diesen Fall). */
val signatureError: String? = null,
/** != null, solange die Löschbestätigung für eine Signatur angezeigt werden soll. */
val pendingDeleteSignatureId: String? = null,
/** true, während ein PDF-Export läuft (Ladeindikator, Button währenddessen deaktiviert). */
val pdfExportInProgress: Boolean = false,
/** Kurze, fachlich übersetzte Erfolgs-/Fehlermeldung nach einem Exportversuch - einmalig als
@@ -68,6 +79,7 @@ data class DocumentationEditUiState(
class DocumentationEditViewModel(
private val documentationId: String,
private val repository: DocumentationRepository,
private val woundCarerRepository: WoundCarerRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(DocumentationEditUiState())
@@ -237,6 +249,68 @@ class DocumentationEditViewModel(
}
}
/** Öffnet den "Signatur hinzufügen"-Dialog (MEDIA-003, Aufgabenstellung Abschnitt 7). */
fun requestAddSignature() {
_uiState.update { it.copy(showAddSignatureDialog = true) }
}
fun dismissAddSignature() {
_uiState.update { it.copy(showAddSignatureDialog = false) }
}
/**
* Speichert eine neu erfasste Signatur. [bitmap] ist bereits die fertige, auf das logische
* 1000x400-Format normalisierte PNG-Bitmap ([de.medapp.wunddoku.ui.documentation.edit.signature.renderSignatureBitmap]).
* Die eigentliche Validierung (nicht-leerer Name, nicht-leere Zeichnung) findet bewusst
* NICHT hier, sondern im Repository statt (zweite, von der UI unabhängige Ebene, s.
* [DocumentationEditUiState.signatureError]) - der Dialog selbst deaktiviert "Speichern"
* bereits vorher, sodass dieser Fehlerfall in der Praxis kaum auftreten sollte.
*/
fun saveSignature(signerName: String, bitmap: Bitmap) {
viewModelScope.launch {
try {
repository.saveSignature(documentationId, signerName, bitmap)
val refreshed = repository.getFullDocumentation(documentationId)
_uiState.update { state ->
state.copy(
documentation = refreshed ?: state.documentation,
showAddSignatureDialog = false,
)
}
} catch (e: IllegalArgumentException) {
_uiState.update { it.copy(signatureError = e.message ?: "Signatur konnte nicht gespeichert werden.") }
}
}
}
fun dismissSignatureError() {
_uiState.update { it.copy(signatureError = null) }
}
/** Öffnet die Löschbestätigung für eine Signatur (Aufgabenstellung Abschnitt 17: Signaturen
* sind unveränderlich - "korrigieren" bedeutet immer löschen + neu erfassen). */
fun requestDeleteSignature(signatureId: String) {
_uiState.update { it.copy(pendingDeleteSignatureId = signatureId) }
}
fun dismissDeleteSignature() {
_uiState.update { it.copy(pendingDeleteSignatureId = null) }
}
fun confirmDeleteSignature() {
val signatureId = _uiState.value.pendingDeleteSignatureId ?: return
viewModelScope.launch {
repository.removeSignature(signatureId)
val refreshed = repository.getFullDocumentation(documentationId)
_uiState.update { state ->
state.copy(
documentation = refreshed ?: state.documentation,
pendingDeleteSignatureId = null,
)
}
}
}
/**
* Löst den PDF-Export an (PDF-001/002). Rein zum manuellen Testen der Export-Funktionalität
* gedacht - Öffnen in einem externen Reader (PDF-005) und Teilen (PDF-006) sind explizit
@@ -291,6 +365,25 @@ class DocumentationEditViewModel(
suspend fun loadPhotoThumbnail(documentationId: String, fileName: String) =
repository.loadPhotoThumbnail(documentationId, fileName)
/** Lädt eine Signatur für die Vorschau in der Dokumentationsbearbeitung - analog
* [loadPhotoThumbnail], siehe dortigen Kommentar zur bewussten Nicht-Haltung im UiState. */
suspend fun loadSignatureBitmap(documentationId: String, fileName: String) =
repository.loadSignatureBitmap(documentationId, fileName)
/** Für die Vorbelegung des "Signatur hinzufügen"-Dialogs beim Öffnen: der zuletzt
* verwendete Wundversorger app-weit (nicht auf diese Dokumentation beschränkt) - `null`,
* wenn noch nie eine Signatur gespeichert wurde. Weiterhin änderbar (Änderungswunsch). */
suspend fun getDefaultWoundCarer(): WoundCarer? = woundCarerRepository.getMostRecentlyUsed()
/** Namensvorschläge für die Autovervollständigung im Signatur-Dialog. */
suspend fun searchWoundCarers(query: String): List<WoundCarer> = woundCarerRepository.search(query)
/** Lädt die hinterlegte Signatur eines bekannten Wundversorgers (Vorbelegung bzw. nach Tippen
* auf einen Namensvorschlag) - unabhängig von [loadSignatureBitmap], das historische, pro
* Dokumentation gespeicherte Signaturen lädt. */
suspend fun loadWoundCarerSignatureBitmap(woundCarerId: String, fileName: String) =
woundCarerRepository.loadSignatureBitmap(woundCarerId, fileName)
/**
* Best-effort-Flush ausstehender, noch gedebouncter Freitext-Änderungen. Aufgerufen vom
* Screen bei `Lifecycle.Event.ON_STOP` – siehe Klassenkommentar zur Nicht-Garantie bei
@@ -319,6 +412,7 @@ class DocumentationEditViewModel(
DocumentationEditViewModel(
documentationId = route.documentationId,
repository = container.documentationRepository,
woundCarerRepository = container.woundCarerRepository,
)
}
}
@@ -0,0 +1,235 @@
package de.medapp.wunddoku.ui.documentation.edit.signature
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import de.medapp.wunddoku.R
import de.medapp.wunddoku.domain.model.WoundCarer
import kotlinx.coroutines.launch
/**
* Dialog zum Erfassen einer neuen Signatur (MEDIA-003): Name des Wundversorgers + Zeichenfläche
* ([SignaturePad]), wie in der Aufgabenstellung (Abschnitt 7) als bevorzugte UX beschrieben -
* bewusst als Dialog INNERHALB von [de.medapp.wunddoku.ui.documentation.edit.DocumentationEditScreen],
* keine eigene Navigationsroute.
*
* Änderungswunsch (Wundversorger-Register): Beim Öffnen wird automatisch der ZULETZT verwendete
* Wundversorger samt seiner zuletzt gespeicherten Signatur vorbelegt ([loadDefaultWoundCarer]) -
* bleibt aber änderbar. Während der Eingabe werden nach den ersten Buchstaben passende bekannte
* Namen als antippbare Vorschläge angezeigt ([searchWoundCarers]); ein Tippen darauf übernimmt
* Name UND zuletzt gespeicherte Signatur dieses Wundversorgers ([loadWoundCarerSignatureBitmap]).
* In diesem "Vorschau-Modus" wird die geladene Signatur als fertiges Bild angezeigt (nicht mehr
* frei bearbeitbar) - über "Neu zeichnen" kann jederzeit zu einer frischen, freihändigen
* Zeichnung für denselben Namen gewechselt werden. Wird der Name manuell so verändert, dass er
* nicht mehr zum gerade geladenen Vorschlag passt, wird die Vorschau verworfen (verhindert, dass
* versehentlich die Signatur einer anderen Person unter einem neuen Namen gespeichert wird).
*
* "Speichern" ist erst aktiv, wenn BEIDE Bedingungen erfüllt sind (erste Absicherungsebene gegen
* leere Signaturen bzw. leeren Namen, s. Aufgabenstellung Abschnitt 12 - die zweite, unabhängige
* Ebene sitzt serverseitig in
* [de.medapp.wunddoku.data.repository.DocumentationRepository.saveSignature]):
* - der (getrimmte) Name ist nicht leer
* - es liegt entweder eine geladene Vorschau-Signatur ODER eine frisch gezeichnete Signatur vor
*/
@Composable
fun AddSignatureDialog(
onDismiss: () -> Unit,
onSave: (signerName: String, bitmap: Bitmap) -> Unit,
loadDefaultWoundCarer: suspend () -> WoundCarer?,
searchWoundCarers: suspend (query: String) -> List<WoundCarer>,
loadWoundCarerSignatureBitmap: suspend (woundCarerId: String, fileName: String) -> Bitmap?,
) {
var signerName by remember { mutableStateOf("") }
var strokes by remember { mutableStateOf<List<SignatureStroke>>(emptyList()) }
var canvasSize by remember { mutableStateOf(Size.Zero) }
// != null => Vorschau-Modus: eine bereits bekannte Signatur wird als fertiges Bild gezeigt
// statt einer leeren, frei bearbeitbaren Zeichenfläche.
var previewBitmap by remember { mutableStateOf<Bitmap?>(null) }
// Name, zu dem [previewBitmap] gehört - dient ausschließlich dazu, die Vorschau zu verwerfen,
// sobald der Name manuell auf etwas anderes geändert wird (s. Klassenkommentar).
var previewOwnerName by remember { mutableStateOf<String?>(null) }
var suggestions by remember { mutableStateOf<List<WoundCarer>>(emptyList()) }
val scope = rememberCoroutineScope()
// Einmalige Vorbelegung beim Öffnen mit dem zuletzt verwendeten Wundversorger.
LaunchedEffect(Unit) {
val default = loadDefaultWoundCarer() ?: return@LaunchedEffect
signerName = default.name
previewOwnerName = default.name
previewBitmap = loadWoundCarerSignatureBitmap(default.id, default.signatureFileName)
}
// Vorschläge nachladen UND Vorschau verwerfen, sobald der Name vom Vorschau-Eigentümer
// abweicht (z. B. weil der Nutzer den vorbelegten Namen überschreibt).
LaunchedEffect(signerName) {
if (previewOwnerName != null && signerName != previewOwnerName) {
previewBitmap = null
previewOwnerName = null
}
suggestions = if (previewOwnerName == signerName) {
emptyList()
} else {
searchWoundCarers(signerName)
}
}
val canSave = signerName.trim().isNotEmpty() && (previewBitmap != null || strokes.isNotEmpty())
Dialog(onDismissRequest = onDismiss) {
Surface(shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surface) {
Column(modifier = Modifier.fillMaxWidth().padding(20.dp)) {
Text(
stringResource(R.string.signature_dialog_title),
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = signerName,
onValueChange = { signerName = it },
label = { Text(stringResource(R.string.signature_dialog_name_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
if (suggestions.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(suggestions, key = { it.id }) { suggestion ->
AssistChip(
onClick = {
signerName = suggestion.name
previewOwnerName = suggestion.name
suggestions = emptyList()
scope.launch {
previewBitmap = loadWoundCarerSignatureBitmap(
suggestion.id,
suggestion.signatureFileName,
)
}
},
label = { Text(suggestion.name) },
)
}
}
}
Spacer(Modifier.height(12.dp))
val currentPreview = previewBitmap
if (currentPreview != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp))
.background(Color.White, RoundedCornerShape(8.dp)),
) {
Image(
bitmap = currentPreview.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxWidth().height(180.dp).padding(8.dp),
contentScale = ContentScale.Fit,
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = {
previewBitmap = null
strokes = emptyList()
}) {
Text(stringResource(R.string.signature_dialog_redraw))
}
}
} else {
Box(
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp))
.background(Color.White, RoundedCornerShape(8.dp))
.onSizeChanged { size -> canvasSize = Size(size.width.toFloat(), size.height.toFloat()) },
) {
SignaturePad(
strokes = strokes,
onStrokesChanged = { strokes = it },
modifier = Modifier.fillMaxWidth().height(180.dp),
)
}
Text(
stringResource(R.string.signature_dialog_single_stroke_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
Row(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = { strokes = emptyList() }, enabled = strokes.isNotEmpty()) {
Text(stringResource(R.string.signature_dialog_reset))
}
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
Spacer(Modifier.width(8.dp))
Button(
onClick = {
val bitmap = previewBitmap ?: renderSignatureBitmap(strokes, canvasSize)
onSave(signerName.trim(), bitmap)
},
enabled = canSave,
) {
Text(stringResource(R.string.signature_dialog_save))
}
}
}
}
}
}
@@ -0,0 +1,156 @@
package de.medapp.wunddoku.ui.documentation.edit.signature
import android.graphics.Bitmap
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
/** Logisches Signaturformat, unabhängig von der tatsächlichen Größe der Compose-Zeichenfläche
* auf einem konkreten Gerät (Aufgabenstellung Abschnitt 10). NUR für die persistierte Bitmap
* relevant - die UI selbst darf/soll responsiv sein. */
const val SIGNATURE_LOGICAL_WIDTH = 1000
const val SIGNATURE_LOGICAL_HEIGHT = 400
/** Strichbreite in Bildschirm-dp während des Zeichnens. Die tatsächliche, im PNG persistierte
* Strichbreite ergibt sich aus [SIGNATURE_LOGICAL_STROKE_WIDTH] nach der Normalisierung. */
private const val SIGNATURE_UI_STROKE_WIDTH_PX = 5f
private const val SIGNATURE_LOGICAL_STROKE_WIDTH = 6f
/**
* Eine einzelne, zusammenhängende Zeichenbewegung (ein "Strich") in tatsächlichen
* Canvas-Pixelkoordinaten (nicht normalisiert).
*/
typealias SignatureStroke = List<Offset>
/**
* Compose-basierte Signaturzeichenfläche (MEDIA-003). Unterstützt Finger UND Stylus ohne
* Sonderbehandlung - Compose liefert beide über dieselbe Zeigereingabe-Pipeline
* ([pointerInput]/[detectDragGestures]), es gibt auf Android keinen technischen Unterschied
* zwischen einer Finger- und einer Stylus-Berührung an dieser Stelle.
*
* FACHLICHE EINSCHRÄNKUNG (Änderungswunsch): Eine gültige Signatur besteht aus GENAU EINEM
* durchgehenden Strich - ein erneutes Ansetzen (zweiter Touch-Down, nachdem bereits ein Strich
* gezeichnet wurde) verwirft die komplette bisherige Zeichnung automatisch und beginnt neu mit
* genau diesem neuen Strich. Es kann also beliebig oft neu versucht werden, aber niemals mehrere
* getrennte Striche zu EINER Signatur zusammengesetzt werden. Umgesetzt, indem [onDragEnd] die
* nach oben gemeldete Strichliste immer komplett ERSETZT (`listOf(currentStroke)`) statt an
* [strokes] anzuhängen - dadurch kann zu jedem Zeitpunkt höchstens ein abgeschlossener Strich in
* [strokes] stehen, unabhängig von der Reihenfolge der Ereignisse.
*
* Zustandslos (state hoisting): [strokes] kommt vom Aufrufer, jede Änderung wird über
* [onStrokesChanged] nach oben gemeldet.
*/
@Composable
fun SignaturePad(
strokes: List<SignatureStroke>,
onStrokesChanged: (List<SignatureStroke>) -> Unit,
modifier: Modifier = Modifier,
) {
var currentStroke by remember { mutableStateOf<SignatureStroke>(emptyList()) }
Canvas(
modifier = modifier
.background(Color.White)
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { offset -> currentStroke = listOf(offset) },
onDrag = { change, _ ->
change.consume()
currentStroke = currentStroke + change.position
},
onDragEnd = {
// Ein "Tap" ohne tatsächliche Bewegung (< 2 Punkte) erzeugt bewusst
// keinen sichtbaren Strich - eine bereits vorhandene, gültige Signatur
// bleibt in diesem Fall unangetastet erhalten. Ein ECHTER Strich ersetzt
// dagegen immer die komplette bisherige Zeichnung (s. Klassenkommentar).
if (currentStroke.size > 1) {
onStrokesChanged(listOf(currentStroke))
}
currentStroke = emptyList()
},
onDragCancel = { currentStroke = emptyList() },
)
},
) {
for (stroke in strokes) {
drawSignatureStroke(stroke)
}
if (currentStroke.size > 1) {
drawSignatureStroke(currentStroke)
}
}
}
private fun DrawScope.drawSignatureStroke(points: SignatureStroke) {
if (points.size < 2) return
val path = Path().apply {
moveTo(points.first().x, points.first().y)
for (index in 1 until points.size) {
lineTo(points[index].x, points[index].y)
}
}
drawPath(
path = path,
color = Color.Black,
style = Stroke(
width = SIGNATURE_UI_STROKE_WIDTH_PX,
cap = StrokeCap.Round,
join = StrokeJoin.Round,
),
)
}
/**
* Rendert die erfassten [strokes] (in tatsächlichen Canvas-Pixelkoordinaten der Größe
* [canvasSize]) auf ein neues, weißes Bitmap im logischen Signaturformat
* ([SIGNATURE_LOGICAL_WIDTH] x [SIGNATURE_LOGICAL_HEIGHT]) - unabhängig von der tatsächlichen
* Gerätegröße der Zeichenfläche (Aufgabenstellung Abschnitt 10: Normalisierung). Persistiert
* wird ausschließlich dieses fertige Bitmap, niemals die Stroke-Rohdaten selbst
* (Aufgabenstellung Abschnitt 11).
*/
fun renderSignatureBitmap(strokes: List<SignatureStroke>, canvasSize: Size): Bitmap {
val bitmap = Bitmap.createBitmap(SIGNATURE_LOGICAL_WIDTH, SIGNATURE_LOGICAL_HEIGHT, Bitmap.Config.ARGB_8888)
val canvas = android.graphics.Canvas(bitmap)
canvas.drawColor(android.graphics.Color.WHITE)
if (canvasSize.width <= 0f || canvasSize.height <= 0f) return bitmap
val scaleX = SIGNATURE_LOGICAL_WIDTH / canvasSize.width
val scaleY = SIGNATURE_LOGICAL_HEIGHT / canvasSize.height
val paint = android.graphics.Paint().apply {
isAntiAlias = true
color = android.graphics.Color.BLACK
style = android.graphics.Paint.Style.STROKE
strokeWidth = SIGNATURE_LOGICAL_STROKE_WIDTH
strokeCap = android.graphics.Paint.Cap.ROUND
strokeJoin = android.graphics.Paint.Join.ROUND
}
for (stroke in strokes) {
if (stroke.size < 2) continue
val path = android.graphics.Path()
path.moveTo(stroke.first().x * scaleX, stroke.first().y * scaleY)
for (index in 1 until stroke.size) {
path.lineTo(stroke[index].x * scaleX, stroke[index].y * scaleY)
}
canvas.drawPath(path, paint)
}
return bitmap
}