feat: [#14] rappels et notifications locales (AlarmManager, BroadcastReceiver, ReminderPickerSheet)
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user