From e7606df296c44289c22f4a0915bec7bb3c8ce23f Mon Sep 17 00:00:00 2001 From: ale Date: Sat, 28 Feb 2026 09:04:32 +0100 Subject: [PATCH] more entropy Signed-off-by: ale --- .../com/manalejandro/motivame/MainActivity.kt | 137 +++++++++++--- .../com/manalejandro/motivame/data/Task.kt | 4 +- .../motivame/data/TaskRepository.kt | 6 +- .../motivame/ui/screens/AddTaskScreen.kt | 179 +++++++++++++++++- .../motivame/ui/viewmodel/TaskViewModel.kt | 7 + .../motivame/worker/DailyReminderWorker.kt | 22 ++- 6 files changed, 315 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/com/manalejandro/motivame/MainActivity.kt b/app/src/main/java/com/manalejandro/motivame/MainActivity.kt index 4b1423f..2df5f4a 100644 --- a/app/src/main/java/com/manalejandro/motivame/MainActivity.kt +++ b/app/src/main/java/com/manalejandro/motivame/MainActivity.kt @@ -7,12 +7,19 @@ import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.* import androidx.lifecycle.viewmodel.compose.viewModel import androidx.work.* +import com.manalejandro.motivame.data.Task +import com.manalejandro.motivame.data.TaskRepository import com.manalejandro.motivame.ui.screens.AddTaskScreen import com.manalejandro.motivame.ui.screens.MainScreen 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.worker.DailyReminderWorker +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 class MainActivity : ComponentActivity() { @@ -20,57 +27,129 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) enableEdgeToEdge() - // Configurar recordatorios diarios - setupDailyReminders() + // Programar recordatorios para todas las tareas activas + scheduleAllReminders() setContent { MotivameTheme { - MotivameApp() + MotivameApp(onRescheduleReminders = { scheduleAllReminders() }) } } } - private fun setupDailyReminders() { - val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.NOT_REQUIRED) - .build() + /** + * Cancela todos los workers anteriores y programa nuevos recordatorios + * para cada tarea activa, distribuyendo los avisos entre las 9:00 y las 21:00. + */ + fun scheduleAllReminders() { + CoroutineScope(Dispatchers.IO).launch { + val repository = TaskRepository(applicationContext) + val tasks = repository.tasks.first() + val notificationEnabled = repository.notificationEnabled.first() - val dailyWorkRequest = PeriodicWorkRequestBuilder( - 1, TimeUnit.DAYS - ) - .setConstraints(constraints) - .setInitialDelay(calculateInitialDelay(), TimeUnit.MILLISECONDS) - .build() + // Cancelar todos los workers existentes de recordatorios de tareas + WorkManager.getInstance(applicationContext) + .cancelAllWorkByTag("task_reminder") - WorkManager.getInstance(applicationContext).enqueueUniquePeriodicWork( - "daily_reminder", - ExistingPeriodicWorkPolicy.KEEP, - dailyWorkRequest - ) + if (!notificationEnabled) return@launch + + tasks.filter { it.isActive }.forEach { task -> + scheduleRemindersForTask(task) + } + } } - private fun calculateInitialDelay(): Long { - val currentTime = System.currentTimeMillis() - val calendar = java.util.Calendar.getInstance().apply { - timeInMillis = currentTime - set(java.util.Calendar.HOUR_OF_DAY, 9) // 9 AM - set(java.util.Calendar.MINUTE, 0) - set(java.util.Calendar.SECOND, 0) + 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 (12 horas = 720 minutos) + val windowStartHour = 9 + val windowEndHour = 21 + val windowMinutes = (windowEndHour - windowStartHour) * 60 // 720 min + + // Distribuir los N avisos a lo largo del ciclo de 'cycleDays' días, + // repartidos uniformemente para que no coincidan todos el mismo día. + // Cada aviso cae en un día y hora distintos dentro del ciclo. + val totalSlots = cycleDays // un aviso por día máximo + val step = totalSlots.toDouble() / reminders // paso fraccionario entre avisos + + for (i in 0 until reminders) { + // Día dentro del ciclo (0-based), distribuido uniformemente + val slotIndex = (i * step).toInt() + val dayOffset = slotIndex % cycleDays + + // Hora dentro de la ventana: distribuida para que los avisos del mismo día + // no se solapen, o usando posición i para variar la hora entre días + val offsetMinutes = if (reminders == 1) { + windowMinutes / 2 // Al mediodía si solo hay 1 aviso + } else { + ((windowMinutes * i) / reminders).coerceIn(0, windowMinutes - 30) + } + + val targetHour = windowStartHour + offsetMinutes / 60 + val targetMinute = offsetMinutes % 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 en milisegundos hasta la próxima ocurrencia de la hora indicada. + */ + private fun calculateDelayToTime(hour: Int, minute: Int): Long = + calculateDelayToTimeWithDayOffset(hour, minute, 0) + + /** + * 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) } - if (calendar.timeInMillis <= currentTime) { - calendar.add(java.util.Calendar.DAY_OF_YEAR, 1) + // 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) } - return calendar.timeInMillis - currentTime + // Aplicar el offset de días adicionales + if (dayOffset > 0) { + calendar.add(Calendar.DAY_OF_YEAR, dayOffset) + } + + return calendar.timeInMillis - now } } @Composable -fun MotivameApp() { +fun MotivameApp(onRescheduleReminders: () -> Unit = {}) { val viewModel: TaskViewModel = viewModel() var currentScreen by remember { mutableStateOf("main") } + // Registrar callback para reprogramar avisos cuando cambian las tareas + LaunchedEffect(viewModel) { + viewModel.onRescheduleReminders = onRescheduleReminders + } + when (currentScreen) { "main" -> MainScreen( viewModel = viewModel, diff --git a/app/src/main/java/com/manalejandro/motivame/data/Task.kt b/app/src/main/java/com/manalejandro/motivame/data/Task.kt index 02f0499..4f83fdb 100644 --- a/app/src/main/java/com/manalejandro/motivame/data/Task.kt +++ b/app/src/main/java/com/manalejandro/motivame/data/Task.kt @@ -7,6 +7,8 @@ data class Task( val title: String, val goals: List, val isActive: Boolean = true, - val createdAt: Long = System.currentTimeMillis() + val createdAt: Long = System.currentTimeMillis(), + val dailyReminders: Int = 3, // Número de avisos por día (entre 9:00 y 21:00) + val repeatEveryDays: Int = 3 // Cada cuántos días se repite el ciclo de avisos ) diff --git a/app/src/main/java/com/manalejandro/motivame/data/TaskRepository.kt b/app/src/main/java/com/manalejandro/motivame/data/TaskRepository.kt index 04d4212..6d52180 100644 --- a/app/src/main/java/com/manalejandro/motivame/data/TaskRepository.kt +++ b/app/src/main/java/com/manalejandro/motivame/data/TaskRepository.kt @@ -119,6 +119,8 @@ class TaskRepository(private val context: Context) { put("goals", JSONArray(task.goals)) put("isActive", task.isActive) put("createdAt", task.createdAt) + put("dailyReminders", task.dailyReminders) + put("repeatEveryDays", task.repeatEveryDays) } jsonArray.put(jsonObject) } @@ -140,7 +142,9 @@ class TaskRepository(private val context: Context) { title = jsonObject.getString("title"), goals = goals, isActive = jsonObject.getBoolean("isActive"), - createdAt = jsonObject.getLong("createdAt") + createdAt = jsonObject.getLong("createdAt"), + dailyReminders = if (jsonObject.has("dailyReminders")) jsonObject.getInt("dailyReminders") else 3, + repeatEveryDays = if (jsonObject.has("repeatEveryDays")) jsonObject.getInt("repeatEveryDays") else 3 ) } } catch (e: Exception) { diff --git a/app/src/main/java/com/manalejandro/motivame/ui/screens/AddTaskScreen.kt b/app/src/main/java/com/manalejandro/motivame/ui/screens/AddTaskScreen.kt index 3b18fb0..a408345 100644 --- a/app/src/main/java/com/manalejandro/motivame/ui/screens/AddTaskScreen.kt +++ b/app/src/main/java/com/manalejandro/motivame/ui/screens/AddTaskScreen.kt @@ -1,6 +1,5 @@ package com.manalejandro.motivame.ui.screens -import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed @@ -26,6 +25,8 @@ fun AddTaskScreen( var taskTitle by remember { mutableStateOf("") } var currentGoal by remember { mutableStateOf("") } var goals by remember { mutableStateOf(listOf()) } + var dailyReminders by remember { mutableStateOf(3) } + var repeatEveryDays by remember { mutableStateOf(3) } Scaffold( topBar = { @@ -181,6 +182,166 @@ fun AddTaskScreen( } } + item { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "🔔 Avisos diarios", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Número de recordatorios entre las 9:00 y las 21:00", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = { if (dailyReminders > 1) dailyReminders-- }, + enabled = dailyReminders > 1 + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = "Reducir", + tint = if (dailyReminders > 1) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "$dailyReminders", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = if (dailyReminders == 1) "aviso" else "avisos", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + IconButton( + onClick = { if (dailyReminders < 10) dailyReminders++ }, + enabled = dailyReminders < 10 + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowUp, + contentDescription = "Aumentar", + tint = if (dailyReminders < 10) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + if (dailyReminders > 1) { + Spacer(modifier = Modifier.height(8.dp)) + val intervalMinutes = 720 / (dailyReminders - 1) + Text( + text = "⏱️ Un aviso cada ${formatInterval(intervalMinutes)} aprox.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + + item { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "📅 Cada cuántos días", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Intervalo de días entre cada ciclo de avisos", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = { if (repeatEveryDays > 1) repeatEveryDays-- }, + enabled = repeatEveryDays > 1 + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = "Reducir días", + tint = if (repeatEveryDays > 1) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "$repeatEveryDays", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = if (repeatEveryDays == 1) "día" else "días", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + IconButton( + onClick = { if (repeatEveryDays < 30) repeatEveryDays++ }, + enabled = repeatEveryDays < 30 + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowUp, + contentDescription = "Aumentar días", + tint = if (repeatEveryDays < 30) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = if (repeatEveryDays == 1) "🔁 Avisos todos los días" + else "🔁 Avisos cada $repeatEveryDays días, repartidos para no coincidir", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + item { Spacer(modifier = Modifier.height(16.dp)) Button( @@ -189,7 +350,9 @@ fun AddTaskScreen( val newTask = Task( title = taskTitle.trim(), goals = goals, - isActive = true + isActive = true, + dailyReminders = dailyReminders, + repeatEveryDays = repeatEveryDays ) viewModel.addTask(newTask) onNavigateBack() @@ -214,6 +377,12 @@ fun AddTaskScreen( } } - - - +private fun formatInterval(minutes: Int): String { + return if (minutes >= 60) { + val h = minutes / 60 + val m = minutes % 60 + if (m == 0) "${h}h" else "${h}h ${m}min" + } else { + "${minutes}min" + } +} diff --git a/app/src/main/java/com/manalejandro/motivame/ui/viewmodel/TaskViewModel.kt b/app/src/main/java/com/manalejandro/motivame/ui/viewmodel/TaskViewModel.kt index 0879267..7a8012e 100644 --- a/app/src/main/java/com/manalejandro/motivame/ui/viewmodel/TaskViewModel.kt +++ b/app/src/main/java/com/manalejandro/motivame/ui/viewmodel/TaskViewModel.kt @@ -23,6 +23,9 @@ class TaskViewModel(application: Application) : AndroidViewModel(application) { private val _soundEnabled = MutableStateFlow(true) val soundEnabled: StateFlow = _soundEnabled.asStateFlow() + /** Callback que se invoca tras cualquier cambio en las tareas para reprogramar avisos */ + var onRescheduleReminders: (() -> Unit)? = null + init { loadTasks() loadSettings() @@ -52,18 +55,21 @@ class TaskViewModel(application: Application) : AndroidViewModel(application) { fun addTask(task: Task) { viewModelScope.launch { repository.addTask(task) + onRescheduleReminders?.invoke() } } fun updateTask(task: Task) { viewModelScope.launch { repository.updateTask(task) + onRescheduleReminders?.invoke() } } fun deleteTask(taskId: String) { viewModelScope.launch { repository.deleteTask(taskId) + onRescheduleReminders?.invoke() } } @@ -71,6 +77,7 @@ class TaskViewModel(application: Application) : AndroidViewModel(application) { viewModelScope.launch { repository.setNotificationEnabled(enabled) _notificationEnabled.value = enabled + onRescheduleReminders?.invoke() } } 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 2be3ecf..00f897a 100644 --- a/app/src/main/java/com/manalejandro/motivame/worker/DailyReminderWorker.kt +++ b/app/src/main/java/com/manalejandro/motivame/worker/DailyReminderWorker.kt @@ -12,16 +12,30 @@ class DailyReminderWorker( params: WorkerParameters ) : CoroutineWorker(context, params) { + companion object { + const val KEY_TASK_ID = "task_id" + } + override suspend fun doWork(): Result { val repository = TaskRepository(applicationContext) val notificationHelper = NotificationHelper(applicationContext) - val tasks = repository.tasks.first() val notificationEnabled = repository.notificationEnabled.first() - val soundEnabled = repository.soundEnabled.first() + if (!notificationEnabled) return Result.success() - if (notificationEnabled && tasks.isNotEmpty()) { - notificationHelper.sendMotivationalReminder(tasks, soundEnabled) + val soundEnabled = repository.soundEnabled.first() + val taskId = inputData.getString(KEY_TASK_ID) + + val tasks = repository.tasks.first() + + val taskToNotify = if (taskId != null) { + tasks.firstOrNull { it.id == taskId && it.isActive } + } else { + tasks.firstOrNull { it.isActive } + } + + taskToNotify?.let { + notificationHelper.sendTaskReminder(it, soundEnabled) } return Result.success()