diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ad117e1..2292d46 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..027c13f
--- /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!!, task.repeatInterval)
+ }
+ } 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..0485bad
--- /dev/null
+++ b/app/src/main/java/fr/benju/tasks/notification/ReminderSchedulerImpl.kt
@@ -0,0 +1,61 @@
+package fr.benju.tasks.notification
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+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 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,
+ 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.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, dueDate, pendingIntent)
+ } else {
+ alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, dueDate, pendingIntent)
+ }
+ }
+
+ override fun cancel(taskId: Long) {
+ val intent = Intent(context, TaskReminderReceiver::class.java)
+ val pendingIntent = PendingIntent.getBroadcast(
+ context,
+ taskId.hashCode(),
+ intent,
+ PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
+ ) ?: return
+
+ val alarmManager = context.getSystemService(AlarmManager::class.java)
+ alarmManager.cancel(pendingIntent)
+ pendingIntent.cancel()
+ }
+}
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..9db109b
--- /dev/null
+++ b/app/src/main/java/fr/benju/tasks/notification/TaskReminderReceiver.kt
@@ -0,0 +1,101 @@
+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 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,
+ Manifest.permission.POST_NOTIFICATIONS
+ ) == PackageManager.PERMISSION_GRANTED
+ } else {
+ true
+ }
+ 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)
+ }
+
+ // ── 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)
+
+ 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/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..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 {
@@ -11,7 +12,9 @@ object TaskTestFactory {
description: String = "Test Description",
priority: Priority = Priority.MEDIUM,
isCompleted: Boolean = false,
- createdAt: Long = System.currentTimeMillis()
+ createdAt: Long = System.currentTimeMillis(),
+ dueDate: Long? = null,
+ repeatInterval: RepeatInterval = RepeatInterval.NONE
): Task {
return Task(
id = id,
@@ -19,7 +22,9 @@ object TaskTestFactory {
description = description,
priority = priority,
isCompleted = isCompleted,
- createdAt = createdAt
+ createdAt = createdAt,
+ 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 3115584..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
@@ -4,10 +4,12 @@ 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
+import fr.benju.tasks.data.database.migration.MIGRATION_2_3
@Database(
entities = [TaskEntity::class],
- version = 1,
+ version = 3,
exportSchema = false
)
abstract class TaskDatabase : RoomDatabase() {
@@ -15,5 +17,6 @@ abstract class TaskDatabase : RoomDatabase() {
companion object {
const val DATABASE_NAME = "task_manager_db"
+ 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 3a82e9b..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
@@ -11,5 +11,7 @@ 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,
+ val repeatInterval: String = "NONE",
)
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/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/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/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..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
@@ -14,7 +15,10 @@ class TaskMapper @Inject constructor() {
description = entity.description,
priority = Priority.valueOf(entity.priority),
isCompleted = entity.isCompleted,
- createdAt = entity.createdAt
+ createdAt = entity.createdAt,
+ dueDate = entity.dueDate,
+ repeatInterval = runCatching { RepeatInterval.valueOf(entity.repeatInterval) }
+ .getOrDefault(RepeatInterval.NONE)
)
}
@@ -25,7 +29,9 @@ class TaskMapper @Inject constructor() {
description = domain.description,
priority = domain.priority.name,
isCompleted = domain.isCompleted,
- createdAt = domain.createdAt
+ createdAt = domain.createdAt,
+ 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 a2a0a24..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
@@ -7,4 +7,6 @@ data class Task(
val priority: Priority,
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
new file mode 100644
index 0000000..41c80b7
--- /dev/null
+++ b/domain/src/main/java/fr/benju/tasks/domain/service/ReminderScheduler.kt
@@ -0,0 +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, repeatInterval: RepeatInterval)
+ fun cancel(taskId: Long)
+}
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 2d020d9..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
@@ -2,17 +2,34 @@
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
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
+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.ZoneId
+import java.time.ZoneOffset
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
@Composable
fun TaskEditorScreen(
@@ -25,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) 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)
+ }
+ }
LaunchedEffect(taskId) {
if (taskId != null) {
@@ -43,6 +80,69 @@ fun TaskEditorScreen(
}
}
+ // ── Date Picker ────────────────────────────────────────────────────────
+ if (viewState.showDatePicker) {
+ // 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.onDateSelected(datePickerState.selectedDateMillis) }) {
+ Text(stringResource(R.string.task_editor_save))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { viewModel.hideDatePicker() }) {
+ Text(stringResource(R.string.task_editor_cancel))
+ }
+ }
+ ) {
+ DatePicker(state = datePickerState)
+ }
+ }
+
+ // ── 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 = DateFormat.is24HourFormat(context)
+ )
+ 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(
@@ -72,7 +172,8 @@ fun TaskEditorScreen(
modifier = modifier
.fillMaxSize()
.padding(paddingValues)
- .padding(16.dp),
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
OutlinedTextField(
@@ -94,6 +195,7 @@ fun TaskEditorScreen(
maxLines = 5
)
+ // ── Priority ──────────────────────────────────────────────────
Text(stringResource(R.string.task_editor_field_priority), style = MaterialTheme.typography.titleMedium)
Row(
@@ -116,6 +218,56 @@ fun TaskEditorScreen(
}
}
+ // ── Due date & time ───────────────────────────────────────────
+ 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 { formatDueDateTime(it) }
+ ?: stringResource(R.string.task_editor_due_date_none)
+ Text(label)
+ }
+ if (viewState.dueDate != 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),
@@ -130,3 +282,10 @@ fun TaskEditorScreen(
}
}
}
+
+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 16478db..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
@@ -4,7 +4,9 @@ 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
import fr.benju.tasks.domain.usecase.GetTaskByIdUseCase
import fr.benju.tasks.domain.usecase.UpdateTaskUseCase
@@ -14,6 +16,8 @@ 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 javax.inject.Inject
@HiltViewModel
@@ -21,7 +25,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 +42,9 @@ class TaskEditorViewModel @Inject constructor(
taskId = task.id,
title = task.title,
description = task.description,
- priority = task.priority
+ priority = task.priority,
+ dueDate = task.dueDate,
+ repeatInterval = task.repeatInterval
)
}
}
@@ -54,6 +61,82 @@ class TaskEditorViewModel @Inject constructor(
_viewState.value = _viewState.value.copy(priority = priority)
}
+ fun updateRepeatInterval(interval: RepeatInterval) {
+ _viewState.value = _viewState.value.copy(repeatInterval = interval)
+ }
+
+ // ── Date picker ──────────────────────────────────────────────────────────
+
+ fun showDatePicker() {
+ _viewState.value = _viewState.value.copy(showDatePicker = true)
+ }
+
+ fun hideDatePicker() {
+ _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 = utcDateMs / MILLIS_PER_DAY
+ _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,
+ 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
+ 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()) {
@@ -68,26 +151,59 @@ class TaskEditorViewModel @Inject constructor(
id = state.taskId ?: 0,
title = state.title,
description = state.description,
- priority = state.priority
+ priority = state.priority,
+ dueDate = state.dueDate,
+ repeatInterval = state.repeatInterval
)
- val result = if (state.taskId == null) {
- addTaskUseCase(task)
+ if (state.taskId == null) {
+ addTaskUseCase(task).fold(
+ onSuccess = { newId ->
+ state.dueDate?.let {
+ reminderScheduler.schedule(newId, state.title, it, state.repeatInterval)
+ }
+ _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, state.repeatInterval)
+ }
+ _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
- )
- }
- )
}
}
+
+ // ── 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 175ade1..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
@@ -2,12 +2,22 @@ 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,
+ /** 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
)
diff --git a/feature/taskeditor/src/main/res/values-de/strings.xml b/feature/taskeditor/src/main/res/values-de/strings.xml
index c127626..611815c 100644
--- a/feature/taskeditor/src/main/res/values-de/strings.xml
+++ b/feature/taskeditor/src/main/res/values-de/strings.xml
@@ -9,6 +9,17 @@
Priorität
Titel ist erforderlich
Aufgabe konnte nicht gespeichert werden. Bitte erneut versuchen.
+
+ 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 86a75d1..86dcc38 100644
--- a/feature/taskeditor/src/main/res/values-es/strings.xml
+++ b/feature/taskeditor/src/main/res/values-es/strings.xml
@@ -9,6 +9,17 @@
Prioridad
El título es obligatorio
No se pudo guardar la tarea. Por favor, inténtalo de nuevo.
+
+ 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 924f425..2c721fe 100644
--- a/feature/taskeditor/src/main/res/values-fr/strings.xml
+++ b/feature/taskeditor/src/main/res/values-fr/strings.xml
@@ -9,6 +9,17 @@
Priorité
Le titre est obligatoire
Impossible d\'enregistrer la tâche. Veuillez réessayer.
+
+ 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 4899c5a..0e6e270 100644
--- a/feature/taskeditor/src/main/res/values-it/strings.xml
+++ b/feature/taskeditor/src/main/res/values-it/strings.xml
@@ -9,6 +9,17 @@
Priorità
Il titolo è obbligatorio
Impossibile salvare l\'attività. Riprova.
+
+ 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 c5dc098..afb933d 100644
--- a/feature/taskeditor/src/main/res/values/strings.xml
+++ b/feature/taskeditor/src/main/res/values/strings.xml
@@ -11,6 +11,19 @@
Title is required
Failed to save the task. Please try again.
+
+ Due Date & Time
+ No due date
+ Clear
+ Pick a time
+
+
+ Repeat
+ None
+ Daily
+ Weekly
+ Monthly
+
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..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
@@ -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(), 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..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
@@ -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,20 @@ 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 {
+ 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, dueDate, task.repeatInterval)
+ }
+ }
+ }.onFailure { error ->
_viewState.value = _viewState.value.copy(error = error.message)
}
}
@@ -60,7 +74,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..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
@@ -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(), 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..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
@@ -33,8 +33,13 @@ 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.ZoneId
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
@Composable
fun TaskCard(
@@ -112,7 +117,30 @@ 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 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 = if (task.repeatInterval != RepeatInterval.NONE) " ↺" else ""
+ Text(
+ text = stringResource(
+ if (isOverdue) R.string.due_date_overdue else R.string.due_date_label,
+ formattedDateTime
+ ) + repeatSuffix,
+ 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