feat: [#12] dates d'échéance, récurrence (DueDatePickerSheet, RecurrencePickerSheet, RRuleBuilder, @Serializable)
This commit is contained in:
@@ -3,6 +3,7 @@ plugins {
|
|||||||
alias(libs.plugins.jetbrains.kotlin.android)
|
alias(libs.plugins.jetbrains.kotlin.android)
|
||||||
alias(libs.plugins.hilt)
|
alias(libs.plugins.hilt)
|
||||||
alias(libs.plugins.ksp)
|
alias(libs.plugins.ksp)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -82,6 +83,9 @@ dependencies {
|
|||||||
// WorkManager
|
// WorkManager
|
||||||
implementation(libs.work.runtime.ktx)
|
implementation(libs.work.runtime.ktx)
|
||||||
|
|
||||||
|
// Serialization
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
// Tests
|
// Tests
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.planify.mobile.domain.model
|
package com.planify.mobile.domain.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class DueDate(
|
data class DueDate(
|
||||||
val date: String,
|
val date: String,
|
||||||
val timezone: String? = null,
|
val timezone: String? = null,
|
||||||
@@ -11,6 +14,7 @@ data class DueDate(
|
|||||||
val recurrencyEnd: String? = null,
|
val recurrencyEnd: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
enum class RecurrencyType {
|
enum class RecurrencyType {
|
||||||
NONE, MINUTELY, HOURLY, EVERY_DAY, EVERY_WEEK, EVERY_MONTH, EVERY_YEAR
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,4 +3,5 @@ plugins {
|
|||||||
alias(libs.plugins.jetbrains.kotlin.android) apply false
|
alias(libs.plugins.jetbrains.kotlin.android) apply false
|
||||||
alias(libs.plugins.hilt) apply false
|
alias(libs.plugins.hilt) apply false
|
||||||
alias(libs.plugins.ksp) apply false
|
alias(libs.plugins.ksp) apply false
|
||||||
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ okhttp = "4.12.0"
|
|||||||
datastore = "1.1.1"
|
datastore = "1.1.1"
|
||||||
securityCrypto = "1.1.0-alpha06"
|
securityCrypto = "1.1.0-alpha06"
|
||||||
workManager = "2.9.0"
|
workManager = "2.9.0"
|
||||||
|
serialization = "1.6.3"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
junitExt = "1.2.1"
|
junitExt = "1.2.1"
|
||||||
espressoCore = "3.6.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" }
|
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||||
security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
|
security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
|
||||||
work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" }
|
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" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitExt" }
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitExt" }
|
||||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
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" }
|
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||||
ksp = { id = "com.google.devtools.ksp", version = "1.9.24-1.0.20" }
|
ksp = { id = "com.google.devtools.ksp", version = "1.9.24-1.0.20" }
|
||||||
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
|
|||||||
Reference in New Issue
Block a user