feat: [#12] dates d'échéance, récurrence (DueDatePickerSheet, RecurrencePickerSheet, RRuleBuilder, @Serializable)

This commit is contained in:
2026-06-06 06:11:02 +02:00
parent 3ab7a48384
commit 520971ccaa
7 changed files with 329 additions and 0 deletions
+4
View File
@@ -3,6 +3,7 @@ plugins {
alias(libs.plugins.jetbrains.kotlin.android)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.serialization)
}
android {
@@ -82,6 +83,9 @@ dependencies {
// WorkManager
implementation(libs.work.runtime.ktx)
// Serialization
implementation(libs.kotlinx.serialization.json)
// Tests
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
@@ -1,5 +1,8 @@
package com.planify.mobile.domain.model
import kotlinx.serialization.Serializable
@Serializable
data class DueDate(
val date: String,
val timezone: String? = null,
@@ -11,6 +14,7 @@ data class DueDate(
val recurrencyEnd: String? = null,
)
@Serializable
enum class RecurrencyType {
NONE, MINUTELY, HOURLY, EVERY_DAY, EVERY_WEEK, EVERY_MONTH, EVERY_YEAR
}
@@ -0,0 +1,75 @@
package com.planify.mobile.domain.util
import com.planify.mobile.domain.model.DueDate
import com.planify.mobile.domain.model.RecurrencyType
object RRuleBuilder {
private val weekDayNames = listOf("MO", "TU", "WE", "TH", "FR", "SA", "SU")
fun build(dueDate: DueDate): String? {
if (!dueDate.isRecurring || dueDate.recurrencyType == RecurrencyType.NONE) return null
val freq = when (dueDate.recurrencyType) {
RecurrencyType.MINUTELY -> "MINUTELY"
RecurrencyType.HOURLY -> "HOURLY"
RecurrencyType.EVERY_DAY -> "DAILY"
RecurrencyType.EVERY_WEEK -> "WEEKLY"
RecurrencyType.EVERY_MONTH -> "MONTHLY"
RecurrencyType.EVERY_YEAR -> "YEARLY"
RecurrencyType.NONE -> return null
}
val parts = mutableListOf("FREQ=$freq")
if (dueDate.recurrencyInterval > 1) {
parts.add("INTERVAL=${dueDate.recurrencyInterval}")
}
if (dueDate.recurrencyType == RecurrencyType.EVERY_WEEK && dueDate.recurrencyWeekDays.isNotEmpty()) {
val days = dueDate.recurrencyWeekDays
.filter { it in 0..6 }
.joinToString(",") { weekDayNames[it] }
parts.add("BYDAY=$days")
}
dueDate.recurrencyCount?.let { parts.add("COUNT=$it") }
dueDate.recurrencyEnd?.let { parts.add("UNTIL=${it.replace("-", "").take(8)}T000000Z") }
return "RRULE:${parts.joinToString(";")}"
}
fun parse(rrule: String): DueDate? {
val line = rrule.removePrefix("RRULE:")
val props = line.split(";").associate {
val (k, v) = it.split("=", limit = 2)
k to v
}
val type = when (props["FREQ"]) {
"MINUTELY" -> RecurrencyType.MINUTELY
"HOURLY" -> RecurrencyType.HOURLY
"DAILY" -> RecurrencyType.EVERY_DAY
"WEEKLY" -> RecurrencyType.EVERY_WEEK
"MONTHLY" -> RecurrencyType.EVERY_MONTH
"YEARLY" -> RecurrencyType.EVERY_YEAR
else -> return null
}
val weekDays = props["BYDAY"]?.split(",")?.mapNotNull { weekDayNames.indexOf(it).takeIf { i -> i >= 0 } } ?: emptyList()
val count = props["COUNT"]?.toIntOrNull()
val until = props["UNTIL"]?.let { raw ->
if (raw.length >= 8) "${raw.take(4)}-${raw.substring(4, 6)}-${raw.substring(6, 8)}" else null
}
return DueDate(
date = "",
isRecurring = true,
recurrencyType = type,
recurrencyInterval = props["INTERVAL"]?.toIntOrNull() ?: 1,
recurrencyWeekDays = weekDays,
recurrencyCount = count,
recurrencyEnd = until,
)
}
}
@@ -0,0 +1,129 @@
package com.planify.mobile.ui.task
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material.icons.outlined.Repeat
import androidx.compose.material3.DatePicker
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.planify.mobile.domain.model.DueDate
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DueDatePickerSheet(
currentDueDate: DueDate?,
onConfirm: (DueDate?) -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = currentDueDate?.date
?.let { runCatching { LocalDate.parse(it).atStartOfDay(ZoneId.UTC).toInstant().toEpochMilli() }.getOrNull() }
)
var showRecurrence by remember { mutableStateOf(false) }
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(Modifier.padding(horizontal = 16.dp)) {
Text("Date d'échéance", style = androidx.compose.material3.MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
val today = LocalDate.now()
listOf(
"Aujourd'hui" to today,
"Demain" to today.plusDays(1),
"Semaine prochaine" to today.plusWeeks(1),
).forEach { (label, date) ->
SuggestionChip(
onClick = {
onConfirm(DueDate(date = date.toString(), isRecurring = currentDueDate?.isRecurring ?: false))
},
label = { Text(label) },
)
}
}
DatePicker(state = datePickerState, showModeToggle = false)
HorizontalDivider()
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { showRecurrence = true }
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Outlined.Repeat, contentDescription = null)
Spacer(Modifier.width(12.dp))
Text("Récurrence")
if (currentDueDate?.isRecurring == true) {
Spacer(Modifier.weight(1f))
Text(currentDueDate.recurrencyType.name.lowercase().replace("_", " "))
}
}
HorizontalDivider()
Spacer(Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = { onConfirm(null) }) {
Icon(Icons.Outlined.Clear, contentDescription = null)
Spacer(Modifier.width(4.dp))
Text("Supprimer")
}
Spacer(Modifier.weight(1f))
TextButton(onClick = onDismiss) { Text("Annuler") }
TextButton(onClick = {
val millis = datePickerState.selectedDateMillis
val date = millis?.let {
Instant.ofEpochMilli(it).atZone(ZoneId.UTC).toLocalDate().toString()
} ?: return@TextButton
onConfirm(DueDate(date = date, isRecurring = currentDueDate?.isRecurring ?: false,
recurrencyType = currentDueDate?.recurrencyType ?: com.planify.mobile.domain.model.RecurrencyType.NONE))
}) { Text("OK") }
}
}
}
if (showRecurrence) {
RecurrencePickerSheet(
current = currentDueDate,
onConfirm = { recDueDate ->
showRecurrence = false
val millis = datePickerState.selectedDateMillis
val date = millis?.let {
Instant.ofEpochMilli(it).atZone(ZoneId.UTC).toLocalDate().toString()
} ?: currentDueDate?.date ?: LocalDate.now().toString()
onConfirm(recDueDate?.copy(date = date))
},
onDismiss = { showRecurrence = false },
)
}
}
@@ -0,0 +1,113 @@
package com.planify.mobile.ui.task
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import com.planify.mobile.domain.model.DueDate
import com.planify.mobile.domain.model.RecurrencyType
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RecurrencePickerSheet(
current: DueDate?,
onConfirm: (DueDate?) -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var selectedType by remember { mutableStateOf(current?.recurrencyType ?: RecurrencyType.EVERY_DAY) }
var interval by remember { mutableStateOf(current?.recurrencyInterval ?: 1) }
var weekDays by remember { mutableStateOf(current?.recurrencyWeekDays ?: emptyList()) }
val options = listOf(
RecurrencyType.EVERY_DAY to "Chaque jour",
RecurrencyType.EVERY_WEEK to "Chaque semaine",
RecurrencyType.EVERY_MONTH to "Chaque mois",
RecurrencyType.EVERY_YEAR to "Chaque année",
)
val dayLabels = listOf("L", "M", "M", "J", "V", "S", "D")
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(
Modifier
.padding(horizontal = 16.dp)
.selectableGroup()
) {
Text("Récurrence", style = androidx.compose.material3.MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(12.dp))
options.forEach { (type, label) ->
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(selected = selectedType == type, onClick = { selectedType = type }, role = Role.RadioButton)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = selectedType == type, onClick = null)
Text(label, modifier = Modifier.padding(start = 12.dp))
}
}
if (selectedType == RecurrencyType.EVERY_WEEK) {
Spacer(Modifier.height(8.dp))
HorizontalDivider()
Spacer(Modifier.height(8.dp))
Text("Jours de la semaine", style = androidx.compose.material3.MaterialTheme.typography.labelMedium)
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
dayLabels.forEachIndexed { index, label ->
FilterChip(
selected = index in weekDays,
onClick = {
weekDays = if (index in weekDays) weekDays - index else weekDays + index
},
label = { Text(label) },
)
}
}
}
Spacer(Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = { onConfirm(null) }) { Text("Pas de récurrence") }
Spacer(Modifier.weight(1f))
TextButton(onClick = onDismiss) { Text("Annuler") }
TextButton(onClick = {
onConfirm(
DueDate(
date = current?.date ?: "",
isRecurring = true,
recurrencyType = selectedType,
recurrencyInterval = interval,
recurrencyWeekDays = if (selectedType == RecurrencyType.EVERY_WEEK) weekDays else emptyList(),
)
)
}) { Text("OK") }
}
Spacer(Modifier.height(8.dp))
}
}
}