fix timezone
Algunas comprobaciones han fallado
Build & Publish APK Release / build (push) Failing after 59s
Algunas comprobaciones han fallado
Build & Publish APK Release / build (push) Failing after 59s
Signed-off-by: ale <ale@manalejandro.com>
Este commit está contenido en:
@@ -41,6 +41,16 @@
|
||||
android:resource="@xml/motivame_widget_info" />
|
||||
</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>
|
||||
|
||||
</manifest>
|
||||
@@ -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<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
|
||||
|
||||
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) {
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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