feat: [#14] rappels et notifications locales (AlarmManager, BroadcastReceiver, ReminderPickerSheet)
This commit is contained in:
@@ -26,6 +26,18 @@
|
||||
</intent-filter>
|
||||
</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>
|
||||
|
||||
</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