1 Commits

Autor SHA1 Mensaje Fecha
ale
e7606df296 more entropy
Algunas comprobaciones han fallado
Build & Publish APK Release / build (push) Failing after 7m16s
Signed-off-by: ale <ale@manalejandro.com>
2026-02-28 09:04:32 +01:00
Se han modificado 6 ficheros con 315 adiciones y 40 borrados

Ver fichero

@@ -7,12 +7,19 @@ import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.work.* 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.AddTaskScreen
import com.manalejandro.motivame.ui.screens.MainScreen import com.manalejandro.motivame.ui.screens.MainScreen
import com.manalejandro.motivame.ui.screens.SettingsScreen 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.worker.DailyReminderWorker 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 import java.util.concurrent.TimeUnit
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -20,57 +27,129 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
// Configurar recordatorios diarios // Programar recordatorios para todas las tareas activas
setupDailyReminders() scheduleAllReminders()
setContent { setContent {
MotivameTheme { MotivameTheme {
MotivameApp() MotivameApp(onRescheduleReminders = { scheduleAllReminders() })
} }
} }
} }
private fun setupDailyReminders() { /**
val constraints = Constraints.Builder() * Cancela todos los workers anteriores y programa nuevos recordatorios
.setRequiredNetworkType(NetworkType.NOT_REQUIRED) * 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()
// Cancelar todos los workers existentes de recordatorios de tareas
WorkManager.getInstance(applicationContext)
.cancelAllWorkByTag("task_reminder")
if (!notificationEnabled) return@launch
tasks.filter { it.isActive }.forEach { task ->
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 (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<DailyReminderWorker>()
.setInitialDelay(delayMs, TimeUnit.MILLISECONDS)
.setInputData(inputData)
.addTag("task_reminder")
.addTag("task_${task.id}")
.build() .build()
val dailyWorkRequest = PeriodicWorkRequestBuilder<DailyReminderWorker>( workManager.enqueue(workRequest)
1, TimeUnit.DAYS }
)
.setConstraints(constraints)
.setInitialDelay(calculateInitialDelay(), TimeUnit.MILLISECONDS)
.build()
WorkManager.getInstance(applicationContext).enqueueUniquePeriodicWork(
"daily_reminder",
ExistingPeriodicWorkPolicy.KEEP,
dailyWorkRequest
)
} }
private fun calculateInitialDelay(): Long { /**
val currentTime = System.currentTimeMillis() * Calcula el retardo en milisegundos hasta la próxima ocurrencia de la hora indicada.
val calendar = java.util.Calendar.getInstance().apply { */
timeInMillis = currentTime private fun calculateDelayToTime(hour: Int, minute: Int): Long =
set(java.util.Calendar.HOUR_OF_DAY, 9) // 9 AM calculateDelayToTimeWithDayOffset(hour, minute, 0)
set(java.util.Calendar.MINUTE, 0)
set(java.util.Calendar.SECOND, 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) { // Si ya pasó esa hora hoy, mover a mañana antes de aplicar el offset
calendar.add(java.util.Calendar.DAY_OF_YEAR, 1) 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 @Composable
fun MotivameApp() { fun MotivameApp(onRescheduleReminders: () -> Unit = {}) {
val viewModel: TaskViewModel = viewModel() val viewModel: TaskViewModel = viewModel()
var currentScreen by remember { mutableStateOf("main") } var currentScreen by remember { mutableStateOf("main") }
// Registrar callback para reprogramar avisos cuando cambian las tareas
LaunchedEffect(viewModel) {
viewModel.onRescheduleReminders = onRescheduleReminders
}
when (currentScreen) { when (currentScreen) {
"main" -> MainScreen( "main" -> MainScreen(
viewModel = viewModel, viewModel = viewModel,

Ver fichero

@@ -7,6 +7,8 @@ data class Task(
val title: String, val title: String,
val goals: List<String>, val goals: List<String>,
val isActive: Boolean = true, 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
) )

Ver fichero

@@ -119,6 +119,8 @@ class TaskRepository(private val context: Context) {
put("goals", JSONArray(task.goals)) put("goals", JSONArray(task.goals))
put("isActive", task.isActive) put("isActive", task.isActive)
put("createdAt", task.createdAt) put("createdAt", task.createdAt)
put("dailyReminders", task.dailyReminders)
put("repeatEveryDays", task.repeatEveryDays)
} }
jsonArray.put(jsonObject) jsonArray.put(jsonObject)
} }
@@ -140,7 +142,9 @@ class TaskRepository(private val context: Context) {
title = jsonObject.getString("title"), title = jsonObject.getString("title"),
goals = goals, goals = goals,
isActive = jsonObject.getBoolean("isActive"), 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) { } catch (e: Exception) {

Ver fichero

@@ -1,6 +1,5 @@
package com.manalejandro.motivame.ui.screens package com.manalejandro.motivame.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
@@ -26,6 +25,8 @@ fun AddTaskScreen(
var taskTitle by remember { mutableStateOf("") } var taskTitle by remember { mutableStateOf("") }
var currentGoal by remember { mutableStateOf("") } var currentGoal by remember { mutableStateOf("") }
var goals by remember { mutableStateOf(listOf<String>()) } var goals by remember { mutableStateOf(listOf<String>()) }
var dailyReminders by remember { mutableStateOf(3) }
var repeatEveryDays by remember { mutableStateOf(3) }
Scaffold( Scaffold(
topBar = { 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 { item {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Button( Button(
@@ -189,7 +350,9 @@ fun AddTaskScreen(
val newTask = Task( val newTask = Task(
title = taskTitle.trim(), title = taskTitle.trim(),
goals = goals, goals = goals,
isActive = true isActive = true,
dailyReminders = dailyReminders,
repeatEveryDays = repeatEveryDays
) )
viewModel.addTask(newTask) viewModel.addTask(newTask)
onNavigateBack() 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"
}
}

Ver fichero

@@ -23,6 +23,9 @@ class TaskViewModel(application: Application) : AndroidViewModel(application) {
private val _soundEnabled = MutableStateFlow(true) private val _soundEnabled = MutableStateFlow(true)
val soundEnabled: StateFlow<Boolean> = _soundEnabled.asStateFlow() val soundEnabled: StateFlow<Boolean> = _soundEnabled.asStateFlow()
/** Callback que se invoca tras cualquier cambio en las tareas para reprogramar avisos */
var onRescheduleReminders: (() -> Unit)? = null
init { init {
loadTasks() loadTasks()
loadSettings() loadSettings()
@@ -52,18 +55,21 @@ class TaskViewModel(application: Application) : AndroidViewModel(application) {
fun addTask(task: Task) { fun addTask(task: Task) {
viewModelScope.launch { viewModelScope.launch {
repository.addTask(task) repository.addTask(task)
onRescheduleReminders?.invoke()
} }
} }
fun updateTask(task: Task) { fun updateTask(task: Task) {
viewModelScope.launch { viewModelScope.launch {
repository.updateTask(task) repository.updateTask(task)
onRescheduleReminders?.invoke()
} }
} }
fun deleteTask(taskId: String) { fun deleteTask(taskId: String) {
viewModelScope.launch { viewModelScope.launch {
repository.deleteTask(taskId) repository.deleteTask(taskId)
onRescheduleReminders?.invoke()
} }
} }
@@ -71,6 +77,7 @@ class TaskViewModel(application: Application) : AndroidViewModel(application) {
viewModelScope.launch { viewModelScope.launch {
repository.setNotificationEnabled(enabled) repository.setNotificationEnabled(enabled)
_notificationEnabled.value = enabled _notificationEnabled.value = enabled
onRescheduleReminders?.invoke()
} }
} }

Ver fichero

@@ -12,16 +12,30 @@ class DailyReminderWorker(
params: WorkerParameters params: WorkerParameters
) : CoroutineWorker(context, params) { ) : CoroutineWorker(context, params) {
companion object {
const val KEY_TASK_ID = "task_id"
}
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
val repository = TaskRepository(applicationContext) val repository = TaskRepository(applicationContext)
val notificationHelper = NotificationHelper(applicationContext) val notificationHelper = NotificationHelper(applicationContext)
val tasks = repository.tasks.first()
val notificationEnabled = repository.notificationEnabled.first() val notificationEnabled = repository.notificationEnabled.first()
val soundEnabled = repository.soundEnabled.first() if (!notificationEnabled) return Result.success()
if (notificationEnabled && tasks.isNotEmpty()) { val soundEnabled = repository.soundEnabled.first()
notificationHelper.sendMotivationalReminder(tasks, soundEnabled) 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() return Result.success()