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