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

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