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>
|
||||
</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" />
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
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