From 520971ccaa59ece65e969e4c0242c62598f0d4e7 Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 6 Jun 2026 06:11:02 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20[#12]=20dates=20d'=C3=A9ch=C3=A9ance,?= =?UTF-8?q?=20r=C3=A9currence=20(DueDatePickerSheet,=20RecurrencePickerShe?= =?UTF-8?q?et,=20RRuleBuilder,=20@Serializable)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 4 + .../planify/mobile/domain/model/DueDate.kt | 4 + .../mobile/domain/util/RRuleBuilder.kt | 75 ++++++++++ .../mobile/ui/task/DueDatePickerSheet.kt | 129 ++++++++++++++++++ .../mobile/ui/task/RecurrencePickerSheet.kt | 113 +++++++++++++++ build.gradle.kts | 1 + gradle/libs.versions.toml | 3 + 7 files changed, 329 insertions(+) create mode 100644 app/src/main/java/com/planify/mobile/domain/util/RRuleBuilder.kt create mode 100644 app/src/main/java/com/planify/mobile/ui/task/DueDatePickerSheet.kt create mode 100644 app/src/main/java/com/planify/mobile/ui/task/RecurrencePickerSheet.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 201015e..1d36efa 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/com/planify/mobile/domain/model/DueDate.kt b/app/src/main/java/com/planify/mobile/domain/model/DueDate.kt index 72e7508..9d85c3f 100644 --- a/app/src/main/java/com/planify/mobile/domain/model/DueDate.kt +++ b/app/src/main/java/com/planify/mobile/domain/model/DueDate.kt @@ -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 } diff --git a/app/src/main/java/com/planify/mobile/domain/util/RRuleBuilder.kt b/app/src/main/java/com/planify/mobile/domain/util/RRuleBuilder.kt new file mode 100644 index 0000000..971d7af --- /dev/null +++ b/app/src/main/java/com/planify/mobile/domain/util/RRuleBuilder.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/planify/mobile/ui/task/DueDatePickerSheet.kt b/app/src/main/java/com/planify/mobile/ui/task/DueDatePickerSheet.kt new file mode 100644 index 0000000..427958e --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/task/DueDatePickerSheet.kt @@ -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 }, + ) + } +} diff --git a/app/src/main/java/com/planify/mobile/ui/task/RecurrencePickerSheet.kt b/app/src/main/java/com/planify/mobile/ui/task/RecurrencePickerSheet.kt new file mode 100644 index 0000000..8bab636 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/task/RecurrencePickerSheet.kt @@ -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)) + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 0adef57..c848b72 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,4 +3,5 @@ plugins { alias(libs.plugins.jetbrains.kotlin.android) apply false alias(libs.plugins.hilt) apply false alias(libs.plugins.ksp) apply false + alias(libs.plugins.kotlin.serialization) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d308142..23fc40d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ okhttp = "4.12.0" datastore = "1.1.1" securityCrypto = "1.1.0-alpha06" workManager = "2.9.0" +serialization = "1.6.3" junit = "4.13.2" junitExt = "1.2.1" espressoCore = "3.6.1" @@ -47,6 +48,7 @@ okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitExt" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } @@ -56,3 +58,4 @@ android-application = { id = "com.android.application", version.ref = "agp" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } ksp = { id = "com.google.devtools.ksp", version = "1.9.24-1.0.20" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }