From bd4cbdd86b6fe5264451b701ce57bfdbb0d9ee49 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 13:46:44 +0000 Subject: [PATCH 1/7] feat: add due dates and local reminder notifications for tasks Agent-Logs-Url: https://github.com/benju69/Tasks/sessions/4f4d9163-7ece-400b-891d-0f60145273f4 Co-authored-by: benju69 <2486590+benju69@users.noreply.github.com> --- app/src/main/AndroidManifest.xml | 17 +++++ .../java/fr/benju/tasks/TaskManagerApp.kt | 13 +++- .../java/fr/benju/tasks/di/DatabaseModule.kt | 2 +- .../fr/benju/tasks/di/NotificationModule.kt | 20 +++++ .../benju/tasks/notification/BootReceiver.kt | 42 +++++++++++ .../tasks/notification/NotificationHelper.kt | 31 ++++++++ .../notification/ReminderSchedulerImpl.kt | 74 +++++++++++++++++++ .../notification/TaskReminderReceiver.kt | 45 +++++++++++ app/src/main/res/values-de/strings.xml | 6 ++ app/src/main/res/values-es/strings.xml | 6 ++ app/src/main/res/values-fr/strings.xml | 6 ++ app/src/main/res/values-it/strings.xml | 6 ++ app/src/main/res/values/strings.xml | 6 ++ .../benju/tasks/core/test/TaskTestFactory.kt | 6 +- .../benju/tasks/data/database/TaskDatabase.kt | 4 +- .../tasks/data/database/entity/TaskEntity.kt | 3 +- .../data/database/migration/Migration1to2.kt | 10 +++ .../fr/benju/tasks/data/mapper/TaskMapper.kt | 6 +- .../java/fr/benju/tasks/domain/model/Task.kt | 1 + .../tasks/domain/service/ReminderScheduler.kt | 6 ++ .../feature/taskeditor/TaskEditorScreen.kt | 56 ++++++++++++++ .../feature/taskeditor/TaskEditorViewModel.kt | 64 +++++++++++----- .../feature/taskeditor/TaskEditorViewState.kt | 2 + .../src/main/res/values-de/strings.xml | 4 + .../src/main/res/values-es/strings.xml | 4 + .../src/main/res/values-fr/strings.xml | 4 + .../src/main/res/values-it/strings.xml | 4 + .../src/main/res/values/strings.xml | 5 ++ .../taskeditor/TaskEditorViewModelTest.kt | 20 ++++- .../feature/tasklist/TaskListViewModel.kt | 21 +++++- .../feature/tasklist/TaskListViewModelTest.kt | 16 +++- .../fr/benju/tasks/ui/components/TaskCard.kt | 32 +++++++- ui/src/main/res/values-de/strings.xml | 3 + ui/src/main/res/values-es/strings.xml | 3 + ui/src/main/res/values-fr/strings.xml | 3 + ui/src/main/res/values-it/strings.xml | 3 + ui/src/main/res/values/strings.xml | 4 + 37 files changed, 521 insertions(+), 37 deletions(-) create mode 100644 app/src/main/java/fr/benju/tasks/di/NotificationModule.kt create mode 100644 app/src/main/java/fr/benju/tasks/notification/BootReceiver.kt create mode 100644 app/src/main/java/fr/benju/tasks/notification/NotificationHelper.kt create mode 100644 app/src/main/java/fr/benju/tasks/notification/ReminderSchedulerImpl.kt create mode 100644 app/src/main/java/fr/benju/tasks/notification/TaskReminderReceiver.kt create mode 100644 data/src/main/java/fr/benju/tasks/data/database/migration/Migration1to2.kt create mode 100644 domain/src/main/java/fr/benju/tasks/domain/service/ReminderScheduler.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ad117e1..793db22 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,10 @@ + + + + + + + + + + + + + diff --git a/app/src/main/java/fr/benju/tasks/TaskManagerApp.kt b/app/src/main/java/fr/benju/tasks/TaskManagerApp.kt index b7118d9..14f0454 100644 --- a/app/src/main/java/fr/benju/tasks/TaskManagerApp.kt +++ b/app/src/main/java/fr/benju/tasks/TaskManagerApp.kt @@ -2,6 +2,17 @@ package fr.benju.tasks import android.app.Application import dagger.hilt.android.HiltAndroidApp +import fr.benju.tasks.notification.NotificationHelper +import javax.inject.Inject @HiltAndroidApp -class TaskManagerApp : Application() +class TaskManagerApp : Application() { + + @Inject + lateinit var notificationHelper: NotificationHelper + + override fun onCreate() { + super.onCreate() + notificationHelper.createNotificationChannel() + } +} diff --git a/app/src/main/java/fr/benju/tasks/di/DatabaseModule.kt b/app/src/main/java/fr/benju/tasks/di/DatabaseModule.kt index b088e27..e168e59 100644 --- a/app/src/main/java/fr/benju/tasks/di/DatabaseModule.kt +++ b/app/src/main/java/fr/benju/tasks/di/DatabaseModule.kt @@ -24,7 +24,7 @@ object DatabaseModule { context, TaskDatabase::class.java, TaskDatabase.DATABASE_NAME - ).build() + ).addMigrations(*TaskDatabase.migrations).build() } @Provides diff --git a/app/src/main/java/fr/benju/tasks/di/NotificationModule.kt b/app/src/main/java/fr/benju/tasks/di/NotificationModule.kt new file mode 100644 index 0000000..c13cf99 --- /dev/null +++ b/app/src/main/java/fr/benju/tasks/di/NotificationModule.kt @@ -0,0 +1,20 @@ +package fr.benju.tasks.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import fr.benju.tasks.domain.service.ReminderScheduler +import fr.benju.tasks.notification.ReminderSchedulerImpl +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class NotificationModule { + + @Binds + @Singleton + abstract fun bindReminderScheduler( + impl: ReminderSchedulerImpl + ): ReminderScheduler +} diff --git a/app/src/main/java/fr/benju/tasks/notification/BootReceiver.kt b/app/src/main/java/fr/benju/tasks/notification/BootReceiver.kt new file mode 100644 index 0000000..55d7191 --- /dev/null +++ b/app/src/main/java/fr/benju/tasks/notification/BootReceiver.kt @@ -0,0 +1,42 @@ +package fr.benju.tasks.notification + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import dagger.hilt.android.AndroidEntryPoint +import fr.benju.tasks.domain.model.TaskFilter +import fr.benju.tasks.domain.service.ReminderScheduler +import fr.benju.tasks.domain.usecase.GetTasksUseCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class BootReceiver : BroadcastReceiver() { + + @Inject + lateinit var getTasksUseCase: GetTasksUseCase + + @Inject + lateinit var reminderScheduler: ReminderScheduler + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Intent.ACTION_BOOT_COMPLETED) return + + val pendingResult = goAsync() + CoroutineScope(Dispatchers.IO).launch { + try { + getTasksUseCase(TaskFilter.ACTIVE) + .first() + .filter { it.dueDate != null } + .forEach { task -> + reminderScheduler.schedule(task.id, task.title, task.dueDate!!) + } + } finally { + pendingResult.finish() + } + } + } +} diff --git a/app/src/main/java/fr/benju/tasks/notification/NotificationHelper.kt b/app/src/main/java/fr/benju/tasks/notification/NotificationHelper.kt new file mode 100644 index 0000000..479c582 --- /dev/null +++ b/app/src/main/java/fr/benju/tasks/notification/NotificationHelper.kt @@ -0,0 +1,31 @@ +package fr.benju.tasks.notification + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import fr.benju.tasks.R +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NotificationHelper @Inject constructor( + @ApplicationContext private val context: Context +) { + + fun createNotificationChannel() { + val channel = NotificationChannel( + CHANNEL_ID, + context.getString(R.string.notification_channel_name), + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = context.getString(R.string.notification_channel_description) + } + val nm = context.getSystemService(NotificationManager::class.java) + nm.createNotificationChannel(channel) + } + + companion object { + const val CHANNEL_ID = "task_reminders" + } +} diff --git a/app/src/main/java/fr/benju/tasks/notification/ReminderSchedulerImpl.kt b/app/src/main/java/fr/benju/tasks/notification/ReminderSchedulerImpl.kt new file mode 100644 index 0000000..56a6900 --- /dev/null +++ b/app/src/main/java/fr/benju/tasks/notification/ReminderSchedulerImpl.kt @@ -0,0 +1,74 @@ +package fr.benju.tasks.notification + +import android.Manifest +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import fr.benju.tasks.domain.service.ReminderScheduler +import java.time.Instant +import java.time.LocalTime +import java.time.ZoneId +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ReminderSchedulerImpl @Inject constructor( + @ApplicationContext private val context: Context +) : ReminderScheduler { + + override fun schedule(taskId: Long, taskTitle: String, dueDate: Long) { + val triggerAt = computeTriggerTime(dueDate) + if (triggerAt <= System.currentTimeMillis()) return + + val intent = Intent(context, TaskReminderReceiver::class.java).apply { + putExtra(TaskReminderReceiver.EXTRA_TASK_ID, taskId) + putExtra(TaskReminderReceiver.EXTRA_TASK_TITLE, taskTitle) + } + val pendingIntent = PendingIntent.getBroadcast( + context, + taskId.toInt(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val alarmManager = context.getSystemService(AlarmManager::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) { + alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent) + } else { + alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent) + } + } + + override fun cancel(taskId: Long) { + val intent = Intent(context, TaskReminderReceiver::class.java) + val pendingIntent = PendingIntent.getBroadcast( + context, + taskId.toInt(), + intent, + PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE + ) ?: return + + val alarmManager = context.getSystemService(AlarmManager::class.java) + alarmManager.cancel(pendingIntent) + pendingIntent.cancel() + } + + /** + * Computes the alarm trigger time as 9:00 AM local time on the due date. + */ + private fun computeTriggerTime(dueDateMs: Long): Long { + val localDate = Instant.ofEpochMilli(dueDateMs) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + return localDate + .atTime(LocalTime.of(9, 0)) + .atZone(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + } +} diff --git a/app/src/main/java/fr/benju/tasks/notification/TaskReminderReceiver.kt b/app/src/main/java/fr/benju/tasks/notification/TaskReminderReceiver.kt new file mode 100644 index 0000000..f66fe4e --- /dev/null +++ b/app/src/main/java/fr/benju/tasks/notification/TaskReminderReceiver.kt @@ -0,0 +1,45 @@ +package fr.benju.tasks.notification + +import android.Manifest +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import fr.benju.tasks.R + +class TaskReminderReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + val taskId = intent.getLongExtra(EXTRA_TASK_ID, -1L) + val taskTitle = intent.getStringExtra(EXTRA_TASK_TITLE) ?: return + if (taskId == -1L) return + + val hasPermission = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } else { + true + } + if (!hasPermission) return + + val notification = NotificationCompat.Builder(context, NotificationHelper.CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_monochrome) + .setContentTitle(context.getString(R.string.notification_title)) + .setContentText(context.getString(R.string.notification_text, taskTitle)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .build() + + NotificationManagerCompat.from(context).notify(taskId.toInt(), notification) + } + + companion object { + const val EXTRA_TASK_ID = "extra_task_id" + const val EXTRA_TASK_TITLE = "extra_task_title" + } +} diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 6d5c9bd..caad4fb 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -31,5 +31,11 @@ Löschen Bearbeiten + + + Aufgabenerinnerungen + Benachrichtigt dich, wenn eine Aufgabe fällig ist + Aufgabe fällig + "%1$s" ist heute fällig diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index fd485d6..0bb749f 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -31,5 +31,11 @@ Eliminar Editar + + + Recordatorios de tareas + Te avisa cuando una tarea está pendiente + Tarea pendiente + "%1$s" vence hoy diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index e2e1240..f4ac992 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -31,5 +31,11 @@ Supprimer Modifier + + + Rappels de tâches + Vous avertit lorsqu\'une tâche est due + Tâche due + "%1$s" est due aujourd\'hui diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 6889cac..df652dc 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -31,5 +31,11 @@ Elimina Modifica + + + Promemoria attività + Ti avvisa quando un\'attività è in scadenza + Attività in scadenza + "%1$s" scade oggi diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 988132c..ec690ed 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -31,4 +31,10 @@ Delete Edit + + + Task Reminders + Notifies you when a task is due + Task Due + "%1$s" is due today diff --git a/core/src/main/java/fr/benju/tasks/core/test/TaskTestFactory.kt b/core/src/main/java/fr/benju/tasks/core/test/TaskTestFactory.kt index fd85624..c8c7d56 100644 --- a/core/src/main/java/fr/benju/tasks/core/test/TaskTestFactory.kt +++ b/core/src/main/java/fr/benju/tasks/core/test/TaskTestFactory.kt @@ -11,7 +11,8 @@ object TaskTestFactory { description: String = "Test Description", priority: Priority = Priority.MEDIUM, isCompleted: Boolean = false, - createdAt: Long = System.currentTimeMillis() + createdAt: Long = System.currentTimeMillis(), + dueDate: Long? = null ): Task { return Task( id = id, @@ -19,7 +20,8 @@ object TaskTestFactory { description = description, priority = priority, isCompleted = isCompleted, - createdAt = createdAt + createdAt = createdAt, + dueDate = dueDate ) } diff --git a/data/src/main/java/fr/benju/tasks/data/database/TaskDatabase.kt b/data/src/main/java/fr/benju/tasks/data/database/TaskDatabase.kt index 3115584..00b48f3 100644 --- a/data/src/main/java/fr/benju/tasks/data/database/TaskDatabase.kt +++ b/data/src/main/java/fr/benju/tasks/data/database/TaskDatabase.kt @@ -4,10 +4,11 @@ import androidx.room.Database import androidx.room.RoomDatabase import fr.benju.tasks.data.database.dao.TaskDao import fr.benju.tasks.data.database.entity.TaskEntity +import fr.benju.tasks.data.database.migration.MIGRATION_1_2 @Database( entities = [TaskEntity::class], - version = 1, + version = 2, exportSchema = false ) abstract class TaskDatabase : RoomDatabase() { @@ -15,5 +16,6 @@ abstract class TaskDatabase : RoomDatabase() { companion object { const val DATABASE_NAME = "task_manager_db" + val migrations = arrayOf(MIGRATION_1_2) } } diff --git a/data/src/main/java/fr/benju/tasks/data/database/entity/TaskEntity.kt b/data/src/main/java/fr/benju/tasks/data/database/entity/TaskEntity.kt index 3a82e9b..89856a4 100644 --- a/data/src/main/java/fr/benju/tasks/data/database/entity/TaskEntity.kt +++ b/data/src/main/java/fr/benju/tasks/data/database/entity/TaskEntity.kt @@ -11,5 +11,6 @@ data class TaskEntity( val description: String, val priority: String, // "LOW", "MEDIUM", "HIGH" val isCompleted: Boolean = false, - val createdAt: Long = System.currentTimeMillis() + val createdAt: Long = System.currentTimeMillis(), + val dueDate: Long? = null, ) diff --git a/data/src/main/java/fr/benju/tasks/data/database/migration/Migration1to2.kt b/data/src/main/java/fr/benju/tasks/data/database/migration/Migration1to2.kt new file mode 100644 index 0000000..dba389b --- /dev/null +++ b/data/src/main/java/fr/benju/tasks/data/database/migration/Migration1to2.kt @@ -0,0 +1,10 @@ +package fr.benju.tasks.data.database.migration + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE tasks ADD COLUMN dueDate INTEGER") + } +} diff --git a/data/src/main/java/fr/benju/tasks/data/mapper/TaskMapper.kt b/data/src/main/java/fr/benju/tasks/data/mapper/TaskMapper.kt index 9eeddec..35d93c2 100644 --- a/data/src/main/java/fr/benju/tasks/data/mapper/TaskMapper.kt +++ b/data/src/main/java/fr/benju/tasks/data/mapper/TaskMapper.kt @@ -14,7 +14,8 @@ class TaskMapper @Inject constructor() { description = entity.description, priority = Priority.valueOf(entity.priority), isCompleted = entity.isCompleted, - createdAt = entity.createdAt + createdAt = entity.createdAt, + dueDate = entity.dueDate ) } @@ -25,7 +26,8 @@ class TaskMapper @Inject constructor() { description = domain.description, priority = domain.priority.name, isCompleted = domain.isCompleted, - createdAt = domain.createdAt + createdAt = domain.createdAt, + dueDate = domain.dueDate ) } } diff --git a/domain/src/main/java/fr/benju/tasks/domain/model/Task.kt b/domain/src/main/java/fr/benju/tasks/domain/model/Task.kt index a2a0a24..0373ebc 100644 --- a/domain/src/main/java/fr/benju/tasks/domain/model/Task.kt +++ b/domain/src/main/java/fr/benju/tasks/domain/model/Task.kt @@ -7,4 +7,5 @@ data class Task( val priority: Priority, val isCompleted: Boolean = false, val createdAt: Long = System.currentTimeMillis(), + val dueDate: Long? = null, ) diff --git a/domain/src/main/java/fr/benju/tasks/domain/service/ReminderScheduler.kt b/domain/src/main/java/fr/benju/tasks/domain/service/ReminderScheduler.kt new file mode 100644 index 0000000..389cd9b --- /dev/null +++ b/domain/src/main/java/fr/benju/tasks/domain/service/ReminderScheduler.kt @@ -0,0 +1,6 @@ +package fr.benju.tasks.domain.service + +interface ReminderScheduler { + fun schedule(taskId: Long, taskTitle: String, dueDate: Long) + fun cancel(taskId: Long) +} diff --git a/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorScreen.kt b/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorScreen.kt index 2d020d9..8330d56 100644 --- a/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorScreen.kt +++ b/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorScreen.kt @@ -5,6 +5,7 @@ package fr.benju.tasks.feature.taskeditor import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -13,6 +14,10 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import fr.benju.tasks.domain.model.Priority +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle @Composable fun TaskEditorScreen( @@ -43,6 +48,27 @@ fun TaskEditorScreen( } } + if (viewState.showDatePicker) { + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = viewState.dueDate + ) + DatePickerDialog( + onDismissRequest = { viewModel.hideDatePicker() }, + confirmButton = { + TextButton(onClick = { viewModel.updateDueDate(datePickerState.selectedDateMillis) }) { + Text(stringResource(R.string.task_editor_save)) + } + }, + dismissButton = { + TextButton(onClick = { viewModel.hideDatePicker() }) { + Text(stringResource(R.string.task_editor_cancel)) + } + } + ) { + DatePicker(state = datePickerState) + } + } + Scaffold( topBar = { CenterAlignedTopAppBar( @@ -116,6 +142,29 @@ fun TaskEditorScreen( } } + // Due date row + Text(stringResource(R.string.task_editor_field_due_date), style = MaterialTheme.typography.titleMedium) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = { viewModel.showDatePicker() }, + modifier = Modifier.weight(1f) + ) { + val label = viewState.dueDate?.let { formatDueDate(it) } + ?: stringResource(R.string.task_editor_due_date_none) + Text(label) + } + if (viewState.dueDate != null) { + TextButton(onClick = { viewModel.updateDueDate(null) }) { + Text(stringResource(R.string.task_editor_due_date_clear)) + } + } + } + viewState.error?.let { errorRes -> Text( text = stringResource(errorRes), @@ -130,3 +179,10 @@ fun TaskEditorScreen( } } } + +private fun formatDueDate(epochMs: Long): String { + val localDate = Instant.ofEpochMilli(epochMs) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + return localDate.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)) +} diff --git a/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewModel.kt b/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewModel.kt index 16478db..1ec7770 100644 --- a/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewModel.kt +++ b/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import fr.benju.tasks.core.dispatchers.ICoroutineDispatchers import fr.benju.tasks.domain.model.Priority import fr.benju.tasks.domain.model.Task +import fr.benju.tasks.domain.service.ReminderScheduler import fr.benju.tasks.domain.usecase.AddTaskUseCase import fr.benju.tasks.domain.usecase.GetTaskByIdUseCase import fr.benju.tasks.domain.usecase.UpdateTaskUseCase @@ -21,7 +22,8 @@ class TaskEditorViewModel @Inject constructor( private val addTaskUseCase: AddTaskUseCase, private val updateTaskUseCase: UpdateTaskUseCase, private val getTaskByIdUseCase: GetTaskByIdUseCase, - private val dispatchers: ICoroutineDispatchers + private val dispatchers: ICoroutineDispatchers, + private val reminderScheduler: ReminderScheduler ) : ViewModel() { private val _viewState = MutableStateFlow(TaskEditorViewState()) @@ -37,7 +39,8 @@ class TaskEditorViewModel @Inject constructor( taskId = task.id, title = task.title, description = task.description, - priority = task.priority + priority = task.priority, + dueDate = task.dueDate ) } } @@ -54,6 +57,18 @@ class TaskEditorViewModel @Inject constructor( _viewState.value = _viewState.value.copy(priority = priority) } + fun updateDueDate(dateMs: Long?) { + _viewState.value = _viewState.value.copy(dueDate = dateMs, showDatePicker = false) + } + + fun showDatePicker() { + _viewState.value = _viewState.value.copy(showDatePicker = true) + } + + fun hideDatePicker() { + _viewState.value = _viewState.value.copy(showDatePicker = false) + } + fun saveTask() { val state = _viewState.value if (state.title.isBlank()) { @@ -68,26 +83,39 @@ class TaskEditorViewModel @Inject constructor( id = state.taskId ?: 0, title = state.title, description = state.description, - priority = state.priority + priority = state.priority, + dueDate = state.dueDate ) - val result = if (state.taskId == null) { - addTaskUseCase(task) + if (state.taskId == null) { + addTaskUseCase(task).fold( + onSuccess = { newId -> + reminderScheduler.cancel(newId) + state.dueDate?.let { reminderScheduler.schedule(newId, state.title, it) } + _saveSuccess.emit(Unit) + }, + onFailure = { + _viewState.value = _viewState.value.copy( + isSaving = false, + error = R.string.task_editor_error_save_failed + ) + } + ) } else { - updateTaskUseCase(task) + updateTaskUseCase(task).fold( + onSuccess = { + reminderScheduler.cancel(state.taskId) + state.dueDate?.let { reminderScheduler.schedule(state.taskId, state.title, it) } + _saveSuccess.emit(Unit) + }, + onFailure = { + _viewState.value = _viewState.value.copy( + isSaving = false, + error = R.string.task_editor_error_save_failed + ) + } + ) } - - result.fold( - onSuccess = { - _saveSuccess.emit(Unit) - }, - onFailure = { - _viewState.value = _viewState.value.copy( - isSaving = false, - error = R.string.task_editor_error_save_failed - ) - } - ) } } } diff --git a/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewState.kt b/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewState.kt index 175ade1..5a1fe13 100644 --- a/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewState.kt +++ b/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewState.kt @@ -8,6 +8,8 @@ data class TaskEditorViewState( val title: String = "", val description: String = "", val priority: Priority = Priority.MEDIUM, + val dueDate: Long? = null, + val showDatePicker: Boolean = false, val isSaving: Boolean = false, @StringRes val error: Int? = null ) diff --git a/feature/taskeditor/src/main/res/values-de/strings.xml b/feature/taskeditor/src/main/res/values-de/strings.xml index c127626..8165fb3 100644 --- a/feature/taskeditor/src/main/res/values-de/strings.xml +++ b/feature/taskeditor/src/main/res/values-de/strings.xml @@ -9,6 +9,10 @@ Priorität Titel ist erforderlich Aufgabe konnte nicht gespeichert werden. Bitte erneut versuchen. + + Fälligkeitsdatum + Kein Fälligkeitsdatum + Löschen Niedrig Mittel Hoch diff --git a/feature/taskeditor/src/main/res/values-es/strings.xml b/feature/taskeditor/src/main/res/values-es/strings.xml index 86a75d1..f44b75f 100644 --- a/feature/taskeditor/src/main/res/values-es/strings.xml +++ b/feature/taskeditor/src/main/res/values-es/strings.xml @@ -9,6 +9,10 @@ Prioridad El título es obligatorio No se pudo guardar la tarea. Por favor, inténtalo de nuevo. + + Fecha de vencimiento + Sin fecha de vencimiento + Borrar Baja Media Alta diff --git a/feature/taskeditor/src/main/res/values-fr/strings.xml b/feature/taskeditor/src/main/res/values-fr/strings.xml index 924f425..51f72f7 100644 --- a/feature/taskeditor/src/main/res/values-fr/strings.xml +++ b/feature/taskeditor/src/main/res/values-fr/strings.xml @@ -9,6 +9,10 @@ Priorité Le titre est obligatoire Impossible d\'enregistrer la tâche. Veuillez réessayer. + + Date d\'échéance + Aucune date d\'échéance + Effacer Faible Moyenne Haute diff --git a/feature/taskeditor/src/main/res/values-it/strings.xml b/feature/taskeditor/src/main/res/values-it/strings.xml index 4899c5a..a75bb8d 100644 --- a/feature/taskeditor/src/main/res/values-it/strings.xml +++ b/feature/taskeditor/src/main/res/values-it/strings.xml @@ -9,6 +9,10 @@ Priorità Il titolo è obbligatorio Impossibile salvare l\'attività. Riprova. + + Data di scadenza + Nessuna data di scadenza + Cancella Bassa Media Alta diff --git a/feature/taskeditor/src/main/res/values/strings.xml b/feature/taskeditor/src/main/res/values/strings.xml index c5dc098..2df26d2 100644 --- a/feature/taskeditor/src/main/res/values/strings.xml +++ b/feature/taskeditor/src/main/res/values/strings.xml @@ -11,6 +11,11 @@ Title is required Failed to save the task. Please try again. + + Due Date + No due date + Clear + Low Medium diff --git a/feature/taskeditor/src/test/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewModelTest.kt b/feature/taskeditor/src/test/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewModelTest.kt index 160233a..d7c7dc0 100644 --- a/feature/taskeditor/src/test/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewModelTest.kt +++ b/feature/taskeditor/src/test/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewModelTest.kt @@ -3,12 +3,16 @@ package fr.benju.tasks.feature.taskeditor import app.cash.turbine.test import fr.benju.tasks.core.test.CoroutineTestExtension import fr.benju.tasks.domain.model.Priority +import fr.benju.tasks.domain.service.ReminderScheduler import fr.benju.tasks.domain.usecase.AddTaskUseCase import fr.benju.tasks.domain.usecase.GetTaskByIdUseCase import fr.benju.tasks.domain.usecase.UpdateTaskUseCase import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every +import io.mockk.just import io.mockk.mockk +import io.mockk.Runs import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBeEqualTo import org.junit.jupiter.api.Test @@ -23,6 +27,10 @@ class TaskEditorViewModelTest { private val addTaskUseCase: AddTaskUseCase = mockk() private val updateTaskUseCase: UpdateTaskUseCase = mockk() private val getTaskByIdUseCase: GetTaskByIdUseCase = mockk() + private val reminderScheduler: ReminderScheduler = mockk { + every { schedule(any(), any(), any()) } just Runs + every { cancel(any()) } just Runs + } @Test fun `updateTitle should update view state`() = runTest { @@ -31,7 +39,8 @@ class TaskEditorViewModelTest { addTaskUseCase, updateTaskUseCase, getTaskByIdUseCase, - coroutineExtension.dispatchers + coroutineExtension.dispatchers, + reminderScheduler ) // When @@ -48,7 +57,8 @@ class TaskEditorViewModelTest { addTaskUseCase, updateTaskUseCase, getTaskByIdUseCase, - coroutineExtension.dispatchers + coroutineExtension.dispatchers, + reminderScheduler ) // When @@ -67,7 +77,8 @@ class TaskEditorViewModelTest { addTaskUseCase, updateTaskUseCase, getTaskByIdUseCase, - coroutineExtension.dispatchers + coroutineExtension.dispatchers, + reminderScheduler ) viewModel.updateTitle("Test Task") @@ -87,7 +98,8 @@ class TaskEditorViewModelTest { addTaskUseCase, updateTaskUseCase, getTaskByIdUseCase, - coroutineExtension.dispatchers + coroutineExtension.dispatchers, + reminderScheduler ) viewModel.updateTitle("Test Task") diff --git a/feature/tasklist/src/main/java/fr/benju/tasks/feature/tasklist/TaskListViewModel.kt b/feature/tasklist/src/main/java/fr/benju/tasks/feature/tasklist/TaskListViewModel.kt index 74e88e9..ebb280c 100644 --- a/feature/tasklist/src/main/java/fr/benju/tasks/feature/tasklist/TaskListViewModel.kt +++ b/feature/tasklist/src/main/java/fr/benju/tasks/feature/tasklist/TaskListViewModel.kt @@ -3,6 +3,7 @@ package fr.benju.tasks.feature.tasklist import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import fr.benju.tasks.domain.model.TaskFilter +import fr.benju.tasks.domain.service.ReminderScheduler import fr.benju.tasks.domain.usecase.DeleteTaskUseCase import fr.benju.tasks.domain.usecase.GetTasksUseCase import fr.benju.tasks.domain.usecase.ToggleTaskStatusUseCase @@ -19,7 +20,8 @@ import javax.inject.Inject class TaskListViewModel @Inject constructor( private val getTasksUseCase: GetTasksUseCase, private val toggleTaskStatusUseCase: ToggleTaskStatusUseCase, - private val deleteTaskUseCase: DeleteTaskUseCase + private val deleteTaskUseCase: DeleteTaskUseCase, + private val reminderScheduler: ReminderScheduler ) : ViewModel() { private val _viewState = MutableStateFlow(TaskListViewState()) @@ -51,8 +53,19 @@ class TaskListViewModel @Inject constructor( } fun onTaskToggled(taskId: Long) { + val task = _viewState.value.tasks.find { it.id == taskId } viewModelScope.launch { - toggleTaskStatusUseCase(taskId).onFailure { error -> + toggleTaskStatusUseCase(taskId).onSuccess { + if (task != null && task.dueDate != null) { + if (!task.isCompleted) { + // Task was active; it just became complete — cancel reminder + reminderScheduler.cancel(taskId) + } else { + // Task was complete; it just became active — reschedule reminder + reminderScheduler.schedule(taskId, task.title, task.dueDate) + } + } + }.onFailure { error -> _viewState.value = _viewState.value.copy(error = error.message) } } @@ -60,7 +73,9 @@ class TaskListViewModel @Inject constructor( fun onDeleteTask(taskId: Long) { viewModelScope.launch { - deleteTaskUseCase(taskId).onFailure { error -> + deleteTaskUseCase(taskId).onSuccess { + reminderScheduler.cancel(taskId) + }.onFailure { error -> _viewState.value = _viewState.value.copy(error = error.message) } } diff --git a/feature/tasklist/src/test/java/fr/benju/tasks/feature/tasklist/TaskListViewModelTest.kt b/feature/tasklist/src/test/java/fr/benju/tasks/feature/tasklist/TaskListViewModelTest.kt index a529f75..751beff 100644 --- a/feature/tasklist/src/test/java/fr/benju/tasks/feature/tasklist/TaskListViewModelTest.kt +++ b/feature/tasklist/src/test/java/fr/benju/tasks/feature/tasklist/TaskListViewModelTest.kt @@ -4,13 +4,16 @@ import app.cash.turbine.test import fr.benju.tasks.core.test.CoroutineTestExtension import fr.benju.tasks.core.test.TaskTestFactory import fr.benju.tasks.domain.model.TaskFilter +import fr.benju.tasks.domain.service.ReminderScheduler import fr.benju.tasks.domain.usecase.DeleteTaskUseCase import fr.benju.tasks.domain.usecase.GetTasksUseCase import fr.benju.tasks.domain.usecase.ToggleTaskStatusUseCase import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every +import io.mockk.just import io.mockk.mockk +import io.mockk.Runs import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBeEqualTo @@ -26,6 +29,10 @@ class TaskListViewModelTest { private val getTasksUseCase: GetTasksUseCase = mockk() private val toggleTaskStatusUseCase: ToggleTaskStatusUseCase = mockk() private val deleteTaskUseCase: DeleteTaskUseCase = mockk() + private val reminderScheduler: ReminderScheduler = mockk { + every { schedule(any(), any(), any()) } just Runs + every { cancel(any()) } just Runs + } @Test fun `init should load tasks`() = runTest { @@ -37,7 +44,8 @@ class TaskListViewModelTest { val viewModel = TaskListViewModel( getTasksUseCase, toggleTaskStatusUseCase, - deleteTaskUseCase + deleteTaskUseCase, + reminderScheduler ) // Then @@ -58,7 +66,8 @@ class TaskListViewModelTest { val viewModel = TaskListViewModel( getTasksUseCase, toggleTaskStatusUseCase, - deleteTaskUseCase + deleteTaskUseCase, + reminderScheduler ) // When @@ -78,7 +87,8 @@ class TaskListViewModelTest { val viewModel = TaskListViewModel( getTasksUseCase, toggleTaskStatusUseCase, - deleteTaskUseCase + deleteTaskUseCase, + reminderScheduler ) // When diff --git a/ui/src/main/java/fr/benju/tasks/ui/components/TaskCard.kt b/ui/src/main/java/fr/benju/tasks/ui/components/TaskCard.kt index 7a62ff1..5c5010f 100644 --- a/ui/src/main/java/fr/benju/tasks/ui/components/TaskCard.kt +++ b/ui/src/main/java/fr/benju/tasks/ui/components/TaskCard.kt @@ -35,6 +35,11 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import fr.benju.tasks.domain.model.Task import fr.benju.tasks.ui.R +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle @Composable fun TaskCard( @@ -112,7 +117,32 @@ fun TaskCard( Spacer(modifier = Modifier.height(4.dp)) - PriorityChip(priority = task.priority) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + PriorityChip(priority = task.priority) + + task.dueDate?.let { dueDateMs -> + val localDate = Instant.ofEpochMilli(dueDateMs) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + val today = LocalDate.now(ZoneId.systemDefault()) + val isOverdue = !task.isCompleted && localDate.isBefore(today) + val formattedDate = localDate.format( + DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT) + ) + Text( + text = stringResource( + if (isOverdue) R.string.due_date_overdue else R.string.due_date_label, + formattedDate + ), + style = MaterialTheme.typography.labelSmall, + color = if (isOverdue) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } } if (onDeleteTask != null) { diff --git a/ui/src/main/res/values-de/strings.xml b/ui/src/main/res/values-de/strings.xml index 8b996ce..ff334a1 100644 --- a/ui/src/main/res/values-de/strings.xml +++ b/ui/src/main/res/values-de/strings.xml @@ -8,5 +8,8 @@ Mittel Hoch Aufgabe löschen + + Fällig: %1$s + Überfällig: %1$s diff --git a/ui/src/main/res/values-es/strings.xml b/ui/src/main/res/values-es/strings.xml index ec61184..b6f6a4c 100644 --- a/ui/src/main/res/values-es/strings.xml +++ b/ui/src/main/res/values-es/strings.xml @@ -8,5 +8,8 @@ Media Alta Eliminar tarea + + Vence: %1$s + Vencida: %1$s diff --git a/ui/src/main/res/values-fr/strings.xml b/ui/src/main/res/values-fr/strings.xml index f19c7b9..cfcbf18 100644 --- a/ui/src/main/res/values-fr/strings.xml +++ b/ui/src/main/res/values-fr/strings.xml @@ -8,5 +8,8 @@ Moyenne Haute Supprimer la tâche + + Échéance : %1$s + En retard : %1$s diff --git a/ui/src/main/res/values-it/strings.xml b/ui/src/main/res/values-it/strings.xml index 7f35276..51f9382 100644 --- a/ui/src/main/res/values-it/strings.xml +++ b/ui/src/main/res/values-it/strings.xml @@ -8,5 +8,8 @@ Media Alta Elimina attività + + Scade: %1$s + Scaduta: %1$s diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 6d8639f..f03e9d3 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -13,5 +13,9 @@ Delete task + + + Due: %1$s + Overdue: %1$s From a2a45843d922e2628cc843a7c480b97459af371e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 13:52:57 +0000 Subject: [PATCH 2/7] fix: remove unnecessary cancel for new tasks in TaskEditorViewModel; fix BootReceiver export flag Agent-Logs-Url: https://github.com/benju69/Tasks/sessions/4f4d9163-7ece-400b-891d-0f60145273f4 Co-authored-by: benju69 <2486590+benju69@users.noreply.github.com> --- app/src/main/AndroidManifest.xml | 2 +- .../java/fr/benju/tasks/notification/ReminderSchedulerImpl.kt | 3 --- .../fr/benju/tasks/feature/taskeditor/TaskEditorViewModel.kt | 1 - 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 793db22..2292d46 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -31,7 +31,7 @@ + android:exported="true"> diff --git a/app/src/main/java/fr/benju/tasks/notification/ReminderSchedulerImpl.kt b/app/src/main/java/fr/benju/tasks/notification/ReminderSchedulerImpl.kt index 56a6900..691709f 100644 --- a/app/src/main/java/fr/benju/tasks/notification/ReminderSchedulerImpl.kt +++ b/app/src/main/java/fr/benju/tasks/notification/ReminderSchedulerImpl.kt @@ -1,13 +1,10 @@ package fr.benju.tasks.notification -import android.Manifest import android.app.AlarmManager import android.app.PendingIntent import android.content.Context import android.content.Intent -import android.content.pm.PackageManager import android.os.Build -import androidx.core.content.ContextCompat import dagger.hilt.android.qualifiers.ApplicationContext import fr.benju.tasks.domain.service.ReminderScheduler import java.time.Instant diff --git a/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewModel.kt b/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewModel.kt index 1ec7770..c16bae3 100644 --- a/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewModel.kt +++ b/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewModel.kt @@ -90,7 +90,6 @@ class TaskEditorViewModel @Inject constructor( if (state.taskId == null) { addTaskUseCase(task).fold( onSuccess = { newId -> - reminderScheduler.cancel(newId) state.dueDate?.let { reminderScheduler.schedule(newId, state.title, it) } _saveSuccess.emit(Unit) }, From f10474b1c2ea9f3dc48153dff09d3e84dbf240f2 Mon Sep 17 00:00:00 2001 From: Benjamin Gonin Date: Sun, 12 Apr 2026 22:56:22 +0200 Subject: [PATCH 3/7] refactor: update UseCaseModule to use SingletonComponent and adjust ViewModel bindings --- .../fr/benju/tasks/data/di/UseCaseModule.kt | 18 +++++++++--------- .../feature/tasklist/TaskListViewModel.kt | 5 +++-- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/data/src/main/java/fr/benju/tasks/data/di/UseCaseModule.kt b/data/src/main/java/fr/benju/tasks/data/di/UseCaseModule.kt index f3f0dfb..bb73161 100644 --- a/data/src/main/java/fr/benju/tasks/data/di/UseCaseModule.kt +++ b/data/src/main/java/fr/benju/tasks/data/di/UseCaseModule.kt @@ -17,51 +17,51 @@ import fr.benju.tasks.domain.usecase.UpdateTaskUseCase import dagger.Binds import dagger.Module import dagger.hilt.InstallIn -import dagger.hilt.android.components.ViewModelComponent -import dagger.hilt.android.scopes.ViewModelScoped +import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @Module -@InstallIn(ViewModelComponent::class) +@InstallIn(SingletonComponent::class) abstract class UseCaseModule { @Binds - @ViewModelScoped + @Singleton abstract fun bindGetTasksUseCase( impl: GetTasksUseCaseImpl ): GetTasksUseCase @Binds - @ViewModelScoped + @Singleton abstract fun bindGetTaskByIdUseCase( impl: GetTaskByIdUseCaseImpl ): GetTaskByIdUseCase @Binds - @ViewModelScoped + @Singleton abstract fun bindUpdateTaskUseCase( impl: UpdateTaskUseCaseImpl ): UpdateTaskUseCase @Binds - @ViewModelScoped + @Singleton abstract fun bindDeleteTaskUseCase( impl: DeleteTaskUseCaseImpl ): DeleteTaskUseCase @Binds + @Singleton abstract fun bindToggleTaskStatusUseCase( impl: ToggleTaskStatusUseCaseImpl ): ToggleTaskStatusUseCase @Binds - @ViewModelScoped + @Singleton abstract fun bindGetDarkModeUseCase( impl: GetDarkModeUseCaseImpl ): GetDarkModeUseCase @Binds - @ViewModelScoped + @Singleton abstract fun bindSetDarkModeUseCase( impl: SetDarkModeUseCaseImpl ): SetDarkModeUseCase diff --git a/feature/tasklist/src/main/java/fr/benju/tasks/feature/tasklist/TaskListViewModel.kt b/feature/tasklist/src/main/java/fr/benju/tasks/feature/tasklist/TaskListViewModel.kt index ebb280c..315c2d2 100644 --- a/feature/tasklist/src/main/java/fr/benju/tasks/feature/tasklist/TaskListViewModel.kt +++ b/feature/tasklist/src/main/java/fr/benju/tasks/feature/tasklist/TaskListViewModel.kt @@ -56,13 +56,14 @@ class TaskListViewModel @Inject constructor( val task = _viewState.value.tasks.find { it.id == taskId } viewModelScope.launch { toggleTaskStatusUseCase(taskId).onSuccess { - if (task != null && task.dueDate != null) { + val dueDate = task?.dueDate + if (task != null && dueDate != null) { if (!task.isCompleted) { // Task was active; it just became complete — cancel reminder reminderScheduler.cancel(taskId) } else { // Task was complete; it just became active — reschedule reminder - reminderScheduler.schedule(taskId, task.title, task.dueDate) + reminderScheduler.schedule(taskId, task.title, dueDate) } } }.onFailure { error -> From afa04223858628280d0f5989daf4bd152c1ae605 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 19:28:12 +0000 Subject: [PATCH 4/7] feat: add exact time selection and repeatable tasks (daily/weekly/monthly) Agent-Logs-Url: https://github.com/benju69/Tasks/sessions/f6886525-4943-4cd6-989e-4a499293e3bf Co-authored-by: benju69 <2486590+benju69@users.noreply.github.com> --- .../benju/tasks/notification/BootReceiver.kt | 2 +- .../notification/ReminderSchedulerImpl.kt | 38 +++---- .../notification/TaskReminderReceiver.kt | 74 +++++++++++-- .../benju/tasks/core/test/TaskTestFactory.kt | 7 +- .../benju/tasks/data/database/TaskDatabase.kt | 5 +- .../tasks/data/database/entity/TaskEntity.kt | 1 + .../data/database/migration/Migration2to3.kt | 10 ++ .../fr/benju/tasks/data/mapper/TaskMapper.kt | 8 +- .../tasks/domain/model/RepeatInterval.kt | 8 ++ .../java/fr/benju/tasks/domain/model/Task.kt | 1 + .../tasks/domain/service/ReminderScheduler.kt | 4 +- .../feature/taskeditor/TaskEditorScreen.kt | 102 +++++++++++++++--- .../feature/taskeditor/TaskEditorViewModel.kt | 98 +++++++++++++++-- .../feature/taskeditor/TaskEditorViewState.kt | 6 ++ .../src/main/res/values-de/strings.xml | 9 +- .../src/main/res/values-es/strings.xml | 9 +- .../src/main/res/values-fr/strings.xml | 9 +- .../src/main/res/values-it/strings.xml | 9 +- .../src/main/res/values/strings.xml | 12 ++- .../taskeditor/TaskEditorViewModelTest.kt | 2 +- .../feature/tasklist/TaskListViewModel.kt | 2 +- .../feature/tasklist/TaskListViewModelTest.kt | 2 +- .../fr/benju/tasks/ui/components/TaskCard.kt | 25 +++-- 23 files changed, 363 insertions(+), 80 deletions(-) create mode 100644 data/src/main/java/fr/benju/tasks/data/database/migration/Migration2to3.kt create mode 100644 domain/src/main/java/fr/benju/tasks/domain/model/RepeatInterval.kt diff --git a/app/src/main/java/fr/benju/tasks/notification/BootReceiver.kt b/app/src/main/java/fr/benju/tasks/notification/BootReceiver.kt index 55d7191..027c13f 100644 --- a/app/src/main/java/fr/benju/tasks/notification/BootReceiver.kt +++ b/app/src/main/java/fr/benju/tasks/notification/BootReceiver.kt @@ -32,7 +32,7 @@ class BootReceiver : BroadcastReceiver() { .first() .filter { it.dueDate != null } .forEach { task -> - reminderScheduler.schedule(task.id, task.title, task.dueDate!!) + reminderScheduler.schedule(task.id, task.title, task.dueDate!!, task.repeatInterval) } } finally { pendingResult.finish() diff --git a/app/src/main/java/fr/benju/tasks/notification/ReminderSchedulerImpl.kt b/app/src/main/java/fr/benju/tasks/notification/ReminderSchedulerImpl.kt index 691709f..0485bad 100644 --- a/app/src/main/java/fr/benju/tasks/notification/ReminderSchedulerImpl.kt +++ b/app/src/main/java/fr/benju/tasks/notification/ReminderSchedulerImpl.kt @@ -6,10 +6,8 @@ import android.content.Context import android.content.Intent import android.os.Build import dagger.hilt.android.qualifiers.ApplicationContext +import fr.benju.tasks.domain.model.RepeatInterval import fr.benju.tasks.domain.service.ReminderScheduler -import java.time.Instant -import java.time.LocalTime -import java.time.ZoneId import javax.inject.Inject import javax.inject.Singleton @@ -18,26 +16,32 @@ class ReminderSchedulerImpl @Inject constructor( @ApplicationContext private val context: Context ) : ReminderScheduler { - override fun schedule(taskId: Long, taskTitle: String, dueDate: Long) { - val triggerAt = computeTriggerTime(dueDate) - if (triggerAt <= System.currentTimeMillis()) return + override fun schedule( + taskId: Long, + taskTitle: String, + dueDate: Long, + repeatInterval: RepeatInterval + ) { + if (dueDate <= System.currentTimeMillis()) return val intent = Intent(context, TaskReminderReceiver::class.java).apply { putExtra(TaskReminderReceiver.EXTRA_TASK_ID, taskId) putExtra(TaskReminderReceiver.EXTRA_TASK_TITLE, taskTitle) + putExtra(TaskReminderReceiver.EXTRA_DUE_DATE_MS, dueDate) + putExtra(TaskReminderReceiver.EXTRA_REPEAT_INTERVAL, repeatInterval.name) } val pendingIntent = PendingIntent.getBroadcast( context, - taskId.toInt(), + taskId.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) val alarmManager = context.getSystemService(AlarmManager::class.java) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) { - alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent) + alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, dueDate, pendingIntent) } else { - alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent) + alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, dueDate, pendingIntent) } } @@ -45,7 +49,7 @@ class ReminderSchedulerImpl @Inject constructor( val intent = Intent(context, TaskReminderReceiver::class.java) val pendingIntent = PendingIntent.getBroadcast( context, - taskId.toInt(), + taskId.hashCode(), intent, PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE ) ?: return @@ -54,18 +58,4 @@ class ReminderSchedulerImpl @Inject constructor( alarmManager.cancel(pendingIntent) pendingIntent.cancel() } - - /** - * Computes the alarm trigger time as 9:00 AM local time on the due date. - */ - private fun computeTriggerTime(dueDateMs: Long): Long { - val localDate = Instant.ofEpochMilli(dueDateMs) - .atZone(ZoneId.systemDefault()) - .toLocalDate() - return localDate - .atTime(LocalTime.of(9, 0)) - .atZone(ZoneId.systemDefault()) - .toInstant() - .toEpochMilli() - } } diff --git a/app/src/main/java/fr/benju/tasks/notification/TaskReminderReceiver.kt b/app/src/main/java/fr/benju/tasks/notification/TaskReminderReceiver.kt index f66fe4e..9db109b 100644 --- a/app/src/main/java/fr/benju/tasks/notification/TaskReminderReceiver.kt +++ b/app/src/main/java/fr/benju/tasks/notification/TaskReminderReceiver.kt @@ -8,15 +8,37 @@ import android.content.pm.PackageManager import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat +import dagger.hilt.android.AndroidEntryPoint import fr.benju.tasks.R +import fr.benju.tasks.domain.model.RepeatInterval +import fr.benju.tasks.domain.service.ReminderScheduler +import fr.benju.tasks.domain.usecase.GetTaskByIdUseCase +import fr.benju.tasks.domain.usecase.UpdateTaskUseCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.time.Instant +import java.time.ZoneId +import javax.inject.Inject +@AndroidEntryPoint class TaskReminderReceiver : BroadcastReceiver() { + @Inject + lateinit var reminderScheduler: ReminderScheduler + + @Inject + lateinit var getTaskByIdUseCase: GetTaskByIdUseCase + + @Inject + lateinit var updateTaskUseCase: UpdateTaskUseCase + override fun onReceive(context: Context, intent: Intent) { val taskId = intent.getLongExtra(EXTRA_TASK_ID, -1L) val taskTitle = intent.getStringExtra(EXTRA_TASK_TITLE) ?: return if (taskId == -1L) return + // ── Show notification ──────────────────────────────────────────────── val hasPermission = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { ContextCompat.checkSelfPermission( context, @@ -25,21 +47,55 @@ class TaskReminderReceiver : BroadcastReceiver() { } else { true } - if (!hasPermission) return + if (hasPermission) { + val notification = NotificationCompat.Builder(context, NotificationHelper.CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_monochrome) + .setContentTitle(context.getString(R.string.notification_title)) + .setContentText(context.getString(R.string.notification_text, taskTitle)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .build() + + NotificationManagerCompat.from(context).notify(taskId.hashCode(), notification) + } - val notification = NotificationCompat.Builder(context, NotificationHelper.CHANNEL_ID) - .setSmallIcon(R.drawable.ic_launcher_monochrome) - .setContentTitle(context.getString(R.string.notification_title)) - .setContentText(context.getString(R.string.notification_text, taskTitle)) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setAutoCancel(true) - .build() + // ── Reschedule repeating tasks ──────────────────────────────────────── + val dueDateMs = intent.getLongExtra(EXTRA_DUE_DATE_MS, -1L) + val repeatInterval = runCatching { + RepeatInterval.valueOf(intent.getStringExtra(EXTRA_REPEAT_INTERVAL) ?: "NONE") + }.getOrDefault(RepeatInterval.NONE) - NotificationManagerCompat.from(context).notify(taskId.toInt(), notification) + if (repeatInterval != RepeatInterval.NONE && dueDateMs != -1L) { + val pendingResult = goAsync() + CoroutineScope(Dispatchers.IO).launch { + try { + val nextDueDate = computeNextOccurrence(dueDateMs, repeatInterval) + val task = getTaskByIdUseCase(taskId) + if (task != null) { + updateTaskUseCase(task.copy(dueDate = nextDueDate)) + reminderScheduler.schedule(taskId, taskTitle, nextDueDate, repeatInterval) + } + } finally { + pendingResult.finish() + } + } + } } companion object { const val EXTRA_TASK_ID = "extra_task_id" const val EXTRA_TASK_TITLE = "extra_task_title" + const val EXTRA_DUE_DATE_MS = "extra_due_date_ms" + const val EXTRA_REPEAT_INTERVAL = "extra_repeat_interval" + + fun computeNextOccurrence(currentMs: Long, interval: RepeatInterval): Long { + val zdt = Instant.ofEpochMilli(currentMs).atZone(ZoneId.systemDefault()) + return when (interval) { + RepeatInterval.DAILY -> zdt.plusDays(1) + RepeatInterval.WEEKLY -> zdt.plusWeeks(1) + RepeatInterval.MONTHLY -> zdt.plusMonths(1) + RepeatInterval.NONE -> zdt + }.toInstant().toEpochMilli() + } } } diff --git a/core/src/main/java/fr/benju/tasks/core/test/TaskTestFactory.kt b/core/src/main/java/fr/benju/tasks/core/test/TaskTestFactory.kt index c8c7d56..fbdab05 100644 --- a/core/src/main/java/fr/benju/tasks/core/test/TaskTestFactory.kt +++ b/core/src/main/java/fr/benju/tasks/core/test/TaskTestFactory.kt @@ -1,6 +1,7 @@ package fr.benju.tasks.core.test import fr.benju.tasks.domain.model.Priority +import fr.benju.tasks.domain.model.RepeatInterval import fr.benju.tasks.domain.model.Task object TaskTestFactory { @@ -12,7 +13,8 @@ object TaskTestFactory { priority: Priority = Priority.MEDIUM, isCompleted: Boolean = false, createdAt: Long = System.currentTimeMillis(), - dueDate: Long? = null + dueDate: Long? = null, + repeatInterval: RepeatInterval = RepeatInterval.NONE ): Task { return Task( id = id, @@ -21,7 +23,8 @@ object TaskTestFactory { priority = priority, isCompleted = isCompleted, createdAt = createdAt, - dueDate = dueDate + dueDate = dueDate, + repeatInterval = repeatInterval ) } diff --git a/data/src/main/java/fr/benju/tasks/data/database/TaskDatabase.kt b/data/src/main/java/fr/benju/tasks/data/database/TaskDatabase.kt index 00b48f3..5492eee 100644 --- a/data/src/main/java/fr/benju/tasks/data/database/TaskDatabase.kt +++ b/data/src/main/java/fr/benju/tasks/data/database/TaskDatabase.kt @@ -5,10 +5,11 @@ import androidx.room.RoomDatabase import fr.benju.tasks.data.database.dao.TaskDao import fr.benju.tasks.data.database.entity.TaskEntity import fr.benju.tasks.data.database.migration.MIGRATION_1_2 +import fr.benju.tasks.data.database.migration.MIGRATION_2_3 @Database( entities = [TaskEntity::class], - version = 2, + version = 3, exportSchema = false ) abstract class TaskDatabase : RoomDatabase() { @@ -16,6 +17,6 @@ abstract class TaskDatabase : RoomDatabase() { companion object { const val DATABASE_NAME = "task_manager_db" - val migrations = arrayOf(MIGRATION_1_2) + val migrations = arrayOf(MIGRATION_1_2, MIGRATION_2_3) } } diff --git a/data/src/main/java/fr/benju/tasks/data/database/entity/TaskEntity.kt b/data/src/main/java/fr/benju/tasks/data/database/entity/TaskEntity.kt index 89856a4..b8faac1 100644 --- a/data/src/main/java/fr/benju/tasks/data/database/entity/TaskEntity.kt +++ b/data/src/main/java/fr/benju/tasks/data/database/entity/TaskEntity.kt @@ -13,4 +13,5 @@ data class TaskEntity( val isCompleted: Boolean = false, val createdAt: Long = System.currentTimeMillis(), val dueDate: Long? = null, + val repeatInterval: String = "NONE", ) diff --git a/data/src/main/java/fr/benju/tasks/data/database/migration/Migration2to3.kt b/data/src/main/java/fr/benju/tasks/data/database/migration/Migration2to3.kt new file mode 100644 index 0000000..57c99fe --- /dev/null +++ b/data/src/main/java/fr/benju/tasks/data/database/migration/Migration2to3.kt @@ -0,0 +1,10 @@ +package fr.benju.tasks.data.database.migration + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE tasks ADD COLUMN repeatInterval TEXT NOT NULL DEFAULT 'NONE'") + } +} diff --git a/data/src/main/java/fr/benju/tasks/data/mapper/TaskMapper.kt b/data/src/main/java/fr/benju/tasks/data/mapper/TaskMapper.kt index 35d93c2..3645666 100644 --- a/data/src/main/java/fr/benju/tasks/data/mapper/TaskMapper.kt +++ b/data/src/main/java/fr/benju/tasks/data/mapper/TaskMapper.kt @@ -2,6 +2,7 @@ package fr.benju.tasks.data.mapper import fr.benju.tasks.data.database.entity.TaskEntity import fr.benju.tasks.domain.model.Priority +import fr.benju.tasks.domain.model.RepeatInterval import fr.benju.tasks.domain.model.Task import javax.inject.Inject @@ -15,7 +16,9 @@ class TaskMapper @Inject constructor() { priority = Priority.valueOf(entity.priority), isCompleted = entity.isCompleted, createdAt = entity.createdAt, - dueDate = entity.dueDate + dueDate = entity.dueDate, + repeatInterval = runCatching { RepeatInterval.valueOf(entity.repeatInterval) } + .getOrDefault(RepeatInterval.NONE) ) } @@ -27,7 +30,8 @@ class TaskMapper @Inject constructor() { priority = domain.priority.name, isCompleted = domain.isCompleted, createdAt = domain.createdAt, - dueDate = domain.dueDate + dueDate = domain.dueDate, + repeatInterval = domain.repeatInterval.name ) } } diff --git a/domain/src/main/java/fr/benju/tasks/domain/model/RepeatInterval.kt b/domain/src/main/java/fr/benju/tasks/domain/model/RepeatInterval.kt new file mode 100644 index 0000000..1872927 --- /dev/null +++ b/domain/src/main/java/fr/benju/tasks/domain/model/RepeatInterval.kt @@ -0,0 +1,8 @@ +package fr.benju.tasks.domain.model + +enum class RepeatInterval { + NONE, + DAILY, + WEEKLY, + MONTHLY, +} diff --git a/domain/src/main/java/fr/benju/tasks/domain/model/Task.kt b/domain/src/main/java/fr/benju/tasks/domain/model/Task.kt index 0373ebc..f16b693 100644 --- a/domain/src/main/java/fr/benju/tasks/domain/model/Task.kt +++ b/domain/src/main/java/fr/benju/tasks/domain/model/Task.kt @@ -8,4 +8,5 @@ data class Task( val isCompleted: Boolean = false, val createdAt: Long = System.currentTimeMillis(), val dueDate: Long? = null, + val repeatInterval: RepeatInterval = RepeatInterval.NONE, ) diff --git a/domain/src/main/java/fr/benju/tasks/domain/service/ReminderScheduler.kt b/domain/src/main/java/fr/benju/tasks/domain/service/ReminderScheduler.kt index 389cd9b..41c80b7 100644 --- a/domain/src/main/java/fr/benju/tasks/domain/service/ReminderScheduler.kt +++ b/domain/src/main/java/fr/benju/tasks/domain/service/ReminderScheduler.kt @@ -1,6 +1,8 @@ package fr.benju.tasks.domain.service +import fr.benju.tasks.domain.model.RepeatInterval + interface ReminderScheduler { - fun schedule(taskId: Long, taskTitle: String, dueDate: Long) + fun schedule(taskId: Long, taskTitle: String, dueDate: Long, repeatInterval: RepeatInterval) fun cancel(taskId: Long) } diff --git a/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorScreen.kt b/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorScreen.kt index 8330d56..526706b 100644 --- a/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorScreen.kt +++ b/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorScreen.kt @@ -3,6 +3,8 @@ package fr.benju.tasks.feature.taskeditor import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -14,8 +16,11 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import fr.benju.tasks.domain.model.Priority +import fr.benju.tasks.domain.model.RepeatInterval import java.time.Instant +import java.time.LocalDate import java.time.ZoneId +import java.time.ZoneOffset import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @@ -48,14 +53,22 @@ fun TaskEditorScreen( } } + // ── Date Picker ──────────────────────────────────────────────────────── if (viewState.showDatePicker) { - val datePickerState = rememberDatePickerState( - initialSelectedDateMillis = viewState.dueDate - ) + // Convert existing local dueDate to UTC date-only ms for the picker (avoids ±1 day shift) + val initialDateMs = viewState.dueDate?.let { localMs -> + Instant.ofEpochMilli(localMs) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .atStartOfDay(ZoneOffset.UTC) + .toInstant() + .toEpochMilli() + } + val datePickerState = rememberDatePickerState(initialSelectedDateMillis = initialDateMs) DatePickerDialog( onDismissRequest = { viewModel.hideDatePicker() }, confirmButton = { - TextButton(onClick = { viewModel.updateDueDate(datePickerState.selectedDateMillis) }) { + TextButton(onClick = { viewModel.onDateSelected(datePickerState.selectedDateMillis) }) { Text(stringResource(R.string.task_editor_save)) } }, @@ -69,6 +82,40 @@ fun TaskEditorScreen( } } + // ── Time Picker ───────────────────────────────────────────────────────── + if (viewState.showTimePicker) { + // Derive initial h/m from the existing dueDate if editing, otherwise default 09:00 + val existingZdt = viewState.dueDate?.let { + Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault()) + } + val timePickerState = rememberTimePickerState( + initialHour = existingZdt?.hour ?: TaskEditorViewModel.DEFAULT_HOUR, + initialMinute = existingZdt?.minute ?: TaskEditorViewModel.DEFAULT_MINUTE, + is24Hour = false + ) + AlertDialog( + onDismissRequest = { viewModel.onTimePickerDismissed() }, + title = { Text(stringResource(R.string.task_editor_field_time)) }, + text = { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth()) { + TimePicker(state = timePickerState) + } + }, + confirmButton = { + TextButton(onClick = { + viewModel.onTimeSelected(timePickerState.hour, timePickerState.minute) + }) { + Text(stringResource(R.string.task_editor_save)) + } + }, + dismissButton = { + TextButton(onClick = { viewModel.onTimePickerDismissed() }) { + Text(stringResource(R.string.task_editor_cancel)) + } + } + ) + } + Scaffold( topBar = { CenterAlignedTopAppBar( @@ -98,7 +145,8 @@ fun TaskEditorScreen( modifier = modifier .fillMaxSize() .padding(paddingValues) - .padding(16.dp), + .padding(16.dp) + .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp) ) { OutlinedTextField( @@ -120,6 +168,7 @@ fun TaskEditorScreen( maxLines = 5 ) + // ── Priority ────────────────────────────────────────────────── Text(stringResource(R.string.task_editor_field_priority), style = MaterialTheme.typography.titleMedium) Row( @@ -142,7 +191,7 @@ fun TaskEditorScreen( } } - // Due date row + // ── Due date & time ─────────────────────────────────────────── Text(stringResource(R.string.task_editor_field_due_date), style = MaterialTheme.typography.titleMedium) Row( @@ -154,17 +203,44 @@ fun TaskEditorScreen( onClick = { viewModel.showDatePicker() }, modifier = Modifier.weight(1f) ) { - val label = viewState.dueDate?.let { formatDueDate(it) } + val label = viewState.dueDate?.let { formatDueDateTime(it) } ?: stringResource(R.string.task_editor_due_date_none) Text(label) } if (viewState.dueDate != null) { - TextButton(onClick = { viewModel.updateDueDate(null) }) { + TextButton(onClick = { viewModel.clearDueDate() }) { Text(stringResource(R.string.task_editor_due_date_clear)) } } } + // ── Repeat ──────────────────────────────────────────────────── + if (viewState.dueDate != null) { + Text(stringResource(R.string.task_editor_field_repeat), style = MaterialTheme.typography.titleMedium) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + RepeatInterval.entries.forEach { interval -> + FilterChip( + selected = viewState.repeatInterval == interval, + onClick = { viewModel.updateRepeatInterval(interval) }, + label = { + Text( + when (interval) { + RepeatInterval.NONE -> stringResource(R.string.task_editor_repeat_none) + RepeatInterval.DAILY -> stringResource(R.string.task_editor_repeat_daily) + RepeatInterval.WEEKLY -> stringResource(R.string.task_editor_repeat_weekly) + RepeatInterval.MONTHLY -> stringResource(R.string.task_editor_repeat_monthly) + } + ) + } + ) + } + } + } + viewState.error?.let { errorRes -> Text( text = stringResource(errorRes), @@ -180,9 +256,9 @@ fun TaskEditorScreen( } } -private fun formatDueDate(epochMs: Long): String { - val localDate = Instant.ofEpochMilli(epochMs) - .atZone(ZoneId.systemDefault()) - .toLocalDate() - return localDate.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)) +private fun formatDueDateTime(epochMs: Long): String { + val zdt = Instant.ofEpochMilli(epochMs).atZone(ZoneId.systemDefault()) + val datePart = zdt.toLocalDate().format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)) + val timePart = zdt.toLocalTime().format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)) + return "$datePart • $timePart" } diff --git a/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewModel.kt b/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewModel.kt index c16bae3..446d9ef 100644 --- a/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewModel.kt +++ b/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import fr.benju.tasks.core.dispatchers.ICoroutineDispatchers import fr.benju.tasks.domain.model.Priority +import fr.benju.tasks.domain.model.RepeatInterval import fr.benju.tasks.domain.model.Task import fr.benju.tasks.domain.service.ReminderScheduler import fr.benju.tasks.domain.usecase.AddTaskUseCase @@ -15,6 +16,9 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZoneOffset import javax.inject.Inject @HiltViewModel @@ -40,7 +44,8 @@ class TaskEditorViewModel @Inject constructor( title = task.title, description = task.description, priority = task.priority, - dueDate = task.dueDate + dueDate = task.dueDate, + repeatInterval = task.repeatInterval ) } } @@ -57,10 +62,12 @@ class TaskEditorViewModel @Inject constructor( _viewState.value = _viewState.value.copy(priority = priority) } - fun updateDueDate(dateMs: Long?) { - _viewState.value = _viewState.value.copy(dueDate = dateMs, showDatePicker = false) + fun updateRepeatInterval(interval: RepeatInterval) { + _viewState.value = _viewState.value.copy(repeatInterval = interval) } + // ── Date picker ────────────────────────────────────────────────────────── + fun showDatePicker() { _viewState.value = _viewState.value.copy(showDatePicker = true) } @@ -69,6 +76,64 @@ class TaskEditorViewModel @Inject constructor( _viewState.value = _viewState.value.copy(showDatePicker = false) } + /** + * Called when the user confirms a date in the DatePickerDialog. + * [utcDateMs] is Material3 DatePickerState.selectedDateMillis (UTC midnight). + * We extract a LocalDate in UTC (time-zone agnostic) and store its epochDay, + * then immediately show the time picker. + */ + fun onDateSelected(utcDateMs: Long?) { + if (utcDateMs == null) { + _viewState.value = _viewState.value.copy(showDatePicker = false) + return + } + val epochDay = LocalDate.ofEpochDay(0) + .plusDays(utcDateMs / MILLIS_PER_DAY) + .toEpochDay() + _viewState.value = _viewState.value.copy( + showDatePicker = false, + showTimePicker = true, + pendingDateEpochDay = epochDay + ) + } + + // ── Time picker ────────────────────────────────────────────────────────── + + fun hideTimePicker() { + _viewState.value = _viewState.value.copy(showTimePicker = false, pendingDateEpochDay = null) + } + + /** + * Called when the user confirms a time. Combines [pendingDateEpochDay] + hour/minute + * in the system time zone to produce the final due-date epoch-ms. + * If dismissed without confirmation, falls back to 09:00 AM. + */ + fun onTimeSelected(hour: Int, minute: Int) { + val epochDay = _viewState.value.pendingDateEpochDay ?: return + val dueDate = combineDateAndTime(epochDay, hour, minute) + _viewState.value = _viewState.value.copy( + dueDate = dueDate, + showTimePicker = false, + pendingDateEpochDay = null + ) + } + + /** User dismissed the time picker → use 09:00 AM on the pending date. */ + fun onTimePickerDismissed() { + val epochDay = _viewState.value.pendingDateEpochDay + if (epochDay != null) { + onTimeSelected(DEFAULT_HOUR, DEFAULT_MINUTE) + } else { + _viewState.value = _viewState.value.copy(showTimePicker = false) + } + } + + fun clearDueDate() { + _viewState.value = _viewState.value.copy(dueDate = null) + } + + // ── Save ───────────────────────────────────────────────────────────────── + fun saveTask() { val state = _viewState.value if (state.title.isBlank()) { @@ -84,13 +149,16 @@ class TaskEditorViewModel @Inject constructor( title = state.title, description = state.description, priority = state.priority, - dueDate = state.dueDate + dueDate = state.dueDate, + repeatInterval = state.repeatInterval ) if (state.taskId == null) { addTaskUseCase(task).fold( onSuccess = { newId -> - state.dueDate?.let { reminderScheduler.schedule(newId, state.title, it) } + state.dueDate?.let { + reminderScheduler.schedule(newId, state.title, it, state.repeatInterval) + } _saveSuccess.emit(Unit) }, onFailure = { @@ -104,7 +172,9 @@ class TaskEditorViewModel @Inject constructor( updateTaskUseCase(task).fold( onSuccess = { reminderScheduler.cancel(state.taskId) - state.dueDate?.let { reminderScheduler.schedule(state.taskId, state.title, it) } + state.dueDate?.let { + reminderScheduler.schedule(state.taskId, state.title, it, state.repeatInterval) + } _saveSuccess.emit(Unit) }, onFailure = { @@ -117,4 +187,20 @@ class TaskEditorViewModel @Inject constructor( } } } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private fun combineDateAndTime(epochDay: Long, hour: Int, minute: Int): Long { + return LocalDate.ofEpochDay(epochDay) + .atTime(hour, minute) + .atZone(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + } + + companion object { + private const val MILLIS_PER_DAY = 86_400_000L + const val DEFAULT_HOUR = 9 + const val DEFAULT_MINUTE = 0 + } } diff --git a/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewState.kt b/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewState.kt index 5a1fe13..508b97f 100644 --- a/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewState.kt +++ b/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewState.kt @@ -2,14 +2,20 @@ package fr.benju.tasks.feature.taskeditor import androidx.annotation.StringRes import fr.benju.tasks.domain.model.Priority +import fr.benju.tasks.domain.model.RepeatInterval data class TaskEditorViewState( val taskId: Long? = null, val title: String = "", val description: String = "", val priority: Priority = Priority.MEDIUM, + /** Epoch-ms combining the chosen local date + time (not UTC midnight). Null = no due date. */ val dueDate: Long? = null, + /** Epoch-day (LocalDate.toEpochDay()) selected in DatePicker, waiting for time to be chosen. */ + val pendingDateEpochDay: Long? = null, + val repeatInterval: RepeatInterval = RepeatInterval.NONE, val showDatePicker: Boolean = false, + val showTimePicker: Boolean = false, val isSaving: Boolean = false, @StringRes val error: Int? = null ) diff --git a/feature/taskeditor/src/main/res/values-de/strings.xml b/feature/taskeditor/src/main/res/values-de/strings.xml index 8165fb3..611815c 100644 --- a/feature/taskeditor/src/main/res/values-de/strings.xml +++ b/feature/taskeditor/src/main/res/values-de/strings.xml @@ -10,9 +10,16 @@ Titel ist erforderlich Aufgabe konnte nicht gespeichert werden. Bitte erneut versuchen. - Fälligkeitsdatum + Fälligkeitsdatum & -uhrzeit Kein Fälligkeitsdatum Löschen + Uhrzeit wählen + + Wiederholen + Kein + Täglich + Wöchentlich + Monatlich Niedrig Mittel Hoch diff --git a/feature/taskeditor/src/main/res/values-es/strings.xml b/feature/taskeditor/src/main/res/values-es/strings.xml index f44b75f..86dcc38 100644 --- a/feature/taskeditor/src/main/res/values-es/strings.xml +++ b/feature/taskeditor/src/main/res/values-es/strings.xml @@ -10,9 +10,16 @@ El título es obligatorio No se pudo guardar la tarea. Por favor, inténtalo de nuevo. - Fecha de vencimiento + Fecha y hora de vencimiento Sin fecha de vencimiento Borrar + Elegir hora + + Repetir + Nunca + Diario + Semanal + Mensual Baja Media Alta diff --git a/feature/taskeditor/src/main/res/values-fr/strings.xml b/feature/taskeditor/src/main/res/values-fr/strings.xml index 51f72f7..2c721fe 100644 --- a/feature/taskeditor/src/main/res/values-fr/strings.xml +++ b/feature/taskeditor/src/main/res/values-fr/strings.xml @@ -10,9 +10,16 @@ Le titre est obligatoire Impossible d\'enregistrer la tâche. Veuillez réessayer. - Date d\'échéance + Date & heure d\'échéance Aucune date d\'échéance Effacer + Choisir une heure + + Répétition + Aucune + Quotidien + Hebdomadaire + Mensuel Faible Moyenne Haute diff --git a/feature/taskeditor/src/main/res/values-it/strings.xml b/feature/taskeditor/src/main/res/values-it/strings.xml index a75bb8d..0e6e270 100644 --- a/feature/taskeditor/src/main/res/values-it/strings.xml +++ b/feature/taskeditor/src/main/res/values-it/strings.xml @@ -10,9 +10,16 @@ Il titolo è obbligatorio Impossibile salvare l\'attività. Riprova. - Data di scadenza + Data e ora di scadenza Nessuna data di scadenza Cancella + Scegli un orario + + Ripeti + Mai + Giornaliero + Settimanale + Mensile Bassa Media Alta diff --git a/feature/taskeditor/src/main/res/values/strings.xml b/feature/taskeditor/src/main/res/values/strings.xml index 2df26d2..afb933d 100644 --- a/feature/taskeditor/src/main/res/values/strings.xml +++ b/feature/taskeditor/src/main/res/values/strings.xml @@ -11,10 +11,18 @@ Title is required Failed to save the task. Please try again. - - Due Date + + Due Date & Time No due date Clear + Pick a time + + + Repeat + None + Daily + Weekly + Monthly Low diff --git a/feature/taskeditor/src/test/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewModelTest.kt b/feature/taskeditor/src/test/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewModelTest.kt index d7c7dc0..caea135 100644 --- a/feature/taskeditor/src/test/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewModelTest.kt +++ b/feature/taskeditor/src/test/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewModelTest.kt @@ -28,7 +28,7 @@ class TaskEditorViewModelTest { private val updateTaskUseCase: UpdateTaskUseCase = mockk() private val getTaskByIdUseCase: GetTaskByIdUseCase = mockk() private val reminderScheduler: ReminderScheduler = mockk { - every { schedule(any(), any(), any()) } just Runs + every { schedule(any(), any(), any(), any()) } just Runs every { cancel(any()) } just Runs } diff --git a/feature/tasklist/src/main/java/fr/benju/tasks/feature/tasklist/TaskListViewModel.kt b/feature/tasklist/src/main/java/fr/benju/tasks/feature/tasklist/TaskListViewModel.kt index 315c2d2..ad56494 100644 --- a/feature/tasklist/src/main/java/fr/benju/tasks/feature/tasklist/TaskListViewModel.kt +++ b/feature/tasklist/src/main/java/fr/benju/tasks/feature/tasklist/TaskListViewModel.kt @@ -63,7 +63,7 @@ class TaskListViewModel @Inject constructor( reminderScheduler.cancel(taskId) } else { // Task was complete; it just became active — reschedule reminder - reminderScheduler.schedule(taskId, task.title, dueDate) + reminderScheduler.schedule(taskId, task.title, dueDate, task.repeatInterval) } } }.onFailure { error -> diff --git a/feature/tasklist/src/test/java/fr/benju/tasks/feature/tasklist/TaskListViewModelTest.kt b/feature/tasklist/src/test/java/fr/benju/tasks/feature/tasklist/TaskListViewModelTest.kt index 751beff..e3ab051 100644 --- a/feature/tasklist/src/test/java/fr/benju/tasks/feature/tasklist/TaskListViewModelTest.kt +++ b/feature/tasklist/src/test/java/fr/benju/tasks/feature/tasklist/TaskListViewModelTest.kt @@ -30,7 +30,7 @@ class TaskListViewModelTest { private val toggleTaskStatusUseCase: ToggleTaskStatusUseCase = mockk() private val deleteTaskUseCase: DeleteTaskUseCase = mockk() private val reminderScheduler: ReminderScheduler = mockk { - every { schedule(any(), any(), any()) } just Runs + every { schedule(any(), any(), any(), any()) } just Runs every { cancel(any()) } just Runs } diff --git a/ui/src/main/java/fr/benju/tasks/ui/components/TaskCard.kt b/ui/src/main/java/fr/benju/tasks/ui/components/TaskCard.kt index 5c5010f..46ee9cd 100644 --- a/ui/src/main/java/fr/benju/tasks/ui/components/TaskCard.kt +++ b/ui/src/main/java/fr/benju/tasks/ui/components/TaskCard.kt @@ -33,10 +33,10 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import fr.benju.tasks.domain.model.RepeatInterval import fr.benju.tasks.domain.model.Task import fr.benju.tasks.ui.R import java.time.Instant -import java.time.LocalDate import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @@ -124,19 +124,22 @@ fun TaskCard( PriorityChip(priority = task.priority) task.dueDate?.let { dueDateMs -> - val localDate = Instant.ofEpochMilli(dueDateMs) - .atZone(ZoneId.systemDefault()) - .toLocalDate() - val today = LocalDate.now(ZoneId.systemDefault()) - val isOverdue = !task.isCompleted && localDate.isBefore(today) - val formattedDate = localDate.format( - DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT) - ) + val zdt = Instant.ofEpochMilli(dueDateMs).atZone(ZoneId.systemDefault()) + val isOverdue = !task.isCompleted && Instant.ofEpochMilli(dueDateMs).isBefore(Instant.now()) + val datePart = zdt.toLocalDate().format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)) + val timePart = zdt.toLocalTime().format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)) + val formattedDateTime = "$datePart • $timePart" + val repeatSuffix = when (task.repeatInterval) { + RepeatInterval.DAILY -> " ↺" + RepeatInterval.WEEKLY -> " ↺" + RepeatInterval.MONTHLY -> " ↺" + RepeatInterval.NONE -> "" + } Text( text = stringResource( if (isOverdue) R.string.due_date_overdue else R.string.due_date_label, - formattedDate - ), + formattedDateTime + ) + repeatSuffix, style = MaterialTheme.typography.labelSmall, color = if (isOverdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant From 9cfde2265b46152534c0b73997ba88653c22bebf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 19:29:22 +0000 Subject: [PATCH 5/7] fix: address code review feedback (repeat suffix, epochDay simplification, 24h format detection) Agent-Logs-Url: https://github.com/benju69/Tasks/sessions/f6886525-4943-4cd6-989e-4a499293e3bf Co-authored-by: benju69 <2486590+benju69@users.noreply.github.com> --- .../fr/benju/tasks/feature/taskeditor/TaskEditorScreen.kt | 5 ++++- .../benju/tasks/feature/taskeditor/TaskEditorViewModel.kt | 4 +--- ui/src/main/java/fr/benju/tasks/ui/components/TaskCard.kt | 7 +------ 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorScreen.kt b/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorScreen.kt index 526706b..6c6947b 100644 --- a/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorScreen.kt +++ b/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorScreen.kt @@ -2,6 +2,7 @@ package fr.benju.tasks.feature.taskeditor +import android.text.format.DateFormat import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -11,6 +12,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -84,6 +86,7 @@ fun TaskEditorScreen( // ── Time Picker ───────────────────────────────────────────────────────── if (viewState.showTimePicker) { + val context = LocalContext.current // Derive initial h/m from the existing dueDate if editing, otherwise default 09:00 val existingZdt = viewState.dueDate?.let { Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault()) @@ -91,7 +94,7 @@ fun TaskEditorScreen( val timePickerState = rememberTimePickerState( initialHour = existingZdt?.hour ?: TaskEditorViewModel.DEFAULT_HOUR, initialMinute = existingZdt?.minute ?: TaskEditorViewModel.DEFAULT_MINUTE, - is24Hour = false + is24Hour = DateFormat.is24HourFormat(context) ) AlertDialog( onDismissRequest = { viewModel.onTimePickerDismissed() }, diff --git a/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewModel.kt b/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewModel.kt index 446d9ef..32ba2bb 100644 --- a/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewModel.kt +++ b/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewModel.kt @@ -87,9 +87,7 @@ class TaskEditorViewModel @Inject constructor( _viewState.value = _viewState.value.copy(showDatePicker = false) return } - val epochDay = LocalDate.ofEpochDay(0) - .plusDays(utcDateMs / MILLIS_PER_DAY) - .toEpochDay() + val epochDay = utcDateMs / MILLIS_PER_DAY _viewState.value = _viewState.value.copy( showDatePicker = false, showTimePicker = true, diff --git a/ui/src/main/java/fr/benju/tasks/ui/components/TaskCard.kt b/ui/src/main/java/fr/benju/tasks/ui/components/TaskCard.kt index 46ee9cd..fec2713 100644 --- a/ui/src/main/java/fr/benju/tasks/ui/components/TaskCard.kt +++ b/ui/src/main/java/fr/benju/tasks/ui/components/TaskCard.kt @@ -129,12 +129,7 @@ fun TaskCard( val datePart = zdt.toLocalDate().format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)) val timePart = zdt.toLocalTime().format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)) val formattedDateTime = "$datePart • $timePart" - val repeatSuffix = when (task.repeatInterval) { - RepeatInterval.DAILY -> " ↺" - RepeatInterval.WEEKLY -> " ↺" - RepeatInterval.MONTHLY -> " ↺" - RepeatInterval.NONE -> "" - } + val repeatSuffix = if (task.repeatInterval != RepeatInterval.NONE) " ↺" else "" Text( text = stringResource( if (isOverdue) R.string.due_date_overdue else R.string.due_date_label, From eb5bc9672a1352ef16f1a907dd5a60f40dad3e7a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 13:16:22 +0000 Subject: [PATCH 6/7] feat: request POST_NOTIFICATIONS permission when user sets due date & time Agent-Logs-Url: https://github.com/benju69/Tasks/sessions/7c00c938-dca7-4a52-8fb4-2b1e40afe766 Co-authored-by: benju69 <2486590+benju69@users.noreply.github.com> --- feature/taskeditor/build.gradle.kts | 1 + .../feature/taskeditor/TaskEditorScreen.kt | 28 +++++++++++++++++-- .../feature/taskeditor/TaskEditorViewModel.kt | 9 ++++-- .../feature/taskeditor/TaskEditorViewState.kt | 2 ++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/feature/taskeditor/build.gradle.kts b/feature/taskeditor/build.gradle.kts index 8ab5340..8279709 100644 --- a/feature/taskeditor/build.gradle.kts +++ b/feature/taskeditor/build.gradle.kts @@ -52,6 +52,7 @@ dependencies { implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.activity.compose) // Hilt implementation(libs.hilt.android) diff --git a/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorScreen.kt b/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorScreen.kt index 6c6947b..a681069 100644 --- a/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorScreen.kt +++ b/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorScreen.kt @@ -2,7 +2,12 @@ package fr.benju.tasks.feature.taskeditor +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build import android.text.format.DateFormat +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -15,12 +20,12 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import fr.benju.tasks.domain.model.Priority import fr.benju.tasks.domain.model.RepeatInterval import java.time.Instant -import java.time.LocalDate import java.time.ZoneId import java.time.ZoneOffset import java.time.format.DateTimeFormatter @@ -37,6 +42,26 @@ fun TaskEditorScreen( val viewState by viewModel.viewState.collectAsStateWithLifecycle() val currentOnTaskSaved by rememberUpdatedState(onTaskSaved) val titleFocusRequester = remember { FocusRequester() } + val context = LocalContext.current + + // ── Notification permission ──────────────────────────────────────────── + val notificationPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { /* result is informational; scheduling works either way (inexact fallback) */ } + + LaunchedEffect(viewState.requestNotificationPermission) { + if (viewState.requestNotificationPermission) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + viewModel.onNotificationPermissionHandled() + } + } LaunchedEffect(taskId) { if (taskId != null) { @@ -86,7 +111,6 @@ fun TaskEditorScreen( // ── Time Picker ───────────────────────────────────────────────────────── if (viewState.showTimePicker) { - val context = LocalContext.current // Derive initial h/m from the existing dueDate if editing, otherwise default 09:00 val existingZdt = viewState.dueDate?.let { Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault()) diff --git a/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewModel.kt b/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewModel.kt index 32ba2bb..ce7cf47 100644 --- a/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewModel.kt +++ b/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewModel.kt @@ -18,7 +18,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import java.time.LocalDate import java.time.ZoneId -import java.time.ZoneOffset import javax.inject.Inject @HiltViewModel @@ -112,10 +111,16 @@ class TaskEditorViewModel @Inject constructor( _viewState.value = _viewState.value.copy( dueDate = dueDate, showTimePicker = false, - pendingDateEpochDay = null + pendingDateEpochDay = null, + requestNotificationPermission = true ) } + /** Called by the UI once the notification-permission request has been launched. */ + fun onNotificationPermissionHandled() { + _viewState.value = _viewState.value.copy(requestNotificationPermission = false) + } + /** User dismissed the time picker → use 09:00 AM on the pending date. */ fun onTimePickerDismissed() { val epochDay = _viewState.value.pendingDateEpochDay diff --git a/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewState.kt b/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewState.kt index 508b97f..1e0bbc1 100644 --- a/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewState.kt +++ b/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorViewState.kt @@ -16,6 +16,8 @@ data class TaskEditorViewState( val repeatInterval: RepeatInterval = RepeatInterval.NONE, val showDatePicker: Boolean = false, val showTimePicker: Boolean = false, + /** Set to true after the user picks a date+time; consumed by the UI to request POST_NOTIFICATIONS. */ + val requestNotificationPermission: Boolean = false, val isSaving: Boolean = false, @StringRes val error: Int? = null ) From f0d068b5a906363f1e6985a955172f0056aab585 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 13:17:25 +0000 Subject: [PATCH 7/7] fix: consume permission flag before launching to prevent re-trigger on rotation Agent-Logs-Url: https://github.com/benju69/Tasks/sessions/7c00c938-dca7-4a52-8fb4-2b1e40afe766 Co-authored-by: benju69 <2486590+benju69@users.noreply.github.com> --- .../feature/taskeditor/TaskEditorScreen.kt | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorScreen.kt b/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorScreen.kt index a681069..eb6af1f 100644 --- a/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorScreen.kt +++ b/feature/taskeditor/src/main/java/fr/benju/tasks/feature/taskeditor/TaskEditorScreen.kt @@ -50,16 +50,16 @@ fun TaskEditorScreen( ) { /* result is informational; scheduling works either way (inexact fallback) */ } LaunchedEffect(viewState.requestNotificationPermission) { - if (viewState.requestNotificationPermission) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && - ContextCompat.checkSelfPermission( - context, - Manifest.permission.POST_NOTIFICATIONS - ) != PackageManager.PERMISSION_GRANTED - ) { - notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } - viewModel.onNotificationPermissionHandled() + if (!viewState.requestNotificationPermission) return@LaunchedEffect + // Always consume the flag; the actual launch only happens when needed (API 33+ and not granted). + viewModel.onNotificationPermissionHandled() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } }