fix timezone
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:
ale
2026-03-11 01:02:35 +01:00
padre b3694fae56
commit 16b7892e0a
Se han modificado 5 ficheros con 221 adiciones y 103 borrados

Ver fichero

@@ -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>

Ver fichero

@@ -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,112 +52,23 @@ 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")
return@launch
}
val tasks = repository.tasks.first() val tasks = repository.tasks.first()
tasks.filter { it.isActive }.forEach { task -> ReminderScheduler.scheduleAll(applicationContext, tasks)
scheduleRemindersForTask(task)
} }
} }
} }
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
fun MotivameApp(onRescheduleReminders: (Boolean) -> Unit = {}) { fun MotivameApp(onRescheduleReminders: (Boolean) -> Unit = {}) {
val viewModel: TaskViewModel = viewModel() val viewModel: TaskViewModel = viewModel()

Ver fichero

@@ -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)
}
}
}

Ver fichero

@@ -15,6 +15,9 @@ class DailyReminderWorker(
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()
} }
} }

Ver fichero

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