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.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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user