diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0f517d7..af122ed 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -41,6 +41,16 @@
android:resource="@xml/motivame_widget_info" />
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/manalejandro/motivame/MainActivity.kt b/app/src/main/java/com/manalejandro/motivame/MainActivity.kt
index 88c092f..8c9dd0c 100644
--- a/app/src/main/java/com/manalejandro/motivame/MainActivity.kt
+++ b/app/src/main/java/com/manalejandro/motivame/MainActivity.kt
@@ -9,7 +9,7 @@ import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.viewModel
-import androidx.work.*
+import androidx.work.WorkManager
import com.manalejandro.motivame.data.Task
import com.manalejandro.motivame.data.TaskRepository
import com.manalejandro.motivame.ui.screens.AddTaskScreen
@@ -18,14 +18,11 @@ import com.manalejandro.motivame.ui.screens.SettingsScreen
import com.manalejandro.motivame.ui.theme.MotivameTheme
import com.manalejandro.motivame.ui.viewmodel.TaskViewModel
import com.manalejandro.motivame.util.LocaleHelper
-import com.manalejandro.motivame.worker.DailyReminderWorker
+import com.manalejandro.motivame.worker.ReminderScheduler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
-import java.util.Calendar
-import java.util.concurrent.TimeUnit
-import kotlin.random.Random
class MainActivity : ComponentActivity() {
@@ -55,110 +52,21 @@ class MainActivity : ComponentActivity() {
/**
* Cancela todos los workers anteriores y programa nuevos recordatorios
- * para cada tarea activa, distribuyendo los avisos entre las 9:00 y las 21:00.
- * @param notificationsEnabled valor ya conocido, para evitar condición de carrera con DataStore.
- * Si es null, se lee del DataStore (solo al arrancar la app).
+ * para cada tarea activa usando [ReminderScheduler].
+ * @param notificationsEnabled valor ya conocido; si es null se lee de DataStore.
*/
fun scheduleAllReminders(notificationsEnabled: Boolean? = null) {
CoroutineScope(Dispatchers.IO).launch {
val repository = TaskRepository(applicationContext)
-
- // Cancelar todos los workers existentes de recordatorios de tareas
- WorkManager.getInstance(applicationContext)
- .cancelAllWorkByTag("task_reminder")
-
val enabled = notificationsEnabled ?: repository.notificationEnabled.first()
- if (!enabled) return@launch
-
- val tasks = repository.tasks.first()
- tasks.filter { it.isActive }.forEach { task ->
- scheduleRemindersForTask(task)
+ if (!enabled) {
+ WorkManager.getInstance(applicationContext).cancelAllWorkByTag("task_reminder")
+ return@launch
}
+ val tasks = repository.tasks.first()
+ ReminderScheduler.scheduleAll(applicationContext, tasks)
}
}
-
- private fun scheduleRemindersForTask(task: Task) {
- val reminders = task.dailyReminders.coerceIn(1, 10)
- val cycleDays = task.repeatEveryDays.coerceIn(1, 30)
- val workManager = WorkManager.getInstance(applicationContext)
-
- // Ventana de notificaciones: 9:00 a 21:00 (720 minutos disponibles)
- val windowStartMinute = 9 * 60 // 540
- val windowEndMinute = 21 * 60 // 1260
- val windowSize = windowEndMinute - windowStartMinute // 720
-
- // Distribuir los N avisos en días distintos dentro del ciclo.
- // Si reminders <= cycleDays cada aviso va a un día diferente;
- // si hay más avisos que días, se reparten de forma ciclica.
- val dayAssignments = (0 until reminders).map { i -> i % cycleDays }
-
- // Generar horas aleatorias únicas (en minutos desde medianoche)
- // Para cada aviso elegimos un minuto al azar dentro de [540, 1260)
- // asegurándonos de que no coincida con ningún otro aviso ya asignado.
- val usedMinutes = mutableSetOf()
- val minuteAssignments = mutableListOf()
-
- repeat(reminders) {
- var candidate: Int
- var attempts = 0
- do {
- candidate = windowStartMinute + Random.nextInt(windowSize)
- attempts++
- // Tras muchos intentos (espacio muy saturado) relajamos la condición
- // exigiendo sólo minutos distintos en el mismo día
- } while (usedMinutes.contains(candidate) && attempts < windowSize)
- usedMinutes.add(candidate)
- minuteAssignments.add(candidate)
- }
-
- for (i in 0 until reminders) {
- val dayOffset = dayAssignments[i]
- val totalMinutes = minuteAssignments[i]
- val targetHour = totalMinutes / 60
- val targetMinute = totalMinutes % 60
-
- val delayMs = calculateDelayToTimeWithDayOffset(targetHour, targetMinute, dayOffset)
-
- val inputData = workDataOf(DailyReminderWorker.KEY_TASK_ID to task.id)
-
- val workRequest = OneTimeWorkRequestBuilder()
- .setInitialDelay(delayMs, TimeUnit.MILLISECONDS)
- .setInputData(inputData)
- .addTag("task_reminder")
- .addTag("task_${task.id}")
- .build()
-
- workManager.enqueue(workRequest)
- }
- }
-
-
- /**
- * Calcula el retardo hasta la hora indicada más un desplazamiento de días.
- * Si la hora ya pasó hoy, se mueve al día siguiente antes de aplicar el offset.
- */
- private fun calculateDelayToTimeWithDayOffset(hour: Int, minute: Int, dayOffset: Int): Long {
- val now = System.currentTimeMillis()
- val calendar = Calendar.getInstance().apply {
- timeInMillis = now
- set(Calendar.HOUR_OF_DAY, hour)
- set(Calendar.MINUTE, minute)
- set(Calendar.SECOND, 0)
- set(Calendar.MILLISECOND, 0)
- }
-
- // Si ya pasó esa hora hoy, mover a mañana antes de aplicar el offset
- if (calendar.timeInMillis <= now) {
- calendar.add(Calendar.DAY_OF_YEAR, 1)
- }
-
- // Aplicar el offset de días adicionales
- if (dayOffset > 0) {
- calendar.add(Calendar.DAY_OF_YEAR, dayOffset)
- }
-
- return calendar.timeInMillis - now
- }
}
@Composable
diff --git a/app/src/main/java/com/manalejandro/motivame/receiver/BootReceiver.kt b/app/src/main/java/com/manalejandro/motivame/receiver/BootReceiver.kt
new file mode 100644
index 0000000..b68ffd9
--- /dev/null
+++ b/app/src/main/java/com/manalejandro/motivame/receiver/BootReceiver.kt
@@ -0,0 +1,35 @@
+package com.manalejandro.motivame.receiver
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import com.manalejandro.motivame.data.TaskRepository
+import com.manalejandro.motivame.worker.ReminderScheduler
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+
+/**
+ * Reprograma todos los recordatorios activos cuando el dispositivo arranca
+ * o cuando se actualiza la app (acción MY_PACKAGE_REPLACED).
+ */
+class BootReceiver : BroadcastReceiver() {
+
+ override fun onReceive(context: Context, intent: Intent) {
+ val action = intent.action ?: return
+ if (action != Intent.ACTION_BOOT_COMPLETED &&
+ action != Intent.ACTION_MY_PACKAGE_REPLACED
+ ) return
+
+ CoroutineScope(Dispatchers.IO).launch {
+ val repository = TaskRepository(context)
+ val enabled = repository.notificationEnabled.first()
+ if (!enabled) return@launch
+
+ val tasks = repository.tasks.first()
+ ReminderScheduler.scheduleAll(context, tasks)
+ }
+ }
+}
+
diff --git a/app/src/main/java/com/manalejandro/motivame/worker/DailyReminderWorker.kt b/app/src/main/java/com/manalejandro/motivame/worker/DailyReminderWorker.kt
index 864492a..76e9863 100644
--- a/app/src/main/java/com/manalejandro/motivame/worker/DailyReminderWorker.kt
+++ b/app/src/main/java/com/manalejandro/motivame/worker/DailyReminderWorker.kt
@@ -14,7 +14,10 @@ class DailyReminderWorker(
) : CoroutineWorker(context, params) {
companion object {
- const val KEY_TASK_ID = "task_id"
+ const val KEY_TASK_ID = "task_id"
+ const val KEY_SCHEDULE_HOUR = "schedule_hour"
+ const val KEY_SCHEDULE_MINUTE = "schedule_minute"
+ const val KEY_CYCLE_DAYS = "cycle_days"
}
override suspend fun doWork(): Result {
@@ -37,6 +40,27 @@ class DailyReminderWorker(
taskToNotify?.let {
notificationHelper.sendTaskReminder(it, soundEnabled)
+
+ // ✅ Auto-reprogramar este mismo aviso para el siguiente ciclo
+ val hour = inputData.getInt(KEY_SCHEDULE_HOUR, -1)
+ val minute = inputData.getInt(KEY_SCHEDULE_MINUTE, -1)
+ val cycleDays = inputData.getInt(KEY_CYCLE_DAYS, it.repeatEveryDays)
+
+ if (hour >= 0 && minute >= 0) {
+ // El siguiente disparo es exactamente [cycleDays] días después,
+ // a la misma hora local — se calcula con dayOffset = 0 porque
+ // la hora ya quedó en el futuro (hoy+cycleDays).
+ val delayMs = ReminderScheduler.calculateDelay(hour, minute, cycleDays)
+ ReminderScheduler.enqueueOne(
+ context = applicationContext,
+ taskId = it.id,
+ hour = hour,
+ minute = minute,
+ cycleDays = cycleDays,
+ dayOffset = 0,
+ delayMs = delayMs
+ )
+ }
}
// Refrescar el widget con la meta actualizada
@@ -45,4 +69,3 @@ class DailyReminderWorker(
return Result.success()
}
}
-
diff --git a/app/src/main/java/com/manalejandro/motivame/worker/ReminderScheduler.kt b/app/src/main/java/com/manalejandro/motivame/worker/ReminderScheduler.kt
new file mode 100644
index 0000000..1d26ef1
--- /dev/null
+++ b/app/src/main/java/com/manalejandro/motivame/worker/ReminderScheduler.kt
@@ -0,0 +1,142 @@
+package com.manalejandro.motivame.worker
+
+import android.content.Context
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.workDataOf
+import com.manalejandro.motivame.data.Task
+import java.util.Calendar
+import java.util.TimeZone
+import java.util.concurrent.TimeUnit
+import kotlin.random.Random
+
+/**
+ * Centraliza toda la lógica para programar/cancelar recordatorios con WorkManager.
+ * Usa siempre la zona horaria local del dispositivo de forma explícita.
+ */
+object ReminderScheduler {
+
+ // Ventana de notificaciones: 9:00 a 21:00 (hora local)
+ private const val WINDOW_START_MINUTE = 9 * 60 // 540
+ private const val WINDOW_END_MINUTE = 21 * 60 // 1260
+ private const val WINDOW_SIZE = WINDOW_END_MINUTE - WINDOW_START_MINUTE // 720
+
+ /** Encola un único recordatorio con los parámetros exactos dados. */
+ fun enqueueOne(
+ context: Context,
+ taskId: String,
+ hour: Int,
+ minute: Int,
+ cycleDays: Int,
+ dayOffset: Int,
+ delayMs: Long
+ ) {
+ val inputData = workDataOf(
+ DailyReminderWorker.KEY_TASK_ID to taskId,
+ DailyReminderWorker.KEY_SCHEDULE_HOUR to hour,
+ DailyReminderWorker.KEY_SCHEDULE_MINUTE to minute,
+ DailyReminderWorker.KEY_CYCLE_DAYS to cycleDays
+ )
+ val workRequest = OneTimeWorkRequestBuilder()
+ .setInitialDelay(delayMs, TimeUnit.MILLISECONDS)
+ .setInputData(inputData)
+ .addTag("task_reminder")
+ .addTag("task_$taskId")
+ .build()
+ WorkManager.getInstance(context).enqueue(workRequest)
+ }
+
+ /** Cancela todos los workers existentes y programa nuevos para cada tarea activa. */
+ fun scheduleAll(context: Context, tasks: List) {
+ val workManager = WorkManager.getInstance(context)
+ workManager.cancelAllWorkByTag("task_reminder")
+ tasks.filter { it.isActive }.forEach { task ->
+ scheduleForTask(context, task)
+ }
+ }
+
+ /** Programa todos los recordatorios para una tarea concreta. */
+ fun scheduleForTask(context: Context, task: Task) {
+ val reminders = task.dailyReminders.coerceIn(1, 10)
+ val cycleDays = task.repeatEveryDays.coerceIn(1, 30)
+ val workManager = WorkManager.getInstance(context)
+
+ // Distribuir N avisos en días del ciclo (uno por día si reminders ≤ cycleDays)
+ val dayAssignments = (0 until reminders).map { i -> i % cycleDays }
+
+ // Generar horas aleatorias únicas dentro de la ventana [9:00, 21:00)
+ val usedMinutes = mutableSetOf()
+ val minuteAssignments = mutableListOf()
+ repeat(reminders) {
+ var candidate: Int
+ var attempts = 0
+ do {
+ candidate = WINDOW_START_MINUTE + Random.nextInt(WINDOW_SIZE)
+ attempts++
+ } while (usedMinutes.contains(candidate) && attempts < WINDOW_SIZE)
+ usedMinutes.add(candidate)
+ minuteAssignments.add(candidate)
+ }
+
+ for (i in 0 until reminders) {
+ val dayOffset = dayAssignments[i]
+ val totalMinutes = minuteAssignments[i]
+ val targetHour = totalMinutes / 60
+ val targetMinute = totalMinutes % 60
+
+ val delayMs = calculateDelay(targetHour, targetMinute, dayOffset)
+
+ val inputData = workDataOf(
+ DailyReminderWorker.KEY_TASK_ID to task.id,
+ DailyReminderWorker.KEY_SCHEDULE_HOUR to targetHour,
+ DailyReminderWorker.KEY_SCHEDULE_MINUTE to targetMinute,
+ DailyReminderWorker.KEY_CYCLE_DAYS to cycleDays
+ )
+
+ val workRequest = OneTimeWorkRequestBuilder()
+ .setInitialDelay(delayMs, TimeUnit.MILLISECONDS)
+ .setInputData(inputData)
+ .addTag("task_reminder")
+ .addTag("task_${task.id}")
+ .build()
+
+ workManager.enqueue(workRequest)
+ }
+ }
+
+ /**
+ * Calcula el retardo (ms) hasta la hora indicada en HORA LOCAL,
+ * usando TimeZone.getDefault() de forma explícita para evitar
+ * que el contexto del hilo de background use UTC u otra zona.
+ *
+ * Si la hora ya pasó hoy, se avanza al día siguiente y luego
+ * se aplica el [dayOffset] adicional del ciclo.
+ */
+ fun calculateDelay(hour: Int, minute: Int, dayOffset: Int): Long {
+ val now = System.currentTimeMillis()
+
+ // ✅ Zona horaria local explícita — clave para que funcione
+ // correctamente desde hilos de background y tras reinicio.
+ val calendar = Calendar.getInstance(TimeZone.getDefault()).apply {
+ timeInMillis = now
+ set(Calendar.HOUR_OF_DAY, hour)
+ set(Calendar.MINUTE, minute)
+ set(Calendar.SECOND, 0)
+ set(Calendar.MILLISECOND, 0)
+ }
+
+ // Si esa hora ya pasó hoy, mover al día siguiente
+ if (calendar.timeInMillis <= now) {
+ calendar.add(Calendar.DAY_OF_YEAR, 1)
+ }
+
+ // Desplazamiento adicional del ciclo
+ if (dayOffset > 0) {
+ calendar.add(Calendar.DAY_OF_YEAR, dayOffset)
+ }
+
+ return calendar.timeInMillis - now
+ }
+}
+
+