Doctor summary: one-page PDF via PdfDocument + FileProvider share

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 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-06-10 14:02:34 +02:00
commit e9a6679242
6 changed files with 279 additions and 0 deletions

View file

@ -43,6 +43,17 @@
</intent-filter>
</activity>
<!-- Doctor-summary PDF sharing (ACTION_SEND with a content:// grant). -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- Targets of our own PendingIntents only — never exported. -->
<receiver android:name=".alarm.DoseAlarmReceiver" android:exported="false" />
<receiver android:name=".alarm.DoseActionReceiver" android:exported="false" />

View file

@ -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<no.naiv.meddetsamme.data.DoseLog>): 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.

View file

@ -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 -> "(manfre)"
0x41 -> "(lørsø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"
}
}

View file

@ -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
}
}

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Only the summaries cache dir is shareable — nothing else leaves the sandbox. -->
<paths>
<cache-path name="summaries" path="summaries/" />
</paths>

View file

@ -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 (manfre)", ScheduleText.describe(dt(mask = 0x3E)))
assertEquals("08:00 (lørsø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),
),
),
)
}
}