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