feat: [#14] rappels et notifications locales (AlarmManager, BroadcastReceiver, ReminderPickerSheet)

This commit is contained in:
2026-06-06 06:15:55 +02:00
parent 0f1afda295
commit 6db1222ff7
5 changed files with 239 additions and 0 deletions
+12
View File
@@ -26,6 +26,18 @@
</intent-filter> </intent-filter>
</activity> </activity>
<receiver
android:name=".data.notification.ReminderReceiver"
android:exported="false" />
<receiver
android:name=".data.notification.BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application> </application>
</manifest> </manifest>
@@ -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
}
}
@@ -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"
}
}
@@ -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()
}
@@ -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<Reminder>,
onUpdate: (List<Reminder>) -> 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)
}
}
}
}
}