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/UI.kt b/app/src/main/kotlin/com/fedeveloper95/med/elements/MainActivity/UI.kt
index 224aa87..702d526 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
@@ -36,6 +36,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
@@ -280,6 +281,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 =
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..7bcf4c3 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,7 +274,13 @@ 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()
val filter = IntentFilter("com.fedeveloper95.med.RELOAD_DATA")
@@ -512,7 +540,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 +631,7 @@ class MedViewModel(application: Application) : AndroidViewModel(application) {
_items.add(newItem)
if (type == ItemType.Medicine) {
NotificationReceiver.scheduleNotification(getApplication(), newItem)
+ applySupplySettings(newItem, supply)
}
}
saveData()
@@ -618,7 +648,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)
@@ -656,6 +687,9 @@ class MedViewModel(application: Application) : AndroidViewModel(application) {
if (rangeEnd != null && rangeEnd != -2L) LocalDate.ofEpochDay(rangeEnd / 86400000) else editStart
}
+ // Entries being replaced get new IDs below — cancel alerts keyed to
+ // the old IDs so no orphaned low-supply notification survives.
+ relatedItems.forEach { InventoryService.cancelLowSupplyNotification(context, it) }
_items.removeAll { it.id in relatedIds }
val baseNewItem = originalItem.copy(
@@ -666,7 +700,11 @@ class MedViewModel(application: Application) : AndroidViewModel(application) {
notes = notes,
intervalGap = intervalGap,
notificationType = notificationType,
- frequencyLabel = freqLabel
+ frequencyLabel = freqLabel,
+ supplyDosesLeft = supply?.dosesLeft,
+ supplyDosesPerRefill = supply?.dosesPerRefill?.takeIf { it > 0 },
+ supplyLowThreshold = supply?.lowThreshold,
+ supplyAlertShown = false
)
relatedItems.forEachIndexed { i, oldItem ->
@@ -717,6 +755,9 @@ class MedViewModel(application: Application) : AndroidViewModel(application) {
return
}
+ // Non-range edits also recreate entries with new IDs — cancel alerts
+ // keyed to the old IDs so no orphaned low-supply notification survives.
+ relatedItems.forEach { InventoryService.cancelLowSupplyNotification(context, it) }
_items.removeAll { it.id in relatedIds }
val newGroupId = System.currentTimeMillis()
@@ -733,7 +774,11 @@ class MedViewModel(application: Application) : AndroidViewModel(application) {
notes = notes,
intervalGap = intervalGap,
notificationType = notificationType,
- frequencyLabel = freqLabel
+ frequencyLabel = freqLabel,
+ supplyDosesLeft = supply?.dosesLeft,
+ supplyDosesPerRefill = supply?.dosesPerRefill?.takeIf { it > 0 },
+ supplyLowThreshold = supply?.lowThreshold,
+ supplyAlertShown = false
)
_items.add(newItem)
if (newItem.type == ItemType.Medicine) {
@@ -741,6 +786,14 @@ class MedViewModel(application: Application) : AndroidViewModel(application) {
}
}
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 +855,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 +922,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..78b5fad 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
},
@@ -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/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..365a236 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,18 @@
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).
+
\ 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/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