feat: [#30] export et backup des données (JSON et iCalendar)

- ExportManager : génère backup JSON (BackupPayload sérialisable) et .ics via VTodoGenerator
- FileProvider déclaré dans AndroidManifest + res/xml/file_paths.xml (cache/exports/)
- @Serializable ajouté sur Project, Task, BackendType, ViewStyle, SortBy, ItemType
- TaskRepository/Impl/Dao : ajout getAllTasks() pour export global
- SettingsViewModel : exportJson(), exportIcal(), clearExportUri()
- SettingsScreen : section Export & Backup avec partage via Intent.ACTION_SEND

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 06:53:58 +02:00
parent bf6351fbb5
commit a8da951a33
11 changed files with 174 additions and 0 deletions
+10
View File
@@ -38,6 +38,16 @@
</intent-filter>
</receiver>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,73 @@
package com.planify.mobile.data.export
import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider
import com.planify.mobile.data.caldav.VTodoGenerator
import com.planify.mobile.domain.model.Project
import com.planify.mobile.domain.model.Task
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import javax.inject.Inject
import javax.inject.Singleton
@Serializable
data class BackupPayload(
val version: Int = 1,
val exportedAt: String,
val projects: List<Project>,
val tasks: List<Task>,
)
@Singleton
class ExportManager @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val json = Json { prettyPrint = true; ignoreUnknownKeys = true }
private val exportDir get() = File(context.cacheDir, "exports").also { it.mkdirs() }
private val authority = "${context.packageName}.fileprovider"
private fun timestamp() =
LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"))
fun exportJson(projects: List<Project>, tasks: List<Task>): Uri {
val file = File(exportDir, "planify_backup_${timestamp()}.json")
val payload = BackupPayload(
exportedAt = timestamp(),
projects = projects,
tasks = tasks,
)
file.writeText(json.encodeToString(payload))
return FileProvider.getUriForFile(context, authority, file)
}
fun exportIcal(tasks: List<Task>): Uri {
val file = File(exportDir, "planify_tasks_${timestamp()}.ics")
val sb = StringBuilder()
sb.appendLine("BEGIN:VCALENDAR")
sb.appendLine("VERSION:2.0")
sb.appendLine("PRODID:-//Planify Mobile//Android//EN")
sb.appendLine("CALSCALE:GREGORIAN")
tasks.forEach { task ->
val ical = VTodoGenerator.generate(task)
val start = ical.indexOf("BEGIN:VTODO")
val end = ical.indexOf("END:VTODO")
if (start >= 0 && end >= 0) {
sb.appendLine(ical.substring(start, end + "END:VTODO".length))
}
}
sb.append("END:VCALENDAR")
file.writeText(sb.toString())
return FileProvider.getUriForFile(context, authority, file)
}
fun parseJsonBackup(jsonString: String): BackupPayload? =
runCatching { json.decodeFromString<BackupPayload>(jsonString) }.getOrNull()
}
@@ -111,4 +111,8 @@ interface TaskDao {
// #25 — Reorder: update child_order for a single task
@Query("UPDATE tasks SET child_order = :order WHERE id = :id")
suspend fun updateChildOrder(id: String, order: Int)
// #30 — Export: all non-deleted tasks
@Query("SELECT * FROM tasks WHERE is_deleted = 0 ORDER BY project_id, child_order ASC")
fun getAllTasks(): Flow<List<TaskEntity>>
}
@@ -78,6 +78,9 @@ class TaskRepositoryImpl @Inject constructor(
orderedIds.forEachIndexed { index, id -> dao.updateChildOrder(id, index) }
}
override fun getAllTasks(): Flow<List<Task>> =
dao.getAllTasks().map { it.map { e -> e.toDomain() } }
private fun TaskEntity.toDomain() = Task(
id = id,
content = content,
@@ -1,3 +1,6 @@
package com.planify.mobile.domain.model
import kotlinx.serialization.Serializable
@Serializable
enum class BackendType { LOCAL, CALDAV, TODOIST }
@@ -1,5 +1,8 @@
package com.planify.mobile.domain.model
import kotlinx.serialization.Serializable
@Serializable
data class Project(
val id: String,
val name: String,
@@ -20,6 +23,8 @@ data class Project(
val syncId: String? = null,
)
@Serializable
enum class ViewStyle { LIST, BOARD }
@Serializable
enum class SortBy { MANUAL, NAME, DUE_DATE, ADDED_DATE, PRIORITY }
@@ -1,5 +1,8 @@
package com.planify.mobile.domain.model
import kotlinx.serialization.Serializable
@Serializable
data class Task(
val id: String,
val content: String,
@@ -25,4 +28,5 @@ data class Task(
val responsibleUid: String? = null,
)
@Serializable
enum class ItemType { TASK, NOTE }
@@ -22,4 +22,5 @@ interface TaskRepository {
fun getRepeatingTasks(): Flow<List<Task>>
fun getTasksByPriority(priority: Int): Flow<List<Task>>
suspend fun reorderTasks(orderedIds: List<String>)
fun getAllTasks(): Flow<List<Task>>
}
@@ -13,6 +13,7 @@ import androidx.compose.material.icons.Icons
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.Sync
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
@@ -22,6 +23,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
@@ -29,7 +31,9 @@ import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -37,6 +41,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.data.preferences.ThemeMode
@@ -49,7 +54,22 @@ fun SettingsScreen(
) {
val state by viewModel.uiState.collectAsState()
val discovery by viewModel.discoveryInProgress.collectAsState()
val exportUri by viewModel.exportUri.collectAsState()
var showAddAccount by remember { mutableStateOf(false) }
val context = LocalContext.current
LaunchedEffect(exportUri) {
exportUri?.let { uri ->
val mime = if (uri.path?.endsWith(".ics") == true) "text/calendar" else "application/json"
val intent = Intent(Intent.ACTION_SEND).apply {
type = mime
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "Exporter"))
viewModel.clearExportUri()
}
}
Column(
modifier = Modifier
@@ -171,6 +191,29 @@ fun SettingsScreen(
)
}
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
// ── Export & Backup ──────────────────────────────────────────────────
SectionTitle("Export & Backup")
OutlinedButton(
onClick = viewModel::exportJson,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
) {
Icon(Icons.Outlined.Download, contentDescription = null)
Text("Exporter en JSON", modifier = Modifier.padding(start = 8.dp))
}
OutlinedButton(
onClick = viewModel::exportIcal,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
) {
Icon(Icons.Outlined.Download, contentDescription = null)
Text("Exporter en iCalendar (.ics)", modifier = Modifier.padding(start = 8.dp))
}
Spacer(Modifier.height(32.dp))
}
@@ -1,20 +1,25 @@
package com.planify.mobile.ui.settings
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.planify.mobile.data.caldav.CalDavCredentialStore
import com.planify.mobile.data.caldav.CalDavDiscovery
import com.planify.mobile.data.caldav.DiscoveryResult
import com.planify.mobile.data.export.ExportManager
import com.planify.mobile.data.preferences.AppPreferences
import com.planify.mobile.data.preferences.ThemeMode
import com.planify.mobile.data.sync.SyncScheduler
import com.planify.mobile.domain.model.Source
import com.planify.mobile.domain.repository.ProjectRepository
import com.planify.mobile.domain.repository.SourceRepository
import com.planify.mobile.domain.repository.TaskRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -37,6 +42,9 @@ class SettingsViewModel @Inject constructor(
private val syncScheduler: SyncScheduler,
private val discovery: CalDavDiscovery,
private val credentialStore: CalDavCredentialStore,
private val exportManager: ExportManager,
private val projectRepository: ProjectRepository,
private val taskRepository: TaskRepository,
) : ViewModel() {
val uiState = combine(
@@ -100,4 +108,20 @@ class SettingsViewModel @Inject constructor(
credentialStore.deletePassword(source.id)
sourceRepository.deleteSource(source.id)
}
private val _exportUri = MutableStateFlow<Uri?>(null)
val exportUri = _exportUri.asStateFlow()
fun exportJson() = viewModelScope.launch {
val projects = projectRepository.getAllProjects().first()
val tasks = taskRepository.getAllTasks().first()
_exportUri.value = exportManager.exportJson(projects, tasks)
}
fun exportIcal() = viewModelScope.launch {
val tasks = taskRepository.getAllTasks().first()
_exportUri.value = exportManager.exportIcal(tasks)
}
fun clearExportUri() { _exportUri.value = null }
}
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="exports" path="exports/" />
</paths>