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.SourceCalDavData
import com.planify.mobile.domain.model.SourceType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
import java.io.StringReader
@@ -21,17 +23,17 @@ sealed class DiscoveryResult {
@Singleton
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 normalizedBase = baseUrl.trimEnd('/')
// Step 1: resolve principal URL
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
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
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? {
@@ -24,7 +24,7 @@ import com.planify.mobile.data.local.entity.TaskEntity
ReminderEntity::class,
SourceEntity::class,
],
version = 1,
version = 2,
exportSchema = true,
)
abstract class AppDatabase : RoomDatabase() {
@@ -45,4 +45,5 @@ data class TaskEntity(
@ColumnInfo(name = "ical_url") val icalUrl: String? = null,
val etag: 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,
dueDate = dueDate?.let { Json.encodeToString(DueDate.serializer(), it) },
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,
collapsed = collapsed,
childOrder = childOrder,
@@ -3,6 +3,7 @@ package com.planify.mobile.ui.settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.Delete
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.Warning
import androidx.compose.material3.AlertDialog
@@ -58,6 +60,7 @@ fun SettingsScreen(
val discovery by viewModel.discoveryInProgress.collectAsState()
val exportUri by viewModel.exportUri.collectAsState()
var showAddAccount by remember { mutableStateOf(false) }
var editingSource by remember { mutableStateOf<com.planify.mobile.domain.model.Source?>(null) }
val context = LocalContext.current
LaunchedEffect(exportUri) {
@@ -156,7 +159,11 @@ fun SettingsScreen(
// ── Comptes CalDAV ────────────────────────────────────────────────────
SectionTitle("Comptes CalDAV")
state.caldavSources.forEach { source ->
CalDavSourceRow(source = source, onDelete = { viewModel.removeCalDavAccount(source) })
CalDavSourceRow(
source = source,
onEdit = { editingSource = source },
onDelete = { viewModel.removeCalDavAccount(source) },
)
}
ListItem(
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
@@ -242,7 +260,7 @@ private fun SectionTitle(text: String) {
}
@Composable
private fun CalDavSourceRow(source: Source, onDelete: () -> Unit) {
private fun CalDavSourceRow(source: Source, onEdit: () -> Unit, onDelete: () -> Unit) {
val connectionFailed = source.caldavData?.calendarHomeUrl == null
ListItem(
leadingContent = {
@@ -266,13 +284,71 @@ private fun CalDavSourceRow(source: Source, onDelete: () -> Unit) {
}
},
trailingContent = {
Row {
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
private fun AddCalDavAccountDialog(
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 {
credentialStore.deletePassword(source.id)
sourceRepository.deleteSource(source.id)
@@ -34,6 +34,7 @@ import com.planify.mobile.domain.model.DueDate
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@@ -46,7 +47,7 @@ fun DueDatePickerSheet(
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val datePickerState = rememberDatePickerState(
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) }
@@ -103,7 +104,7 @@ fun DueDatePickerSheet(
TextButton(onClick = {
val millis = datePickerState.selectedDateMillis
val date = millis?.let {
Instant.ofEpochMilli(it).atZone(ZoneId.UTC).toLocalDate().toString()
Instant.ofEpochMilli(it).atZone(ZoneOffset.UTC).toLocalDate().toString()
} ?: return@TextButton
onConfirm(DueDate(date = date, isRecurring = currentDueDate?.isRecurring ?: false,
recurrencyType = currentDueDate?.recurrencyType ?: com.planify.mobile.domain.model.RecurrencyType.NONE))
@@ -119,7 +120,7 @@ fun DueDatePickerSheet(
showRecurrence = false
val millis = datePickerState.selectedDateMillis
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()
onConfirm(recDueDate?.copy(date = date))
},
+1
View File
@@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.application) 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.ksp) apply false
alias(libs.plugins.kotlin.serialization) apply false
+13 -11
View File
@@ -1,20 +1,21 @@
[versions]
agp = "8.4.0"
kotlin = "1.9.24"
agp = "8.7.3"
kotlin = "2.0.21"
ksp = "2.0.21-1.0.28"
coreKtx = "1.13.1"
lifecycleRuntimeKtx = "2.8.3"
activityCompose = "1.9.0"
composeBom = "2024.06.00"
hilt = "2.51.1"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.9.3"
composeBom = "2024.12.01"
hilt = "2.52"
hiltNavigationCompose = "1.2.0"
navigationCompose = "2.7.7"
navigationCompose = "2.8.5"
room = "2.6.1"
coroutines = "1.8.1"
coroutines = "1.9.0"
okhttp = "4.12.0"
datastore = "1.1.1"
securityCrypto = "1.1.0-alpha06"
workManager = "2.9.0"
serialization = "1.6.3"
workManager = "2.10.0"
serialization = "1.7.3"
junit = "4.13.2"
junitExt = "1.2.1"
espressoCore = "3.6.1"
@@ -56,6 +57,7 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
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" }
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" }