From f2e1f0942f13a3ab140a09cc458de665433a5554 Mon Sep 17 00:00:00 2001 From: nufuturepro Date: Mon, 14 Sep 2026 23:23:05 -0400 Subject: [PATCH] Universal data portability, supply tracking, and alarm fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds JSON/CSV export+import, per-medication supply tracking with low-supply alerts, a lowest-supply home-screen widget, and hardens every edit path so taken-dose history can never be silently destroyed (no-op guard, sibling-slot preservation, history-follows-time-slot, phantom-fragment dropping) — covered by an 18-test JVM suite (MedUpdatePlanner). Stats counts each med+time once per day, illness renames keep their notes, and dose alarms re-arm on app start. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 11 + .../med/AdvancedSettingsActivity.kt | 135 +++++++- .../MainActivity/MedicineBottomSheet.kt | 170 +++++++++- .../med/elements/MainActivity/Tabs/Stats.kt | 6 +- .../med/elements/MainActivity/UI.kt | 74 ++++- .../med/services/AppWearListenerService.kt | 2 + .../med/services/CsvPortability.kt | 210 +++++++++++++ .../com/fedeveloper95/med/services/Data.kt | 266 ++++++++++------ .../med/services/InventoryService.kt | 137 ++++++++ .../com/fedeveloper95/med/services/MedApp.kt | 10 +- .../med/services/MedSupplyWidgetProvider.kt | 112 +++++++ .../med/services/MedUpdatePlanner.kt | 222 +++++++++++++ .../med/services/NotificationReciver.kt | 26 +- .../main/res/drawable/widget_background.xml | 6 + app/src/main/res/layout/med_supply_widget.xml | 66 ++++ app/src/main/res/values/colors.xml | 4 +- app/src/main/res/values/strings.xml | 36 ++- .../main/res/xml/med_supply_widget_info.xml | 10 + .../med/services/MedUpdatePlannerTest.kt | 295 ++++++++++++++++++ local.properties | 8 - 21 files changed, 1662 insertions(+), 145 deletions(-) create mode 100644 app/src/main/kotlin/com/fedeveloper95/med/services/CsvPortability.kt create mode 100644 app/src/main/kotlin/com/fedeveloper95/med/services/InventoryService.kt create mode 100644 app/src/main/kotlin/com/fedeveloper95/med/services/MedSupplyWidgetProvider.kt create mode 100644 app/src/main/kotlin/com/fedeveloper95/med/services/MedUpdatePlanner.kt create mode 100644 app/src/main/res/drawable/widget_background.xml create mode 100644 app/src/main/res/layout/med_supply_widget.xml create mode 100644 app/src/main/res/xml/med_supply_widget_info.xml create mode 100644 app/src/test/kotlin/com/fedeveloper95/med/services/MedUpdatePlannerTest.kt delete mode 100644 local.properties 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