Comparar commits
1 Commits
| Autor | SHA1 | Fecha | |
|---|---|---|---|
|
16b7892e0a
|
@@ -41,6 +41,16 @@
|
|||||||
android:resource="@xml/motivame_widget_info" />
|
android:resource="@xml/motivame_widget_info" />
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<!-- Reprogramar recordatorios tras reinicio del dispositivo o actualización de la app -->
|
||||||
|
<receiver
|
||||||
|
android:name=".receiver.BootReceiver"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -9,7 +9,7 @@ import androidx.activity.enableEdgeToEdge
|
|||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.work.*
|
import androidx.work.WorkManager
|
||||||
import com.manalejandro.motivame.data.Task
|
import com.manalejandro.motivame.data.Task
|
||||||
import com.manalejandro.motivame.data.TaskRepository
|
import com.manalejandro.motivame.data.TaskRepository
|
||||||
import com.manalejandro.motivame.ui.screens.AddTaskScreen
|
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.theme.MotivameTheme
|
||||||
import com.manalejandro.motivame.ui.viewmodel.TaskViewModel
|
import com.manalejandro.motivame.ui.viewmodel.TaskViewModel
|
||||||
import com.manalejandro.motivame.util.LocaleHelper
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.Calendar
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import kotlin.random.Random
|
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
@@ -55,110 +52,21 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancela todos los workers anteriores y programa nuevos recordatorios
|
* Cancela todos los workers anteriores y programa nuevos recordatorios
|
||||||
* para cada tarea activa, distribuyendo los avisos entre las 9:00 y las 21:00.
|
* para cada tarea activa usando [ReminderScheduler].
|
||||||
* @param notificationsEnabled valor ya conocido, para evitar condición de carrera con DataStore.
|
* @param notificationsEnabled valor ya conocido; si es null se lee de DataStore.
|
||||||
* Si es null, se lee del DataStore (solo al arrancar la app).
|
|
||||||
*/
|
*/
|
||||||
fun scheduleAllReminders(notificationsEnabled: Boolean? = null) {
|
fun scheduleAllReminders(notificationsEnabled: Boolean? = null) {
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
val repository = TaskRepository(applicationContext)
|
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()
|
val enabled = notificationsEnabled ?: repository.notificationEnabled.first()
|
||||||
if (!enabled) return@launch
|
if (!enabled) {
|
||||||
|
WorkManager.getInstance(applicationContext).cancelAllWorkByTag("task_reminder")
|
||||||
val tasks = repository.tasks.first()
|
return@launch
|
||||||
tasks.filter { it.isActive }.forEach { task ->
|
|
||||||
scheduleRemindersForTask(task)
|
|
||||||
}
|
}
|
||||||
|
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<Int>()
|
|
||||||
val minuteAssignments = mutableListOf<Int>()
|
|
||||||
|
|
||||||
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<DailyReminderWorker>()
|
|
||||||
.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
|
@Composable
|
||||||
|
|||||||
35
app/src/main/java/com/manalejandro/motivame/receiver/BootReceiver.kt
Archivo normal
35
app/src/main/java/com/manalejandro/motivame/receiver/BootReceiver.kt
Archivo normal
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -14,7 +14,10 @@ class DailyReminderWorker(
|
|||||||
) : CoroutineWorker(context, params) {
|
) : CoroutineWorker(context, params) {
|
||||||
|
|
||||||
companion object {
|
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 {
|
override suspend fun doWork(): Result {
|
||||||
@@ -37,6 +40,27 @@ class DailyReminderWorker(
|
|||||||
|
|
||||||
taskToNotify?.let {
|
taskToNotify?.let {
|
||||||
notificationHelper.sendTaskReminder(it, soundEnabled)
|
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
|
// Refrescar el widget con la meta actualizada
|
||||||
@@ -45,4 +69,3 @@ class DailyReminderWorker(
|
|||||||
return Result.success()
|
return Result.success()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
142
app/src/main/java/com/manalejandro/motivame/worker/ReminderScheduler.kt
Archivo normal
142
app/src/main/java/com/manalejandro/motivame/worker/ReminderScheduler.kt
Archivo normal
@@ -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<DailyReminderWorker>()
|
||||||
|
.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<Task>) {
|
||||||
|
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<Int>()
|
||||||
|
val minuteAssignments = mutableListOf<Int>()
|
||||||
|
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<DailyReminderWorker>()
|
||||||
|
.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Referencia en una nueva incidencia
Block a user