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> </intent-filter>
</receiver> </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> </application>
</manifest> </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 // #25 — Reorder: update child_order for a single task
@Query("UPDATE tasks SET child_order = :order WHERE id = :id") @Query("UPDATE tasks SET child_order = :order WHERE id = :id")
suspend fun updateChildOrder(id: String, order: Int) 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) } 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( private fun TaskEntity.toDomain() = Task(
id = id, id = id,
content = content, content = content,
@@ -1,3 +1,6 @@
package com.planify.mobile.domain.model package com.planify.mobile.domain.model
import kotlinx.serialization.Serializable
@Serializable
enum class BackendType { LOCAL, CALDAV, TODOIST } enum class BackendType { LOCAL, CALDAV, TODOIST }
@@ -1,5 +1,8 @@
package com.planify.mobile.domain.model package com.planify.mobile.domain.model
import kotlinx.serialization.Serializable
@Serializable
data class Project( data class Project(
val id: String, val id: String,
val name: String, val name: String,
@@ -20,6 +23,8 @@ data class Project(
val syncId: String? = null, val syncId: String? = null,
) )
@Serializable
enum class ViewStyle { LIST, BOARD } enum class ViewStyle { LIST, BOARD }
@Serializable
enum class SortBy { MANUAL, NAME, DUE_DATE, ADDED_DATE, PRIORITY } enum class SortBy { MANUAL, NAME, DUE_DATE, ADDED_DATE, PRIORITY }
@@ -1,5 +1,8 @@
package com.planify.mobile.domain.model package com.planify.mobile.domain.model
import kotlinx.serialization.Serializable
@Serializable
data class Task( data class Task(
val id: String, val id: String,
val content: String, val content: String,
@@ -25,4 +28,5 @@ data class Task(
val responsibleUid: String? = null, val responsibleUid: String? = null,
) )
@Serializable
enum class ItemType { TASK, NOTE } enum class ItemType { TASK, NOTE }
@@ -22,4 +22,5 @@ interface TaskRepository {
fun getRepeatingTasks(): Flow<List<Task>> fun getRepeatingTasks(): Flow<List<Task>>
fun getTasksByPriority(priority: Int): Flow<List<Task>> fun getTasksByPriority(priority: Int): Flow<List<Task>>
suspend fun reorderTasks(orderedIds: List<String>) 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.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.Sync import androidx.compose.material.icons.outlined.Sync
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -22,6 +23,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SegmentedButtonDefaults
@@ -29,7 +31,9 @@ import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import android.content.Intent
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -37,6 +41,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.data.preferences.ThemeMode import com.planify.mobile.data.preferences.ThemeMode
@@ -49,7 +54,22 @@ fun SettingsScreen(
) { ) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val discovery by viewModel.discoveryInProgress.collectAsState() val discovery by viewModel.discoveryInProgress.collectAsState()
val exportUri by viewModel.exportUri.collectAsState()
var showAddAccount by remember { mutableStateOf(false) } 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( Column(
modifier = Modifier 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)) Spacer(Modifier.height(32.dp))
} }
@@ -1,20 +1,25 @@
package com.planify.mobile.ui.settings package com.planify.mobile.ui.settings
import android.net.Uri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.planify.mobile.data.caldav.CalDavCredentialStore import com.planify.mobile.data.caldav.CalDavCredentialStore
import com.planify.mobile.data.caldav.CalDavDiscovery import com.planify.mobile.data.caldav.CalDavDiscovery
import com.planify.mobile.data.caldav.DiscoveryResult 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.AppPreferences
import com.planify.mobile.data.preferences.ThemeMode import com.planify.mobile.data.preferences.ThemeMode
import com.planify.mobile.data.sync.SyncScheduler import com.planify.mobile.data.sync.SyncScheduler
import com.planify.mobile.domain.model.Source 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.SourceRepository
import com.planify.mobile.domain.repository.TaskRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -37,6 +42,9 @@ class SettingsViewModel @Inject constructor(
private val syncScheduler: SyncScheduler, private val syncScheduler: SyncScheduler,
private val discovery: CalDavDiscovery, private val discovery: CalDavDiscovery,
private val credentialStore: CalDavCredentialStore, private val credentialStore: CalDavCredentialStore,
private val exportManager: ExportManager,
private val projectRepository: ProjectRepository,
private val taskRepository: TaskRepository,
) : ViewModel() { ) : ViewModel() {
val uiState = combine( val uiState = combine(
@@ -100,4 +108,20 @@ class SettingsViewModel @Inject constructor(
credentialStore.deletePassword(source.id) credentialStore.deletePassword(source.id)
sourceRepository.deleteSource(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>