Compare commits

...

8 Commits

Author SHA1 Message Date
niklas.ortmann 63bc93ebd2 MEDIA-005
Export fertig
2026-09-27 17:27:14 +02:00
niklas.ortmann 08091a15a2 MEDIA-005
JPG-Export
2026-09-24 13:28:22 +02:00
niklas.ortmann 4a62d195ac Merge pull request 'Box-Inhalte' (#7) from dev-Inhalte into dev
Reviewed-on: #7
2026-09-24 13:13:23 +02:00
niklas.ortmann 075905c6e0 Update DocumentationCatalog.kt 2026-09-16 10:08:29 +02:00
niklas.ortmann 7948d2c3c6 Box-Inhalte
(müssen mit Kunden besprochen werden)
2026-09-09 08:44:07 +02:00
niklas.ortmann 2ddc1d960a Merge pull request 'Kundenwuensche' (#6) from dev-klock into dev
Reviewed-on: #6
2026-09-07 11:42:06 +02:00
niklas.ortmann 32d23f5d52 Kundenwuensche
Nur angehakte Checkboxen im PDF
Fotos in Originalgröße per Antippen
Freitext zuerst in jedem Block
Akkordeon-Umbau (angefangen)
2026-09-07 09:46:22 +02:00
niklas.ortmann cda0b4d3fe Merge pull request 'PDF/MEDIA' (#5) from dev-PDF into dev
Reviewed-on: #5
2026-09-06 21:20:29 +02:00
21 changed files with 1983 additions and 167 deletions
@@ -0,0 +1,629 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "12448571f5ffcf00398fa1d8260c9b2b",
"entities": [
{
"tableName": "patients",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `birthDate` TEXT NOT NULL, `street` TEXT NOT NULL, `postalCode` TEXT NOT NULL, `city` TEXT NOT NULL, `phone1` TEXT NOT NULL, `phone2` TEXT, `phone3` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "firstName",
"columnName": "firstName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastName",
"columnName": "lastName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "birthDate",
"columnName": "birthDate",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "street",
"columnName": "street",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "postalCode",
"columnName": "postalCode",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "city",
"columnName": "city",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "phone1",
"columnName": "phone1",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "phone2",
"columnName": "phone2",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "phone3",
"columnName": "phone3",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_patients_lastName_firstName",
"unique": false,
"columnNames": [
"lastName",
"firstName"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_lastName_firstName` ON `${TABLE_NAME}` (`lastName`, `firstName`)"
},
{
"name": "index_patients_birthDate",
"unique": false,
"columnNames": [
"birthDate"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_birthDate` ON `${TABLE_NAME}` (`birthDate`)"
}
],
"foreignKeys": []
},
{
"tableName": "documentations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `patientId` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `continuedFromDocumentationId` TEXT, `pdfLastGeneratedAt` INTEGER, `catalogVersion` INTEGER NOT NULL, `patientFirstNameSnapshot` TEXT NOT NULL, `patientLastNameSnapshot` TEXT NOT NULL, `patientBirthDateSnapshot` TEXT NOT NULL, `patientStreetSnapshot` TEXT NOT NULL, `patientPostalCodeSnapshot` TEXT NOT NULL, `patientCitySnapshot` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`patientId`) REFERENCES `patients`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT , FOREIGN KEY(`continuedFromDocumentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientId",
"columnName": "patientId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "continuedFromDocumentationId",
"columnName": "continuedFromDocumentationId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pdfLastGeneratedAt",
"columnName": "pdfLastGeneratedAt",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "catalogVersion",
"columnName": "catalogVersion",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "patientFirstNameSnapshot",
"columnName": "patientFirstNameSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientLastNameSnapshot",
"columnName": "patientLastNameSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientBirthDateSnapshot",
"columnName": "patientBirthDateSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientStreetSnapshot",
"columnName": "patientStreetSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientPostalCodeSnapshot",
"columnName": "patientPostalCodeSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientCitySnapshot",
"columnName": "patientCitySnapshot",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_documentations_patientId_createdAt",
"unique": false,
"columnNames": [
"patientId",
"createdAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_patientId_createdAt` ON `${TABLE_NAME}` (`patientId`, `createdAt`)"
},
{
"name": "index_documentations_status",
"unique": false,
"columnNames": [
"status"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_status` ON `${TABLE_NAME}` (`status`)"
},
{
"name": "index_documentations_continuedFromDocumentationId",
"unique": false,
"columnNames": [
"continuedFromDocumentationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_continuedFromDocumentationId` ON `${TABLE_NAME}` (`continuedFromDocumentationId`)"
}
],
"foreignKeys": [
{
"table": "patients",
"onDelete": "RESTRICT",
"onUpdate": "NO ACTION",
"columns": [
"patientId"
],
"referencedColumns": [
"id"
]
},
{
"table": "documentations",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"continuedFromDocumentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "block_states",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `blockDefinitionId` TEXT NOT NULL, `titleSnapshot` TEXT NOT NULL, `blockOrder` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "documentationId",
"columnName": "documentationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockDefinitionId",
"columnName": "blockDefinitionId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "titleSnapshot",
"columnName": "titleSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockOrder",
"columnName": "blockOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_block_states_documentationId",
"unique": false,
"columnNames": [
"documentationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_block_states_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
},
{
"name": "index_block_states_documentationId_blockDefinitionId",
"unique": true,
"columnNames": [
"documentationId",
"blockDefinitionId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_block_states_documentationId_blockDefinitionId` ON `${TABLE_NAME}` (`documentationId`, `blockDefinitionId`)"
}
],
"foreignKeys": [
{
"table": "documentations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"documentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "check_item_states",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blockStateId` TEXT NOT NULL, `checkItemDefinitionId` TEXT NOT NULL, `labelSnapshot` TEXT NOT NULL, `itemOrder` INTEGER NOT NULL, `checked` INTEGER NOT NULL, `isFreeText` INTEGER NOT NULL, `freeTextValue` TEXT NOT NULL, `isStaticHint` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`blockStateId`) REFERENCES `block_states`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockStateId",
"columnName": "blockStateId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "checkItemDefinitionId",
"columnName": "checkItemDefinitionId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "labelSnapshot",
"columnName": "labelSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itemOrder",
"columnName": "itemOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "checked",
"columnName": "checked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isFreeText",
"columnName": "isFreeText",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "freeTextValue",
"columnName": "freeTextValue",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isStaticHint",
"columnName": "isStaticHint",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_check_item_states_blockStateId",
"unique": false,
"columnNames": [
"blockStateId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_check_item_states_blockStateId` ON `${TABLE_NAME}` (`blockStateId`)"
},
{
"name": "index_check_item_states_blockStateId_checkItemDefinitionId",
"unique": true,
"columnNames": [
"blockStateId",
"checkItemDefinitionId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_check_item_states_blockStateId_checkItemDefinitionId` ON `${TABLE_NAME}` (`blockStateId`, `checkItemDefinitionId`)"
}
],
"foreignKeys": [
{
"table": "block_states",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"blockStateId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "photos",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "documentationId",
"columnName": "documentationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fileName",
"columnName": "fileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "orderIndex",
"columnName": "orderIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_photos_documentationId_orderIndex",
"unique": false,
"columnNames": [
"documentationId",
"orderIndex"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_photos_documentationId_orderIndex` ON `${TABLE_NAME}` (`documentationId`, `orderIndex`)"
}
],
"foreignKeys": [
{
"table": "documentations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"documentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "signatures",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `signerName` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "documentationId",
"columnName": "documentationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signerName",
"columnName": "signerName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fileName",
"columnName": "fileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "orderIndex",
"columnName": "orderIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_signatures_documentationId",
"unique": false,
"columnNames": [
"documentationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
}
],
"foreignKeys": [
{
"table": "documentations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"documentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "wound_carers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `signatureFileName` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signatureFileName",
"columnName": "signatureFileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '12448571f5ffcf00398fa1d8260c9b2b')"
]
}
}
@@ -44,6 +44,10 @@ import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter
* Version 4 (vorher 3, Änderungswunsch): neue, eigenständige Tabelle `wound_carers` * Version 4 (vorher 3, Änderungswunsch): neue, eigenständige Tabelle `wound_carers`
* (Namensvorschläge + Standard-Vorbelegung im Signatur-Dialog, s. [WoundCarerEntity]) - rein * (Namensvorschläge + Standard-Vorbelegung im Signatur-Dialog, s. [WoundCarerEntity]) - rein
* additiv ([MIGRATION_3_4]), keine bestehenden Tabellen betroffen. * additiv ([MIGRATION_3_4]), keine bestehenden Tabellen betroffen.
*
* Version 5 (vorher 4, finale fachliche Katalog-Inhalte): `check_item_states.isStaticHint` neu
* (nicht editierbarer Hinweistext, z. B. "+ bei Bedarf") - rein additive Spalte mit
* Default-Wert ([MIGRATION_4_5]), keine bestehenden Daten verändert.
*/ */
@Database( @Database(
entities = [ entities = [
@@ -55,7 +59,7 @@ import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter
SignatureEntity::class, SignatureEntity::class,
WoundCarerEntity::class, WoundCarerEntity::class,
], ],
version = 4, version = 5,
exportSchema = true, exportSchema = true,
) )
@TypeConverters(DocumentationStatusConverter::class) @TypeConverters(DocumentationStatusConverter::class)
@@ -83,7 +87,7 @@ abstract class AppDatabase : RoomDatabase() {
DATABASE_NAME, DATABASE_NAME,
) )
// Kein fallbackToDestructiveMigration() mehr ab Version 3 - s. Klassenkommentar. // Kein fallbackToDestructiveMigration() mehr ab Version 3 - s. Klassenkommentar.
.addMigrations(MIGRATION_2_3, MIGRATION_3_4) .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
.build().also { instance = it } .build().also { instance = it }
} }
} }
@@ -111,3 +111,23 @@ val MIGRATION_3_4 = object : Migration(3, 4) {
) )
} }
} }
/**
* Migration von Schema-Version 4 auf 5 (finale fachliche Katalog-Inhalte: neuer Content-Typ
* "statischer Hinweistext", z. B. "+ bei Bedarf" bei "Empfohlene Wundversorgung").
*
* Rein additiv - eine einzelne neue, NOT NULL-Spalte mit Default `false` (0) auf einer
* bestehenden Tabelle. Anders als bei der Signaturen-Migration (2->3, dort war ein
* NULL->NOT-NULL-Wechsel auf einer bereits befüllten Spalte nötig) ist hier ein einfaches
* `ALTER TABLE ... ADD COLUMN` mit Default-Wert ausreichend und von SQLite direkt unterstützt -
* kein Tabellenneubau nötig. Bestehende Zeilen erhalten automatisch `isStaticHint = 0`
* (entspricht ihrem bisherigen, unveränderten Verhalten als normale Checkbox- bzw.
* Freitext-Zeile).
*/
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE `check_item_states` ADD COLUMN `isStaticHint` INTEGER NOT NULL DEFAULT 0"
)
}
}
@@ -15,6 +15,18 @@ import androidx.room.PrimaryKey
* zusätzliche CheckItemStateEntity mit `isFreeText = true` - das ist die Freitext-Option des * zusätzliche CheckItemStateEntity mit `isFreeText = true` - das ist die Freitext-Option des
* Blocks (Änderungswunsch: Freitext als weitere auswählbare Option statt eigenes Feld am * Blocks (Änderungswunsch: Freitext als weitere auswählbare Option statt eigenes Feld am
* Block). Für normale Checkbox-Optionen bleibt `isFreeText = false` und `freeTextValue` leer. * Block). Für normale Checkbox-Optionen bleibt `isFreeText = false` und `freeTextValue` leer.
* Derselbe Mechanismus wird jetzt auch für vom KATALOG selbst definierte Freitext-Checkboxen
* verwendet (z. B. "Wundgrund"/"Erscheinung" oder die vier Empfänger-Freitexte bei "Kopie
* nachrichtlich an") - technisch identisch zur automatisch angehängten Option, nur mit eigener,
* fachlich benannter [labelSnapshot] statt des generischen "Freitext"-Labels.
*
* [isStaticHint]: NEU. Markiert eine Zeile als reinen, nicht editierbaren Hinweistext (z. B.
* "+ bei Bedarf") - keine Checkbox in der UI, kein Umschalten möglich. Solche Zeilen werden
* beim Anlegen (s. DocumentationRepository.insertBlocksFromCatalog) mit `checked = true`
* fest verankert, damit die bestehende, unverändert gebliebene "nur angehakte Elemente"-Regel
* im PDF-Export sie automatisch mit anzeigt, ohne dass diese Regel selbst angepasst werden
* musste. [BlockEditedPolicy] schließt `isStaticHint`-Zeilen dagegen explizit von der
* "bearbeitet"-Prüfung aus, da ein reiner Hinweistext keine Nutzereingabe darstellt.
*/ */
@Entity( @Entity(
tableName = "check_item_states", tableName = "check_item_states",
@@ -40,4 +52,5 @@ data class CheckItemStateEntity(
val checked: Boolean, val checked: Boolean,
val isFreeText: Boolean = false, val isFreeText: Boolean = false,
val freeTextValue: String = "", val freeTextValue: String = "",
val isStaticHint: Boolean = false,
) )
@@ -71,6 +71,7 @@ fun CheckItemStateEntity.toDomain(): CheckItem = CheckItem(
order = itemOrder, order = itemOrder,
checked = checked, checked = checked,
isFreeText = isFreeText, isFreeText = isFreeText,
isStaticHint = isStaticHint,
freeTextValue = freeTextValue, freeTextValue = freeTextValue,
) )
@@ -29,10 +29,13 @@ import de.medapp.wunddoku.pdf.DocumentationPdfExporter
import de.medapp.wunddoku.pdf.DocumentationToPdfExportModelMapper import de.medapp.wunddoku.pdf.DocumentationToPdfExportModelMapper
import de.medapp.wunddoku.pdf.PdfExportError import de.medapp.wunddoku.pdf.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.pdf.PdfToImagesConverter
import de.medapp.wunddoku.pdf.PdfToImagesResult
import de.medapp.wunddoku.signature.SignatureBitmapValidation import de.medapp.wunddoku.signature.SignatureBitmapValidation
import de.medapp.wunddoku.signature.SignatureStorage import de.medapp.wunddoku.signature.SignatureStorage
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import java.io.File
import java.time.Instant import java.time.Instant
import java.util.UUID import java.util.UUID
@@ -60,6 +63,7 @@ class DocumentationRepository(
private val signatureStorage: SignatureStorage, private val signatureStorage: SignatureStorage,
private val woundCarerRepository: WoundCarerRepository, private val woundCarerRepository: WoundCarerRepository,
private val pdfExporter: DocumentationPdfExporter, private val pdfExporter: DocumentationPdfExporter,
private val pdfToImagesConverter: PdfToImagesConverter,
private val db: AppDatabase, private val db: AppDatabase,
) { ) {
@@ -185,6 +189,7 @@ class DocumentationRepository(
itemOrder = item.itemOrder, itemOrder = item.itemOrder,
checked = item.checked, checked = item.checked,
isFreeText = item.isFreeText, isFreeText = item.isFreeText,
isStaticHint = item.isStaticHint,
freeTextValue = item.freeTextValue, freeTextValue = item.freeTextValue,
) )
} }
@@ -283,6 +288,16 @@ class DocumentationRepository(
return pdfExporter.export(model) return pdfExporter.export(model)
} }
/**
* Rendert eine bereits erzeugte PDF-Datei (s. [exportPdf]) seitenweise in JPEG-Dateien
* (Änderungswunsch: "PDF zusätzlich als JPEG speichern/senden können"). Nimmt bewusst die
* fertige [pdfFile] entgegen, statt die Dokumentation erneut zu laden/zu mappen - der
* Aufrufer hat sie direkt aus einem vorangegangenen [exportPdf]-Aufruf zur Verfügung, ein
* erneutes Laden wäre unnötige Doppelarbeit.
*/
suspend fun convertPdfToImages(documentationId: String, pdfFile: File): PdfToImagesResult =
pdfToImagesConverter.convert(documentationId, pdfFile)
/** /**
* Liefert eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter * Liefert eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter
* der Dokumentationsbearbeitung (kein Laden der vollen gespeicherten Auflösung). Wird von * der Dokumentationsbearbeitung (kein Laden der vollen gespeicherten Auflösung). Wird von
@@ -292,6 +307,19 @@ class DocumentationRepository(
suspend fun loadPhotoThumbnail(documentationId: String, fileName: String): Bitmap? = suspend fun loadPhotoThumbnail(documentationId: String, fileName: String): Bitmap? =
photoStorage.decodeThumbnail(documentationId, fileName, ImageDownsampler.THUMBNAIL_MAX_DIMENSION_PX) photoStorage.decodeThumbnail(documentationId, fileName, ImageDownsampler.THUMBNAIL_MAX_DIMENSION_PX)
/**
* Lädt ein Foto in Originalgröße für die Vollbildansicht beim Antippen einer Foto-Kachel
* (Änderungswunsch). Verwendet bewusst [de.medapp.wunddoku.media.PhotoStorage.decodeFitted]
* statt eines unskalierten Rohzugriffs: Fotos werden bereits beim Import auf eine sinnvolle
* Maximalauflösung begrenzt (s. PhotoStorage-Importlogik), ein zusätzliches Herunterrechnen
* auf höchstens die tatsächliche Displaygröße ([maxWidthPx]/[maxHeightPx], vom Aufrufer aus
* der Bildschirmauflösung ermittelt) vermeidet unnötig hohen Speicherbedarf, ohne das Bild
* sichtbar zu verkleinern - der Nutzer sieht es exakt so groß wie der Bildschirm es ohnehin
* darstellen könnte.
*/
suspend fun loadPhotoFullSize(documentationId: String, fileName: String, maxWidthPx: Int, maxHeightPx: Int): Bitmap? =
photoStorage.decodeFitted(documentationId, fileName, maxWidthPx, maxHeightPx)
/** /**
* Lädt eine Signatur für die Vorschau in der Dokumentationsbearbeitung. Kein separates * Lädt eine Signatur für die Vorschau in der Dokumentationsbearbeitung. Kein separates
* Thumbnail-Downsampling nötig - das logische Signaturformat (1000x400px) ist bereits klein * Thumbnail-Downsampling nötig - das logische Signaturformat (1000x400px) ist bereits klein
@@ -363,9 +391,30 @@ class DocumentationRepository(
/** /**
* Legt für jeden Katalog-Block einen BlockState an, sowie je ein CheckItemState pro * Legt für jeden Katalog-Block einen BlockState an, sowie je ein CheckItemState pro
* Katalog-Checkbox PLUS eine zusätzliche, automatisch angehängte Freitext-Option * Katalog-Eintrag PLUS eine zusätzliche, automatisch angehängte generische Freitext-Option
* (isFreeText = true, letzte Position im Block) - siehe DocumentationCatalog.FREE_TEXT_LABEL * (isFreeText = true, Label "Freitext") - siehe DocumentationCatalog.FREE_TEXT_LABEL und
* und BlockDefinition.freeTextDefinitionId. * BlockDefinition.freeTextDefinitionId. Diese generische Option wird WEITERHIN für JEDEN
* Block angehängt, unabhängig davon, ob der Block selbst bereits eigene, fachlich benannte
* `isFreeText`-Einträge definiert (z. B. "Wundgrund + Erscheinung" oder "Kopie
* nachrichtlich an") - so bestätigt und akzeptiert, auch wenn das für einzelne Blöcke eine
* zusätzliche, generisch benannte Freitext-Checkbox neben den fachlichen Einträgen bedeutet.
*
* Katalog-Einträge werden 1:1 aus [BlockDefinition.items] übernommen: `isFreeText` und
* `isStaticHint` steuern direkt das Verhalten der jeweiligen Zeile (normale Checkbox,
* eigene benannte Freitext-Checkbox, oder nicht editierbarer Hinweistext) - keine
* block-spezifische Sonderlogik nötig, die Katalogdefinition allein entscheidet.
*
* Hinweistext-Einträge (`isStaticHint = true`) werden bereits HIER mit `checked = true`
* angelegt (statt änderbar per UI-Checkbox) - dadurch greift die bestehende, unveränderte
* "nur angehakte Elemente"-Regel im PDF-Export automatisch auch für sie, ohne dass diese
* Regel selbst angepasst werden musste. [de.medapp.wunddoku.domain.usecase.BlockEditedPolicy]
* schließt solche Zeilen dagegen explizit aus der "bearbeitet"-Prüfung aus.
*
* Die generische Freitext-Option erscheint als ERSTES Element im Block (itemOrder = 0,
* niedriger als jeder Katalog-Eintrag, dessen `order` bei 1 beginnt). Betrifft ausschließlich
* NEU erstellte Dokumentationen: bereits bestehende BlockState-/CheckItemState-Zeilen
* behalten ihre historisch gespeicherte itemOrder unverändert (Snapshot-Prinzip, s.
* Klassendokumentation zu titleSnapshot/labelSnapshot).
*/ */
private suspend fun insertBlocksFromCatalog(documentationId: String, now: Long) { private suspend fun insertBlocksFromCatalog(documentationId: String, now: Long) {
for (blockDef in DocumentationCatalog.blocks) { for (blockDef in DocumentationCatalog.blocks) {
@@ -383,29 +432,34 @@ class DocumentationRepository(
) )
) )
val normalItems = blockDef.items.map { itemDef -> val catalogItems = blockDef.items.map { itemDef ->
CheckItemStateEntity( CheckItemStateEntity(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
blockStateId = blockStateId, blockStateId = blockStateId,
checkItemDefinitionId = itemDef.id, checkItemDefinitionId = itemDef.id,
labelSnapshot = itemDef.label, labelSnapshot = itemDef.label,
itemOrder = itemDef.order, itemOrder = itemDef.order,
checked = false, // Hinweistexte starten bereits "angehakt" (s. Methodenkommentar) - alle
isFreeText = false, // anderen Einträge (Checkbox oder eigene Freitext-Checkbox) starten
// unangehakt/leer, wie bisher.
checked = itemDef.isStaticHint,
isFreeText = itemDef.isFreeText,
isStaticHint = itemDef.isStaticHint,
freeTextValue = "", freeTextValue = "",
) )
} }
val freeTextItem = CheckItemStateEntity( val genericFreeTextItem = CheckItemStateEntity(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
blockStateId = blockStateId, blockStateId = blockStateId,
checkItemDefinitionId = blockDef.freeTextDefinitionId, checkItemDefinitionId = blockDef.freeTextDefinitionId,
labelSnapshot = DocumentationCatalog.FREE_TEXT_LABEL, labelSnapshot = DocumentationCatalog.FREE_TEXT_LABEL,
itemOrder = blockDef.items.size + 1, itemOrder = 0,
checked = false, checked = false,
isFreeText = true, isFreeText = true,
isStaticHint = false,
freeTextValue = "", freeTextValue = "",
) )
checkItemStateDao.insertAll(normalItems + freeTextItem) checkItemStateDao.insertAll(catalogItems + genericFreeTextItem)
} }
} }
} }
@@ -8,7 +8,9 @@ import de.medapp.wunddoku.data.repository.WoundCarerRepository
import de.medapp.wunddoku.media.InternalPhotoStorage import de.medapp.wunddoku.media.InternalPhotoStorage
import de.medapp.wunddoku.media.PhotoStorage import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.pdf.AndroidPdfDocumentExporter import de.medapp.wunddoku.pdf.AndroidPdfDocumentExporter
import de.medapp.wunddoku.pdf.AndroidPdfToImagesConverter
import de.medapp.wunddoku.pdf.DocumentationPdfExporter import de.medapp.wunddoku.pdf.DocumentationPdfExporter
import de.medapp.wunddoku.pdf.PdfToImagesConverter
import de.medapp.wunddoku.signature.InternalSignatureStorage import de.medapp.wunddoku.signature.InternalSignatureStorage
import de.medapp.wunddoku.signature.SignatureStorage import de.medapp.wunddoku.signature.SignatureStorage
@@ -38,6 +40,7 @@ class AppContainer(context: Context) {
private val pdfExporter: DocumentationPdfExporter by lazy { private val pdfExporter: DocumentationPdfExporter by lazy {
AndroidPdfDocumentExporter(context, photoStorage, signatureStorage) AndroidPdfDocumentExporter(context, photoStorage, signatureStorage)
} }
private val pdfToImagesConverter: PdfToImagesConverter by lazy { AndroidPdfToImagesConverter(context) }
val patientRepository: PatientRepository by lazy { val patientRepository: PatientRepository by lazy {
PatientRepository(patientDao = database.patientDao()) PatientRepository(patientDao = database.patientDao())
@@ -62,6 +65,7 @@ class AppContainer(context: Context) {
signatureStorage = signatureStorage, signatureStorage = signatureStorage,
woundCarerRepository = woundCarerRepository, woundCarerRepository = woundCarerRepository,
pdfExporter = pdfExporter, pdfExporter = pdfExporter,
pdfToImagesConverter = pdfToImagesConverter,
db = database, db = database,
) )
} }
@@ -3,10 +3,30 @@ package de.medapp.wunddoku.domain.catalog
/** /**
* Zentrale, statische Definition aller Dokumentationsblöcke und ihrer Checkboxen. * Zentrale, statische Definition aller Dokumentationsblöcke und ihrer Checkboxen.
* *
* WICHTIG: Die hier verwendeten Inhalte sind ausschließlich neutrale Platzhalter * Enthält die mit dem Kunden final abgestimmten Dokumentationsabschnitte. Basis ist die
* (Vorgabe aus der Aufgabenstellung, Abschnitt 25: "Erfinde keine medizinischen Inhalte"). * handschriftlich vorgegebene 22-Punkte-Reihenfolge, ergänzt/verfeinert nach Abgleich mit dem
* Die späteren echten Inhalte (ca. 20 fachliche Blöcke) werden ausschließlich in dieser * ursprünglichen Papierformular ("Wunddokumentation" von Harald Klock) - dabei wurden:
* Datei ergänzt – keine andere Stelle im Code (UI, Datenbank) muss dafür geändert werden. *
* - mehrere Abschnitte, die im Chat/der handschriftlichen Liste nur als Freitext geführt waren,
* auf dem Papierbogen aber eine differenzierte Checkbox-Struktur zeigen, entsprechend
* erweitert (Wundgrund, Belag, Wundrand, Exsudat, Größe, Fistel/Tasche, Lokalisation,
* Empfohlene Wundversorgung),
* - zwei auf dem Papierbogen vorhandene, aber in der handschriftlichen Liste fehlende
* Abschnitte wieder aufgenommen ("Weitere Überlegung" - vermutlich versehentlich in der
* handschriftlichen Liste ausgelassen; "Nächster Termin" - auf dem Bogen als eigenes Feld
* vorhanden),
* - "Verlauf" bewusst NICHT um die alten Papier-Checkboxen erweitert (explizit vom Kunden
* bestätigt: bleibt reiner Freitext, die alte Checkbox-Auswahl wurde bewusst verworfen),
* - "Wundumgebung" und "Auswirkungen auf das alltägliche Leben" um die Papier-Checkbox-Listen
* ergänzt, jeweils MIT vorangestelltem freiem Textfeld (die ohnehin an jedem Block
* automatisch vorhandene generische Freitext-Option deckt das ab, s. [FREE_TEXT_LABEL]),
* - "Wundart" um die Papier-Checkbox "nicht bekannt/diagnostiziert" ergänzt (zusätzlich zum
* Freitext - NICHT Teil der früher verworfenen Wundarten-Liste, sondern ein eigenständiges
* Status-Flag).
*
* Frühere, im Kundenchat zwischenzeitlich diskutierte, aber später ausdrücklich verworfene
* Inhalte (Wundart-Typenliste, Verlauf-Checkboxen, "Schmerzempfinden 1-10") sind weiterhin
* bewusst NICHT enthalten.
* *
* Diese Definitionen sind bewusst reiner Kotlin-Code und keine Room-Tabellen: Sie ändern sich * Diese Definitionen sind bewusst reiner Kotlin-Code und keine Room-Tabellen: Sie ändern sich
* nur durch neue App-Versionen, nicht durch Nutzerinteraktion. Siehe Architektur-Dokument * nur durch neue App-Versionen, nicht durch Nutzerinteraktion. Siehe Architektur-Dokument
@@ -16,51 +36,370 @@ package de.medapp.wunddoku.domain.catalog
* Metadatum in Documentation.catalogVersion übernommen. Erhöhe diesen Wert, wenn sich Blöcke * Metadatum in Documentation.catalogVersion übernommen. Erhöhe diesen Wert, wenn sich Blöcke
* oder Checkboxen inhaltlich ändern (nicht bei rein redaktionellen Korrekturen wie * oder Checkboxen inhaltlich ändern (nicht bei rein redaktionellen Korrekturen wie
* Tippfehlern) – rein zu Nachvollziehbarkeitszwecken, nicht sicherheitsrelevant für die * Tippfehlern) – rein zu Nachvollziehbarkeitszwecken, nicht sicherheitsrelevant für die
* historische Korrektheit (das übernehmen die Label-/Titel-Snapshots). * historische Korrektheit (das übernehmen die Label-/Titel-Snapshots). Auf 3 erhöht mit dem
* Abgleich gegen das ursprüngliche Papierformular.
*/ */
object DocumentationCatalog { object DocumentationCatalog {
const val CATALOG_VERSION: Int = 1 const val CATALOG_VERSION: Int = 3
/** /**
* Label der automatisch an jeden Block angehängten Freitext-Option (Änderungswunsch: * Label der automatisch an jeden Block angehängten Freitext-Option (Änderungswunsch:
* "Freitext soll selbst wie eine weitere auswählbare Option behandelt werden"). Zentral * "Freitext soll selbst wie eine weitere auswählbare Option behandelt werden"). Zentral
* hier definiert, damit Repository (Erstellung/Fortsetzen) und ggf. der spätere PDF-Export * hier definiert, damit Repository (Erstellung/Fortsetzen) und PDF-Export dieselbe
* dieselbe Bezeichnung verwenden. * Bezeichnung verwenden. Gilt weiterhin unverändert für ALLE Blöcke, auch für solche, die
* fachlich "nur Checkboxen" wären - ausdrücklich bestätigt und kein Fehler.
*/ */
const val FREE_TEXT_LABEL: String = "Freitext" const val FREE_TEXT_LABEL: String = "Freitext"
val blocks: List<BlockDefinition> = listOf( val blocks: List<BlockDefinition> = listOf(
BlockDefinition( BlockDefinition(
id = "block_01", id = "wundart",
title = "Block 1", title = "Wundart",
order = 1, order = 1,
items = listOf( items = listOf(
CheckItemDefinition(id = "block_01_a", label = "Option A", order = 1), CheckItemDefinition(id = "wundart_nicht_bekannt_diagnostiziert", label = "nicht bekannt/diagnostiziert", order = 1),
CheckItemDefinition(id = "block_01_b", label = "Option B", order = 2),
), ),
), ),
BlockDefinition( BlockDefinition(
id = "block_02", id = "lokalisation",
title = "Block 2", title = "Lokalisation",
order = 2, order = 2,
items = listOf( items = listOf(
CheckItemDefinition(id = "block_02_a", label = "Option A", order = 1), CheckItemDefinition(id = "lokalisation_multiple_wunden", label = "multiple Wunden im Bereich", order = 1),
CheckItemDefinition(id = "block_02_b", label = "Option B", order = 2),
CheckItemDefinition(id = "block_02_c", label = "Option C", order = 3),
), ),
), ),
BlockDefinition( BlockDefinition(
id = "block_03", id = "groesse",
title = "Block 3", title = "Größe",
order = 3, order = 3,
items = listOf( items = listOf(
CheckItemDefinition(id = "block_03_a", label = "Option A", order = 1), CheckItemDefinition(id = "groesse_multiple_wunden_max", label = "multiple Wunden mit max. (cm)", order = 1, isFreeText = true),
CheckItemDefinition(id = "block_03_b", label = "Option B", order = 2), CheckItemDefinition(id = "groesse_wunde", label = "Wunde", order = 2),
CheckItemDefinition(id = "groesse_areal", label = "Areal", order = 3),
CheckItemDefinition(id = "groesse_masse", label = "Maße (Länge x Breite, ca. cm)", order = 4, isFreeText = true),
CheckItemDefinition(id = "groesse_vertikale_tiefe", label = "Vertikale Tiefe (ca. cm)", order = 5, isFreeText = true),
), ),
), ),
// Weitere Blöcke (insgesamt ca. 20) werden hier ergänzt, sobald die fachlichen BlockDefinition(
// Inhalte geliefert werden. Struktur und Reihenfolge bleiben identisch. id = "verlauf",
title = "Verlauf",
order = 4,
items = emptyList(),
),
BlockDefinition(
id = "exsudat",
title = "Exsudat",
order = 5,
items = listOf(
CheckItemDefinition(id = "exsudat_kein", label = "Kein", order = 1),
CheckItemDefinition(id = "exsudat_wenig", label = "Wenig", order = 2),
CheckItemDefinition(id = "exsudat_maessig", label = "Mäßig", order = 3),
CheckItemDefinition(id = "exsudat_viel", label = "Viel", order = 4),
CheckItemDefinition(id = "exsudat_uebermaessig", label = "Übermäßig", order = 5),
CheckItemDefinition(id = "exsudat_waessrig", label = "Wässrig", order = 6),
CheckItemDefinition(id = "exsudat_zaehfluessig", label = "Zähflüssig", order = 7),
CheckItemDefinition(id = "exsudat_eitrig", label = "Eitrig", order = 8),
CheckItemDefinition(id = "exsudat_blutig", label = "Blutig", order = 9),
CheckItemDefinition(id = "exsudat_klar", label = "Klar", order = 10),
CheckItemDefinition(id = "exsudat_gelblich", label = "Gelblich", order = 11),
CheckItemDefinition(id = "exsudat_gruenlich", label = "Grünlich", order = 12),
CheckItemDefinition(id = "exsudat_braeunlich", label = "Bräunlich", order = 13),
CheckItemDefinition(id = "exsudat_roetlich", label = "Rötlich", order = 14),
CheckItemDefinition(id = "exsudat_weisslich", label = "Weißlich", order = 15),
),
),
BlockDefinition(
id = "wundgrund_erscheinung",
title = "Wundgrund + Erscheinung",
order = 6,
items = listOf(
CheckItemDefinition(id = "wundgrund_eben", label = "Eben", order = 1),
CheckItemDefinition(id = "wundgrund_unregelmaessig", label = "Unregelmäßig", order = 2),
CheckItemDefinition(id = "wundgrund_knospig", label = "Knospig", order = 3),
CheckItemDefinition(id = "wundgrund_auf_niveau", label = "Auf Niveau", order = 4),
CheckItemDefinition(id = "wundgrund_ueber_niveau", label = "Über Niveau", order = 5),
CheckItemDefinition(id = "wundgrund_glaenzend", label = "Glänzend", order = 6),
CheckItemDefinition(id = "wundgrund_grob", label = "Grob", order = 7),
CheckItemDefinition(id = "wundgrund_fein", label = "Fein", order = 8),
CheckItemDefinition(id = "wundgrund_glatt", label = "Glatt", order = 9),
CheckItemDefinition(id = "wundgrund_rot", label = "Rot", order = 10),
CheckItemDefinition(id = "wundgrund_blass", label = "Blass", order = 11),
CheckItemDefinition(id = "wundgrund_knochen_sehnen", label = "Knochen/Sehnen", order = 12),
CheckItemDefinition(id = "wundgrund_haemorrhagie", label = "Hämorrhagie", order = 13),
CheckItemDefinition(id = "wundgrund_nicht_kaum_einsehbar", label = "Nicht/kaum einsehbar", order = 14),
CheckItemDefinition(id = "wundgrund_erscheinung_erscheinung", label = "Erscheinung", order = 15, isFreeText = true),
),
),
BlockDefinition(
id = "belag",
title = "Belag",
order = 7,
items = listOf(
CheckItemDefinition(id = "belag_nein", label = "Nein", order = 1),
CheckItemDefinition(id = "belag_ja", label = "Ja", order = 2),
CheckItemDefinition(id = "belag_fibrin", label = "Fibrin", order = 3),
CheckItemDefinition(id = "belag_fremdkoerper", label = "Fremdkörper", order = 4),
CheckItemDefinition(id = "belag_nekrose", label = "Nekrose", order = 5),
CheckItemDefinition(id = "belag_kruste", label = "Kruste", order = 6),
CheckItemDefinition(id = "belag_mit_fibrinsaum", label = "Mit Fibrinsaum", order = 7),
CheckItemDefinition(id = "belag_trocken", label = "Trocken", order = 8),
CheckItemDefinition(id = "belag_feucht", label = "Feucht", order = 9),
CheckItemDefinition(id = "belag_100_prozent", label = "100%", order = 10),
CheckItemDefinition(id = "belag_75_prozent", label = "75%", order = 11),
CheckItemDefinition(id = "belag_50_prozent", label = "50%", order = 12),
CheckItemDefinition(id = "belag_25_prozent_plus", label = "25%+<", order = 13),
),
),
BlockDefinition(
id = "wundrand",
title = "Wundrand",
order = 8,
items = listOf(
CheckItemDefinition(id = "wundrand_unauffaellig", label = "Unauffällig", order = 1),
CheckItemDefinition(id = "wundrand_partiell_unterschiedlich", label = "Partiell unterschiedlich", order = 2),
CheckItemDefinition(id = "wundrand_rosarot", label = "Rosarot", order = 3),
CheckItemDefinition(id = "wundrand_rot", label = "Rot", order = 4),
CheckItemDefinition(id = "wundrand_blaeulich", label = "Bläulich", order = 5),
CheckItemDefinition(id = "wundrand_blass", label = "Blass", order = 6),
CheckItemDefinition(id = "wundrand_blutig", label = "Blutig", order = 7),
CheckItemDefinition(id = "wundrand_mit_kruste_belegt", label = "Mit Kruste belegt", order = 8),
CheckItemDefinition(id = "wundrand_mazeriert", label = "Mazeriert", order = 9),
CheckItemDefinition(id = "wundrand_unterminiert", label = "Unterminiert", order = 10),
CheckItemDefinition(id = "wundrand_trocken", label = "Trocken", order = 11),
CheckItemDefinition(id = "wundrand_glatt", label = "Glatt", order = 12),
CheckItemDefinition(id = "wundrand_flach_einlaufend", label = "Flach einlaufend", order = 13),
CheckItemDefinition(id = "wundrand_wallartig", label = "Wallartig", order = 14),
CheckItemDefinition(id = "wundrand_wulstig", label = "Wulstig", order = 15),
CheckItemDefinition(id = "wundrand_zerklueftet", label = "Zerklüftet", order = 16),
CheckItemDefinition(id = "wundrand_ueberhaengende_strukturen_lefzen", label = "Überhängende Strukturen/Lefzen", order = 17),
),
),
BlockDefinition(
id = "wundumgebung",
title = "Wundumgebung",
order = 9,
items = listOf(
CheckItemDefinition(id = "wundumgebung_unauffaellig", label = "Unauffällig", order = 1),
CheckItemDefinition(id = "wundumgebung_oedem", label = "Ödem", order = 2),
CheckItemDefinition(id = "wundumgebung_ekzem", label = "Ekzem", order = 3),
CheckItemDefinition(id = "wundumgebung_schuppig", label = "Schuppig", order = 4),
CheckItemDefinition(id = "wundumgebung_narbengewebe", label = "Narbengewebe", order = 5),
CheckItemDefinition(id = "wundumgebung_hornhaut_verhaertet", label = "Hornhaut/verhärtet", order = 6),
CheckItemDefinition(id = "wundumgebung_geroetet", label = "Gerötet", order = 7),
CheckItemDefinition(id = "wundumgebung_blaeulich", label = "Bläulich", order = 8),
CheckItemDefinition(id = "wundumgebung_blass", label = "Blass", order = 9),
CheckItemDefinition(id = "wundumgebung_hyperkeratose", label = "Hyperkeratose", order = 10),
CheckItemDefinition(id = "wundumgebung_pergamenthaut", label = "Pergamenthaut", order = 11),
CheckItemDefinition(id = "wundumgebung_rissig_kratzspuren", label = "Rissig/Kratzspuren", order = 12),
CheckItemDefinition(id = "wundumgebung_spannungsblasen", label = "Spannungsblasen", order = 13),
CheckItemDefinition(id = "wundumgebung_haematome", label = "Hämatome", order = 14),
),
),
BlockDefinition(
id = "fistel_tasche",
title = "Fistel / Tasche",
order = 10,
items = listOf(
CheckItemDefinition(id = "fistel_tasche_nein", label = "Nein", order = 1),
CheckItemDefinition(id = "fistel_tasche_ja", label = "Ja", order = 2),
CheckItemDefinition(id = "fistel_tasche_zirkulaer", label = "Zirkulär", order = 3),
CheckItemDefinition(id = "fistel_tasche_von_bis_uhr", label = "Von ... bis ... Uhr", order = 4, isFreeText = true),
CheckItemDefinition(id = "fistel_tasche_tiefe", label = "Tiefe (ca. cm)", order = 5, isFreeText = true),
),
),
BlockDefinition(
id = "entzuendungszeichen",
title = "Entzündungszeichen",
order = 11,
items = listOf(
CheckItemDefinition(id = "entzuendungszeichen_nein", label = "Nein", order = 1),
CheckItemDefinition(id = "entzuendungszeichen_temperatur_erhoeht", label = "Temperatur erhöht", order = 2),
CheckItemDefinition(id = "entzuendungszeichen_schwellung", label = "Schwellung", order = 3),
CheckItemDefinition(id = "entzuendungszeichen_roetung", label = "Rötung", order = 4),
CheckItemDefinition(id = "entzuendungszeichen_funktionsstoerung", label = "Funktionsstörung", order = 5),
CheckItemDefinition(id = "entzuendungszeichen_schmerz", label = "Schmerz", order = 6),
),
),
BlockDefinition(
id = "geruch",
title = "Geruch",
order = 12,
items = listOf(
CheckItemDefinition(id = "geruch_ja", label = "Ja", order = 1),
CheckItemDefinition(id = "geruch_nein", label = "Nein", order = 2),
CheckItemDefinition(id = "geruch_gering", label = "Gering", order = 3),
CheckItemDefinition(id = "geruch_deutlich_wahrnehmbar", label = "Deutlich wahrnehmbar", order = 4),
),
),
BlockDefinition(
id = "schmerz",
title = "Schmerz",
order = 13,
items = listOf(
CheckItemDefinition(id = "schmerz_ja", label = "Ja", order = 1),
CheckItemDefinition(id = "schmerz_nein", label = "Nein", order = 2),
CheckItemDefinition(id = "schmerz_immer", label = "Immer", order = 3),
CheckItemDefinition(id = "schmerz_zeitweise", label = "Zeitweise", order = 4),
CheckItemDefinition(id = "schmerz_bei_verbandwechsel", label = "Bei Verbandwechsel", order = 5),
CheckItemDefinition(id = "schmerz_vorwiegend_nachts", label = "Vorwiegend nachts", order = 6),
CheckItemDefinition(id = "schmerz_wunde", label = "Wunde", order = 7),
CheckItemDefinition(id = "schmerz_umgebung", label = "Umgebung", order = 8),
),
),
BlockDefinition(
id = "juckreiz",
title = "Juckreiz",
order = 14,
items = listOf(
CheckItemDefinition(id = "juckreiz_ja", label = "Ja", order = 1),
CheckItemDefinition(id = "juckreiz_nein", label = "Nein", order = 2),
CheckItemDefinition(id = "juckreiz_wunde", label = "Wunde", order = 3),
CheckItemDefinition(id = "juckreiz_umgebung", label = "Umgebung", order = 4),
CheckItemDefinition(id = "juckreiz_immer_oft", label = "Immer / oft", order = 5),
CheckItemDefinition(id = "juckreiz_zeitweise", label = "Zeitweise", order = 6),
),
),
BlockDefinition(
id = "vitalwerte",
title = "Vitalwerte",
order = 15,
items = emptyList(),
),
BlockDefinition(
id = "auswirkungen_alltag",
title = "Auswirkungen auf das alltägliche Leben",
order = 16,
items = listOf(
CheckItemDefinition(id = "auswirkungen_alltag_ja", label = "Ja", order = 1),
CheckItemDefinition(id = "auswirkungen_alltag_nein", label = "Nein", order = 2),
CheckItemDefinition(id = "auswirkungen_alltag_nicht_ermittelbar", label = "Nicht ermittelbar", order = 3),
),
),
BlockDefinition(
id = "bekannte_allergie",
title = "Bekannte Allergie",
order = 17,
items = emptyList(),
),
BlockDefinition(
id = "beratung_erfolgte",
title = "Beratung erfolgte",
order = 18,
items = listOf(
CheckItemDefinition(id = "beratung_verbandwechsel", label = "Verbandwechsel", order = 1),
CheckItemDefinition(id = "beratung_hygiene", label = "Hygiene", order = 2),
CheckItemDefinition(id = "beratung_ernaehrung", label = "Ernährung", order = 3),
CheckItemDefinition(id = "beratung_lagerung_druckentlastung", label = "Lagerung / Druckentlastung", order = 4),
CheckItemDefinition(id = "beratung_kleidung_schuhe", label = "Kleidung / Schuhe", order = 5),
CheckItemDefinition(id = "beratung_hautpflege", label = "Hautpflege", order = 6),
),
),
BlockDefinition(
id = "weitere_ueberlegung",
title = "Weitere Überlegung",
order = 19,
items = listOf(
CheckItemDefinition(id = "weitere_ueberlegung_hausarztvisite", label = "Hausarztvisite", order = 1),
CheckItemDefinition(id = "weitere_ueberlegung_kompressionstherapie", label = "Kompressionstherapie", order = 2),
CheckItemDefinition(id = "weitere_ueberlegung_wundabstrich", label = "Wundabstrich", order = 3),
CheckItemDefinition(id = "weitere_ueberlegung_angepasste_schmerztherapie", label = "Angepasste Schmerztherapie", order = 4),
CheckItemDefinition(id = "weitere_ueberlegung_anpassung_wundversorgung", label = "Anpassung der Wundversorgung", order = 5),
CheckItemDefinition(id = "weitere_ueberlegung_facharztliches_konsil", label = "Fachärztliches Konsil", order = 6),
CheckItemDefinition(id = "weitere_ueberlegung_ernaehrungsberatung", label = "Ernährungsberatung", order = 7),
CheckItemDefinition(id = "weitere_ueberlegung_pflegeberatung", label = "Pflegeberatung", order = 8),
CheckItemDefinition(id = "weitere_ueberlegung_hilfsmittel", label = "Hilfsmittel", order = 9),
CheckItemDefinition(id = "weitere_ueberlegung_bewegung_lagerungsplan", label = "Bewegung / Lagerungsplan", order = 10),
CheckItemDefinition(id = "weitere_ueberlegung_verbandwechsel_pflegedienst", label = "Verbandwechsel durch Pflegefachkräfte / Pflegedienst", order = 11),
CheckItemDefinition(
id = "weitere_ueberlegung_aerztliche_abklaerung_dringend",
label = "Ärztliche Abklärung zeitnah/dringend erforderlich / erbeten, Gefahr von bleibenden körperlichen Schäden / lebensbedrohlichen Prozessen",
order = 12,
),
),
),
BlockDefinition(
id = "sonstiges",
title = "Sonstiges",
order = 20,
items = emptyList(),
),
BlockDefinition(
id = "empfohlene_wundversorgung",
title = "Empfohlene Wundversorgung",
order = 21,
items = listOf(
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_reinigen_mit", label = "Reinigen mit:", order = 1, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_sterile_kompresse", label = "Sterile Kompresse", order = 2),
CheckItemDefinition(id = "empfohlene_wundversorgung_nacl", label = "NaCl 0,9%", order = 3),
CheckItemDefinition(id = "empfohlene_wundversorgung_octenisept", label = "Octenisept", order = 4),
CheckItemDefinition(id = "empfohlene_wundversorgung_granudacyn", label = "Granudacyn", order = 5),
CheckItemDefinition(id = "empfohlene_wundversorgung_getraenkte_kompresse", label = "Getränkte Kompresse für ca. ... Min. auflegen", order = 6, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_tupfend", label = "Tupfend", order = 7),
CheckItemDefinition(id = "empfohlene_wundversorgung_wischend", label = "Wischend", order = 8),
CheckItemDefinition(id = "empfohlene_wundversorgung_nachspuelen_nacl", label = "Nachspülen mit NaCl 0,9%", order = 9),
CheckItemDefinition(id = "empfohlene_wundversorgung_umgebung_trocken_tupfen", label = "Umgebung trocken tupfen", order = 10),
CheckItemDefinition(id = "empfohlene_wundversorgung_primaer_abdecken_austamponieren", label = "Primär Wunde abdecken/austamponieren", order = 11),
CheckItemDefinition(id = "empfohlene_wundversorgung_ueber_wundrand_hinaus", label = "Über Wundrand hinaus", order = 12),
CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit", label = "Nicht über Wundrand mit", order = 13, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_wundrandschutz_mit", label = "Wundrandschutz mit (bis 3x Wo.):", order = 14, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_secura", label = "Secura", order = 15),
CheckItemDefinition(id = "empfohlene_wundversorgung_cavilon", label = "Cavilon", order = 16),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_sekundaere_wundauflage", label = "Sekundäre Wundauflage:", order = 17, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_sekundaer_sterile_kompresse", label = "Sterile Kompresse", order = 18),
CheckItemDefinition(id = "empfohlene_wundversorgung_saugkompresse", label = "Saugkompresse", order = 19),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_fixieren_mit", label = "Fixieren mit:", order = 20, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_mullbinde", label = "Mullbinde", order = 21),
CheckItemDefinition(id = "empfohlene_wundversorgung_klebevlies", label = "Klebevlies", order = 22),
CheckItemDefinition(id = "empfohlene_wundversorgung_folienverband", label = "Folienverband", order = 23),
CheckItemDefinition(id = "empfohlene_wundversorgung_schlauchverband", label = "Schlauchverband", order = 24),
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde", label = "Kohäsive Fixierbinde", order = 25),
CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband", label = "Kompressionsverband", order = 26),
CheckItemDefinition(id = "empfohlene_wundversorgung_wechselintervall", label = "Wechselintervall", order = 27, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_bedarf", label = "+ bei Bedarf", order = 28, isStaticHint = true),
CheckItemDefinition(
id = "empfohlene_wundversorgung_hinweis_abklaerung",
label = "Generell: bei akuter Verschlechterung sofort ärztliche Abklärung",
order = 29,
isStaticHint = true,
),
),
),
BlockDefinition(
id = "ziel",
title = "Ziel",
order = 22,
items = listOf(
CheckItemDefinition(id = "ziel_sauberes_wundmilieu", label = "Sauberes Wundmilieu", order = 1),
CheckItemDefinition(id = "ziel_infektionsprophylaxe", label = "Infektionsprophylaxe", order = 2),
CheckItemDefinition(id = "ziel_abpolsterung", label = "Abpolsterung", order = 3),
CheckItemDefinition(id = "ziel_schmerzreduktion", label = "Schmerzreduktion", order = 4),
CheckItemDefinition(id = "ziel_angepasstes_exsudatmanagement", label = "Angepasstes Exsudatmanagement", order = 5),
CheckItemDefinition(id = "ziel_stabile_wundverhaeltnisse", label = "Stabile Wundverhältnisse", order = 6),
CheckItemDefinition(id = "ziel_abheilung", label = "Abheilung", order = 7),
CheckItemDefinition(id = "ziel_geruchsreduzierung", label = "Geruchsreduzierung", order = 8),
CheckItemDefinition(id = "ziel_einfache_durchfuehrung", label = "Einfache Durchführung", order = 9),
CheckItemDefinition(id = "ziel_wundrandschutz", label = "Wundrandschutz", order = 10),
CheckItemDefinition(id = "ziel_aufbruch_vorhandenes_material", label = "Aufbruch des vorhandenen Materials", order = 11),
),
),
BlockDefinition(
id = "kopie_nachrichtlich_an",
title = "Kopie nachrichtlich an",
order = 23,
items = listOf(
CheckItemDefinition(id = "kopie_nachrichtlich_an_arzt", label = "Ärztin / Arzt", order = 1, isFreeText = true),
CheckItemDefinition(id = "kopie_nachrichtlich_an_pflege", label = "Pflege", order = 2, isFreeText = true),
CheckItemDefinition(id = "kopie_nachrichtlich_an_patient", label = "Patient", order = 3, isFreeText = true),
CheckItemDefinition(id = "kopie_nachrichtlich_an_sonstige", label = "Sonstige", order = 4, isFreeText = true),
),
),
BlockDefinition(
id = "naechster_termin",
title = "Nächster Termin",
order = 24,
items = emptyList(),
),
) )
fun findBlock(blockDefinitionId: String): BlockDefinition? = fun findBlock(blockDefinitionId: String): BlockDefinition? =
@@ -74,16 +413,39 @@ data class BlockDefinition(
val items: List<CheckItemDefinition>, val items: List<CheckItemDefinition>,
) { ) {
/** /**
* Stabile, aus der Block-ID abgeleitete ID für die automatisch angehängte Freitext-Option * Stabile, aus der Block-ID abgeleitete ID für die automatisch angehängte, generische
* dieses Blocks (siehe DocumentationCatalog.FREE_TEXT_LABEL). Kein Bestandteil von [items], * Freitext-Option dieses Blocks (siehe DocumentationCatalog.FREE_TEXT_LABEL). Kein
* da die Freitext-Option kein fachlicher Katalog-Eintrag ist, sondern strukturell zu jedem * Bestandteil von [items], da diese generische Freitext-Option kein fachlicher
* Block gehört. * Katalog-Eintrag ist, sondern strukturell zu JEDEM Block gehört - unabhängig davon, ob
* der Block selbst bereits eigene `isFreeText`-Einträge in [items] definiert.
*/ */
val freeTextDefinitionId: String get() = "${id}_freetext" val freeTextDefinitionId: String get() = "${id}_freetext"
} }
/**
* Ein einzelner Katalog-Eintrag innerhalb eines Blocks.
*
* Normalfall: reine Checkbox ([isFreeText] = false, [isStaticHint] = false).
*
* [isFreeText] = true: eine vom Katalog selbst definierte, fachlich eigenständig benannte
* Freitext-Checkbox (z. B. "Erscheinung", "Wechselintervall", oder ein Empfänger bei "Kopie
* nachrichtlich an") - technisch identisch zur automatisch an JEDEN Block angehängten
* generischen Freitext-Option (s. DocumentationRepository.insertBlocksFromCatalog), nur mit
* eigenem Label statt "Freitext". Ein Block kann beliebig viele solcher Einträge definieren.
*
* [isStaticHint] = true: reiner, nicht editierbarer Hinweistext (z. B. "+ bei Bedarf" oder eine
* optische Zwischenüberschrift wie "Reinigen mit:") - [label] ist hier direkt der anzuzeigende
* Text selbst, keine Checkbox-Beschriftung.
*
* [isFreeText] und [isStaticHint] schließen sich fachlich gegenseitig aus (ein Eintrag ist
* entweder normale Checkbox, Freitext-Checkbox oder Hinweistext) - es gibt aber bewusst keine
* Laufzeitprüfung dafür, um die Katalogdefinition einfach zu halten; beide gleichzeitig auf
* `true` zu setzen ergibt schlicht keinen sinnvollen Katalogeintrag.
*/
data class CheckItemDefinition( data class CheckItemDefinition(
val id: String, val id: String,
val label: String, val label: String,
val order: Int, val order: Int,
val isFreeText: Boolean = false,
val isStaticHint: Boolean = false,
) )
@@ -9,12 +9,17 @@ package de.medapp.wunddoku.domain.model
* historische Dokumentationen unabhängig von späteren Katalog-Änderungen unverändert korrekt * historische Dokumentationen unabhängig von späteren Katalog-Änderungen unverändert korrekt
* dargestellt werden. * dargestellt werden.
* *
* [isFreeText]/[freeTextValue]: Der Freitext eines Blocks wird NICHT mehr als separates Feld am * [isFreeText]/[freeTextValue]: Der Freitext eines Blocks wird NICHT als separates Feld am
* Block geführt, sondern als eine weitere, besondere Checkbox-Option behandelt - jeder Block * Block geführt, sondern als eine weitere, besondere Checkbox-Option behandelt. Jeder Block
* bekommt automatisch genau ein CheckItem mit `isFreeText = true`. Ist [isFreeText] `false`, * bekommt automatisch genau ein CheckItem mit `isFreeText = true`; zusätzlich kann der Katalog
* ist [freeTextValue] stets leer und ohne Bedeutung. Diese Struktur erlaubt es dem späteren * selbst beliebig viele weitere `isFreeText = true`-Optionen mit eigenem, fachlichem Label
* PDF-Export, normale Optionen, die Freitext-Option und deren Inhalt eindeutig zu * definieren (z. B. "Wundgrund", "Erscheinung", oder die vier Empfänger bei "Kopie
* unterscheiden, ohne eine Sonderlogik außerhalb des bestehenden Checkbox-Modells zu benötigen. * nachrichtlich an") - technisch identisch, nur mit eigenem Label statt des generischen
* "Freitext". Ist [isFreeText] `false`, ist [freeTextValue] stets leer und ohne Bedeutung.
*
* [isStaticHint]: reiner, nicht editierbarer Hinweistext (z. B. "+ bei Bedarf") - keine
* Checkbox in der UI, [checked] ist bei solchen Zeilen technisch immer `true` (s. Entity-
* Kommentar), zählt aber NICHT als Bearbeitung (s. [de.medapp.wunddoku.domain.usecase.BlockEditedPolicy]).
*/ */
data class CheckItem( data class CheckItem(
val checkItemStateId: String, val checkItemStateId: String,
@@ -24,4 +29,5 @@ data class CheckItem(
val checked: Boolean, val checked: Boolean,
val isFreeText: Boolean = false, val isFreeText: Boolean = false,
val freeTextValue: String = "", val freeTextValue: String = "",
val isStaticHint: Boolean = false,
) )
@@ -23,8 +23,15 @@ object BlockEditedPolicy {
* nötig: Das Freitextfeld ist in der UI deaktiviert, solange seine Checkbox nicht angehakt * nötig: Das Freitextfeld ist in der UI deaktiviert, solange seine Checkbox nicht angehakt
* ist, ein Ankreuzen zählt also bereits als bewusste Bearbeitung, unabhängig davon, ob schon * ist, ein Ankreuzen zählt also bereits als bewusste Bearbeitung, unabhängig davon, ob schon
* Text eingegeben wurde. * Text eingegeben wurde.
*
* EINZIGE Ausnahme: Hinweistext-Zeilen (CheckItem.isStaticHint = true) werden hierbei
* IGNORIERT, obwohl ihr `checked` technisch immer `true` ist (s. Entity-Kommentar zu
* isStaticHint) - ein reiner, nicht editierbarer Hinweistext ist keine Nutzereingabe und
* darf einen Block nicht allein dadurch als "bearbeitet" erscheinen lassen (z. B.
* "Empfohlene Wundversorgung" enthält zwei feste Hinweiszeilen, soll aber nur dann als
* bearbeitet gelten, wenn tatsächlich eine Empfehlung eingetragen wurde).
*/ */
fun isEdited(block: DocumentationBlock): Boolean { fun isEdited(block: DocumentationBlock): Boolean {
return block.items.any { it.checked } return block.items.any { it.checked && !it.isStaticHint }
} }
} }
@@ -2,7 +2,6 @@ package de.medapp.wunddoku.pdf
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.Paint import android.graphics.Paint
import android.graphics.pdf.PdfDocument import android.graphics.pdf.PdfDocument
@@ -84,10 +83,9 @@ class AndroidPdfDocumentExporter(
private const val SIGNATURE_SPACING_AFTER_BLOCK = 16f private const val SIGNATURE_SPACING_AFTER_BLOCK = 16f
private const val SIGNATURE_SECTION_TITLE_SPACING_AFTER = 8f private const val SIGNATURE_SECTION_TITLE_SPACING_AFTER = 8f
// Vektor-Checkbox: Kantenlänge des gezeichneten Kästchens und Abstand zum Label-Text. // Änderungswunsch: kein Checkbox-Symbol mehr im PDF - nur noch eine schlichte Einrückung
private const val CHECKBOX_SIZE = 10f // der (bereits ausschließlich angehakten) Optionen unter dem Blocktitel.
private const val CHECKBOX_GAP = 6f private const val ITEM_INDENT = 10f
private const val CHECKBOX_INDENT = CHECKBOX_SIZE + CHECKBOX_GAP
/** /**
* Zielauflösung für den Fotoanhang, bewusst als Kompromiss zwischen Bildqualität und * Zielauflösung für den Fotoanhang, bewusst als Kompromiss zwischen Bildqualität und
@@ -244,21 +242,6 @@ class AndroidPdfDocumentExporter(
color = Color.DKGRAY color = Color.DKGRAY
} }
// Reine Vektor-Checkbox-Paints - keine Textschrift/Glyphen-Abhängigkeit.
val checkboxOutline = Paint().apply {
isAntiAlias = true
style = Paint.Style.STROKE
strokeWidth = 1.2f
color = Color.BLACK
}
val checkboxMark = Paint().apply {
isAntiAlias = true
style = Paint.Style.STROKE
strokeWidth = 1.5f
strokeCap = Paint.Cap.ROUND
color = Color.BLACK
}
/** Für das Zeichnen der Fotoanhang-Bitmaps (PDF-004). `isFilterBitmap` sorgt für /** Für das Zeichnen der Fotoanhang-Bitmaps (PDF-004). `isFilterBitmap` sorgt für
* weiche statt treppenartige Kanten beim Canvas-seitigen Skalieren. */ * weiche statt treppenartige Kanten beim Canvas-seitigen Skalieren. */
val photo = Paint().apply { val photo = Paint().apply {
@@ -294,24 +277,23 @@ class AndroidPdfDocumentExporter(
cursor.drawDivider(spacingBefore = 4f, spacingAfter = 12f) cursor.drawDivider(spacingBefore = 4f, spacingAfter = 12f)
} }
/** Änderungswunsch: KEIN Checkbox-Symbol mehr im PDF. Da [PdfExportBlock.items] bereits nur
* angehakte Optionen enthält (s. Mapper), wäre ein "immer angekreuztes" Kästchen ohnehin
* redundant - stattdessen ein einfacher, leicht eingerückter Label-Text je Option. */
private fun drawItem(cursor: PdfPageCursor, item: PdfExportItem, paints: ExportPaints) { private fun drawItem(cursor: PdfPageCursor, item: PdfExportItem, paints: ExportPaints) {
// Die Freitext-Option bekommt einen abschließenden Doppelpunkt, normale Optionen nicht. // Die Freitext-Option bekommt einen abschließenden Doppelpunkt, normale Optionen nicht.
val labelText = if (item.isFreeText) "${item.label}:" else item.label val labelText = if (item.isFreeText) "${item.label}:" else item.label
val checked = item.checked
cursor.drawText( cursor.drawText(
text = labelText, text = labelText,
paint = paints.item, paint = paints.item,
indent = CHECKBOX_INDENT, indent = ITEM_INDENT,
spacingAfter = 3f, spacingAfter = 3f,
leadingMark = { canvas, left, top, bottom ->
drawCheckboxGlyph(canvas, left, top, bottom, checked, paints)
},
) )
// Freitext nur zeichnen, wenn die Option angehakt UND tatsächlich Text vorhanden ist - // Freitext nur zeichnen, wenn tatsächlich Text vorhanden ist - ein angehakter, aber
// ein angehakter, aber leerer Freitext erzeugt keinen leeren Bereich im PDF. // leerer Freitext erzeugt keinen leeren Bereich im PDF.
if (item.isFreeText && checked && item.freeTextValue.isNotBlank()) { if (item.isFreeText && item.freeTextValue.isNotBlank()) {
cursor.drawText( cursor.drawText(
text = item.freeTextValue, text = item.freeTextValue,
paint = paints.freeText, paint = paints.freeText,
@@ -321,33 +303,6 @@ class AndroidPdfDocumentExporter(
} }
} }
/**
* Zeichnet ein Checkbox-Symbol rein per Canvas-Vektorprimitiven: ein Quadrat-Umriss, bei
* `checked = true` zusätzlich ein Kreuz darin. [top]/[bottom] sind die Geräte-Y-Koordinaten
* der zugehörigen ersten Textzeile - das Kästchen wird vertikal darauf zentriert.
*/
private fun drawCheckboxGlyph(
canvas: Canvas,
left: Float,
top: Float,
bottom: Float,
checked: Boolean,
paints: ExportPaints,
) {
val centerY = (top + bottom) / 2f
val boxTop = centerY - CHECKBOX_SIZE / 2f
val boxBottom = centerY + CHECKBOX_SIZE / 2f
val boxRight = left + CHECKBOX_SIZE
canvas.drawRect(left, boxTop, boxRight, boxBottom, paints.checkboxOutline)
if (checked) {
val inset = CHECKBOX_SIZE * 0.2f
canvas.drawLine(left + inset, boxTop + inset, boxRight - inset, boxBottom - inset, paints.checkboxMark)
canvas.drawLine(boxRight - inset, boxTop + inset, left + inset, boxBottom - inset, paints.checkboxMark)
}
}
/** /**
* Zeichnet den kompletten Signaturbereich (PDF-003): eine einmalige Überschrift * Zeichnet den kompletten Signaturbereich (PDF-003): eine einmalige Überschrift
* "Unterschriften", gefolgt von einem atomaren Block je Signatur (bereits vom Mapper stabil * "Unterschriften", gefolgt von einem atomaren Block je Signatur (bereits vom Mapper stabil
@@ -28,12 +28,16 @@ object DocumentationToPdfExportModelMapper {
.map { block -> .map { block ->
PdfExportBlock( PdfExportBlock(
title = block.title, title = block.title,
// Änderungswunsch: nur angehakte Optionen erscheinen im PDF - nicht
// angehakte Checkboxen werden komplett weggelassen (nicht nur unausgefüllt
// dargestellt). Ein leeres Ergebnis ist hier ausgeschlossen: BlockEditedPolicy
// (Filter oben) lässt nur Blöcke mit mindestens einer angehakten Option durch.
items = block.items items = block.items
.filter { it.checked }
.sortedBy { it.order } .sortedBy { it.order }
.map { item -> .map { item ->
PdfExportItem( PdfExportItem(
label = item.label, label = item.label,
checked = item.checked,
isFreeText = item.isFreeText, isFreeText = item.isFreeText,
freeTextValue = item.freeTextValue, freeTextValue = item.freeTextValue,
) )
@@ -28,14 +28,15 @@ data class PdfExportModel(
data class PdfExportBlock( data class PdfExportBlock(
val title: String, val title: String,
/** Bereits sortiert nach itemOrder. Enthält ALLE Optionen des Blocks (angehakt und nicht /** Bereits gefiltert (NUR angehakte Optionen, s. Mapper - Änderungswunsch) und sortiert
* angehakt) - siehe Architekturentscheidung zur Checkbox-Darstellung. */ * (itemOrder). Nicht angehakte Optionen erscheinen im PDF überhaupt nicht mehr. */
val items: List<PdfExportItem>, val items: List<PdfExportItem>,
) )
/** Kein `checked`-Feld mehr: Da [PdfExportBlock.items] bereits ausschließlich angehakte
* Optionen enthält, wäre es immer `true` und damit bedeutungslos - s. Mapper-Filter. */
data class PdfExportItem( data class PdfExportItem(
val label: String, val label: String,
val checked: Boolean,
val isFreeText: Boolean, val isFreeText: Boolean,
val freeTextValue: String, val freeTextValue: String,
) )
@@ -0,0 +1,99 @@
package de.medapp.wunddoku.pdf
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import java.io.File
/**
* Übergibt alle als JPEG gerenderten PDF-Seiten (s. [PdfToImagesConverter]) GEMEINSAM an den
* Android-"Senden an"-Dialog (`ACTION_SEND_MULTIPLE`) - Änderungswunsch: "alle generierten
* Bilder über 'Senden an' direkt verschicken können", nicht Seite für Seite einzeln.
*
* Bewusst getrennt von [PdfToImagesConverter] (reine Datei-Logik): das Starten einer Activity
* ist eine UI-/Plattform-Aktion, keine Repository-Aufgabe - exakt dieselbe Trennung wie bei
* [DocumentationPdfExporter] (Erzeugung) vs. [PdfOpener] (Anzeige).
*
* VulneraDoc implementiert ausdrücklich KEINEN eigenen Versand-Mechanismus; welche konkrete App
* (Mail, Messenger, Cloud-Speicher, ...) die Bilder entgegennimmt, entscheidet ausschließlich
* Android/der Nutzer über den System-Dialog.
*/
interface PdfImageSharer {
fun share(imageFiles: List<File>): PdfImageShareResult
}
sealed interface PdfImageShareResult {
data object Success : PdfImageShareResult
sealed interface Failure : PdfImageShareResult {
/** Es wurden keine Bilddateien übergeben - nichts zum Teilen vorhanden. */
data object NoImages : Failure
/** FileProvider konnte für mindestens eine Datei keine content://-Uri erzeugen. */
data class UriResolutionFailed(val message: String) : Failure
/** Keine installierte App kann Bilder (image/jpeg) entgegennehmen. */
data object NoAppAvailable : Failure
data class Unexpected(val message: String) : Failure
}
}
fun PdfImageShareResult.Failure.toUserMessage(): String = when (this) {
PdfImageShareResult.Failure.NoImages ->
"Es sind keine Bilder zum Versenden vorhanden."
is PdfImageShareResult.Failure.UriResolutionFailed ->
"Die Bilder konnten nicht zum Versenden freigegeben werden."
PdfImageShareResult.Failure.NoAppAvailable ->
"Auf diesem Gerät ist keine App zum Versenden von Bildern installiert."
is PdfImageShareResult.Failure.Unexpected ->
"Die Bilder konnten nicht versendet werden."
}
class AndroidPdfImageSharer(private val context: Context) : PdfImageSharer {
override fun share(imageFiles: List<File>): PdfImageShareResult {
if (imageFiles.isEmpty()) return PdfImageShareResult.Failure.NoImages
val authority = "${context.packageName}.fileprovider"
val uris = try {
imageFiles.map { file -> FileProvider.getUriForFile(context, authority, file) }
} catch (e: IllegalArgumentException) {
// Wird von FileProvider geworfen, wenn eine Datei außerhalb der in file_paths.xml
// freigegebenen Verzeichnisse liegt oder die Authority nicht passt.
return PdfImageShareResult.Failure.UriResolutionFailed(e.message ?: "Uri konnte nicht erzeugt werden.")
}
val intent = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
type = "image/jpeg"
putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris))
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
// Zusätzlich zum Flag oben wird die Leseberechtigung explizit über ClipData an JEDE
// einzelne Uri gehängt - bei ACTION_SEND_MULTIPLE mit mehreren content://-Uris ist
// das der zuverlässige Weg, damit wirklich ALLE empfangenden Apps Zugriff auf ALLE
// Bilder bekommen (nicht nur auf die im Intent-Datenfeld primär referenzierte).
clipData = ClipData.newUri(context.contentResolver, "Wunddokumentation", uris.first()).apply {
for (uri in uris.drop(1)) {
addItem(ClipData.Item(uri))
}
}
}
val chooser = Intent.createChooser(intent, "Bilder senden an")
return try {
context.startActivity(chooser)
PdfImageShareResult.Success
} catch (e: ActivityNotFoundException) {
// Bewusst KEIN vorheriges intent.resolveActivity(...) - s. Begründung in
// AndroidPdfOpener.open() (Package-Visibility-Einschränkungen ab Android 11).
PdfImageShareResult.Failure.NoAppAvailable
} catch (e: SecurityException) {
PdfImageShareResult.Failure.Unexpected(e.message ?: "Zugriff auf die Bilddateien wurde verweigert.")
} catch (e: Exception) {
PdfImageShareResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler beim Versenden der Bilder.")
}
}
}
@@ -0,0 +1,72 @@
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
/**
* Übergibt die fertige PDF-Datei selbst (statt in JPEG-Seiten umgewandelt, s.
* [PdfToImagesConverter]/[PdfImageSharer]) an den Android-"Senden an"-Dialog
* (`ACTION_SEND`, `application/pdf`) - Änderungswunsch: Nutzer soll beim Antippen des
* Teilen-Buttons wählen können, ob als JPG ODER als PDF geteilt wird.
*
* Bewusst getrennt von [PdfOpener] (öffnet dieselbe Datei stattdessen per `ACTION_VIEW` in
* einem PDF-Reader) - unterschiedliche Nutzerabsicht (ansehen vs. weitergeben), auch wenn
* beide dieselbe Datei über denselben FileProvider-Pfad freigeben.
*/
interface PdfSharer {
fun share(pdfFile: File): PdfShareResult
}
sealed interface PdfShareResult {
data object Success : PdfShareResult
sealed interface Failure : PdfShareResult {
data class UriResolutionFailed(val message: String) : Failure
data object NoAppAvailable : Failure
data class Unexpected(val message: String) : Failure
}
}
fun PdfShareResult.Failure.toUserMessage(): String = when (this) {
is PdfShareResult.Failure.UriResolutionFailed ->
"Die PDF-Datei konnte nicht zum Versenden freigegeben werden."
PdfShareResult.Failure.NoAppAvailable ->
"Auf diesem Gerät ist keine App zum Versenden von PDFs installiert."
is PdfShareResult.Failure.Unexpected ->
"Die PDF-Datei konnte nicht versendet werden."
}
class AndroidPdfSharer(private val context: Context) : PdfSharer {
override fun share(pdfFile: File): PdfShareResult {
val authority = "${context.packageName}.fileprovider"
val uri = try {
FileProvider.getUriForFile(context, authority, pdfFile)
} catch (e: IllegalArgumentException) {
return PdfShareResult.Failure.UriResolutionFailed(e.message ?: "Uri konnte nicht erzeugt werden.")
}
val intent = Intent(Intent.ACTION_SEND).apply {
type = "application/pdf"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(intent, "PDF senden an")
return try {
context.startActivity(chooser)
PdfShareResult.Success
} catch (e: ActivityNotFoundException) {
// Bewusst KEIN vorheriges intent.resolveActivity(...) - s. Begründung in
// AndroidPdfOpener.open() (Package-Visibility-Einschränkungen ab Android 11).
PdfShareResult.Failure.NoAppAvailable
} catch (e: SecurityException) {
PdfShareResult.Failure.Unexpected(e.message ?: "Zugriff auf die PDF-Datei wurde verweigert.")
} catch (e: Exception) {
PdfShareResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler beim Versenden der PDF-Datei.")
}
}
}
@@ -0,0 +1,117 @@
package de.medapp.wunddoku.pdf
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.pdf.PdfRenderer
import android.os.ParcelFileDescriptor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import kotlin.math.roundToInt
/**
* Rendert eine bereits fertig erzeugte PDF-Datei (s. [DocumentationPdfExporter]) seitenweise in
* einzelne JPEG-Dateien - Änderungswunsch: "PDF zusätzlich als JPEG speichern/senden können".
*
* Bewusst als eigener Schritt NACH dem PDF-Export statt als Alternative dazu: Es gibt weiterhin
* genau eine Quelle der Wahrheit für das Seitenlayout (der bestehende [PdfPageCursor]-basierte
* Export) - dieser Konverter rastert lediglich das bereits fertige Ergebnis, statt eine zweite,
* parallele Layout-Logik zu pflegen.
*
* Verwendet [android.graphics.pdf.PdfRenderer] (Android-Bordmittel seit API 21, unproblematisch
* bei minSdk 26) - keine zusätzliche Bibliothek nötig. Eine mehrseitige Wunddokumentation (Kopf/
* Blöcke, Signaturen, ggf. 30+ Fotoseiten) ergibt dabei bewusst EIN JPEG PRO SEITE, kein
* einzelnes Sammelbild - beim späteren Teilen (s. [PdfImageSharer]) werden alle Seiten
* gemeinsam als Bilder-Mehrfachauswahl übergeben.
*/
interface PdfToImagesConverter {
suspend fun convert(documentationId: String, pdfFile: File): PdfToImagesResult
}
sealed interface PdfToImagesResult {
data class Success(val imageFiles: List<File>) : PdfToImagesResult
sealed interface Failure : PdfToImagesResult {
/** Die übergebene PDF-Datei existiert nicht (mehr). */
data object FileNotFound : Failure
/** PdfRenderer meldet 0 Seiten - sollte bei einem selbst erzeugten PDF nie vorkommen. */
data object EmptyDocument : Failure
data class Unexpected(val message: String) : Failure
}
}
fun PdfToImagesResult.Failure.toUserMessage(): String = when (this) {
PdfToImagesResult.Failure.FileNotFound ->
"Die PDF-Datei wurde nicht gefunden. Bitte zuerst die Dokumentation erneut exportieren."
PdfToImagesResult.Failure.EmptyDocument ->
"Die PDF-Datei enthält keine Seiten."
is PdfToImagesResult.Failure.Unexpected ->
"Die PDF-Datei konnte nicht in Bilder umgewandelt werden."
}
class AndroidPdfToImagesConverter(private val context: Context) : PdfToImagesConverter {
override suspend fun convert(documentationId: String, pdfFile: File): PdfToImagesResult =
withContext(Dispatchers.IO) {
if (!pdfFile.exists()) return@withContext PdfToImagesResult.Failure.FileNotFound
val outputDir = File(File(context.filesDir, "pdf_images"), documentationId)
// Bilder eines vorherigen Exports zuerst entfernen, damit bei einer inzwischen
// kürzeren PDF (z. B. Fotos gelöscht) keine veralteten, zu vielen Seiten-Dateien
// liegen bleiben.
outputDir.deleteRecursively()
outputDir.mkdirs()
try {
ParcelFileDescriptor.open(pdfFile, ParcelFileDescriptor.MODE_READ_ONLY).use { fd ->
PdfRenderer(fd).use { renderer ->
if (renderer.pageCount == 0) {
return@withContext PdfToImagesResult.Failure.EmptyDocument
}
val imageFiles = mutableListOf<File>()
for (pageIndex in 0 until renderer.pageCount) {
renderer.openPage(pageIndex).use { page ->
val scale = RENDER_DPI / POINTS_PER_INCH
val width = (page.width * scale).roundToInt().coerceAtLeast(1)
val height = (page.height * scale).roundToInt().coerceAtLeast(1)
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
// WICHTIG: PdfRenderer zeichnet nur die tatsächlich im PDF
// vorhandenen Inhalte - unbedeckte Flächen bleiben transparent
// (Alpha 0). JPEG kennt keine Transparenz; ohne dieses
// Vorab-Weißfüllen würde jede unbedeckte Fläche beim
// Komprimieren als SCHWARZ statt als weißer Seitenhintergrund
// enden (bekannte PdfRenderer-Falle, s. offizielle Android-
// Beispiele).
bitmap.eraseColor(Color.WHITE)
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
val imageFile = File(outputDir, "seite_%02d.jpg".format(pageIndex + 1))
FileOutputStream(imageFile).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out)
}
bitmap.recycle()
imageFiles.add(imageFile)
}
}
PdfToImagesResult.Success(imageFiles)
}
}
} catch (e: Exception) {
PdfToImagesResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler bei der Bildkonvertierung.")
}
}
companion object {
/** PDF-Koordinaten sind in Punkten (1/72 Zoll) angegeben - diese Ziel-Auflösung ergibt
* ein für Ausdruck/Weitergabe ausreichend scharfes, aber nicht unnötig großes Bild. */
private const val RENDER_DPI = 200f
private const val POINTS_PER_INCH = 72f
private const val JPEG_QUALITY = 90
}
}
@@ -7,6 +7,7 @@ import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -15,6 +16,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -24,8 +26,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.BrokenImage import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.PictureAsPdf import androidx.compose.material.icons.filled.PictureAsPdf
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog 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
@@ -41,6 +47,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@@ -55,11 +62,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
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.compose.ui.window.Dialog
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.LocalLifecycleOwner
@@ -70,8 +79,12 @@ 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 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.pdf.AndroidPdfImageSharer
import de.medapp.wunddoku.pdf.AndroidPdfOpener import de.medapp.wunddoku.pdf.AndroidPdfOpener
import de.medapp.wunddoku.pdf.AndroidPdfSharer
import de.medapp.wunddoku.pdf.PdfImageShareResult
import de.medapp.wunddoku.pdf.PdfOpenResult import de.medapp.wunddoku.pdf.PdfOpenResult
import de.medapp.wunddoku.pdf.PdfShareResult
import de.medapp.wunddoku.pdf.toUserMessage import de.medapp.wunddoku.pdf.toUserMessage
import de.medapp.wunddoku.ui.documentation.edit.signature.AddSignatureDialog import de.medapp.wunddoku.ui.documentation.edit.signature.AddSignatureDialog
@@ -95,6 +108,13 @@ fun DocumentationEditScreen(
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// Vollbildansicht eines Fotos beim Antippen einer Kachel (Änderungswunsch) - rein lokaler
// UI-Zustand, bewusst nicht im ViewModel gehalten (analog zu den transient geladenen
// Thumbnail-/Signatur-Bitmaps, s. Kommentare an PhotoThumbnail/loadPhotoThumbnail): welches
// Foto gerade großformatig angezeigt wird, ist reine Präsentationslogik ohne fachliche
// Bedeutung, die einen Prozesstod überdauern müsste.
var viewedPhoto by remember { mutableStateOf<Photo?>(null) }
// 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, ...). KEINE Garantie bei hartem // Bereich verlässt (App gewechselt, Bildschirm gesperrt, ...). KEINE Garantie bei hartem
// Prozessabbruch, aber sinnvolle zusätzliche Absicherung für den häufigen Fall. // Prozessabbruch, aber sinnvolle zusätzliche Absicherung für den häufigen Fall.
@@ -125,6 +145,8 @@ fun DocumentationEditScreen(
// Starten einer externen Activity ist eine UI-/Plattform-Aktion, keine Repository-Aufgabe. // Starten einer externen Activity ist eine UI-/Plattform-Aktion, keine Repository-Aufgabe.
val context = LocalContext.current val context = LocalContext.current
val pdfOpener = remember(context) { AndroidPdfOpener(context) } val pdfOpener = remember(context) { AndroidPdfOpener(context) }
val pdfImageSharer = remember(context) { AndroidPdfImageSharer(context) }
val pdfSharer = remember(context) { AndroidPdfSharer(context) }
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(uiState.pdfExportMessage) { LaunchedEffect(uiState.pdfExportMessage) {
val message = uiState.pdfExportMessage ?: return@LaunchedEffect val message = uiState.pdfExportMessage ?: return@LaunchedEffect
@@ -145,6 +167,34 @@ fun DocumentationEditScreen(
viewModel.dismissPdfExportMessage() viewModel.dismissPdfExportMessage()
} }
// Änderungswunsch: erzeugte Bilder direkt über den Android-"Senden an"-Dialog verschicken
// können. Sobald das ViewModel frische JPEG-Seiten bereitstellt (s.
// DocumentationEditViewModel.shareAsImages), öffnet dieser Effekt den System-
// Share-Dialog - Intent/Activity-Start ist bewusst UI-Aufgabe, nicht Sache des ViewModels.
LaunchedEffect(uiState.imagesToShare) {
val images = uiState.imagesToShare ?: return@LaunchedEffect
when (val shareResult = pdfImageSharer.share(images)) {
PdfImageShareResult.Success -> Unit
is PdfImageShareResult.Failure -> snackbarHostState.showSnackbar(shareResult.toUserMessage())
}
viewModel.onImageShareHandled()
}
// Analog zum Bilderversand oben, nur für die Format-Auswahl "PDF" (s.
// DocumentationEditViewModel.shareAsPdf).
LaunchedEffect(uiState.pdfToShare) {
val pdfFile = uiState.pdfToShare ?: return@LaunchedEffect
when (val shareResult = pdfSharer.share(pdfFile)) {
PdfShareResult.Success -> Unit
is PdfShareResult.Failure -> snackbarHostState.showSnackbar(shareResult.toUserMessage())
}
viewModel.onPdfShareHandled()
}
LaunchedEffect(uiState.shareMessage) {
val message = uiState.shareMessage ?: return@LaunchedEffect
snackbarHostState.showSnackbar(message)
viewModel.dismissShareMessage()
}
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
@@ -153,8 +203,8 @@ fun DocumentationEditScreen(
if (uiState.blockCount > 0) { if (uiState.blockCount > 0) {
Text( Text(
stringResource( stringResource(
R.string.documentation_edit_position, R.string.documentation_edit_progress,
uiState.currentBlockIndex + 1, uiState.editedBlockCount,
uiState.blockCount, uiState.blockCount,
) )
) )
@@ -163,9 +213,8 @@ fun DocumentationEditScreen(
} }
}, },
actions = { actions = {
// PDF-005 (Öffnen)/PDF-006 (Teilen) folgen später - dieser Button erzeugt // PDF-005 (Öffnen) ist umgesetzt (s. LaunchedEffect oben); dieser Button
// ausschließlich die Datei im internen Speicher (PDF-001/002), als // erzeugt die Datei im internen Speicher (PDF-001/002).
// minimaler Trigger zum manuellen Testen der Export-Funktionalität.
if (uiState.pdfExportInProgress) { if (uiState.pdfExportInProgress) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
} else { } else {
@@ -173,6 +222,16 @@ fun DocumentationEditScreen(
Icon(Icons.Filled.PictureAsPdf, contentDescription = "PDF erzeugen") Icon(Icons.Filled.PictureAsPdf, contentDescription = "PDF erzeugen")
} }
} }
// Änderungswunsch: PDF zusätzlich als JPEG speichern/versenden, wahlweise
// auch als PDF selbst - Format wird per Dialog abgefragt (s. weiter unten),
// BEVOR irgendetwas exportiert wird.
if (uiState.shareInProgress) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
} else {
IconButton(onClick = viewModel::requestShareFormatChoice) {
Icon(Icons.Filled.Share, contentDescription = "Teilen")
}
}
// "Fertig" beendet die Bearbeitung bereits jetzt (Inhalte sind ohnehin per // "Fertig" beendet die Bearbeitung bereits jetzt (Inhalte sind ohnehin per
// Autosave gesichert, kein expliziter Speichern-Button nötig). // Autosave gesichert, kein expliziter Speichern-Button nötig).
Button(onClick = onFinished) { Text("Fertig") } Button(onClick = onFinished) { Text("Fertig") }
@@ -193,23 +252,20 @@ fun DocumentationEditScreen(
else -> { else -> {
val documentation = uiState.documentation val documentation = uiState.documentation
val currentBlock = documentation?.blocks?.getOrNull(uiState.currentBlockIndex) if (documentation == null || documentation.blocks.isEmpty()) {
if (documentation == null || currentBlock == null) {
Column(modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp)) { Column(modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp)) {
Text("Diese Dokumentation enthält keine Blöcke.") Text("Diese Dokumentation enthält keine Blöcke.")
} }
} else { } else {
DocumentationEditContent( DocumentationEditContent(
modifier = Modifier.fillMaxSize().padding(padding), modifier = Modifier.fillMaxSize().padding(padding),
block = currentBlock, blocks = documentation.blocks,
expandedBlockId = uiState.expandedBlockId,
onToggleBlockExpanded = viewModel::toggleBlockExpanded,
photos = documentation.photos, photos = documentation.photos,
signatures = documentation.signatures, signatures = documentation.signatures,
onCheckedChanged = viewModel::onCheckedChanged, onCheckedChanged = viewModel::onCheckedChanged,
onFreeTextChanged = viewModel::onFreeTextChanged, onFreeTextChanged = viewModel::onFreeTextChanged,
canGoBack = uiState.currentBlockIndex > 0,
canGoNext = uiState.currentBlockIndex < uiState.blockCount - 1,
onPrevious = viewModel::onPreviousBlock,
onNext = viewModel::onNextBlock,
photoImportInProgress = uiState.photoImportInProgress, photoImportInProgress = uiState.photoImportInProgress,
photoImportError = uiState.photoImportError, photoImportError = uiState.photoImportError,
onDismissPhotoImportError = viewModel::dismissPhotoImportError, onDismissPhotoImportError = viewModel::dismissPhotoImportError,
@@ -220,6 +276,7 @@ fun DocumentationEditScreen(
}, },
onDeletePhotoRequested = viewModel::requestDeletePhoto, onDeletePhotoRequested = viewModel::requestDeletePhoto,
loadThumbnail = viewModel::loadPhotoThumbnail, loadThumbnail = viewModel::loadPhotoThumbnail,
onPhotoClick = { viewedPhoto = it },
onAddSignatureClick = viewModel::requestAddSignature, onAddSignatureClick = viewModel::requestAddSignature,
onDeleteSignatureRequested = viewModel::requestDeleteSignature, onDeleteSignatureRequested = viewModel::requestDeleteSignature,
loadSignatureBitmap = viewModel::loadSignatureBitmap, loadSignatureBitmap = viewModel::loadSignatureBitmap,
@@ -295,32 +352,90 @@ fun DocumentationEditScreen(
}, },
) )
} }
// Änderungswunsch: Foto in Originalgröße anzeigen.
val photoToView = viewedPhoto
if (photoToView != null) {
PhotoViewerDialog(
photo = photoToView,
loadFullSize = viewModel::loadPhotoFullSize,
onDismiss = { viewedPhoto = null },
)
}
// Änderungswunsch: Format-Auswahl beim Antippen des Teilen-Buttons.
if (uiState.showShareFormatDialog) {
ShareFormatDialog(
onDismiss = viewModel::dismissShareFormatDialog,
onChooseImages = viewModel::shareAsImages,
onChoosePdf = viewModel::shareAsPdf,
)
}
} }
/** /**
* Gesamter scrollbarer Inhalt der Dokumentationsbearbeitung: aktueller Block (Titel, Optionen, * Fragt beim Antippen des Teilen-Buttons ab, ob als JPG (Bilder, eine Datei pro Seite) oder als
* Freitext-Option, Vor-/Zurück-Navigation) gefolgt vom dokumentationsweiten Fotobereich. Als * PDF (die Datei selbst) geteilt werden soll (Änderungswunsch). Erst NACH dieser Auswahl wird
* überhaupt exportiert (s. [DocumentationEditViewModel.shareAsImages]/[DocumentationEditViewModel.shareAsPdf]) -
* kein unnötiger Export, falls der Nutzer den Dialog stattdessen abbricht.
*/
@Composable
private fun ShareFormatDialog(
onDismiss: () -> Unit,
onChooseImages: () -> Unit,
onChoosePdf: () -> Unit,
) {
Dialog(onDismissRequest = onDismiss) {
Surface(shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surface) {
Column(modifier = Modifier.fillMaxWidth().padding(20.dp)) {
Text("Teilen als", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text(
"Als Bilder (JPG) oder als PDF teilen?",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
Spacer(Modifier.width(8.dp))
OutlinedButton(onClick = onChooseImages) { Text("JPG") }
Spacer(Modifier.width(8.dp))
Button(onClick = onChoosePdf) { Text("PDF") }
}
}
}
}
}
/**
* Gesamter scrollbarer Inhalt der Dokumentationsbearbeitung: Akkordeon aller Blöcke
* (Änderungswunsch) - zunächst stehen nur die Überschriften untereinander, Antippen einer
* Überschrift klappt genau diesen Block auf und schließt dabei automatisch einen zuvor
* aufgeklappten (s. [DocumentationEditViewModel.toggleBlockExpanded] - IMMER höchstens ein
* Block gleichzeitig offen) - gefolgt vom dokumentationsweiten Signatur- und Fotobereich. Als
* einzelne [LazyColumn] umgesetzt, damit Foto-Zeilen erst decodiert werden, wenn sie sichtbar * einzelne [LazyColumn] umgesetzt, damit Foto-Zeilen erst decodiert werden, wenn sie sichtbar
* sind/werden. * sind/werden.
*/ */
@Composable @Composable
private fun DocumentationEditContent( private fun DocumentationEditContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
block: DocumentationBlock, blocks: List<DocumentationBlock>,
expandedBlockId: String?,
onToggleBlockExpanded: (blockStateId: String) -> Unit,
photos: List<Photo>, photos: List<Photo>,
signatures: List<Signature>, signatures: List<Signature>,
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,
canGoNext: Boolean,
onPrevious: () -> Unit,
onNext: () -> Unit,
photoImportInProgress: Boolean, photoImportInProgress: Boolean,
photoImportError: String?, photoImportError: String?,
onDismissPhotoImportError: () -> Unit, onDismissPhotoImportError: () -> Unit,
onAddPhotosClick: () -> Unit, onAddPhotosClick: () -> Unit,
onDeletePhotoRequested: (photoId: String) -> Unit, onDeletePhotoRequested: (photoId: String) -> Unit,
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?, loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
onPhotoClick: (Photo) -> Unit,
onAddSignatureClick: () -> Unit, onAddSignatureClick: () -> Unit,
onDeleteSignatureRequested: (signatureId: String) -> Unit, onDeleteSignatureRequested: (signatureId: String) -> Unit,
loadSignatureBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?, loadSignatureBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?,
@@ -330,34 +445,14 @@ private fun DocumentationEditContent(
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
item(key = "block_title") { items(blocks, key = { it.blockStateId }) { block ->
Text(block.title, style = MaterialTheme.typography.headlineSmall) BlockAccordionItem(
} block = block,
expanded = block.blockStateId == expandedBlockId,
items(block.items, key = { it.checkItemStateId }) { item -> onHeaderClick = { onToggleBlockExpanded(block.blockStateId) },
if (item.isFreeText) {
FreeTextCheckItemRow(
item = item,
onCheckedChanged = onCheckedChanged, onCheckedChanged = onCheckedChanged,
onFreeTextChanged = onFreeTextChanged, onFreeTextChanged = onFreeTextChanged,
) )
} else {
CheckItemRow(item = item, onCheckedChanged = onCheckedChanged)
}
}
item(key = "block_navigation") {
Row(
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))
}
}
} }
// MEDIA-003/004: dokumentationsweiter Signaturbereich, analog zum Fotobereich direkt // MEDIA-003/004: dokumentationsweiter Signaturbereich, analog zum Fotobereich direkt
@@ -451,7 +546,12 @@ private fun DocumentationEditContent(
} }
} else { } else {
items(photos.chunked(PHOTO_GRID_COLUMNS), key = { row -> row.joinToString(separator = "_") { it.id } }) { row -> items(photos.chunked(PHOTO_GRID_COLUMNS), key = { row -> row.joinToString(separator = "_") { it.id } }) { row ->
PhotoRow(photos = row, loadThumbnail = loadThumbnail, onDeleteRequested = onDeletePhotoRequested) PhotoRow(
photos = row,
loadThumbnail = loadThumbnail,
onDeleteRequested = onDeletePhotoRequested,
onPhotoClick = onPhotoClick,
)
} }
} }
} }
@@ -464,6 +564,7 @@ private fun PhotoRow(
photos: List<Photo>, photos: List<Photo>,
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?, loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
onDeleteRequested: (photoId: String) -> Unit, onDeleteRequested: (photoId: String) -> Unit,
onPhotoClick: (Photo) -> Unit,
) { ) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
photos.forEach { photo -> photos.forEach { photo ->
@@ -471,6 +572,7 @@ private fun PhotoRow(
photo = photo, photo = photo,
loadThumbnail = loadThumbnail, loadThumbnail = loadThumbnail,
onDeleteRequested = { onDeleteRequested(photo.id) }, onDeleteRequested = { onDeleteRequested(photo.id) },
onClick = { onPhotoClick(photo) },
) )
} }
} }
@@ -483,12 +585,17 @@ private fun PhotoRow(
* (siehe Kommentar an [DocumentationEditViewModel.loadPhotoThumbnail]). Verlässt die Kachel die * (siehe Kommentar an [DocumentationEditViewModel.loadPhotoThumbnail]). Verlässt die Kachel die
* Komposition (z. B. weil die Dokumentation viele Fotos hat und diese Zeile aktuell nicht * Komposition (z. B. weil die Dokumentation viele Fotos hat und diese Zeile aktuell nicht
* sichtbar ist), wird der Zustand automatisch freigegeben. * sichtbar ist), wird der Zustand automatisch freigegeben.
*
* Ein Antippen der Kachel (außerhalb des Löschen-Buttons) öffnet die Vollbildansicht
* (Änderungswunsch) - [onClick] statt eines eigenen Ladevorgangs hier, da die Kachel selbst nur
* das kleine, bereits geladene Thumbnail kennt.
*/ */
@Composable @Composable
private fun PhotoThumbnail( private fun PhotoThumbnail(
photo: Photo, photo: Photo,
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?, loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
onDeleteRequested: () -> Unit, onDeleteRequested: () -> Unit,
onClick: () -> Unit,
) { ) {
var bitmap by remember(photo.id) { mutableStateOf<Bitmap?>(null) } var bitmap by remember(photo.id) { mutableStateOf<Bitmap?>(null) }
var loadFailed by remember(photo.id) { mutableStateOf(false) } var loadFailed by remember(photo.id) { mutableStateOf(false) }
@@ -503,7 +610,8 @@ private fun PhotoThumbnail(
modifier = Modifier modifier = Modifier
.size(96.dp) .size(96.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant), .background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(enabled = bitmap != null, onClick = onClick),
) { ) {
val currentBitmap = bitmap val currentBitmap = bitmap
when { when {
@@ -611,6 +719,73 @@ private fun SignatureRow(
} }
} }
/**
* Ein einzelner Block im Akkordeon (Änderungswunsch): eine antippbare Kopfzeile mit Titel,
* einem kleinen "bearbeitet"-Indikator (grüner Haken, s. [DocumentationBlock.isEdited]) und
* einem Pfeil-Icon, das den Auf-/Zugeklappt-Zustand anzeigt. Der eigentliche Inhalt (Checkboxen
* inkl. Freitext-Option) wird ausschließlich gerendert, wenn [expanded] `true` ist - das
* entspricht fachlich einem Dropdown/Expansion-Panel, technisch einfach bedingtes Komponieren
* ohne eigene Lazy-Liste (ein Block hat nur wenige Optionen).
*/
@Composable
private fun BlockAccordionItem(
block: DocumentationBlock,
expanded: Boolean,
onHeaderClick: () -> Unit,
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit,
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onHeaderClick)
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (block.isEdited) {
Icon(
Icons.Filled.CheckCircle,
contentDescription = stringResource(R.string.documentation_edit_block_edited),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(8.dp))
}
Text(
block.title,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
Icon(
if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = null,
)
}
if (expanded) {
Column(
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
for (item in block.items) {
when {
item.isStaticHint -> StaticHintRow(item = item)
item.isFreeText -> FreeTextCheckItemRow(
item = item,
onCheckedChanged = onCheckedChanged,
onFreeTextChanged = onFreeTextChanged,
)
else -> CheckItemRow(item = item, onCheckedChanged = onCheckedChanged)
}
}
}
}
HorizontalDivider()
}
}
@Composable @Composable
private fun CheckItemRow( private fun CheckItemRow(
item: CheckItem, item: CheckItem,
@@ -654,3 +829,18 @@ private fun FreeTextCheckItemRow(
) )
} }
} }
/**
* Ein rein anzeigender, nicht editierbarer Hinweistext (z. B. "+ bei Bedarf" bei "Empfohlene
* Wundversorgung") - keine Checkbox, keine Interaktion, [item.label] ist direkt der
* anzuzeigende Text selbst. Optisch dezent (kursiv, gedämpfte Farbe) abgesetzt von echten,
* ausfüllbaren Optionen, damit auf den ersten Blick klar ist, dass hier nichts einzugeben ist.
*/
@Composable
private fun StaticHintRow(item: CheckItem) {
Text(
item.label,
style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -15,6 +15,8 @@ import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.model.WoundCarer import de.medapp.wunddoku.domain.model.WoundCarer
import de.medapp.wunddoku.pdf.PdfExportError import de.medapp.wunddoku.pdf.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.pdf.PdfToImagesResult
import de.medapp.wunddoku.pdf.toUserMessage
import de.medapp.wunddoku.ui.navigation.Screen import de.medapp.wunddoku.ui.navigation.Screen
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@@ -28,7 +30,11 @@ import java.io.File
data class DocumentationEditUiState( data class DocumentationEditUiState(
val documentation: Documentation? = null, val documentation: Documentation? = null,
val currentBlockIndex: Int = 0, /** ID des BlockState, der gerade im Akkordeon aufgeklappt ist - `null`, wenn keiner
* aufgeklappt ist (Änderungswunsch: zunächst nur alle Überschriften untereinander, Antippen
* einer Überschrift klappt sie auf). Es ist IMMER höchstens ein Block gleichzeitig
* aufgeklappt, s. [DocumentationEditViewModel.toggleBlockExpanded]. */
val expandedBlockId: String? = null,
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). */ /** true, während ausgewählte Fotos importiert werden (z. B. für einen Ladeindikator). */
@@ -59,8 +65,37 @@ data class DocumentationEditUiState(
* referenziert werden kann, während ein neuer Export läuft. * referenziert werden kann, während ein neuer Export läuft.
*/ */
val lastExportedPdfFile: File? = null, val lastExportedPdfFile: File? = null,
/** true, während PDF-Export + Konvertierung für den Versand laufen - gemeinsamer
* Ladezustand für BEIDE Formate (JPG über [DocumentationEditViewModel.shareAsImages],
* PDF über [DocumentationEditViewModel.shareAsPdf]), da immer zuerst frisch exportiert
* wird. */
val shareInProgress: Boolean = false,
/** Fehlermeldung, falls Export oder Konvertierung fehlgeschlagen sind - ein erfolgreicher
* Export löst dagegen KEINE Meldung aus, sondern direkt [imagesToShare] bzw. [pdfToShare]. */
val shareMessage: String? = null,
/** true, solange die Auswahl "Als JPG oder PDF teilen?" angezeigt wird (Änderungswunsch:
* Format-Auswahl beim Antippen des Teilen-Buttons, bevor irgendetwas exportiert wird). */
val showShareFormatDialog: Boolean = false,
/**
* Frisch erzeugte JPEG-Seiten, bereit zum Teilen (Änderungswunsch: "alle generierten Bilder
* über 'Senden an' direkt verschicken können"). Wird vom Screen per LaunchedEffect
* beobachtet, der daraufhin den System-"Senden an"-Dialog öffnet und anschließend
* [DocumentationEditViewModel.onImageShareHandled] aufruft - das Teilen selbst (Intent/
* Activity-Start) ist bewusst UI-Aufgabe, nicht Sache des ViewModels.
*/
val imagesToShare: List<File>? = null,
/** Analog [imagesToShare], nur für die Variante "als PDF teilen" - Gegenstück
* [DocumentationEditViewModel.onPdfShareHandled]. */
val pdfToShare: File? = null,
) { ) {
val blockCount: Int get() = documentation?.blocks?.size ?: 0 val blockCount: Int get() = documentation?.blocks?.size ?: 0
/** Anzahl bereits bearbeiteter Blöcke (s. [de.medapp.wunddoku.domain.usecase.BlockEditedPolicy])
* - ersetzt die frühere "X / Y"-Positionsanzeige, die sich auf den einzelnen, gerade
* angezeigten Block bezog; im Akkordeon-Layout sind ALLE Blöcke gleichzeitig sichtbar
* (nur der Inhalt ist ein-/ausklappbar), daher ist ein Fortschritt sinnvoller als eine
* Position. */
val editedBlockCount: Int get() = documentation?.blocks?.count { it.isEdited } ?: 0
} }
/** /**
@@ -163,17 +198,17 @@ class DocumentationEditViewModel(
viewModelScope.launch { repository.setChecked(checkItemStateId, checked) } viewModelScope.launch { repository.setChecked(checkItemStateId, checked) }
} }
fun onNextBlock() { /**
* Klappt den Block mit [blockStateId] auf bzw. zu (Änderungswunsch: Akkordeon statt
* Vor-/Zurück-Navigation). Antippen eines bereits aufgeklappten Blocks klappt ihn zu;
* Antippen eines anderen Blocks klappt diesen auf und ersetzt dabei automatisch einen zuvor
* aufgeklappten Block - es ist also IMMER höchstens ein Block gleichzeitig aufgeklappt,
* ganz ohne zusätzliche Schließen-Logik für den vorherigen.
*/
fun toggleBlockExpanded(blockStateId: String) {
_uiState.update { state -> _uiState.update { state ->
val lastIndex = state.blockCount - 1 val newExpandedId = if (state.expandedBlockId == blockStateId) null else blockStateId
if (lastIndex < 0) return@update state state.copy(expandedBlockId = newExpandedId)
state.copy(currentBlockIndex = (state.currentBlockIndex + 1).coerceAtMost(lastIndex))
}
}
fun onPreviousBlock() {
_uiState.update { state ->
state.copy(currentBlockIndex = (state.currentBlockIndex - 1).coerceAtLeast(0))
} }
} }
@@ -346,6 +381,120 @@ class DocumentationEditViewModel(
_uiState.update { it.copy(pdfExportMessage = null) } _uiState.update { it.copy(pdfExportMessage = null) }
} }
/**
* Öffnet die Auswahl "Als JPG oder PDF teilen?" (Änderungswunsch: Format-Auswahl beim
* Antippen des Teilen-Buttons, BEVOR irgendetwas exportiert wird - vorher wurde beim
* Antippen sofort und ausschließlich als JPG geteilt).
*/
fun requestShareFormatChoice() {
_uiState.update { it.copy(showShareFormatDialog = true) }
}
fun dismissShareFormatDialog() {
_uiState.update { it.copy(showShareFormatDialog = false) }
}
/** Nutzer hat sich für "Als JPG teilen" entschieden. */
fun shareAsImages() {
_uiState.update { it.copy(showShareFormatDialog = false) }
exportAndShareAsImages()
}
/** Nutzer hat sich für "Als PDF teilen" entschieden. */
fun shareAsPdf() {
_uiState.update { it.copy(showShareFormatDialog = false) }
exportAndSharePdf()
}
/**
* Exportiert die Dokumentation frisch als PDF und wandelt das Ergebnis anschließend
* seitenweise in JPEG-Bilder um (Änderungswunsch: "PDF zusätzlich als JPEG speichern/senden
* können"). Bewusst ein eigener, vollständiger Export-Lauf statt Wiederverwendung von
* [uiState.lastExportedPdfFile]: so spiegeln die Bilder IMMER den aktuellen Stand wider,
* unabhängig davon, ob/wann zuvor schon einmal "PDF erzeugen" angetippt wurde.
*
* Das eigentliche Teilen (Android-"Senden an"-Dialog) übernimmt NICHT diese Methode, sondern
* der Screen anhand von [DocumentationEditUiState.imagesToShare] - Intent/Activity-Start ist
* UI-Aufgabe (s. [de.medapp.wunddoku.pdf.PdfImageSharer]).
*/
private fun exportAndShareAsImages() {
viewModelScope.launch {
_uiState.update {
it.copy(shareInProgress = true, shareMessage = null, imagesToShare = null)
}
val exportResult = repository.exportPdf(documentationId)
val pdfFile = when (exportResult) {
is PdfExportResult.Success -> exportResult.file
is PdfExportResult.Failure -> {
_uiState.update {
it.copy(
shareInProgress = false,
shareMessage = "PDF-Erzeugung fehlgeschlagen: ${exportResult.error.toUserMessage()}",
)
}
return@launch
}
}
when (val imagesResult = repository.convertPdfToImages(documentationId, pdfFile)) {
is PdfToImagesResult.Success -> _uiState.update {
it.copy(shareInProgress = false, imagesToShare = imagesResult.imageFiles)
}
is PdfToImagesResult.Failure -> _uiState.update {
it.copy(
shareInProgress = false,
shareMessage = "Bildexport fehlgeschlagen: ${imagesResult.toUserMessage()}",
)
}
}
}
}
/**
* Exportiert die Dokumentation frisch als PDF und stellt sie zum Teilen bereit
* (Änderungswunsch: Format-Auswahl "PDF" statt "JPG") - ohne die JPEG-Konvertierung, die
* geteilte Datei ist die PDF selbst. Das eigentliche Teilen übernimmt der Screen anhand von
* [DocumentationEditUiState.pdfToShare] (s. [de.medapp.wunddoku.pdf.PdfSharer]).
*/
private fun exportAndSharePdf() {
viewModelScope.launch {
_uiState.update {
it.copy(shareInProgress = true, shareMessage = null, pdfToShare = null)
}
when (val exportResult = repository.exportPdf(documentationId)) {
is PdfExportResult.Success -> _uiState.update {
it.copy(shareInProgress = false, pdfToShare = exportResult.file)
}
is PdfExportResult.Failure -> _uiState.update {
it.copy(
shareInProgress = false,
shareMessage = "PDF-Erzeugung fehlgeschlagen: ${exportResult.error.toUserMessage()}",
)
}
}
}
}
/** Wird vom Screen aufgerufen, nachdem der "Senden an"-Dialog für [DocumentationEditUiState.imagesToShare]
* geöffnet wurde (erfolgreich oder nicht) - verhindert ein erneutes Öffnen bei der nächsten
* Recomposition, solange keine neuen Bilder erzeugt wurden. */
fun onImageShareHandled() {
_uiState.update { it.copy(imagesToShare = null) }
}
/** Analog [onImageShareHandled], nur für [DocumentationEditUiState.pdfToShare]. */
fun onPdfShareHandled() {
_uiState.update { it.copy(pdfToShare = null) }
}
fun dismissShareMessage() {
_uiState.update { it.copy(shareMessage = null) }
}
private fun PdfExportError.toUserMessage(): String = when (this) { private fun PdfExportError.toUserMessage(): String = when (this) {
PdfExportError.DocumentationNotFound -> "Die Dokumentation wurde nicht gefunden." PdfExportError.DocumentationNotFound -> "Die Dokumentation wurde nicht gefunden."
is PdfExportError.DirectoryCreationFailed -> "Zielverzeichnis konnte nicht angelegt werden." is PdfExportError.DirectoryCreationFailed -> "Zielverzeichnis konnte nicht angelegt werden."
@@ -365,6 +514,10 @@ class DocumentationEditViewModel(
suspend fun loadPhotoThumbnail(documentationId: String, fileName: String) = suspend fun loadPhotoThumbnail(documentationId: String, fileName: String) =
repository.loadPhotoThumbnail(documentationId, fileName) repository.loadPhotoThumbnail(documentationId, fileName)
/** Für die Vollbildansicht beim Antippen einer Foto-Kachel (Änderungswunsch). */
suspend fun loadPhotoFullSize(documentationId: String, fileName: String, maxWidthPx: Int, maxHeightPx: Int) =
repository.loadPhotoFullSize(documentationId, fileName, maxWidthPx, maxHeightPx)
/** Lädt eine Signatur für die Vorschau in der Dokumentationsbearbeitung - analog /** Lädt eine Signatur für die Vorschau in der Dokumentationsbearbeitung - analog
* [loadPhotoThumbnail], siehe dortigen Kommentar zur bewussten Nicht-Haltung im UiState. */ * [loadPhotoThumbnail], siehe dortigen Kommentar zur bewussten Nicht-Haltung im UiState. */
suspend fun loadSignatureBitmap(documentationId: String, fileName: String) = suspend fun loadSignatureBitmap(documentationId: String, fileName: String) =
@@ -0,0 +1,124 @@
package de.medapp.wunddoku.ui.documentation.edit
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import de.medapp.wunddoku.R
import de.medapp.wunddoku.domain.model.Photo
/**
* Vollbild-Ansicht eines einzelnen Fotos in Originalgröße (Änderungswunsch: "Fotos in der App
* durch Antippen in Originalgröße angezeigt werden"). Lädt das Foto über [loadFullSize] separat
* von der kleinen Grid-Vorschau ([DocumentationEditViewModel.loadPhotoThumbnail]) - dieselbe
* Trennung wie bei den Signatur-Vorschauen: die kleine Kachel und die Vollbildansicht sind
* unabhängige Ladevorgänge mit unterschiedlicher Zielauflösung.
*
* "Originalgröße" bedeutet hier: so groß wie der Bildschirm es zulässt, ohne unnötig über die
* tatsächliche Displayauflösung hinaus zu decodieren (s. Kommentar an
* [de.medapp.wunddoku.data.repository.DocumentationRepository.loadPhotoFullSize]) - eine echte
* 1:1-Pixel-Darstellung würde bei modernen Fotoauflösungen ohnehin über die Bildschirmgröße
* hinausgehen und wäre nur per Zoom/Scroll sichtbar, was hier nicht gefordert ist.
*
* Volle Bildschirmbreite/-höhe via [DialogProperties.usePlatformDefaultWidth] = false, da der
* Standard-Dialog-Rahmen von Compose sonst deutlich schmaler als der Bildschirm wäre. Schließen
* über den Schließen-Button ODER Antippen des dunklen Hintergrunds.
*/
@Composable
fun PhotoViewerDialog(
photo: Photo,
loadFullSize: suspend (documentationId: String, fileName: String, maxWidthPx: Int, maxHeightPx: Int) -> Bitmap?,
onDismiss: () -> Unit,
) {
var bitmap by remember(photo.id) { mutableStateOf<Bitmap?>(null) }
var loadFailed by remember(photo.id) { mutableStateOf(false) }
val configuration = LocalConfiguration.current
val density = LocalDensity.current
LaunchedEffect(photo.id) {
val maxWidthPx = with(density) { configuration.screenWidthDp.dp.roundToPx() }
val maxHeightPx = with(density) { configuration.screenHeightDp.dp.roundToPx() }
val result = runCatching {
loadFullSize(photo.documentationId, photo.fileName, maxWidthPx, maxHeightPx)
}.getOrNull()
bitmap = result
loadFailed = result == null
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.clickable(onClick = onDismiss),
) {
val currentBitmap = bitmap
when {
currentBitmap != null -> Image(
bitmap = currentBitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize().padding(16.dp),
contentScale = ContentScale.Fit,
)
loadFailed -> Box(modifier = Modifier.fillMaxSize()) {
Icon(
Icons.Filled.BrokenImage,
contentDescription = null,
tint = Color.White,
modifier = Modifier.align(Alignment.Center).size(48.dp),
)
Text(
stringResource(R.string.documentation_edit_photo_load_failed),
color = Color.White,
modifier = Modifier.align(Alignment.BottomCenter).padding(24.dp),
)
}
else -> CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center),
color = Color.White,
)
}
IconButton(
onClick = onDismiss,
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
) {
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.action_cancel), tint = Color.White)
}
}
}
}
+3 -3
View File
@@ -32,14 +32,14 @@
<string name="patient_form_phone3">Telefonnummer 3 (optional)</string> <string name="patient_form_phone3">Telefonnummer 3 (optional)</string>
<string name="patient_form_save">Speichern</string> <string name="patient_form_save">Speichern</string>
<string name="documentation_edit_position">%1$d / %2$d</string> <string name="documentation_edit_progress">%1$d / %2$d bearbeitet</string>
<string name="documentation_edit_previous">Zurück</string> <string name="documentation_edit_block_edited">Block bearbeitet</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_photos_title">Fotos</string>
<string name="documentation_edit_add_photos">Hinzufügen</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_no_photos">Noch keine Fotos vorhanden.</string>
<string name="documentation_edit_photo_load_failed">Foto konnte nicht geladen werden.</string>
<string name="documentation_edit_delete_photo_confirm_title">Foto löschen?</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="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>
+1
View File
@@ -7,6 +7,7 @@
--> -->
<paths xmlns:android="http://schemas.android.com/apk/res/android"> <paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="pdf_exports" path="pdf/" /> <files-path name="pdf_exports" path="pdf/" />
<files-path name="pdf_images" path="pdf_images/" />
<files-path name="photos" path="photos/" /> <files-path name="photos" path="photos/" />
<files-path name="signatures" path="signatures/" /> <files-path name="signatures" path="signatures/" />
</paths> </paths>