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:
parent
c66bee5cf0
commit
e9a6679242
6 changed files with 279 additions and 0 deletions
|
|
@ -43,6 +43,17 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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. -->
|
<!-- Targets of our own PendingIntents only — never exported. -->
|
||||||
<receiver android:name=".alarm.DoseAlarmReceiver" android:exported="false" />
|
<receiver android:name=".alarm.DoseAlarmReceiver" android:exported="false" />
|
||||||
<receiver android:name=".alarm.DoseActionReceiver" android:exported="false" />
|
<receiver android:name=".alarm.DoseActionReceiver" android:exported="false" />
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,17 @@ object ScheduleEngine {
|
||||||
return supply <= lowStockLeadDays
|
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).
|
* Renewal warning: e-resept expires within [leadDays] (or already has).
|
||||||
* Independent of stock by design — a full box doesn't renew a prescription.
|
* Independent of stock by design — a full box doesn't renew a prescription.
|
||||||
|
|
|
||||||
48
app/src/main/java/no/naiv/meddetsamme/domain/ScheduleText.kt
Normal file
48
app/src/main/java/no/naiv/meddetsamme/domain/ScheduleText.kt
Normal 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 -> "(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"
|
||||||
|
}
|
||||||
|
}
|
||||||
142
app/src/main/java/no/naiv/meddetsamme/pdf/DoctorSummaryPdf.kt
Normal file
142
app/src/main/java/no/naiv/meddetsamme/pdf/DoctorSummaryPdf.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
5
app/src/main/res/xml/file_paths.xml
Normal file
5
app/src/main/res/xml/file_paths.xml
Normal 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>
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue