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