From a8da951a337605844e5f925262ad0658ded03e68 Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 6 Jun 2026 06:53:58 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20[#30]=20export=20et=20backup=20des=20do?= =?UTF-8?q?nn=C3=A9es=20(JSON=20et=20iCalendar)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/src/main/AndroidManifest.xml | 10 +++ .../mobile/data/export/ExportManager.kt | 73 +++++++++++++++++++ .../planify/mobile/data/local/dao/TaskDao.kt | 4 + .../data/repository/TaskRepositoryImpl.kt | 3 + .../mobile/domain/model/BackendType.kt | 3 + .../planify/mobile/domain/model/Project.kt | 5 ++ .../com/planify/mobile/domain/model/Task.kt | 4 + .../domain/repository/TaskRepository.kt | 1 + .../mobile/ui/settings/SettingsScreen.kt | 43 +++++++++++ .../mobile/ui/settings/SettingsViewModel.kt | 24 ++++++ app/src/main/res/xml/file_paths.xml | 4 + 11 files changed, 174 insertions(+) create mode 100644 app/src/main/java/com/planify/mobile/data/export/ExportManager.kt create mode 100644 app/src/main/res/xml/file_paths.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a316bb8..055d5fb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -38,6 +38,16 @@ + + + + diff --git a/app/src/main/java/com/planify/mobile/data/export/ExportManager.kt b/app/src/main/java/com/planify/mobile/data/export/ExportManager.kt new file mode 100644 index 0000000..058f963 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/export/ExportManager.kt @@ -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, + val tasks: List, +) + +@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, tasks: List): 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): 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(jsonString) }.getOrNull() +} diff --git a/app/src/main/java/com/planify/mobile/data/local/dao/TaskDao.kt b/app/src/main/java/com/planify/mobile/data/local/dao/TaskDao.kt index 124b427..98e8c09 100644 --- a/app/src/main/java/com/planify/mobile/data/local/dao/TaskDao.kt +++ b/app/src/main/java/com/planify/mobile/data/local/dao/TaskDao.kt @@ -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> } diff --git a/app/src/main/java/com/planify/mobile/data/repository/TaskRepositoryImpl.kt b/app/src/main/java/com/planify/mobile/data/repository/TaskRepositoryImpl.kt index 1763910..13b84d8 100644 --- a/app/src/main/java/com/planify/mobile/data/repository/TaskRepositoryImpl.kt +++ b/app/src/main/java/com/planify/mobile/data/repository/TaskRepositoryImpl.kt @@ -78,6 +78,9 @@ class TaskRepositoryImpl @Inject constructor( orderedIds.forEachIndexed { index, id -> dao.updateChildOrder(id, index) } } + override fun getAllTasks(): Flow> = + dao.getAllTasks().map { it.map { e -> e.toDomain() } } + private fun TaskEntity.toDomain() = Task( id = id, content = content, diff --git a/app/src/main/java/com/planify/mobile/domain/model/BackendType.kt b/app/src/main/java/com/planify/mobile/domain/model/BackendType.kt index a37643b..6fb7fee 100644 --- a/app/src/main/java/com/planify/mobile/domain/model/BackendType.kt +++ b/app/src/main/java/com/planify/mobile/domain/model/BackendType.kt @@ -1,3 +1,6 @@ package com.planify.mobile.domain.model +import kotlinx.serialization.Serializable + +@Serializable enum class BackendType { LOCAL, CALDAV, TODOIST } diff --git a/app/src/main/java/com/planify/mobile/domain/model/Project.kt b/app/src/main/java/com/planify/mobile/domain/model/Project.kt index 9bed99c..1d37c41 100644 --- a/app/src/main/java/com/planify/mobile/domain/model/Project.kt +++ b/app/src/main/java/com/planify/mobile/domain/model/Project.kt @@ -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 } diff --git a/app/src/main/java/com/planify/mobile/domain/model/Task.kt b/app/src/main/java/com/planify/mobile/domain/model/Task.kt index 0632479..0ac1ba1 100644 --- a/app/src/main/java/com/planify/mobile/domain/model/Task.kt +++ b/app/src/main/java/com/planify/mobile/domain/model/Task.kt @@ -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 } diff --git a/app/src/main/java/com/planify/mobile/domain/repository/TaskRepository.kt b/app/src/main/java/com/planify/mobile/domain/repository/TaskRepository.kt index e0eae9c..7f811cd 100644 --- a/app/src/main/java/com/planify/mobile/domain/repository/TaskRepository.kt +++ b/app/src/main/java/com/planify/mobile/domain/repository/TaskRepository.kt @@ -22,4 +22,5 @@ interface TaskRepository { fun getRepeatingTasks(): Flow> fun getTasksByPriority(priority: Int): Flow> suspend fun reorderTasks(orderedIds: List) + fun getAllTasks(): Flow> } diff --git a/app/src/main/java/com/planify/mobile/ui/settings/SettingsScreen.kt b/app/src/main/java/com/planify/mobile/ui/settings/SettingsScreen.kt index c401b57..a70d491 100644 --- a/app/src/main/java/com/planify/mobile/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/planify/mobile/ui/settings/SettingsScreen.kt @@ -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)) } diff --git a/app/src/main/java/com/planify/mobile/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/planify/mobile/ui/settings/SettingsViewModel.kt index 2066ff0..9103b39 100644 --- a/app/src/main/java/com/planify/mobile/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/planify/mobile/ui/settings/SettingsViewModel.kt @@ -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(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 } } diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..282e644 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + +