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>
|
</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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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