From e9a667924207d8d32420b078f516a7cbeda34bc9 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Wed, 10 Jun 2026 14:02:34 +0200 Subject: [PATCH] Doctor summary: one-page PDF via PdfDocument + FileProvider share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 7. Human-readable A4 summary (meds, schedule, supply days, rx expiry/reit, 30-day adherence, notes) — deliberately distinct from the machine-readable JSON backup, zero PDF dependencies. The Norwegian schedule phrasing (ScheduleText) and adherence math are pure and unit-tested; a wrong schedule line on a doctor's desk is a clinical communication error, so it gets engine-grade testing. FileProvider exposes only cacheDir/summaries. Co-Authored-By: Claude Fable 5 --- app/src/main/AndroidManifest.xml | 11 ++ .../naiv/meddetsamme/domain/ScheduleEngine.kt | 11 ++ .../naiv/meddetsamme/domain/ScheduleText.kt | 48 ++++++ .../naiv/meddetsamme/pdf/DoctorSummaryPdf.kt | 142 ++++++++++++++++++ app/src/main/res/xml/file_paths.xml | 5 + .../meddetsamme/domain/ScheduleTextTest.kt | 62 ++++++++ 6 files changed, 279 insertions(+) create mode 100644 app/src/main/java/no/naiv/meddetsamme/domain/ScheduleText.kt create mode 100644 app/src/main/java/no/naiv/meddetsamme/pdf/DoctorSummaryPdf.kt create mode 100644 app/src/main/res/xml/file_paths.xml create mode 100644 app/src/test/java/no/naiv/meddetsamme/domain/ScheduleTextTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d974fda..c00a0b2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -43,6 +43,17 @@ + + + + + diff --git a/app/src/main/java/no/naiv/meddetsamme/domain/ScheduleEngine.kt b/app/src/main/java/no/naiv/meddetsamme/domain/ScheduleEngine.kt index a07ca6c..f9bb601 100644 --- a/app/src/main/java/no/naiv/meddetsamme/domain/ScheduleEngine.kt +++ b/app/src/main/java/no/naiv/meddetsamme/domain/ScheduleEngine.kt @@ -107,6 +107,17 @@ object ScheduleEngine { return supply <= lowStockLeadDays } + /** + * Adherence as a whole percentage of resolved-or-overdue scheduled doses + * that were TAKEN. Null when there is nothing to measure. The caller picks + * the window (e.g. logs from the last 30 days). + */ + fun adherencePercent(logs: List): Int? { + if (logs.isEmpty()) return null + val taken = logs.count { it.status == no.naiv.meddetsamme.data.DoseStatus.TAKEN } + return (taken * 100.0 / logs.size).toInt() + } + /** * Renewal warning: e-resept expires within [leadDays] (or already has). * Independent of stock by design — a full box doesn't renew a prescription. diff --git a/app/src/main/java/no/naiv/meddetsamme/domain/ScheduleText.kt b/app/src/main/java/no/naiv/meddetsamme/domain/ScheduleText.kt new file mode 100644 index 0000000..e3b2c9d --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/domain/ScheduleText.kt @@ -0,0 +1,48 @@ +package no.naiv.meddetsamme.domain + +import no.naiv.meddetsamme.data.DoseTime + +/** + * Human-readable schedule descriptions (Norwegian, 24h) for the doctor + * summary and the UI. Pure — unit-tested like the engine, since a wrong + * description on a doctor's desk is a clinical communication error. + */ +object ScheduleText { + + /** Mask bit order matches ScheduleEngine: bit 0 = Sunday. */ + private val DAY_ABBREV = listOf("søn", "man", "tir", "ons", "tor", "fre", "lør") + + fun describe(doseTime: DoseTime): String { + val time = "%02d:%02d".format(doseTime.minuteOfDay / 60, doseTime.minuteOfDay % 60) + return "$time ${daysText(doseTime)}" + } + + fun daysText(doseTime: DoseTime): String { + if (doseTime.intervalDays > 1) { + return "(hver ${doseTime.intervalDays}. dag)" + } + return when (val mask = doseTime.daysOfWeekMask and 0x7F) { + 0x7F -> "(daglig)" + 0x3E -> "(man–fre)" + 0x41 -> "(lør–søn)" + 0 -> "(aldri)" + else -> { + // Week listed Monday-first for humans, despite bit 0 = Sunday. + val days = (1..7).map { it % 7 } + .filter { mask and (1 shl it) != 0 } + .map { DAY_ABBREV[it] } + "(${days.joinToString(", ")})" + } + } + } + + /** "1 tablett", "2,5 ml" — norsk desimalkomma. */ + fun amountText(amount: Double, unit: String): String { + val number = if (amount == amount.toLong().toDouble()) { + amount.toLong().toString() + } else { + amount.toString().replace('.', ',') + } + return "$number $unit" + } +} diff --git a/app/src/main/java/no/naiv/meddetsamme/pdf/DoctorSummaryPdf.kt b/app/src/main/java/no/naiv/meddetsamme/pdf/DoctorSummaryPdf.kt new file mode 100644 index 0000000..6a2ca71 --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/pdf/DoctorSummaryPdf.kt @@ -0,0 +1,142 @@ +package no.naiv.meddetsamme.pdf + +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Typeface +import android.graphics.pdf.PdfDocument +import androidx.core.content.FileProvider +import java.io.File +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import no.naiv.meddetsamme.data.MedDatabase +import no.naiv.meddetsamme.domain.ScheduleEngine +import no.naiv.meddetsamme.domain.ScheduleText + +/** + * Human-readable doctor summary as a one-page A4 PDF via the platform + * PdfDocument — deliberately distinct from the machine-readable JSON backup + * (brief feature #3), and deliberately dependency-free. + */ +class DoctorSummaryPdf(private val context: Context) { + + private companion object { + // A4 in PostScript points (PdfDocument's unit). + const val PAGE_W = 595 + const val PAGE_H = 842 + const val MARGIN = 48f + const val ADHERENCE_WINDOW_DAYS = 30L + } + + private val dateFmt = DateTimeFormatter.ofPattern("d. MMMM yyyy") + + /** Generates the PDF and returns a ready-to-launch share chooser intent. */ + suspend fun buildShareIntent(): Intent { + val file = render() + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + val send = Intent(Intent.ACTION_SEND) + .setType("application/pdf") + .putExtra(Intent.EXTRA_STREAM, uri) + .putExtra(Intent.EXTRA_SUBJECT, "Medisinoversikt") + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + return Intent.createChooser(send, "Del medisinoversikt") + } + + suspend fun render(): File { + val db = MedDatabase.get(context) + val meds = db.medicationDao().getActive() + val today = LocalDate.now() + val windowStart = Instant.now().minusSeconds(ADHERENCE_WINDOW_DAYS * 86_400).toEpochMilli() + + val doc = PdfDocument() + val title = paint(18f, bold = true) + val heading = paint(12f, bold = true) + val body = paint(10f) + val small = paint(9f).apply { color = Color.DKGRAY } + + var page = doc.startPage(PdfDocument.PageInfo.Builder(PAGE_W, PAGE_H, doc.pages.size + 1).create()) + var y = MARGIN + + fun newPageIfNeeded(needed: Float) { + if (y + needed > PAGE_H - MARGIN) { + doc.finishPage(page) + page = doc.startPage(PdfDocument.PageInfo.Builder(PAGE_W, PAGE_H, doc.pages.size + 1).create()) + y = MARGIN + } + } + + fun line(text: String, p: Paint, gap: Float = 4f) { + newPageIfNeeded(p.textSize + gap) + y += p.textSize + page.canvas.drawText(text, MARGIN, y, p) + y += gap + } + + line("Medisinoversikt", title, 6f) + line("Generert ${dateFmt.format(today)} — Med det samme", small, 14f) + + for (med in meds) { + val doseTimes = db.doseTimeDao().getForMed(med.id) + val rate = ScheduleEngine.dailyConsumption(doseTimes) + val supplyDays = ScheduleEngine.daysOfSupply(med.inventoryUnits, rate) + val logs = db.doseLogDao().getRange(windowStart, System.currentTimeMillis()) + .filter { it.medId == med.id } + val adherence = ScheduleEngine.adherencePercent(logs) + + newPageIfNeeded(80f) // keep a med's block from straddling pages when possible + + line("${med.name} ${med.strength}".trim(), heading, 3f) + + val formLine = buildString { + append(med.form.name.lowercase().replaceFirstChar { it.uppercase() }) + if (med.withFood) append(" — tas med mat") + med.atcCode?.let { append(" · ATC $it") } + } + line(formLine, small, 3f) + + for (dt in doseTimes) { + line( + "${ScheduleText.amountText(dt.amount, med.unit)} kl. ${ScheduleText.describe(dt)}", + body, + 2f, + ) + } + + val supplyText = buildString { + append("Lager: ${ScheduleText.amountText(med.inventoryUnits, med.unit)}") + supplyDays?.let { append(" ≈ ${it.toInt()} dager") } + } + line(supplyText, body, 2f) + + med.rxExpiryEpochDay?.let { epochDay -> + val expiry = LocalDate.ofEpochDay(epochDay) + val reit = med.refillsRemaining?.let { ", $it reit igjen" } ?: "" + line("Resept utløper ${dateFmt.format(expiry)}$reit", body, 2f) + } + + adherence?.let { line("Etterlevelse siste 30 dager: $it %", body, 2f) } + if (med.notes.isNotBlank()) line("Merknad: ${med.notes}", small, 2f) + y += 10f + } + + if (meds.isEmpty()) line("Ingen aktive medisiner.", body) + + doc.finishPage(page) + + val dir = File(context.cacheDir, "summaries").apply { mkdirs() } + val file = File(dir, "medisinoversikt-$today.pdf") + file.outputStream().use(doc::writeTo) + doc.close() + return file + } + + private fun paint(size: Float, bold: Boolean = false) = Paint().apply { + textSize = size + isAntiAlias = true + typeface = Typeface.create(Typeface.SANS_SERIF, if (bold) Typeface.BOLD else Typeface.NORMAL) + color = Color.BLACK + } +} diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..c7cfc37 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/test/java/no/naiv/meddetsamme/domain/ScheduleTextTest.kt b/app/src/test/java/no/naiv/meddetsamme/domain/ScheduleTextTest.kt new file mode 100644 index 0000000..46d7c74 --- /dev/null +++ b/app/src/test/java/no/naiv/meddetsamme/domain/ScheduleTextTest.kt @@ -0,0 +1,62 @@ +package no.naiv.meddetsamme.domain + +import no.naiv.meddetsamme.data.DoseLog +import no.naiv.meddetsamme.data.DoseStatus +import no.naiv.meddetsamme.data.DoseTime +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ScheduleTextTest { + + private fun dt(minute: Int = 480, mask: Int = 0x7F, interval: Int = 0) = + DoseTime(medId = 1, minuteOfDay = minute, amount = 1.0, daysOfWeekMask = mask, intervalDays = interval, anchorEpochDay = 0) + + @Test + fun `daily, weekday, weekend and interval phrasing`() { + assertEquals("08:00 (daglig)", ScheduleText.describe(dt())) + assertEquals("08:00 (man–fre)", ScheduleText.describe(dt(mask = 0x3E))) + assertEquals("08:00 (lør–søn)", ScheduleText.describe(dt(mask = 0x41))) + assertEquals("08:00 (hver 2. dag)", ScheduleText.describe(dt(interval = 2))) + } + + @Test + fun `arbitrary masks list days monday-first`() { + // Monday(bit1) + Wednesday(bit3) + Sunday(bit0) + assertEquals("(man, ons, søn)", ScheduleText.daysText(dt(mask = 0b0001011))) + } + + @Test + fun `24h clock with leading zeros`() { + assertEquals("06:05 (daglig)", ScheduleText.describe(dt(minute = 6 * 60 + 5))) + assertEquals("22:30 (daglig)", ScheduleText.describe(dt(minute = 22 * 60 + 30))) + } + + @Test + fun `amounts use norwegian decimal comma and drop trailing zeroes`() { + assertEquals("1 tablett", ScheduleText.amountText(1.0, "tablett")) + assertEquals("2,5 ml", ScheduleText.amountText(2.5, "ml")) + } + + @Test + fun `adherence math`() { + fun log(status: DoseStatus) = + DoseLog(medId = 1, doseTimeId = 1, scheduledAtMillis = 0, amount = 1.0, status = status) + + assertNull(ScheduleEngine.adherencePercent(emptyList())) + assertEquals( + 100, + ScheduleEngine.adherencePercent(listOf(log(DoseStatus.TAKEN))), + ) + // 3 of 4 taken; the snoozed-but-never-resolved one counts against. + assertEquals( + 75, + ScheduleEngine.adherencePercent( + listOf( + log(DoseStatus.TAKEN), log(DoseStatus.TAKEN), + log(DoseStatus.TAKEN), log(DoseStatus.SNOOZED), + ), + ), + ) + } +}