fix: champ is_deleted manquant dans TaskEntity, ZoneOffset.UTC, sérialisation des labels, toolchain Kotlin 2.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 07:45:48 +02:00
parent 5356e957ba
commit 0fd300ffdc
9 changed files with 141 additions and 24 deletions
@@ -4,6 +4,8 @@ import com.planify.mobile.domain.model.CalDavType
import com.planify.mobile.domain.model.Source import com.planify.mobile.domain.model.Source
import com.planify.mobile.domain.model.SourceCalDavData import com.planify.mobile.domain.model.SourceCalDavData
import com.planify.mobile.domain.model.SourceType import com.planify.mobile.domain.model.SourceType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory import org.xmlpull.v1.XmlPullParserFactory
import java.io.StringReader import java.io.StringReader
@@ -21,17 +23,17 @@ sealed class DiscoveryResult {
@Singleton @Singleton
class CalDavDiscovery @Inject constructor(private val client: CalDavClient) { class CalDavDiscovery @Inject constructor(private val client: CalDavClient) {
suspend fun discover(baseUrl: String, username: String, password: String): DiscoveryResult { suspend fun discover(baseUrl: String, username: String, password: String): DiscoveryResult = withContext(Dispatchers.IO) {
val credentials = CalDavClient.basicCredentials(username, password) val credentials = CalDavClient.basicCredentials(username, password)
val normalizedBase = baseUrl.trimEnd('/') val normalizedBase = baseUrl.trimEnd('/')
// Step 1: resolve principal URL // Step 1: resolve principal URL
val principalUrl = resolvePrincipalUrl(normalizedBase, credentials, username) val principalUrl = resolvePrincipalUrl(normalizedBase, credentials, username)
?: return DiscoveryResult.Failure("Impossible de trouver le principal CalDAV") ?: return@withContext DiscoveryResult.Failure("Impossible de trouver le principal CalDAV")
// Step 2: find calendar home // Step 2: find calendar home
val calendarHome = resolveCalendarHome(principalUrl, credentials) val calendarHome = resolveCalendarHome(principalUrl, credentials)
?: return DiscoveryResult.Failure("Impossible de trouver le calendar home") ?: return@withContext DiscoveryResult.Failure("Impossible de trouver le calendar home")
// Step 3: list VTODO-capable calendars // Step 3: list VTODO-capable calendars
val calendars = listCalendars(calendarHome, credentials, username, baseUrl) val calendars = listCalendars(calendarHome, credentials, username, baseUrl)
@@ -54,7 +56,7 @@ class CalDavDiscovery @Inject constructor(private val client: CalDavClient) {
), ),
) )
} }
return DiscoveryResult.Success(sources) DiscoveryResult.Success(sources)
} }
private fun resolvePrincipalUrl(baseUrl: String, credentials: String, username: String): String? { private fun resolvePrincipalUrl(baseUrl: String, credentials: String, username: String): String? {
@@ -24,7 +24,7 @@ import com.planify.mobile.data.local.entity.TaskEntity
ReminderEntity::class, ReminderEntity::class,
SourceEntity::class, SourceEntity::class,
], ],
version = 1, version = 2,
exportSchema = true, exportSchema = true,
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@@ -45,4 +45,5 @@ data class TaskEntity(
@ColumnInfo(name = "ical_url") val icalUrl: String? = null, @ColumnInfo(name = "ical_url") val icalUrl: String? = null,
val etag: String? = null, val etag: String? = null,
@ColumnInfo(name = "responsible_uid") val responsibleUid: String? = null, @ColumnInfo(name = "responsible_uid") val responsibleUid: String? = null,
@ColumnInfo(name = "is_deleted") val isDeleted: Boolean = false,
) )
@@ -117,7 +117,7 @@ class TaskRepositoryImpl @Inject constructor(
checked = checked, checked = checked,
dueDate = dueDate?.let { Json.encodeToString(DueDate.serializer(), it) }, dueDate = dueDate?.let { Json.encodeToString(DueDate.serializer(), it) },
deadlineDate = deadlineDate, deadlineDate = deadlineDate,
labels = Json.encodeToString(kotlinx.serialization.builtins.ListSerializer(kotlinx.serialization.builtins.serializer()), labels), labels = Json.encodeToString(kotlinx.serialization.builtins.ListSerializer(kotlinx.serialization.serializer<String>()), labels),
pinned = pinned, pinned = pinned,
collapsed = collapsed, collapsed = collapsed,
childOrder = childOrder, childOrder = childOrder,
@@ -3,6 +3,7 @@ package com.planify.mobile.ui.settings
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -14,6 +15,7 @@ import androidx.compose.material.icons.outlined.AccountCircle
import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Sync import androidx.compose.material.icons.outlined.Sync
import androidx.compose.material.icons.outlined.Warning import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@@ -58,6 +60,7 @@ fun SettingsScreen(
val discovery by viewModel.discoveryInProgress.collectAsState() val discovery by viewModel.discoveryInProgress.collectAsState()
val exportUri by viewModel.exportUri.collectAsState() val exportUri by viewModel.exportUri.collectAsState()
var showAddAccount by remember { mutableStateOf(false) } var showAddAccount by remember { mutableStateOf(false) }
var editingSource by remember { mutableStateOf<com.planify.mobile.domain.model.Source?>(null) }
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(exportUri) { LaunchedEffect(exportUri) {
@@ -156,7 +159,11 @@ fun SettingsScreen(
// ── Comptes CalDAV ──────────────────────────────────────────────────── // ── Comptes CalDAV ────────────────────────────────────────────────────
SectionTitle("Comptes CalDAV") SectionTitle("Comptes CalDAV")
state.caldavSources.forEach { source -> state.caldavSources.forEach { source ->
CalDavSourceRow(source = source, onDelete = { viewModel.removeCalDavAccount(source) }) CalDavSourceRow(
source = source,
onEdit = { editingSource = source },
onDelete = { viewModel.removeCalDavAccount(source) },
)
} }
ListItem( ListItem(
headlineContent = { Text("Ajouter un compte") }, headlineContent = { Text("Ajouter un compte") },
@@ -229,6 +236,17 @@ fun SettingsScreen(
}, },
) )
} }
editingSource?.let { source ->
EditCalDavAccountDialog(
source = source,
onDismiss = { editingSource = null },
onConfirm = { url, user, pwd ->
viewModel.updateCalDavAccount(source, url, user, pwd)
editingSource = null
},
)
}
} }
@Composable @Composable
@@ -242,7 +260,7 @@ private fun SectionTitle(text: String) {
} }
@Composable @Composable
private fun CalDavSourceRow(source: Source, onDelete: () -> Unit) { private fun CalDavSourceRow(source: Source, onEdit: () -> Unit, onDelete: () -> Unit) {
val connectionFailed = source.caldavData?.calendarHomeUrl == null val connectionFailed = source.caldavData?.calendarHomeUrl == null
ListItem( ListItem(
leadingContent = { leadingContent = {
@@ -266,13 +284,71 @@ private fun CalDavSourceRow(source: Source, onDelete: () -> Unit) {
} }
}, },
trailingContent = { trailingContent = {
IconButton(onClick = onDelete) { Row {
Icon(Icons.Outlined.Delete, contentDescription = "Supprimer", tint = MaterialTheme.colorScheme.error) IconButton(onClick = onEdit) {
Icon(Icons.Outlined.Edit, contentDescription = "Modifier")
}
Spacer(Modifier.width(4.dp))
IconButton(onClick = onDelete) {
Icon(Icons.Outlined.Delete, contentDescription = "Supprimer", tint = MaterialTheme.colorScheme.error)
}
} }
}, },
) )
} }
@Composable
private fun EditCalDavAccountDialog(
source: Source,
onDismiss: () -> Unit,
onConfirm: (url: String, username: String, password: String) -> Unit,
) {
var url by remember { mutableStateOf(source.caldavData?.serverUrl ?: "") }
var username by remember { mutableStateOf(source.caldavData?.username ?: "") }
var password by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Modifier le compte CalDAV") },
text = {
Column {
OutlinedTextField(
value = url,
onValueChange = { url = it },
label = { Text("URL du serveur") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Nom d'utilisateur") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Nouveau mot de passe") },
placeholder = { Text("Laisser vide pour conserver") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
)
}
},
confirmButton = {
Button(
onClick = { onConfirm(url.trim(), username.trim(), password) },
enabled = url.isNotBlank() && username.isNotBlank(),
) { Text("Enregistrer") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Annuler") } },
)
}
@Composable @Composable
private fun AddCalDavAccountDialog( private fun AddCalDavAccountDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
@@ -126,6 +126,40 @@ class SettingsViewModel @Inject constructor(
} }
} }
fun updateCalDavAccount(source: Source, newUrl: String, newUsername: String, newPassword: String) {
_discoveryState.update { true to null }
val effectivePassword = newPassword.ifBlank { credentialStore.getPassword(source.id) }
viewModelScope.launch {
when (val result = discovery.discover(newUrl, newUsername, effectivePassword)) {
is DiscoveryResult.Success -> {
credentialStore.deletePassword(source.id)
sourceRepository.deleteSource(source.id)
result.sources.forEach { s ->
credentialStore.savePassword(s.id, effectivePassword)
sourceRepository.insertSource(s)
}
_discoveryState.update { false to null }
if (uiState.value.syncEnabled) syncScheduler.schedule()
}
is DiscoveryResult.Failure -> {
val now = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
val updated = source.copy(
displayName = newUsername,
updatedAt = now,
caldavData = source.caldavData?.copy(
serverUrl = newUrl,
username = newUsername,
calendarHomeUrl = null,
),
)
credentialStore.savePassword(source.id, effectivePassword)
sourceRepository.updateSource(updated)
_discoveryState.update { false to result.message }
}
}
}
}
fun removeCalDavAccount(source: Source) = viewModelScope.launch { fun removeCalDavAccount(source: Source) = viewModelScope.launch {
credentialStore.deletePassword(source.id) credentialStore.deletePassword(source.id)
sourceRepository.deleteSource(source.id) sourceRepository.deleteSource(source.id)
@@ -34,6 +34,7 @@ import com.planify.mobile.domain.model.DueDate
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -46,7 +47,7 @@ fun DueDatePickerSheet(
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val datePickerState = rememberDatePickerState( val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = currentDueDate?.date initialSelectedDateMillis = currentDueDate?.date
?.let { runCatching { LocalDate.parse(it).atStartOfDay(ZoneId.UTC).toInstant().toEpochMilli() }.getOrNull() } ?.let { runCatching { LocalDate.parse(it).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() }.getOrNull() }
) )
var showRecurrence by remember { mutableStateOf(false) } var showRecurrence by remember { mutableStateOf(false) }
@@ -103,7 +104,7 @@ fun DueDatePickerSheet(
TextButton(onClick = { TextButton(onClick = {
val millis = datePickerState.selectedDateMillis val millis = datePickerState.selectedDateMillis
val date = millis?.let { val date = millis?.let {
Instant.ofEpochMilli(it).atZone(ZoneId.UTC).toLocalDate().toString() Instant.ofEpochMilli(it).atZone(ZoneOffset.UTC).toLocalDate().toString()
} ?: return@TextButton } ?: return@TextButton
onConfirm(DueDate(date = date, isRecurring = currentDueDate?.isRecurring ?: false, onConfirm(DueDate(date = date, isRecurring = currentDueDate?.isRecurring ?: false,
recurrencyType = currentDueDate?.recurrencyType ?: com.planify.mobile.domain.model.RecurrencyType.NONE)) recurrencyType = currentDueDate?.recurrencyType ?: com.planify.mobile.domain.model.RecurrencyType.NONE))
@@ -119,7 +120,7 @@ fun DueDatePickerSheet(
showRecurrence = false showRecurrence = false
val millis = datePickerState.selectedDateMillis val millis = datePickerState.selectedDateMillis
val date = millis?.let { val date = millis?.let {
Instant.ofEpochMilli(it).atZone(ZoneId.UTC).toLocalDate().toString() Instant.ofEpochMilli(it).atZone(ZoneOffset.UTC).toLocalDate().toString()
} ?: currentDueDate?.date ?: LocalDate.now().toString() } ?: currentDueDate?.date ?: LocalDate.now().toString()
onConfirm(recDueDate?.copy(date = date)) onConfirm(recDueDate?.copy(date = date))
}, },
+1
View File
@@ -1,6 +1,7 @@
plugins { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.jetbrains.kotlin.android) apply false alias(libs.plugins.jetbrains.kotlin.android) apply false
alias(libs.plugins.compose.compiler) 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 alias(libs.plugins.kotlin.serialization) apply false
+13 -11
View File
@@ -1,20 +1,21 @@
[versions] [versions]
agp = "8.4.0" agp = "8.7.3"
kotlin = "1.9.24" kotlin = "2.0.21"
ksp = "2.0.21-1.0.28"
coreKtx = "1.13.1" coreKtx = "1.13.1"
lifecycleRuntimeKtx = "2.8.3" lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.9.0" activityCompose = "1.9.3"
composeBom = "2024.06.00" composeBom = "2024.12.01"
hilt = "2.51.1" hilt = "2.52"
hiltNavigationCompose = "1.2.0" hiltNavigationCompose = "1.2.0"
navigationCompose = "2.7.7" navigationCompose = "2.8.5"
room = "2.6.1" room = "2.6.1"
coroutines = "1.8.1" coroutines = "1.9.0"
okhttp = "4.12.0" 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.10.0"
serialization = "1.6.3" serialization = "1.7.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"
@@ -56,6 +57,7 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } 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" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", 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.ref = "ksp" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }