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 + } +} + +