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