PDF/MEDIA
MEDIA-003 MEDIA-004 PDF-003
This commit is contained in:
+171
@@ -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,
|
||||
|
||||
+94
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+235
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user