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),
+ ),
+ ),
+ )
+ }
+}