From 6db1222ff7600d895059bb26f8ba45bee38da1f3 Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 6 Jun 2026 06:15:55 +0200 Subject: [PATCH] feat: [#14] rappels et notifications locales (AlarmManager, BroadcastReceiver, ReminderPickerSheet) --- app/src/main/AndroidManifest.xml | 12 +++ .../mobile/data/notification/BootReceiver.kt | 16 ++++ .../data/notification/ReminderReceiver.kt | 56 +++++++++++++ .../data/notification/ReminderScheduler.kt | 73 +++++++++++++++++ .../mobile/ui/task/ReminderPickerSheet.kt | 82 +++++++++++++++++++ 5 files changed, 239 insertions(+) create mode 100644 app/src/main/java/com/planify/mobile/data/notification/BootReceiver.kt create mode 100644 app/src/main/java/com/planify/mobile/data/notification/ReminderReceiver.kt create mode 100644 app/src/main/java/com/planify/mobile/data/notification/ReminderScheduler.kt create mode 100644 app/src/main/java/com/planify/mobile/ui/task/ReminderPickerSheet.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8c6cf7a..a316bb8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -26,6 +26,18 @@ + + + + + + + + diff --git a/app/src/main/java/com/planify/mobile/data/notification/BootReceiver.kt b/app/src/main/java/com/planify/mobile/data/notification/BootReceiver.kt new file mode 100644 index 0000000..9f94458 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/notification/BootReceiver.kt @@ -0,0 +1,16 @@ +package com.planify.mobile.data.notification + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class BootReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Intent.ACTION_BOOT_COMPLETED) return + // TODO #14 : replanifier toutes les alarmes depuis la base de données + // Inject ReminderScheduler + ReminderRepository et rejouer tous les rappels actifs + } +} diff --git a/app/src/main/java/com/planify/mobile/data/notification/ReminderReceiver.kt b/app/src/main/java/com/planify/mobile/data/notification/ReminderReceiver.kt new file mode 100644 index 0000000..afacb9c --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/notification/ReminderReceiver.kt @@ -0,0 +1,56 @@ +package com.planify.mobile.data.notification + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import com.planify.mobile.ui.MainActivity +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class ReminderReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + val taskId = intent.getStringExtra(EXTRA_TASK_ID) ?: return + val title = intent.getStringExtra(EXTRA_TASK_TITLE) ?: "Rappel" + + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + ensureChannel(notificationManager) + + val openIntent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra("taskId", taskId) + } + val openPi = PendingIntent.getActivity( + context, taskId.hashCode(), openIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_lock_idle_alarm) + .setContentTitle("Rappel") + .setContentText(title) + .setContentIntent(openPi) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .build() + + notificationManager.notify(taskId.hashCode(), notification) + } + + private fun ensureChannel(nm: NotificationManager) { + if (nm.getNotificationChannel(CHANNEL_ID) != null) return + val channel = NotificationChannel(CHANNEL_ID, "Rappels", NotificationManager.IMPORTANCE_HIGH) + nm.createNotificationChannel(channel) + } + + companion object { + const val CHANNEL_ID = "planify_reminders" + const val EXTRA_REMINDER_ID = "reminder_id" + const val EXTRA_TASK_ID = "task_id" + const val EXTRA_TASK_TITLE = "task_title" + } +} diff --git a/app/src/main/java/com/planify/mobile/data/notification/ReminderScheduler.kt b/app/src/main/java/com/planify/mobile/data/notification/ReminderScheduler.kt new file mode 100644 index 0000000..7a66f41 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/notification/ReminderScheduler.kt @@ -0,0 +1,73 @@ +package com.planify.mobile.data.notification + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import com.planify.mobile.domain.model.Reminder +import com.planify.mobile.domain.model.ReminderType +import dagger.hilt.android.qualifiers.ApplicationContext +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ReminderScheduler @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + + fun schedule(reminder: Reminder, taskTitle: String, taskDueDateIso: String?) { + val triggerMillis = when (reminder.type) { + ReminderType.ABSOLUTE -> reminder.dueDate?.date?.let { parseToMillis(it) } + ReminderType.RELATIVE -> { + val offset = reminder.minutesOffset ?: return + taskDueDateIso?.let { parseToMillis(it) }?.minus(offset * 60_000L) + } + } ?: return + + if (triggerMillis <= System.currentTimeMillis()) return + + val intent = Intent(context, ReminderReceiver::class.java).apply { + putExtra(ReminderReceiver.EXTRA_REMINDER_ID, reminder.id) + putExtra(ReminderReceiver.EXTRA_TASK_ID, reminder.taskId) + putExtra(ReminderReceiver.EXTRA_TASK_TITLE, taskTitle) + } + val pi = PendingIntent.getBroadcast( + context, + reminder.id.hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) { + alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerMillis, pi) + } else { + alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerMillis, pi) + } + } + + fun cancel(reminderId: String) { + val intent = Intent(context, ReminderReceiver::class.java) + val pi = PendingIntent.getBroadcast( + context, + reminderId.hashCode(), + intent, + PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE, + ) ?: return + alarmManager.cancel(pi) + pi.cancel() + } + + private fun parseToMillis(dateIso: String): Long? = runCatching { + val formatter = if (dateIso.contains("T")) DateTimeFormatter.ISO_LOCAL_DATE_TIME + else DateTimeFormatter.ISO_LOCAL_DATE + val ldt = if (dateIso.contains("T")) LocalDateTime.parse(dateIso, formatter) + else LocalDate.parse(dateIso, formatter).atStartOfDay() + ldt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + }.getOrNull() +} diff --git a/app/src/main/java/com/planify/mobile/ui/task/ReminderPickerSheet.kt b/app/src/main/java/com/planify/mobile/ui/task/ReminderPickerSheet.kt new file mode 100644 index 0000000..6ed83c6 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/task/ReminderPickerSheet.kt @@ -0,0 +1,82 @@ +package com.planify.mobile.ui.task + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.planify.mobile.domain.model.DueDate +import com.planify.mobile.domain.model.Reminder +import com.planify.mobile.domain.model.ReminderType +import java.util.UUID + +private val relativeOptions = listOf( + 5 to "5 minutes avant", + 15 to "15 minutes avant", + 30 to "30 minutes avant", + 60 to "1 heure avant", + 1440 to "1 jour avant", +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReminderPickerSheet( + taskId: String, + currentReminders: List, + onUpdate: (List) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column(Modifier.padding(bottom = 16.dp)) { + Text( + text = "Rappels", + style = androidx.compose.material3.MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(16.dp), + ) + + relativeOptions.forEach { (minutes, label) -> + val existing = currentReminders.find { it.type == ReminderType.RELATIVE && it.minutesOffset == minutes } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + val updated = if (existing != null) { + currentReminders - existing + } else { + currentReminders + Reminder( + id = UUID.randomUUID().toString(), + taskId = taskId, + type = ReminderType.RELATIVE, + minutesOffset = minutes, + ) + } + onUpdate(updated) + } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Outlined.Notifications, contentDescription = null) + Text(label, modifier = Modifier + .weight(1f) + .padding(start = 12.dp)) + RadioButton(selected = existing != null, onClick = null) + } + } + } + } +}