diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 1d3f047..ffeabae 100755
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -53,6 +53,7 @@ android {
}
dependencies {
+ testImplementation("junit:junit:4.13.2")
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.core.splashscreen)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 80a0c01..1a0c832 100755
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -121,6 +121,17 @@
+
+
+
+
+
+
+
Unit) {
}
}
+ val exportCsvLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/csv")) { uri ->
+ uri?.let {
+ scope.launch(Dispatchers.IO) {
+ exportCsv(context, it)
+ }
+ }
+ }
+
val importLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
uri?.let {
@@ -297,7 +311,7 @@ fun AdvancedSettingsScreen(onBack: () -> Unit) {
containerColor = Color(0xFF80da88),
iconColor = Color(0xFF00522c),
index = 0,
- count = 2,
+ count = 3,
onClick = {
val timestamp = Calendar.getInstance().timeInMillis
exportLauncher.launch("med_backup_$timestamp.json")
@@ -311,9 +325,23 @@ fun AdvancedSettingsScreen(onBack: () -> Unit) {
containerColor = Color(0xFF67d4ff),
iconColor = Color(0xFF004e5d),
index = 1,
- count = 2,
+ count = 3,
onClick = {
- importLauncher.launch(arrayOf("application/json"))
+ importLauncher.launch(arrayOf("application/json", "text/csv", "text/comma-separated-values", "text/plain"))
+ }
+ )
+
+ AdvancedSegmentedItem(
+ icon = Icons.Rounded.TableChart,
+ title = stringResource(R.string.settings_export_csv_title),
+ subtitle = stringResource(R.string.settings_export_csv_desc),
+ containerColor = Color(0xFFb5ccff),
+ iconColor = Color(0xFF1c2f5c),
+ index = 2,
+ count = 3,
+ onClick = {
+ val timestamp = Calendar.getInstance().timeInMillis
+ exportCsvLauncher.launch("med_data_$timestamp.csv")
}
)
}
@@ -588,17 +616,11 @@ private suspend fun exportSettings(context: Context, uri: Uri) {
private suspend fun importSettings(context: Context, uri: Uri): Boolean {
return withContext(Dispatchers.IO) {
try {
- val sb = StringBuilder()
- context.contentResolver.openInputStream(uri)?.use { inputStream ->
- BufferedReader(InputStreamReader(inputStream)).use { reader ->
- var line = reader.readLine()
- while (line != null) {
- sb.append(line)
- line = reader.readLine()
- }
- }
- }
- val root = JSONObject(sb.toString())
+ val fileText = context.contentResolver.openInputStream(uri)?.use { inputStream ->
+ BufferedReader(InputStreamReader(inputStream)).use { it.readText() }
+ } ?: ""
+ val isJson = fileText.trimStart().startsWith("{")
+ val root = if (isJson) JSONObject(fileText) else JSONObject()
if (root.has("med_prefs")) {
try {
@@ -716,6 +738,29 @@ private suspend fun importSettings(context: Context, uri: Uri): Boolean {
}
}
+ // Universal CSV import: any spreadsheet following the documented CSV
+ // schema can be turned into medicines/events.
+ var csvCount = -1
+ if (!isJson) {
+ try {
+ val csvItems = CsvPortability.parseCsv(fileText)
+ csvCount = csvItems.size
+ csvItems.forEach { item ->
+ val dupe = importedItems.indexOfFirst { it.id == item.id }
+ if (dupe == -1) importedItems.add(item)
+ }
+ } catch (e: Exception) {
+ withContext(Dispatchers.Main) {
+ Toast.makeText(
+ context,
+ e.message ?: context.getString(R.string.import_error),
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ return@withContext false
+ }
+ }
+
val currentItems = try {
DataRepository.loadData(context)
} catch (e: Exception) {
@@ -745,11 +790,55 @@ private suspend fun importSettings(context: Context, uri: Uri): Boolean {
}
try {
+ // Safety net: snapshot current data before applying the import, so a
+ // bad import file can never destroy the user's existing records.
+ if (currentItems.isNotEmpty()) {
+ val backupArray = JSONArray()
+ currentItems.forEach { backupArray.put(it.toJson()) }
+ val backupRoot = JSONObject()
+ backupRoot.put("med_data_v2", backupArray)
+ java.io.File(context.filesDir, PRE_IMPORT_BACKUP_FILE)
+ .writeText(backupRoot.toString())
+ }
+
DataRepository.saveData(context, mergedItems)
context.deleteFile("med_data.dat")
} catch (e: Exception) {
}
+ // Schedule alarms for all medicines now, so reminders work immediately
+ // after the restart — imported meds would otherwise stay silent until
+ // the next device reboot (alarms are only auto-rescheduled on boot).
+ try {
+ mergedItems.forEach { item ->
+ if (item.type == ItemType.Medicine) {
+ NotificationReceiver.scheduleNotification(context, item)
+ }
+ }
+ } catch (e: Exception) {
+ }
+
+ // An imported backup that is already below its low-supply threshold
+ // must alert immediately, not after the next dose event.
+ try {
+ InventoryService.createNotificationChannel(context)
+ if (InventoryService.evaluateAll(context, mergedItems)) {
+ DataRepository.saveData(context, mergedItems)
+ }
+ } catch (e: Exception) {
+ }
+
+ if (csvCount >= 0) {
+ withContext(Dispatchers.Main) {
+ Toast.makeText(
+ context,
+ if (csvCount > 0) context.getString(R.string.import_csv_summary, csvCount)
+ else context.getString(R.string.import_csv_zero),
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+
true
} catch (e: Exception) {
withContext(Dispatchers.Main) {
@@ -764,6 +853,24 @@ private suspend fun importSettings(context: Context, uri: Uri): Boolean {
}
}
+private suspend fun exportCsv(context: Context, uri: Uri) {
+ withContext(Dispatchers.IO) {
+ try {
+ val items = DataRepository.loadData(context)
+ context.contentResolver.openOutputStream(uri)?.use {
+ it.write(CsvPortability.toCsv(items).toByteArray(Charsets.UTF_8))
+ }
+ withContext(Dispatchers.Main) {
+ Toast.makeText(context, context.getString(R.string.export_success), Toast.LENGTH_SHORT).show()
+ }
+ } catch (e: Exception) {
+ withContext(Dispatchers.Main) {
+ Toast.makeText(context, context.getString(R.string.export_error), Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+}
+
class LegacyObjectInputStream(inputStream: InputStream) : ObjectInputStream(inputStream) {
override fun readClassDescriptor(): ObjectStreamClass {
val desc = super.readClassDescriptor()
diff --git a/app/src/main/kotlin/com/fedeveloper95/med/elements/MainActivity/MedicineBottomSheet.kt b/app/src/main/kotlin/com/fedeveloper95/med/elements/MainActivity/MedicineBottomSheet.kt
index 6b51ae8..be74db1 100644
--- a/app/src/main/kotlin/com/fedeveloper95/med/elements/MainActivity/MedicineBottomSheet.kt
+++ b/app/src/main/kotlin/com/fedeveloper95/med/elements/MainActivity/MedicineBottomSheet.kt
@@ -68,6 +68,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedToggleButton
import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.Surface
+import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.ToggleButtonDefaults
import androidx.compose.material3.rememberModalBottomSheetState
@@ -100,6 +101,7 @@ import androidx.compose.ui.unit.dp
import com.fedeveloper95.med.AVAILABLE_ICONS
import com.fedeveloper95.med.R
import com.fedeveloper95.med.elements.TimePicker
+import com.fedeveloper95.med.services.InventoryEntry
import com.fedeveloper95.med.services.MedData
import com.fedeveloper95.med.ui.theme.GoogleSansFlex
import kotlinx.coroutines.launch
@@ -113,7 +115,7 @@ import java.util.Locale
@Composable
fun MedicineBottomSheet(
onDismiss: () -> Unit,
- onConfirm: (String, String?, String?, List, List?, String?, Int?, Int, Long?, Long?) -> Unit,
+ onConfirm: (String, String?, String?, List, List?, String?, Int?, InventoryEntry?, Int, Long?, Long?) -> Unit,
initialItem: MedData? = null,
initialText: String = ""
) {
@@ -182,6 +184,26 @@ fun MedicineBottomSheet(
var notificationType by remember { mutableIntStateOf(initialItem?.notificationType ?: 0) }
+ // --- Supply (inventory) tracking ---
+ var supplyEnabled by remember {
+ mutableStateOf(initialItem?.supplyDosesLeft != null)
+ }
+ var supplyLeft by remember {
+ mutableIntStateOf(initialItem?.supplyDosesLeft ?: 30)
+ }
+ var supplyRefill by remember {
+ mutableIntStateOf(initialItem?.supplyDosesPerRefill ?: 30)
+ }
+ var supplyThreshold by remember {
+ mutableIntStateOf(initialItem?.supplyLowThreshold ?: 5)
+ }
+
+ fun inventoryEntry(): InventoryEntry? = if (supplyEnabled) InventoryEntry(
+ dosesLeft = supplyLeft,
+ dosesPerRefill = supplyRefill,
+ lowThreshold = supplyThreshold
+ ) else null
+
val focusRequester = remember { FocusRequester() }
LaunchedEffect(frequencyType, timesPerDay) {
@@ -256,6 +278,7 @@ fun MedicineBottomSheet(
days,
notes.takeIf { it.isNotBlank() },
gap,
+ inventoryEntry(),
notificationType,
start,
end
@@ -436,6 +459,141 @@ fun MedicineBottomSheet(
item { Spacer(modifier = Modifier.height(16.dp)) }
+ item {
+ val itemColors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceContainer)
+ SegmentedListItem(
+ onClick = { supplyEnabled = !supplyEnabled },
+ colors = itemColors,
+ shapes = ListItemDefaults.segmentedShapes(index = 0, count = 1),
+ modifier = Modifier.clip(RoundedCornerShape(20.dp)),
+ trailingContent = {
+ Switch(
+ checked = supplyEnabled,
+ onCheckedChange = { supplyEnabled = it }
+ )
+ },
+ content = {
+ Text(
+ text = stringResource(R.string.supply_track_label),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontFamily = GoogleSansFlex
+ )
+ }
+ )
+ }
+
+ if (supplyEnabled) {
+ item { Spacer(modifier = Modifier.height(16.dp)) }
+
+ item {
+ val itemColors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceContainer)
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)
+ ) {
+ SegmentedListItem(
+ onClick = {},
+ colors = itemColors,
+ shapes = ListItemDefaults.segmentedShapes(index = 0, count = 3),
+ trailingContent = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ IconButton(onClick = {
+ if (supplyLeft > 0) supplyLeft--
+ }) {
+ Icon(Icons.Rounded.Remove, contentDescription = null)
+ }
+ Text(
+ text = supplyLeft.toString(),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(horizontal = 8.dp)
+ )
+ IconButton(onClick = {
+ if (supplyLeft < 9999) supplyLeft++
+ }) {
+ Icon(Icons.Rounded.Add, contentDescription = null)
+ }
+ }
+ },
+ content = {
+ Text(
+ text = stringResource(R.string.supply_doses_left),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontFamily = GoogleSansFlex
+ )
+ }
+ )
+
+ SegmentedListItem(
+ onClick = {},
+ colors = itemColors,
+ shapes = ListItemDefaults.segmentedShapes(index = 1, count = 3),
+ trailingContent = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ IconButton(onClick = {
+ if (supplyRefill > 1) supplyRefill--
+ }) {
+ Icon(Icons.Rounded.Remove, contentDescription = null)
+ }
+ Text(
+ text = supplyRefill.toString(),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(horizontal = 8.dp)
+ )
+ IconButton(onClick = {
+ if (supplyRefill < 9999) supplyRefill++
+ }) {
+ Icon(Icons.Rounded.Add, contentDescription = null)
+ }
+ }
+ },
+ content = {
+ Text(
+ text = stringResource(R.string.supply_refill_size),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontFamily = GoogleSansFlex
+ )
+ }
+ )
+
+ SegmentedListItem(
+ onClick = {},
+ colors = itemColors,
+ shapes = ListItemDefaults.segmentedShapes(index = 2, count = 3),
+ trailingContent = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ IconButton(onClick = {
+ if (supplyThreshold > 0) supplyThreshold--
+ }) {
+ Icon(Icons.Rounded.Remove, contentDescription = null)
+ }
+ Text(
+ text = supplyThreshold.toString(),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(horizontal = 8.dp)
+ )
+ IconButton(onClick = {
+ if (supplyThreshold < 9999) supplyThreshold++
+ }) {
+ Icon(Icons.Rounded.Add, contentDescription = null)
+ }
+ }
+ },
+ content = {
+ Text(
+ text = stringResource(R.string.supply_alert_when_below),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontFamily = GoogleSansFlex
+ )
+ }
+ )
+ }
+ }
+ }
+
item {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
@@ -783,7 +941,13 @@ fun MedicineBottomSheet(
frequencyType != initialFreqType ||
(frequencyType == 1 && selectedDays != initialDaysSet) ||
(frequencyType == 2 && currentGap != (initialItem.intervalGap
- ?: 2))
+ ?: 2)) ||
+ supplyEnabled != (initialItem.supplyDosesLeft != null) ||
+ (supplyEnabled && (supplyLeft != initialItem.supplyDosesLeft ||
+ supplyRefill != (initialItem.supplyDosesPerRefill
+ ?: 0) ||
+ supplyThreshold != (initialItem.supplyLowThreshold
+ ?: 0)))
}
if (isModified) {
@@ -810,6 +974,7 @@ fun MedicineBottomSheet(
days,
notes.takeIf { it.isNotBlank() },
gap,
+ inventoryEntry(),
notificationType,
null,
null
@@ -839,6 +1004,7 @@ fun MedicineBottomSheet(
days,
notes.takeIf { it.isNotBlank() },
gap,
+ inventoryEntry(),
notificationType,
null,
null
diff --git a/app/src/main/kotlin/com/fedeveloper95/med/elements/MainActivity/Tabs/Stats.kt b/app/src/main/kotlin/com/fedeveloper95/med/elements/MainActivity/Tabs/Stats.kt
index 8732773..b4eaaf1 100644
--- a/app/src/main/kotlin/com/fedeveloper95/med/elements/MainActivity/Tabs/Stats.kt
+++ b/app/src/main/kotlin/com/fedeveloper95/med/elements/MainActivity/Tabs/Stats.kt
@@ -91,7 +91,7 @@ enum class DayStatus {
}
fun getScheduledMedsForDate(date: LocalDate, items: List): List {
- return items.filter { item ->
+ val scheduled = items.filter { item ->
item.type == ItemType.Medicine &&
!date.isBefore(item.creationDate) &&
(item.endDate == null || !date.isAfter(item.endDate)) &&
@@ -101,6 +101,10 @@ fun getScheduledMedsForDate(date: LocalDate, items: List): List): DayStatus {
diff --git a/app/src/main/kotlin/com/fedeveloper95/med/elements/MainActivity/UI.kt b/app/src/main/kotlin/com/fedeveloper95/med/elements/MainActivity/UI.kt
index 224aa87..2bc4701 100755
--- a/app/src/main/kotlin/com/fedeveloper95/med/elements/MainActivity/UI.kt
+++ b/app/src/main/kotlin/com/fedeveloper95/med/elements/MainActivity/UI.kt
@@ -3,6 +3,7 @@
package com.fedeveloper95.med.elements.MainActivity
import android.graphics.Color.parseColor
+import android.widget.Toast
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
@@ -36,6 +37,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Event
+import androidx.compose.material.icons.rounded.Inventory2
import androidx.compose.material.icons.rounded.MedicalServices
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material3.Card
@@ -67,6 +69,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -89,10 +92,12 @@ import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import java.time.temporal.WeekFields
-import java.util.Locale
+import java.util.Locale/** Window in which a second tap on a taken dose confirms the un-take. */
+private const val UNTAKE_CONFIRM_WINDOW_MS = 3000L
@OptIn(ExperimentalFoundationApi::class)
@Composable
+
fun MedDataCard(
item: MedData,
currentViewDate: LocalDate,
@@ -153,6 +158,14 @@ fun MedDataCard(
val toggleEnabled = isMedicine && !currentViewDate.isAfter(LocalDate.now())
+ // Accidental un-take protection: the first tap on a taken dose only "locks
+ // in" a pending-undo state (harmless, nothing is written); a second tap
+ // within the window actually un-logs the dose. Tapping again while pending
+ // re-confirms the dose instead of toggling history.
+ var pendingUntakeUntil by remember(item.id) { mutableStateOf(0L) }
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+
val icSick = ImageVector.vectorResource(R.drawable.ic_sick)
val icMind = ImageVector.vectorResource(R.drawable.ic_mind)
val icMixture = ImageVector.vectorResource(R.drawable.ic_mixture)
@@ -280,6 +293,34 @@ fun MedDataCard(
softWrap = false
)
}
+ if (isMedicine && item.supplyDosesLeft != null) {
+ val low = item.supplyLowThreshold != null &&
+ item.supplyDosesLeft <= item.supplyLowThreshold
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ Icons.Rounded.Inventory2,
+ null,
+ modifier = Modifier.size(12.dp),
+ tint = if (low) MaterialTheme.colorScheme.error
+ else cardContentColor.copy(alpha = 0.7f)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = stringResource(
+ R.string.supply_badge_format,
+ item.supplyDosesLeft
+ ),
+ fontFamily = GoogleSansFlex,
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = if (low) FontWeight.SemiBold else FontWeight.Normal,
+ color = if (low) MaterialTheme.colorScheme.error
+ else cardContentColor.copy(alpha = 0.7f),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ softWrap = false
+ )
+ }
+ }
Row(verticalAlignment = Alignment.CenterVertically) {
if (isMedicine) {
val scheduledTime =
@@ -385,7 +426,36 @@ fun MedDataCard(
selected = isTakenToday,
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
- onToggle()
+ if (isTakenToday) {
+ val now = System.currentTimeMillis()
+ if (now < pendingUntakeUntil) {
+ // Second tap inside the window: confirm undo.
+ pendingUntakeUntil = 0L
+ onToggle()
+ } else {
+ // First tap: arm the two-tap confirmation.
+ pendingUntakeUntil = now + UNTAKE_CONFIRM_WINDOW_MS
+ Toast.makeText(
+ context,
+ context.getString(R.string.tap_again_to_untake),
+ Toast.LENGTH_SHORT
+ ).show()
+ scope.launch {
+ delay(UNTAKE_CONFIRM_WINDOW_MS + 50L)
+ if (System.currentTimeMillis() >= pendingUntakeUntil &&
+ pendingUntakeUntil != 0L
+ ) {
+ pendingUntakeUntil = 0L
+ }
+ // No-op by design: just clears the
+ // pending flag; if the user confirmed,
+ // the flag was already reset to 0.
+ }
+ // Keep the dose logged: do not call onToggle.
+ }
+ } else {
+ onToggle()
+ }
},
enabled = toggleEnabled
)
diff --git a/app/src/main/kotlin/com/fedeveloper95/med/services/AppWearListenerService.kt b/app/src/main/kotlin/com/fedeveloper95/med/services/AppWearListenerService.kt
index c5ece46..af95561 100644
--- a/app/src/main/kotlin/com/fedeveloper95/med/services/AppWearListenerService.kt
+++ b/app/src/main/kotlin/com/fedeveloper95/med/services/AppWearListenerService.kt
@@ -231,6 +231,8 @@ class AppWearListenerService : WearableListenerService() {
history.remove(today)
}
items[i] = item.copy(takenHistory = history)
+ items[i] = applyInventoryChange(applicationContext, items[i], isTaken)
+ NotificationReceiver.scheduleNotification(applicationContext, items[i])
updated = true
}
}
diff --git a/app/src/main/kotlin/com/fedeveloper95/med/services/CsvPortability.kt b/app/src/main/kotlin/com/fedeveloper95/med/services/CsvPortability.kt
new file mode 100644
index 0000000..505da4e
--- /dev/null
+++ b/app/src/main/kotlin/com/fedeveloper95/med/services/CsvPortability.kt
@@ -0,0 +1,210 @@
+package com.fedeveloper95.med.services
+
+import com.fedeveloper95.med.ItemType
+import java.time.LocalDate
+import java.time.LocalTime
+
+/**
+ * Universal CSV interchange format for medicines and events.
+ *
+ * One row per MedData entry; rows sharing a group_id belong to the same schedule
+ * group. The format is deliberately plain so it can be edited in any spreadsheet
+ * application:
+ *
+ * id,group_id,type,title,icon_name,color_code,frequency_label,creation_date,
+ * creation_time,taken_dates,taken_times,recurrence_days,end_date,interval_days,
+ * notes,display_order,category,notification_type
+ *
+ * - type: Medicine | Event | Illness
+ * - dates/times are ISO (2026-08-15, 12:00)
+ * - taken_dates and taken_times are parallel |-separated lists, e.g.
+ * "2026-08-15|2026-08-16" and "12:10|12:31". A missing time falls back to 00:00.
+ * - recurrence_days: |-separated day names, e.g. "MONDAY|FRIDAY" (empty = daily)
+ * - interval_days: repeat every N days (empty = not interval-based)
+ *
+ * Fully RFC 4180 compliant: cells containing commas, quotes or newlines are
+ * quoted, and embedded quotes are doubled.
+ */
+object CsvPortability {
+
+ val HEADERS = listOf(
+ "id", "group_id", "type", "title", "icon_name", "color_code",
+ "frequency_label", "creation_date", "creation_time",
+ "taken_dates", "taken_times", "recurrence_days", "end_date",
+ "interval_days", "notes", "display_order", "category", "notification_type",
+ "supply_doses_left", "supply_refill_size", "supply_low_threshold"
+ )
+
+ private const val LIST_SEPARATOR = "|"
+
+ fun toCsv(items: List): String {
+ val sb = StringBuilder()
+ sb.append(HEADERS.joinToString(",")).append("\r\n")
+ items.forEach { m ->
+ val dates = m.takenHistory.keys.sorted()
+ val cells = listOf(
+ m.id.toString(),
+ m.groupId?.toString() ?: "",
+ m.type.name,
+ m.title,
+ m.iconName ?: "",
+ m.colorCode ?: "",
+ m.frequencyLabel ?: "",
+ m.creationDate.toString(),
+ m.creationTime.toString(),
+ dates.joinToString(LIST_SEPARATOR),
+ dates.joinToString(LIST_SEPARATOR) {
+ (m.takenHistory[it] ?: LocalTime.MIDNIGHT).toString()
+ },
+ m.recurrenceDays?.joinToString(LIST_SEPARATOR) { it.name } ?: "",
+ m.endDate?.toString() ?: "",
+ m.intervalGap?.toString() ?: "",
+ m.notes ?: "",
+ m.displayOrder.toString(),
+ m.category ?: "",
+ m.notificationType.toString(),
+ m.supplyDosesLeft?.toString() ?: "",
+ m.supplyDosesPerRefill?.toString() ?: "",
+ m.supplyLowThreshold?.toString() ?: ""
+ )
+ sb.append(cells.joinToString(",") { encodeCell(it) }).append("\r\n")
+ }
+ return sb.toString()
+ }
+
+ fun parseCsv(text: String): List {
+ val rows = parseRows(text)
+ if (rows.isEmpty()) return emptyList()
+
+ val header = rows[0].map { it.trim().lowercase() }
+ fun col(name: String) = header.indexOf(name)
+ // id/type are optional (auto-generated / default to Medicine); the rest are
+ // the minimum needed to place an entry on the calendar.
+ val required = listOf("title", "creation_date", "creation_time")
+ val missing = required.filter { col(it) == -1 }
+ if (missing.isNotEmpty()) {
+ throw IllegalArgumentException("CSV is missing required columns: ${missing.joinToString(", ")}")
+ }
+
+ val items = mutableListOf()
+ rows.drop(1).forEachIndexed { ri, cells ->
+ fun cell(name: String): String {
+ val i = col(name)
+ return if (i == -1 || i >= cells.size) "" else cells[i].trim()
+ }
+ try {
+ val rawType = cell("type")
+ val type = if (rawType.isEmpty()) ItemType.Medicine
+ else ItemType.entries.firstOrNull { it.name.equals(rawType, ignoreCase = true) }
+ ?: throw IllegalArgumentException("unknown type '$rawType'")
+
+ val dates = cell("taken_dates").split(LIST_SEPARATOR).map { it.trim() }
+ .filter { it.isNotEmpty() }
+ val times = cell("taken_times").split(LIST_SEPARATOR).map { it.trim() }
+ val history = HashMap()
+ dates.forEachIndexed { di, d ->
+ history[LocalDate.parse(d)] = times.getOrNull(di)
+ ?.takeIf { it.isNotEmpty() }
+ ?.let { LocalTime.parse(it) }
+ ?: LocalTime.MIDNIGHT
+ }
+
+ val recDays = cell("recurrence_days").split(LIST_SEPARATOR)
+ .map { it.trim() }.filter { it.isNotEmpty() }
+ .map { parseDayOfWeek(it) }
+
+ items.add(
+ MedData(
+ id = cell("id").toLongOrNull() ?: System.nanoTime(),
+ groupId = cell("group_id").toLongOrNull(),
+ type = type,
+ title = cell("title"),
+ iconName = cell("icon_name").ifEmpty { null },
+ colorCode = cell("color_code").ifEmpty { null },
+ frequencyLabel = cell("frequency_label").ifEmpty { null },
+ creationDate = LocalDate.parse(cell("creation_date")),
+ creationTime = LocalTime.parse(cell("creation_time")),
+ takenHistory = history,
+ recurrenceDays = recDays.ifEmpty { null },
+ endDate = cell("end_date").takeIf { it.isNotEmpty() }?.let { LocalDate.parse(it) },
+ notes = cell("notes").ifEmpty { null },
+ displayOrder = cell("display_order").toIntOrNull() ?: 0,
+ intervalGap = cell("interval_days").toIntOrNull(),
+ category = cell("category").ifEmpty { null },
+ notificationType = cell("notification_type").toIntOrNull() ?: 0,
+ supplyDosesLeft = cell("supply_doses_left").toIntOrNull(),
+ supplyDosesPerRefill = cell("supply_refill_size").toIntOrNull(),
+ supplyLowThreshold = cell("supply_low_threshold").toIntOrNull()
+ )
+ )
+ } catch (e: IllegalArgumentException) {
+ throw IllegalArgumentException("CSV row ${ri + 2}: ${e.message}")
+ } catch (e: Exception) {
+ throw IllegalArgumentException("CSV row ${ri + 2}: ${e.message ?: "invalid value"}")
+ }
+ }
+ return items
+ }
+
+ private fun parseDayOfWeek(raw: String): java.time.DayOfWeek =
+ java.time.DayOfWeek.valueOf(raw.uppercase())
+
+ private fun encodeCell(raw: String?): String {
+ val v = raw ?: ""
+ return if (v.contains(',') || v.contains('"') || v.contains('\n') || v.contains('\r')) {
+ "\"" + v.replace("\"", "\"\"") + "\""
+ } else v
+ }
+
+ /** Character-stream RFC 4180 parser (handles quoted cells spanning multiple lines). */
+ private fun parseRows(text: String): List> {
+ val s = if (text.isNotEmpty() && text[0] == '\uFEFF') text.substring(1) else text
+ val rows = mutableListOf>()
+ var cell = StringBuilder()
+ var row = mutableListOf()
+ var inQuotes = false
+ var i = 0
+ while (i < s.length) {
+ val c = s[i]
+ when {
+ inQuotes -> when {
+ c == '"' && i + 1 < s.length && s[i + 1] == '"' -> {
+ cell.append('"'); i++
+ }
+
+ c == '"' -> inQuotes = false
+ else -> cell.append(c)
+ }
+
+ c == '"' -> inQuotes = true
+ c == ',' -> {
+ row.add(cell.toString())
+ cell = StringBuilder()
+ }
+
+ c == '\r' -> {
+ if (i + 1 < s.length && s[i + 1] == '\n') i++
+ row.add(cell.toString())
+ cell = StringBuilder()
+ rows.add(row)
+ row = mutableListOf()
+ }
+
+ c == '\n' -> {
+ row.add(cell.toString())
+ cell = StringBuilder()
+ rows.add(row)
+ row = mutableListOf()
+ }
+
+ else -> cell.append(c)
+ }
+ i++
+ }
+ if (cell.isNotEmpty() || row.isNotEmpty()) {
+ row.add(cell.toString())
+ rows.add(row)
+ }
+ return rows
+ }
+}
diff --git a/app/src/main/kotlin/com/fedeveloper95/med/services/Data.kt b/app/src/main/kotlin/com/fedeveloper95/med/services/Data.kt
index b5a0c20..496003e 100644
--- a/app/src/main/kotlin/com/fedeveloper95/med/services/Data.kt
+++ b/app/src/main/kotlin/com/fedeveloper95/med/services/Data.kt
@@ -30,6 +30,7 @@ import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import java.util.Collections
+import kotlin.math.max
sealed class EditItem {
abstract val uniqueId: String
@@ -43,6 +44,13 @@ sealed class EditItem {
}
}
+/** Supply (inventory) settings for a medicine, as entered in the editor. */
+data class InventoryEntry(
+ val dosesLeft: Int,
+ val dosesPerRefill: Int,
+ val lowThreshold: Int
+)
+
@Keep
data class MedData(
val id: Long = System.currentTimeMillis(),
@@ -61,7 +69,11 @@ data class MedData(
val displayOrder: Int = 0,
val intervalGap: Int? = null,
val category: String? = null,
- val notificationType: Int = 0
+ val notificationType: Int = 0,
+ val supplyDosesLeft: Int? = null,
+ val supplyDosesPerRefill: Int? = null,
+ val supplyLowThreshold: Int? = null,
+ val supplyAlertShown: Boolean = false
) : Serializable {
fun toJson(): JSONObject {
@@ -92,6 +104,10 @@ data class MedData(
json.put("intervalGap", intervalGap ?: JSONObject.NULL)
json.put("category", category ?: JSONObject.NULL)
json.put("notificationType", notificationType)
+ json.put("supplyDosesLeft", supplyDosesLeft ?: JSONObject.NULL)
+ json.put("supplyDosesPerRefill", supplyDosesPerRefill ?: JSONObject.NULL)
+ json.put("supplyLowThreshold", supplyLowThreshold ?: JSONObject.NULL)
+ json.put("supplyAlertShown", supplyAlertShown)
return json
}
@@ -131,7 +147,11 @@ data class MedData(
displayOrder = json.optInt("displayOrder", 0),
intervalGap = if (json.isNull("intervalGap")) null else json.getInt("intervalGap"),
category = if (json.isNull("category")) null else json.getString("category"),
- notificationType = json.optInt("notificationType", 0)
+ notificationType = json.optInt("notificationType", 0),
+ supplyDosesLeft = if (json.isNull("supplyDosesLeft")) null else json.optInt("supplyDosesLeft"),
+ supplyDosesPerRefill = if (json.isNull("supplyDosesPerRefill")) null else json.optInt("supplyDosesPerRefill"),
+ supplyLowThreshold = if (json.isNull("supplyLowThreshold")) null else json.optInt("supplyLowThreshold"),
+ supplyAlertShown = json.optBoolean("supplyAlertShown", false)
)
}
}
@@ -184,6 +204,8 @@ object DataRepository {
} catch (e: Exception) {
e.printStackTrace()
}
+ // Every data mutation funnels through here — keep the supply widget current.
+ MedSupplyWidgetProvider.updateAll(context)
}
private fun migrateLegacyData(context: Context): List {
@@ -252,9 +274,25 @@ class MedViewModel(application: Application) : AndroidViewModel(application) {
}
init {
+ InventoryService.createNotificationChannel(application)
loadData()
+ // A supply restored from a backup or rebooted below its threshold must
+ // alert without waiting for the next dose event.
+ if (InventoryService.evaluateAll(application, _items)) {
+ saveData()
+ }
syncToWear()
+ // Re-arm dose alarms for every medicine on startup. Alarms die on
+ // force-stop or a crashed process, and until now they only came back
+ // when the user happened to edit or take a dose.
+ _items.filter { it.type == ItemType.Medicine }.forEach { item ->
+ try {
+ NotificationReceiver.scheduleNotification(getApplication(), item)
+ } catch (e: Exception) {
+ }
+ }
+
val filter = IntentFilter("com.fedeveloper95.med.RELOAD_DATA")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
application.registerReceiver(updateReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
@@ -512,7 +550,8 @@ class MedViewModel(application: Application) : AndroidViewModel(application) {
notes: String? = null,
category: String? = null,
intervalGap: Int? = null,
- notificationType: Int = 0
+ notificationType: Int = 0,
+ supply: InventoryEntry? = null
) {
val groupId = System.currentTimeMillis()
val baseDate = selectedDate
@@ -602,6 +641,7 @@ class MedViewModel(application: Application) : AndroidViewModel(application) {
_items.add(newItem)
if (type == ItemType.Medicine) {
NotificationReceiver.scheduleNotification(getApplication(), newItem)
+ applySupplySettings(newItem, supply)
}
}
saveData()
@@ -618,7 +658,8 @@ class MedViewModel(application: Application) : AndroidViewModel(application) {
intervalGap: Int?,
notificationType: Int = 0,
rangeStart: Long? = -2L,
- rangeEnd: Long? = -2L
+ rangeEnd: Long? = -2L,
+ supply: InventoryEntry? = null
) {
val context = getApplication()
val freqLabel = if (intervalGap == 14) context.getString(R.string.frequency_unit_biweek)
@@ -629,118 +670,68 @@ class MedViewModel(application: Application) : AndroidViewModel(application) {
)
else context.getString(R.string.frequency_daily)
- val isMedicine = originalItem.type == ItemType.Medicine
- val isRangeUpdate = rangeStart != -2L
-
- val relatedItems = if (originalItem.groupId != null) {
- _items.filter { it.groupId == originalItem.groupId }
- } else {
- listOf(originalItem)
- }
-
- val relatedIds = relatedItems.map { it.id }.toSet()
-
- if (isMedicine && isRangeUpdate) {
- var editStart = selectedDate
- var editEnd: LocalDate? = null
-
- if (rangeStart == null && rangeEnd == null) {
- editStart = selectedDate
- editEnd = selectedDate
- } else if (rangeStart == -1L) {
- editStart = selectedDate
- editEnd = originalItem.endDate
- } else if (rangeStart != null) {
- editStart = LocalDate.ofEpochDay(rangeStart / 86400000)
- editEnd =
- if (rangeEnd != null && rangeEnd != -2L) LocalDate.ofEpochDay(rangeEnd / 86400000) else editStart
- }
-
- _items.removeAll { it.id in relatedIds }
-
- val baseNewItem = originalItem.copy(
+ // All edit rules (no-op guard, slot preservation, history inheritance,
+ // phantom-fragment dropping) live in the pure, unit-tested planner.
+ val plan = MedUpdatePlanner.plan(
+ MedUpdatePlanner.Request(
+ originalItem = originalItem,
title = title,
iconName = iconName,
colorCode = colorCode,
- recurrenceDays = days,
+ times = times,
+ days = days,
notes = notes,
intervalGap = intervalGap,
notificationType = notificationType,
- frequencyLabel = freqLabel
- )
+ freqLabel = freqLabel,
+ rangeStart = rangeStart,
+ rangeEnd = rangeEnd,
+ selectedDate = selectedDate
+ ),
+ supply,
+ _items.filter { it.type == originalItem.type }
+ )
- relatedItems.forEachIndexed { i, oldItem ->
- if (editStart.isAfter(oldItem.creationDate)) {
- val newEndDate = editStart.minusDays(1)
- val finalEndDate =
- if (oldItem.endDate != null && oldItem.endDate.isBefore(newEndDate)) oldItem.endDate else newEndDate
- _items.add(
- oldItem.copy(
- id = System.nanoTime() + i,
- endDate = finalEndDate
- )
- )
- }
- }
+ when (plan) {
+ is MedUpdatePlanner.Plan.None -> return
- val editedGroupId = System.currentTimeMillis()
- times.forEachIndexed { i, time ->
- val editedPart = baseNewItem.copy(
- id = System.nanoTime() + 100 + i,
- groupId = editedGroupId,
- creationTime = time,
- creationDate = editStart,
- endDate = editEnd,
- takenHistory = HashMap()
- )
- _items.add(editedPart)
- NotificationReceiver.scheduleNotification(getApplication(), editedPart)
+ is MedUpdatePlanner.Plan.SupplyOnly -> {
+ applySupplySettings(originalItem, supply)
+ return
}
- if (editEnd != null) {
- val newCreationDate = editEnd.plusDays(1)
- relatedItems.forEachIndexed { i, oldItem ->
- if (oldItem.endDate == null || oldItem.endDate.isAfter(editEnd)) {
- val finalCreationDate =
- if (oldItem.creationDate.isAfter(newCreationDate)) oldItem.creationDate else newCreationDate
- _items.add(
- oldItem.copy(
- id = System.nanoTime() + 200 + i,
- creationDate = finalCreationDate
- )
- )
+ is MedUpdatePlanner.Plan.Rebuild -> {
+ // Entries being replaced get new IDs — cancel alerts keyed to
+ // the old IDs so no orphaned low-supply notification survives.
+ plan.removeIds.forEach { id ->
+ _items.firstOrNull { it.id == id }?.let {
+ InventoryService.cancelLowSupplyNotification(context, it)
}
}
- }
+ _items.removeAll { it.id in plan.removeIds }
- saveData()
- return
- }
-
- _items.removeAll { it.id in relatedIds }
-
- val newGroupId = System.currentTimeMillis()
- times.forEachIndexed { i, time ->
- val newItem = originalItem.copy(
- id = System.nanoTime() + i,
- groupId = newGroupId,
- title = title,
- iconName = iconName,
- colorCode = colorCode,
- creationTime = time,
- creationDate = originalItem.creationDate,
- recurrenceDays = days,
- notes = notes,
- intervalGap = intervalGap,
- notificationType = notificationType,
- frequencyLabel = freqLabel
- )
- _items.add(newItem)
- if (newItem.type == ItemType.Medicine) {
- NotificationReceiver.scheduleNotification(getApplication(), newItem)
+ val newGroupId = System.currentTimeMillis()
+ plan.entries.forEachIndexed { i, entry ->
+ val withIds = entry.copy(
+ id = System.nanoTime() + i,
+ groupId = if (entry.groupId == MedUpdatePlanner.NEW_GROUP) newGroupId else entry.groupId
+ )
+ _items.add(withIds)
+ if (withIds.type == ItemType.Medicine) {
+ NotificationReceiver.scheduleNotification(getApplication(), withIds)
+ }
+ }
}
}
saveData()
+ refreshLowSupplyAlerts()
+ }
+
+ /** Re-evaluates every item against its low-supply threshold and persists changes. */
+ private fun refreshLowSupplyAlerts() {
+ if (InventoryService.evaluateAll(getApplication(), _items)) {
+ saveData()
+ }
}
fun deleteItem(item: MedData, deleteDate: LocalDate) {
@@ -802,6 +793,59 @@ class MedViewModel(application: Application) : AndroidViewModel(application) {
saveData()
}
+ /**
+ * Applies supply (inventory) settings from the editor to all entries of the
+ * item's group. `null` supply turns tracking off. Turning tracking on for a
+ * group that had none seeds every dose slot with the full amount; switching
+ * to per-refill mode scales an existing running count.
+ */
+ fun applySupplySettings(item: MedData, supply: InventoryEntry?) {
+ val targets = if (item.groupId != null) {
+ _items.filter { it.groupId == item.groupId }
+ } else {
+ listOf(item)
+ }
+
+ val refillsEnabled = supply != null && supply.dosesPerRefill > 0
+ val scale = if (refillsEnabled && item.supplyDosesPerRefill != null && item.supplyDosesPerRefill > 0) {
+ supply.dosesPerRefill.toFloat() / item.supplyDosesPerRefill
+ } else 1f
+
+ targets.forEach { target ->
+ val newLeft = when {
+ supply == null -> null
+ target.supplyDosesLeft == null -> supply.dosesLeft
+ refillsEnabled -> max(0, Math.round(target.supplyDosesLeft * scale))
+ else -> target.supplyDosesLeft
+ }
+ val index = _items.indexOfFirst { it.id == target.id }
+ if (index != -1) {
+ _items[index] = target.copy(
+ supplyDosesLeft = newLeft,
+ supplyDosesPerRefill = supply?.dosesPerRefill?.takeIf { refillsEnabled },
+ supplyLowThreshold = supply?.lowThreshold,
+ supplyAlertShown = false
+ )
+ }
+ }
+ saveData()
+ }
+
+ /**
+ * Applies supply settings to the group that was just created by [addItem].
+ * The new group carries the entered title on each of its entries.
+ */
+ fun setSupplyOnNewestGroup(title: String, supply: InventoryEntry?) {
+ if (supply == null) return
+ val newest = _items.lastOrNull { it.type == ItemType.Medicine && it.title == title } ?: return
+ if (supply == null) {
+ // Sanity fallback: created without tracking (should not happen when
+ // called from the medicine sheet, which omits the argument then).
+ return
+ }
+ applySupplySettings(newest, supply)
+ }
+
fun restoreItem(item: MedData) {
val index = _items.indexOfFirst { it.id == item.id }
if (index != -1) {
@@ -816,13 +860,29 @@ class MedViewModel(application: Application) : AndroidViewModel(application) {
if (item.type != ItemType.Medicine) return
if (date.isAfter(LocalDate.now())) return
- val newHistory = HashMap(item.takenHistory)
+ val index = _items.indexOfFirst { it.id == item.id }
+ if (index == -1) return
+ // Work from the stored item, not the (possibly stale) UI copy: a dose
+ // logged from a notification or watch may have changed history or stock
+ // since this card was rendered.
+ val current = _items[index]
+ val newHistory = HashMap(current.takenHistory)
if (newHistory.containsKey(date)) newHistory.remove(date) else newHistory[date] =
LocalTime.now()
- val index = _items.indexOfFirst { it.id == item.id }
- if (index != -1) _items[index] = item.copy(takenHistory = newHistory)
+ _items[index] = applyInventoryChange(
+ getApplication(),
+ current.copy(takenHistory = newHistory),
+ isTaken = newHistory.containsKey(date)
+ )
saveData()
+
+ // Re-arm the alarm so a dose logged early (or un-done) updates the
+ // schedule immediately, matching the notification's Take action.
+ try {
+ NotificationReceiver.scheduleNotification(getApplication(), _items[index])
+ } catch (e: Exception) {
+ }
}
fun confirmIllness(item: MedData, date: LocalDate) {
diff --git a/app/src/main/kotlin/com/fedeveloper95/med/services/InventoryService.kt b/app/src/main/kotlin/com/fedeveloper95/med/services/InventoryService.kt
new file mode 100644
index 0000000..01c7a75
--- /dev/null
+++ b/app/src/main/kotlin/com/fedeveloper95/med/services/InventoryService.kt
@@ -0,0 +1,137 @@
+package com.fedeveloper95.med.services
+
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import com.fedeveloper95.med.MainActivity
+import com.fedeveloper95.med.R
+
+/**
+ * Per-medication supply (inventory) tracking.
+ *
+ * Supply data lives on each [MedData] as:
+ * - [MedData.supplyDosesLeft] current stock in doses (null = tracking off)
+ * - [MedData.supplyDosesPerRefill] how many doses a refill adds (e.g. 30 tablets)
+ * - [MedData.supplyLowThreshold] fire one alert when dosesLeft <= this
+ * - [MedData.supplyAlertShown] deduplication flag for the low-supply alert
+ *
+ * Doses are counted per calendar day (each MedData entry is one daily time slot),
+ * so taking or un-taking a dose adjusts the stock by exactly one dose.
+ *
+ * Low-supply alerts use per-item notification IDs so multiple medications can be
+ * low at the same time without overwriting each other, and stale alerts are
+ * cancelled when stock is refilled or the item is edited/deleted.
+ */
+object InventoryService {
+
+ const val INVENTORY_CHANNEL_ID = "med_inventory_v1"
+
+ /** Base for per-item alert IDs; dose alarms use `item.id.toInt()` directly. */
+ private const val ALERT_ID_BASE = 900000000
+
+ private fun alertId(item: MedData): Int = ALERT_ID_BASE + (item.id % 100000).toInt()
+
+ /**
+ * Applies the stock change for a dose being logged or un-logged.
+ * Returns the updated item; the caller is responsible for persisting it.
+ */
+ fun applyInventoryChange(context: Context, item: MedData, isTaken: Boolean): MedData {
+ val left = item.supplyDosesLeft ?: return item
+ val updated = item.copy(supplyDosesLeft = (left + if (isTaken) -1 else 1).coerceAtLeast(0))
+ return evaluateItem(context, updated)
+ }
+
+ /**
+ * Evaluates one item against its threshold: posts the low-supply alert when the
+ * stock is newly at/below it, and cancels a stale alert once stock is refilled.
+ * Returns the item with [MedData.supplyAlertShown] updated accordingly.
+ */
+ fun evaluateItem(context: Context, item: MedData): MedData {
+ val left = item.supplyDosesLeft ?: return item
+ val threshold = item.supplyLowThreshold ?: return item
+ if (left > threshold) {
+ cancelLowSupplyNotification(context, item)
+ return item
+ }
+ if (item.supplyAlertShown) return item
+ postLowSupplyNotification(context, item)
+ return item.copy(supplyAlertShown = true)
+ }
+
+ /**
+ * Evaluates every item in place — used after app start, boot/reinstall, or
+ * import so a supply that is *already* low alerts even without a dose event.
+ * Within one pass only the first entry per title alerts (dose slots of one
+ * medication share a title). Returns true when any item changed and the list
+ * should be persisted.
+ */
+ fun evaluateAll(context: Context, items: MutableList): Boolean {
+ var changed = false
+ val alertedTitles = mutableSetOf()
+ for (i in items.indices) {
+ val item = items[i]
+ val left = item.supplyDosesLeft ?: continue
+ val threshold = item.supplyLowThreshold
+ if (threshold != null && left <= threshold && !item.supplyAlertShown &&
+ alertedTitles.add(item.title)
+ ) {
+ items[i] = evaluateItem(context, item)
+ changed = true
+ }
+ }
+ return changed
+ }
+
+ private fun postLowSupplyNotification(context: Context, item: MedData) {
+ val remaining = item.supplyDosesPerRefill ?: 0
+ val text = if (remaining > 0) {
+ context.getString(R.string.inventory_low_desc_refill, item.supplyDosesLeft ?: 0, remaining)
+ } else {
+ context.getString(R.string.inventory_low_desc, item.supplyDosesLeft ?: 0)
+ }
+
+ val contentIntent = PendingIntent.getActivity(
+ context,
+ item.id.toInt(),
+ Intent(context, MainActivity::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val notification = NotificationCompat.Builder(context, INVENTORY_CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(context.getString(R.string.inventory_low_title, item.title))
+ .setContentText(text)
+ .setStyle(NotificationCompat.BigTextStyle().bigText(text))
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setCategory(NotificationCompat.CATEGORY_REMINDER)
+ .setAutoCancel(true)
+ .setContentIntent(contentIntent)
+ .build()
+
+ val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ nm.notify(alertId(item), notification)
+ }
+
+ /** Clears the low-supply alert notification for this item, if one is showing. */
+ fun cancelLowSupplyNotification(context: Context, item: MedData) {
+ val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ nm.cancel(alertId(item))
+ }
+
+ fun createNotificationChannel(context: Context) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val channel = android.app.NotificationChannel(
+ INVENTORY_CHANNEL_ID,
+ context.getString(R.string.inventory_channel_name),
+ NotificationManager.IMPORTANCE_HIGH
+ ).apply {
+ description = context.getString(R.string.inventory_channel_desc)
+ }
+ nm.createNotificationChannel(channel)
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/fedeveloper95/med/services/MedApp.kt b/app/src/main/kotlin/com/fedeveloper95/med/services/MedApp.kt
index 84b1059..60a2f44 100644
--- a/app/src/main/kotlin/com/fedeveloper95/med/services/MedApp.kt
+++ b/app/src/main/kotlin/com/fedeveloper95/med/services/MedApp.kt
@@ -1242,7 +1242,7 @@ fun MedApp(
if (isMed) {
MedicineBottomSheet(
onDismiss = { editingItem = null },
- onConfirm = { title, iconName, colorCode, times, days, notes, intervalGap, notificationType, rangeStart, rangeEnd ->
+ onConfirm = { title, iconName, colorCode, times, days, notes, intervalGap, supply, notificationType, rangeStart, rangeEnd ->
viewModel.updateItem(
itemToEdit,
title,
@@ -1254,7 +1254,8 @@ fun MedApp(
intervalGap,
notificationType,
rangeStart,
- rangeEnd
+ rangeEnd,
+ supply
)
editingItem = null
},
@@ -1273,7 +1274,7 @@ fun MedApp(
colorCode,
listOf(itemToEdit.creationTime),
null,
- null,
+ itemToEdit.notes,
null
)
viewModel.selectedDate = prevSelected
@@ -1307,7 +1308,7 @@ fun MedApp(
if (useBottomSheet) {
MedicineBottomSheet(
onDismiss = { showMedicineDialog = false },
- onConfirm = { title, iconName, colorCode, times, days, notes, intervalGap, notificationType, rangeStart, rangeEnd ->
+ onConfirm = { title, iconName, colorCode, times, days, notes, intervalGap, supply, notificationType, rangeStart, rangeEnd ->
viewModel.addItem(
ItemType.Medicine,
title,
@@ -1319,6 +1320,7 @@ fun MedApp(
intervalGap = intervalGap,
notificationType = notificationType
)
+ viewModel.setSupplyOnNewestGroup(title, supply)
showMedicineDialog = false
},
initialText = preFilledText
diff --git a/app/src/main/kotlin/com/fedeveloper95/med/services/MedSupplyWidgetProvider.kt b/app/src/main/kotlin/com/fedeveloper95/med/services/MedSupplyWidgetProvider.kt
new file mode 100644
index 0000000..c44a7e3
--- /dev/null
+++ b/app/src/main/kotlin/com/fedeveloper95/med/services/MedSupplyWidgetProvider.kt
@@ -0,0 +1,112 @@
+package com.fedeveloper95.med.services
+
+import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.appwidget.AppWidgetProvider
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.graphics.Color
+import android.widget.RemoteViews
+import com.fedeveloper95.med.ItemType
+import com.fedeveloper95.med.MainActivity
+import com.fedeveloper95.med.R
+
+/**
+ * Home-screen widget that always shows the medication with the lowest supply.
+ *
+ * Refreshed automatically from [DataRepository.saveData] (every data mutation
+ * flows through it) and by the system on boot / widget add via [onUpdate].
+ */
+class MedSupplyWidgetProvider : AppWidgetProvider() {
+
+ override fun onUpdate(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetIds: IntArray
+ ) {
+ for (appWidgetId in appWidgetIds) {
+ updateWidget(context, appWidgetManager, appWidgetId)
+ }
+ }
+
+ companion object {
+ /** Re-renders every placed supply widget. Safe to call from any thread. */
+ fun updateAll(context: Context) {
+ try {
+ val manager = AppWidgetManager.getInstance(context) ?: return
+ val ids = manager.getAppWidgetIds(
+ ComponentName(context, MedSupplyWidgetProvider::class.java)
+ )
+ for (appWidgetId in ids) {
+ updateWidget(context, manager, appWidgetId)
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+
+ private fun updateWidget(
+ context: Context,
+ manager: AppWidgetManager,
+ appWidgetId: Int
+ ) {
+ val items = try {
+ DataRepository.loadData(context)
+ } catch (e: Exception) {
+ emptyList()
+ }
+
+ val tracked = items.filter {
+ it.type == ItemType.Medicine && it.supplyDosesLeft != null
+ }
+
+ val views = RemoteViews(context.packageName, R.layout.med_supply_widget)
+
+ if (tracked.isEmpty()) {
+ views.setViewVisibility(R.id.widget_content, android.view.View.GONE)
+ views.setViewVisibility(R.id.widget_empty, android.view.View.VISIBLE)
+ } else {
+ val lowest = tracked.minByOrNull { it.supplyDosesLeft ?: 0 }!!
+ val left = lowest.supplyDosesLeft ?: 0
+ val low = lowest.supplyLowThreshold != null &&
+ left <= lowest.supplyLowThreshold!!
+
+ views.setViewVisibility(R.id.widget_content, android.view.View.VISIBLE)
+ views.setViewVisibility(R.id.widget_empty, android.view.View.GONE)
+ views.setTextViewText(R.id.widget_title, lowest.title)
+ views.setTextViewText(
+ R.id.widget_count,
+ context.getString(R.string.supply_badge_format, left)
+ )
+ views.setTextColor(
+ R.id.widget_count,
+ if (low) context.getColor(R.color.widget_low) else Color.WHITE
+ )
+
+ val refill = lowest.supplyDosesPerRefill ?: 0
+ if (refill > 0) {
+ views.setViewVisibility(R.id.widget_progress, android.view.View.VISIBLE)
+ views.setProgressBar(
+ R.id.widget_progress,
+ refill,
+ left.coerceIn(0, refill),
+ false
+ )
+ } else {
+ views.setViewVisibility(R.id.widget_progress, android.view.View.GONE)
+ }
+ }
+
+ val pendingIntent = PendingIntent.getActivity(
+ context,
+ 0,
+ Intent(context, MainActivity::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ views.setOnClickPendingIntent(R.id.widget_root, pendingIntent)
+
+ manager.updateAppWidget(appWidgetId, views)
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/fedeveloper95/med/services/MedUpdatePlanner.kt b/app/src/main/kotlin/com/fedeveloper95/med/services/MedUpdatePlanner.kt
new file mode 100644
index 0000000..8398400
--- /dev/null
+++ b/app/src/main/kotlin/com/fedeveloper95/med/services/MedUpdatePlanner.kt
@@ -0,0 +1,222 @@
+package com.fedeveloper95.med.services
+
+import com.fedeveloper95.med.ItemType
+import java.time.DayOfWeek
+import java.time.LocalDate
+import java.time.LocalTime
+
+/**
+ * Pure (JVM-testable) planner for editing an existing item.
+ *
+ * Extracted from [MedViewModel.updateItem] so the rules that protect taken
+ * history can be unit tested without an Android device:
+ *
+ * 1. An edit that changes nothing (except supply) must not recreate entries —
+ * recreation resets takenHistory and silently destroys logged doses.
+ * 2. The editor edits ONE entry of a medication, but the medication is a GROUP
+ * of entries (one per daily time slot). A rebuild must cover every slot,
+ * keeping each slot's own schedule and taken history.
+ * 3. A changed time carries its slot's taken history to the new time.
+ * 4. A range edit keeps history-bearing fragments before/after the edited
+ * window; empty fragments (phantom schedules) are dropped, and history is
+ * trimmed to the window each fragment actually covers.
+ *
+ * The planner only computes; the caller applies the result (assigns real IDs,
+ * cancels stale notifications, re-arms alarms, persists).
+ */
+object MedUpdatePlanner {
+
+ /** Sentinel groupId on planned entries meaning "assign a fresh group id". */
+ const val NEW_GROUP = -1L
+
+ /** Not-a-range sentinel used by the caller's [Request.rangeStart]. */
+ const val NOT_A_RANGE = -2L
+
+ data class Request(
+ val originalItem: MedData,
+ val title: String,
+ val iconName: String?,
+ val colorCode: String?,
+ val times: List,
+ val days: List?,
+ val notes: String?,
+ val intervalGap: Int?,
+ val notificationType: Int = 0,
+ val freqLabel: String = "",
+ val rangeStart: Long? = NOT_A_RANGE,
+ val rangeEnd: Long? = NOT_A_RANGE,
+ val selectedDate: LocalDate = LocalDate.now()
+ )
+
+ sealed class Plan {
+ /** Nothing changed — do not touch anything. */
+ object None : Plan()
+
+ /** Only supply changed — apply it to the whole group in place. */
+ object SupplyOnly : Plan()
+
+ /**
+ * Remove [removeIds] and add [entries]. Entries are fully built except:
+ * `id` is a 0-based placeholder and `groupId` is either a real id to
+ * reuse or [NEW_GROUP] (caller assigns a fresh one). History maps are
+ * fresh copies; the caller must not share them with existing items.
+ */
+ data class Rebuild(
+ val removeIds: Set,
+ val entries: List
+ ) : Plan()
+ }
+
+ fun plan(request: Request, supply: InventoryEntry?, groupMembers: List): Plan {
+ val original = request.originalItem
+ val isMedicine = original.type == ItemType.Medicine
+ val isRangeUpdate = request.rangeStart != NOT_A_RANGE
+
+ val relatedItems = if (original.groupId != null) {
+ groupMembers.filter { it.groupId == original.groupId && it.type == original.type }
+ } else {
+ listOf(original)
+ }
+
+ // Rule 1: a no-op (or supply-only) save must not recreate anything.
+ val timesUnchanged = request.times.size == relatedItems.size &&
+ request.times.toSet() == relatedItems.map { it.creationTime }.toSet()
+ val daysUnchanged = when {
+ request.days == null && original.recurrenceDays == null -> true
+ request.days == null || original.recurrenceDays == null -> false
+ else -> request.days.toSet() == original.recurrenceDays.toSet()
+ }
+ val fieldsUnchanged = request.title == original.title &&
+ request.iconName == original.iconName &&
+ request.colorCode == original.colorCode &&
+ request.notes == original.notes &&
+ request.intervalGap == original.intervalGap &&
+ request.notificationType == original.notificationType
+
+ if (timesUnchanged && daysUnchanged && fieldsUnchanged) {
+ if (!isMedicine) return Plan.None
+ // Distinguish "nothing changed at all" from "only the supply":
+ // only the latter may touch the item (in place, via SupplyOnly).
+ val effectiveStoredSupply = original.supplyDosesLeft?.let {
+ InventoryEntry(it, original.supplyDosesPerRefill ?: 0, original.supplyLowThreshold ?: 0)
+ }
+ return if (supply != effectiveStoredSupply) Plan.SupplyOnly else Plan.None
+ }
+
+ // Rule 3: history follows its time slot; a brand-new time inherits the
+ // history of the entry that was being edited.
+ val historyByTime = relatedItems.associate { it.creationTime to it.takenHistory }
+ val editedHistory = relatedItems.firstOrNull { it.id == original.id }?.takenHistory
+
+ // Rule 2: the sheet edits one entry (times holds exactly that one time);
+ // the rebuild covers every slot — the opened slot follows the sheet's
+ // (possibly changed) time, siblings keep their own.
+ val sheetTime = request.times.firstOrNull()
+ val rebuildTimes = relatedItems.map {
+ if (it.id == original.id) (sheetTime ?: it.creationTime) else it.creationTime
+ }.distinct()
+
+ fun buildEntry(
+ base: MedData,
+ time: LocalTime,
+ creationDate: LocalDate,
+ endDate: LocalDate?,
+ history: Map,
+ reuseOldGroup: Boolean
+ ): MedData = base.copy(
+ id = 0,
+ groupId = if (reuseOldGroup) base.groupId else NEW_GROUP,
+ title = request.title,
+ iconName = request.iconName,
+ colorCode = request.colorCode,
+ creationTime = time,
+ creationDate = creationDate,
+ recurrenceDays = request.days,
+ notes = request.notes,
+ intervalGap = request.intervalGap,
+ notificationType = request.notificationType,
+ frequencyLabel = request.freqLabel,
+ endDate = endDate,
+ supplyDosesLeft = supply?.dosesLeft,
+ supplyDosesPerRefill = supply?.dosesPerRefill?.takeIf { it > 0 },
+ supplyLowThreshold = supply?.lowThreshold,
+ supplyAlertShown = false,
+ takenHistory = HashMap(history)
+ )
+
+ if (isMedicine && isRangeUpdate) {
+ val editStart: LocalDate
+ val editEnd: LocalDate?
+ if (request.rangeStart == null && request.rangeEnd == null) {
+ editStart = request.selectedDate
+ editEnd = request.selectedDate
+ } else if (request.rangeStart == -1L) {
+ editStart = request.selectedDate
+ editEnd = original.endDate
+ } else {
+ val startEpoch = requireNotNull(request.rangeStart) { "rangeStart must not be null here" }
+ editStart = LocalDate.ofEpochDay(startEpoch / 86400000)
+ editEnd =
+ if (request.rangeEnd != null && request.rangeEnd != NOT_A_RANGE)
+ LocalDate.ofEpochDay(request.rangeEnd / 86400000)
+ else editStart
+ }
+
+ val entries = mutableListOf()
+
+ // History-bearing fragment before the edited window.
+ relatedItems.forEach { oldItem ->
+ if (editStart.isAfter(oldItem.creationDate)) {
+ val earlier = oldItem.takenHistory.filterKeys { it.isBefore(editStart) }
+ // Rule 4: no phantom schedule fragments without history.
+ if (earlier.isNotEmpty()) {
+ val newEndDate = editStart.minusDays(1)
+ val finalEndDate =
+ if (oldItem.endDate != null && oldItem.endDate.isBefore(newEndDate))
+ oldItem.endDate
+ else newEndDate
+ entries += buildEntry(oldItem, oldItem.creationTime, oldItem.creationDate, finalEndDate, earlier, reuseOldGroup = true)
+ }
+ }
+ }
+
+ // The edited window itself, one entry per slot.
+ rebuildTimes.forEach { time ->
+ val window = (historyByTime[time] ?: editedHistory ?: emptyMap())
+ .filterKeys { !it.isBefore(editStart) && (editEnd == null || !it.isAfter(editEnd)) }
+ entries += buildEntry(original, time, editStart, editEnd, window, reuseOldGroup = false)
+ }
+
+ // History-bearing fragment after the edited window.
+ if (editEnd != null) {
+ val newCreationDate = editEnd.plusDays(1)
+ relatedItems.forEach { oldItem ->
+ if (oldItem.endDate == null || oldItem.endDate.isAfter(editEnd)) {
+ val later = oldItem.takenHistory.filterKeys { it.isAfter(editEnd) }
+ if (later.isNotEmpty()) {
+ val finalCreationDate =
+ if (oldItem.creationDate.isAfter(newCreationDate)) oldItem.creationDate
+ else newCreationDate
+ entries += buildEntry(oldItem, oldItem.creationTime, finalCreationDate, oldItem.endDate, later, reuseOldGroup = true)
+ }
+ }
+ }
+ }
+
+ return Plan.Rebuild(relatedItems.map { it.id }.toSet(), entries)
+ }
+
+ // Plain (non-range) edit: rebuild every slot with its full history.
+ val entries = rebuildTimes.map { time ->
+ buildEntry(
+ original,
+ time,
+ original.creationDate,
+ original.endDate,
+ historyByTime[time] ?: editedHistory ?: emptyMap(),
+ reuseOldGroup = false
+ )
+ }
+ return Plan.Rebuild(relatedItems.map { it.id }.toSet(), entries)
+ }
+}
diff --git a/app/src/main/kotlin/com/fedeveloper95/med/services/NotificationReciver.kt b/app/src/main/kotlin/com/fedeveloper95/med/services/NotificationReciver.kt
index 3354bd9..cab8335 100644
--- a/app/src/main/kotlin/com/fedeveloper95/med/services/NotificationReciver.kt
+++ b/app/src/main/kotlin/com/fedeveloper95/med/services/NotificationReciver.kt
@@ -25,6 +25,14 @@ import java.time.LocalTime
import java.time.ZoneId
import java.time.temporal.ChronoUnit
+/**
+ * Applies the stock change for a dose being logged or un-logged and updates the
+ * in-memory item. Central helper shared by the UI (MedViewModel) and the
+ * notification's Take action so inventory stays consistent everywhere.
+ */
+fun applyInventoryChange(context: Context, item: MedData, isTaken: Boolean): MedData =
+ InventoryService.applyInventoryChange(context, item, isTaken)
+
class NotificationReceiver : BroadcastReceiver() {
companion object {
@@ -96,7 +104,9 @@ class NotificationReceiver : BroadcastReceiver() {
var date = LocalDate.now()
val now = LocalTime.now()
- if (isValidDate(item, date) && item.creationTime.isAfter(now)) {
+ if (isValidDate(item, date) && !item.takenHistory.containsKey(date) && item.creationTime.isAfter(now)) {
+ // Today's slot is still ahead and not taken yet -> alarm for today.
+ // (If it was already taken, e.g. early, fall through to the next day.)
return LocalDateTime.of(date, item.creationTime)
}
@@ -133,6 +143,13 @@ class NotificationReceiver : BroadcastReceiver() {
scheduleNotification(context, item)
}
}
+ InventoryService.createNotificationChannel(context)
+ // A supply that is already below its threshold at boot/reinstall
+ // must alert without waiting for the next dose event.
+ val evalList = items.toMutableList()
+ if (InventoryService.evaluateAll(context, evalList)) {
+ DataRepository.saveData(context, evalList)
+ }
}
ACTION_SHOW_NOTIFICATION -> {
@@ -149,7 +166,9 @@ class NotificationReceiver : BroadcastReceiver() {
items.filter {
it.type == ItemType.Medicine &&
it.creationTime == triggerItem.creationTime &&
- isValidDate(it, LocalDate.now())
+ isValidDate(it, LocalDate.now()) &&
+ // Skip meds already logged today (e.g. taken early).
+ !it.takenHistory.containsKey(LocalDate.now())
}
}
@@ -318,12 +337,15 @@ class NotificationReceiver : BroadcastReceiver() {
val newHistory = HashMap(item.takenHistory)
newHistory[LocalDate.now()] = LocalTime.now()
items[index] = item.copy(takenHistory = newHistory)
+ items[index] = applyInventoryChange(context, items[index], isTaken = true)
isDataUpdated = true
scheduleNotification(context, items[index])
}
}
if (isDataUpdated) {
+ DataRepository.saveData(context, items)
+ InventoryService.evaluateAll(context, items)
DataRepository.saveData(context, items)
context.sendBroadcast(
Intent("com.fedeveloper95.med.REFRESH_DATA").setPackage(
diff --git a/app/src/main/res/drawable/widget_background.xml b/app/src/main/res/drawable/widget_background.xml
new file mode 100644
index 0000000..19f5a64
--- /dev/null
+++ b/app/src/main/res/drawable/widget_background.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/med_supply_widget.xml b/app/src/main/res/layout/med_supply_widget.xml
new file mode 100644
index 0000000..65862cf
--- /dev/null
+++ b/app/src/main/res/layout/med_supply_widget.xml
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index a6b3dae..6cdc1e3 100755
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -1,2 +1,4 @@
-
\ No newline at end of file
+
+ #FF8A80
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 94a8e9e..91ab855 100755
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -99,15 +99,19 @@
Initial Setup
Run the welcome wizard again
Backup & Restore
- Export Settings
- Save configuration
- Import Settings
- Restore from a backup file
+ Export Data
+ Full backup: meds, history & settings
+ Export CSV
+ Spreadsheet-friendly format for any app
+ Import Data
+ Restore from a JSON backup or CSV file
+ Imported %1$d entries from CSV
+ No entries found in CSV file
Restart Required
- Settings have been imported. Restart the app to apply changes.
- Settings exported successfully
- Error exporting settings
- Error importing settings
+ Data has been imported. Restart the app to apply changes.
+ Data exported successfully
+ Error exporting data
+ Error importing data
Color
Time
@@ -130,6 +134,7 @@
Specific Days
%1$dx Daily
Daily
+ Every %1$d days
Edit time
Taken %1$s%2$s
Scheduled for %1$s
@@ -311,4 +316,19 @@
Image set
Tap to set
+
+ Supply alerts
+ Alerts when a medication supply is running low
+ Track supply
+ Doses left
+ Refill size (doses per refill)
+ Alert when supply is at or below
+ %1$d left
+ Lowest-supply medication
+ No meds tracked for supply
+ Low supply: %1$s
+ Only %1$d doses left — time to refill.
+ Only %1$d doses left — time to refill (%2$d doses per refill).
+ Tap again to un-log this dose
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/med_supply_widget_info.xml b/app/src/main/res/xml/med_supply_widget_info.xml
new file mode 100644
index 0000000..82c150c
--- /dev/null
+++ b/app/src/main/res/xml/med_supply_widget_info.xml
@@ -0,0 +1,10 @@
+
+
diff --git a/app/src/test/kotlin/com/fedeveloper95/med/services/MedUpdatePlannerTest.kt b/app/src/test/kotlin/com/fedeveloper95/med/services/MedUpdatePlannerTest.kt
new file mode 100644
index 0000000..972404a
--- /dev/null
+++ b/app/src/test/kotlin/com/fedeveloper95/med/services/MedUpdatePlannerTest.kt
@@ -0,0 +1,295 @@
+package com.fedeveloper95.med.services
+
+import com.fedeveloper95.med.ItemType
+import com.fedeveloper95.med.elements.MainActivity.Tabs.getScheduledMedsForDate
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.time.DayOfWeek
+import java.time.LocalDate
+import java.time.LocalTime
+
+/**
+ * Regression tests for the edit rules that protect taken history:
+ * every path through [MedUpdatePlanner.plan] (no-op, supply-only, plain
+ * rebuild, range rebuild) plus the Stats dedupe helper.
+ */
+class MedUpdatePlannerTest {
+
+ private val today: LocalDate = LocalDate.of(2026, 9, 14)
+ private val noon: LocalTime = LocalTime.of(12, 0)
+ private val eightAm: LocalTime = LocalTime.of(8, 0)
+
+ private fun med(
+ id: Long,
+ title: String = "Sertraline",
+ time: LocalTime = noon,
+ start: LocalDate = LocalDate.of(2026, 8, 15),
+ end: LocalDate? = null,
+ history: Map = emptyMap(),
+ supplyLeft: Int? = null,
+ groupId: Long? = 1L
+ ): MedData = MedData(
+ id = id,
+ groupId = groupId,
+ type = ItemType.Medicine,
+ title = title,
+ creationDate = start,
+ creationTime = time,
+ takenHistory = HashMap(history),
+ endDate = end,
+ supplyDosesLeft = supplyLeft,
+ supplyDosesPerRefill = if (supplyLeft != null) 30 else null,
+ supplyLowThreshold = if (supplyLeft != null) 4 else null
+ )
+
+ private fun hist(vararg days: Int): Map =
+ days.associate { today.withDayOfMonth(it) to LocalTime.of(12, 30) }
+
+ private val noopRequest = MedUpdatePlanner.Request(
+ originalItem = med(1),
+ title = "Sertraline",
+ iconName = null,
+ colorCode = null,
+ times = listOf(noon),
+ days = null,
+ notes = null,
+ intervalGap = null,
+ selectedDate = today
+ )
+
+ // ------------------------------------------------------------- no-op path
+
+ @Test
+ fun `unchanged save plans None and does not rebuild`() {
+ val plan = MedUpdatePlanner.plan(noopRequest, null, listOf(med(1, history = hist(1, 2))))
+ assertEquals(MedUpdatePlanner.Plan.None, plan)
+ }
+
+ @Test
+ fun `unchanged save with supply change plans SupplyOnly`() {
+ val supply = InventoryEntry(dosesLeft = 39, dosesPerRefill = 90, lowThreshold = 4)
+ val plan = MedUpdatePlanner.plan(noopRequest, supply, listOf(med(1, supplyLeft = 40)))
+ assertEquals(MedUpdatePlanner.Plan.SupplyOnly, plan)
+ }
+
+ @Test
+ fun `non-medicine unchanged save plans None without supply handling`() {
+ val event = med(1).copy(type = ItemType.Event)
+ val request = noopRequest.copy(originalItem = event)
+ val plan = MedUpdatePlanner.plan(request, InventoryEntry(10, 30, 4), listOf(event))
+ assertEquals(MedUpdatePlanner.Plan.None, plan)
+ }
+
+ @Test
+ fun `reordered days set is still unchanged`() {
+ val item = med(1, history = hist(1)).copy(recurrenceDays = listOf(DayOfWeek.TUESDAY, DayOfWeek.MONDAY))
+ val request = noopRequest.copy(
+ originalItem = item,
+ days = listOf(DayOfWeek.MONDAY, DayOfWeek.TUESDAY)
+ )
+ val plan = MedUpdatePlanner.plan(request, null, listOf(item))
+ assertEquals(MedUpdatePlanner.Plan.None, plan)
+ }
+
+ // ---------------------------------------------------------- rebuild paths
+
+ @Test
+ fun `real edit rebuilds with full history preserved`() {
+ val history = hist(1, 2, 3)
+ val request = noopRequest.copy(title = "Sertraline HCL")
+ val plan = MedUpdatePlanner.plan(request, null, listOf(med(1, history = history))) as MedUpdatePlanner.Plan.Rebuild
+
+ assertEquals(setOf(1L), plan.removeIds)
+ assertEquals(1, plan.entries.size)
+ assertEquals("Sertraline HCL", plan.entries[0].title)
+ assertEquals(history, plan.entries[0].takenHistory)
+ }
+
+ @Test
+ fun `editing one slot preserves sibling slots and their history`() {
+ val morning = med(1, time = eightAm, history = hist(1, 2))
+ val evening = med(2, time = LocalTime.of(20, 0), history = hist(2, 3))
+ val request = noopRequest.copy(originalItem = morning, title = "Renamed", times = listOf(eightAm))
+
+ val plan = MedUpdatePlanner.plan(request, null, listOf(morning, evening)) as MedUpdatePlanner.Plan.Rebuild
+
+ assertEquals(setOf(1L, 2L), plan.removeIds)
+ assertEquals(2, plan.entries.size)
+ val rebuiltEvening = plan.entries.first { it.creationTime == LocalTime.of(20, 0) }
+ assertEquals("Renamed", rebuiltEvening.title)
+ assertEquals(hist(2, 3), rebuiltEvening.takenHistory)
+ val rebuiltMorning = plan.entries.first { it.creationTime == eightAm }
+ assertEquals(hist(1, 2), rebuiltMorning.takenHistory)
+ }
+
+ @Test
+ fun `changed time carries its history to the new time`() {
+ val history = hist(5, 10)
+ val request = noopRequest.copy(times = listOf(LocalTime.of(13, 0)))
+ val plan = MedUpdatePlanner.plan(request, null, listOf(med(1, history = history))) as MedUpdatePlanner.Plan.Rebuild
+
+ assertEquals(1, plan.entries.size)
+ assertEquals(LocalTime.of(13, 0), plan.entries[0].creationTime)
+ assertEquals(history, plan.entries[0].takenHistory)
+ }
+
+ @Test
+ fun `rebuild entries get fresh group and placeholder ids`() {
+ val request = noopRequest.copy(title = "New")
+ val plan = MedUpdatePlanner.plan(request, null, listOf(med(1, history = hist(1)))) as MedUpdatePlanner.Plan.Rebuild
+
+ assertEquals(0L, plan.entries[0].id)
+ assertEquals(MedUpdatePlanner.NEW_GROUP, plan.entries[0].groupId)
+ }
+
+ @Test
+ fun `group scoping is type-safe so events sharing a groupId survive`() {
+ val medicine = med(1, history = hist(1))
+ val event = med(2).copy(type = ItemType.Event)
+ val request = noopRequest.copy(originalItem = medicine, title = "Renamed")
+
+ val plan = MedUpdatePlanner.plan(request, null, listOf(medicine, event)) as MedUpdatePlanner.Plan.Rebuild
+
+ assertEquals(setOf(1L), plan.removeIds)
+ assertFalse(plan.removeIds.contains(event.id))
+ }
+
+ // ----------------------------------------------------------- range edits
+
+ @Test
+ fun `only-this-event edit splits history around the edited day`() {
+ // Took Sep 1, 2, 14 (today), and 20; renaming only today's entry.
+ val history = hist(1, 2, 14, 20)
+ val request = noopRequest.copy(title = "Renamed", rangeStart = null, rangeEnd = null)
+ val plan = MedUpdatePlanner.plan(request, null, listOf(med(1, history = history))) as MedUpdatePlanner.Plan.Rebuild
+
+ val edited = plan.entries.first { it.groupId == MedUpdatePlanner.NEW_GROUP }
+ assertEquals(today, edited.creationDate)
+ assertEquals(today, edited.endDate)
+ assertEquals(mapOf(today to LocalTime.of(12, 30)), edited.takenHistory)
+
+ val before = plan.entries.filter { it.groupId == 1L && it.endDate != null }
+ assertEquals(1, before.size)
+ assertEquals(today.minusDays(1), before[0].endDate)
+ assertEquals(hist(1, 2), before[0].takenHistory)
+
+ val after = plan.entries.filter { it.groupId == 1L && it.endDate == null }
+ assertEquals(1, after.size)
+ assertEquals(today.plusDays(1), after[0].creationDate)
+ assertEquals(hist(20), after[0].takenHistory)
+ }
+
+ @Test
+ fun `only-this-event edit on never-taken med produces no phantom fragments`() {
+ val request = noopRequest.copy(title = "Renamed", rangeStart = null, rangeEnd = null)
+ val plan = MedUpdatePlanner.plan(request, null, listOf(med(1, history = emptyMap()))) as MedUpdatePlanner.Plan.Rebuild
+
+ assertEquals(1, plan.entries.size) // only the edited day
+ }
+
+ @Test
+ fun `only-this-event edit keeps later records as an after fragment`() {
+ // Med starts today, already logged a future date (Sep 20): the before
+ // fragment is empty and must be dropped, but the Sep 20 record is real
+ // history and must survive as an after fragment.
+ val item = med(1, start = today, history = hist(20))
+ val request = noopRequest.copy(originalItem = item, title = "Renamed", rangeStart = null, rangeEnd = null)
+ val plan = MedUpdatePlanner.plan(request, null, listOf(item)) as MedUpdatePlanner.Plan.Rebuild
+
+ assertEquals(2, plan.entries.size) // edited day + after fragment
+ val after = plan.entries.filter { it.endDate == null }
+ assertEquals(1, after.size)
+ assertEquals(hist(20), after[0].takenHistory)
+ }
+
+ @Test
+ fun `all-following edit keeps before fragment with earlier history only`() {
+ val history = hist(1, 2, 20)
+ val request = noopRequest.copy(title = "Renamed", rangeStart = -1L, rangeEnd = null)
+ val plan = MedUpdatePlanner.plan(request, null, listOf(med(1, history = history))) as MedUpdatePlanner.Plan.Rebuild
+
+ val edited = plan.entries.first { it.groupId == MedUpdatePlanner.NEW_GROUP }
+ assertEquals(today, edited.creationDate)
+ assertNull(edited.endDate)
+ // Everything from editStart onward follows the ongoing entry.
+ assertEquals(hist(20), edited.takenHistory)
+
+ val before = plan.entries.first { it.groupId == 1L }
+ assertEquals(today.minusDays(1), before.endDate)
+ assertEquals(hist(1, 2), before.takenHistory)
+ }
+
+ @Test
+ fun `range edit on never-taken med produces no phantom fragments`() {
+ val request = noopRequest.copy(title = "Renamed", rangeStart = null, rangeEnd = null)
+ val plan = MedUpdatePlanner.plan(request, null, listOf(med(1, history = emptyMap()))) as MedUpdatePlanner.Plan.Rebuild
+
+ assertEquals(1, plan.entries.size)
+ }
+
+ @Test
+ fun `range edit applies supply to the rebuilt window`() {
+ val supply = InventoryEntry(dosesLeft = 7, dosesPerRefill = 30, lowThreshold = 2)
+ val request = noopRequest.copy(title = "Renamed", rangeStart = null, rangeEnd = null)
+ val plan = MedUpdatePlanner.plan(request, supply, listOf(med(1, supplyLeft = 39, history = hist(1)))) as MedUpdatePlanner.Plan.Rebuild
+
+ val edited = plan.entries.first { it.groupId == MedUpdatePlanner.NEW_GROUP }
+ assertEquals(7, edited.supplyDosesLeft)
+ assertFalse(edited.supplyAlertShown)
+ }
+
+ // ------------------------------------------------------ multi-slot range
+
+ @Test
+ fun `range edit covers every slot of a multi-dose med`() {
+ val morning = med(1, time = eightAm, history = hist(1, 5, 14, 20))
+ val evening = med(2, time = LocalTime.of(20, 0), history = hist(2, 5, 14, 20))
+ val request = noopRequest.copy(
+ originalItem = morning,
+ title = "Renamed",
+ times = listOf(eightAm),
+ rangeStart = null,
+ rangeEnd = null
+ )
+
+ val plan = MedUpdatePlanner.plan(request, null, listOf(morning, evening)) as MedUpdatePlanner.Plan.Rebuild
+
+ assertEquals(setOf(1L, 2L), plan.removeIds)
+ val editedSlots = plan.entries.filter { it.groupId == MedUpdatePlanner.NEW_GROUP }
+ assertEquals(2, editedSlots.size)
+ assertEquals(setOf(eightAm, LocalTime.of(20, 0)), editedSlots.map { it.creationTime }.toSet())
+ // Both slots keep their taken record for the edited day.
+ assertTrue(editedSlots.all { it.takenHistory.containsKey(today) })
+ // After-fragments exist for both slots and keep their own later history.
+ val afterFragments = plan.entries.filter { it.groupId == 1L && it.endDate == null }
+ assertEquals(2, afterFragments.size)
+ assertTrue(afterFragments.all { it.takenHistory.containsKey(today.plusDays(1).withDayOfMonth(20)) })
+ }
+
+ // ------------------------------------------------------------ Stats dedupe
+
+ @Test
+ fun `stats dedupes duplicate schedule fragments`() {
+ val original = med(1, history = hist(1))
+ val phantom = med(2) // same title/time, no end date — duplicate fragment
+ val duplicate = med(3)
+ val items = listOf(original, phantom, duplicate, med(4, title = "Other"))
+
+ val scheduled = getScheduledMedsForDate(today, items)
+ assertEquals(
+ listOf("Sertraline" to noon, "Other" to noon),
+ scheduled.map { it.title to it.creationTime }
+ )
+ }
+
+ @Test
+ fun `stats keeps distinct time slots of the same med`() {
+ val morning = med(1, time = eightAm)
+ val evening = med(2, time = LocalTime.of(20, 0))
+ val scheduled = getScheduledMedsForDate(today, listOf(morning, evening))
+ assertEquals(2, scheduled.size)
+ }
+}
diff --git a/local.properties b/local.properties
deleted file mode 100644
index 7f199d3..0000000
--- a/local.properties
+++ /dev/null
@@ -1,8 +0,0 @@
-## This file must *NOT* be checked into Version Control Systems,
-# as it contains information specific to your local configuration.
-#
-# Location of the SDK. This is only used by Gradle.
-# For customization when using a Version Control System, please read the
-# header note.
-#Wed Feb 25 14:59:44 CET 2026
-sdk.dir=/home/fede/Coding/Android/sdk
\ No newline at end of file