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:
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="exports" path="exports/" />
|
||||
</paths>
|
||||
Reference in New Issue
Block a user