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 @@
+
+
+
+