Compare commits

..

12 Commits

Author SHA1 Message Date
Gato 8fce0f2578 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>
2026-06-06 06:55:49 +02:00
Gato 47aa839e3a feat: [#28][#29] écran paramètres (thème, sync, notifs, comptes CalDAV) + thème dynamique Material You piloté par DataStore 2026-06-06 06:55:49 +02:00
Gato ee1dac46cb feat: [#27] sync CalDAV en arrière-plan (WorkManager PeriodicWork, SyncScheduler, reprise au démarrage) 2026-06-06 06:55:49 +02:00
Gato d3e9ad4753 Merge pull request 'Develop' (#37) from develop into main
Reviewed-on: Gato/Planify-mobile#37
2026-06-06 06:45:17 +02:00
Gato 289ff97698 Merge pull request 'Milestone/lot 5 avance' (#36) from milestone/lot-5-avance into develop
Reviewed-on: Gato/Planify-mobile#36
2026-06-06 06:44:57 +02:00
Gato 1316c6555b feat: [#26] filtres intelligents (Toutes, Terminées, Récurrentes, Priorités) + navigation Scheduled/Search/Filter/Labels 2026-06-06 06:39:14 +02:00
Gato 5d1c69484a feat: [#25] drag & drop dans la vue liste du projet (long-press handle, reorderTasks) 2026-06-06 06:39:10 +02:00
Gato 5fc6c8a3d4 feat: [#24] recherche globale (debounce 300ms, min 2 chars, live results) 2026-06-06 06:39:06 +02:00
Gato 1146b146c0 feat: [#23] vue Labels (toutes les tâches associées à un label) 2026-06-06 06:39:03 +02:00
Gato 86aab6c3da feat: [#22] vue Scheduled (tâches planifiées groupées par date : aujourd'hui, demain, cette semaine, plus tard) 2026-06-06 06:39:00 +02:00
Gato 7deeb23f33 Merge pull request 'Develop' (#35) from develop into main
Reviewed-on: Gato/Planify-mobile#35
2026-06-06 06:33:58 +02:00
Gato 84e40d12c8 Merge pull request 'Milestone/lot 4 caldav' (#34) from milestone/lot-4-caldav into develop
Reviewed-on: Gato/Planify-mobile#34
2026-06-06 06:33:42 +02:00
31 changed files with 1429 additions and 19 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>>
} }
@@ -3,14 +3,17 @@ package com.planify.mobile.data.notification
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import com.planify.mobile.data.sync.SyncScheduler
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class BootReceiver : BroadcastReceiver() { class BootReceiver : BroadcastReceiver() {
@Inject lateinit var syncScheduler: SyncScheduler
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
// TODO #14 : replanifier toutes les alarmes depuis la base de données syncScheduler.schedule()
// Inject ReminderScheduler + ReminderRepository et rejouer tous les rappels actifs
} }
} }
@@ -0,0 +1,64 @@
package com.planify.mobile.data.preferences
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore("app_prefs")
@Singleton
class AppPreferences @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val store = context.dataStore
// ── Keys ─────────────────────────────────────────────────────────────────
private val keyTheme = stringPreferencesKey("theme_mode")
private val keySyncEnabled = booleanPreferencesKey("sync_enabled")
private val keySyncInterval = intPreferencesKey("sync_interval_minutes")
private val keyNotifications = booleanPreferencesKey("notifications_enabled")
// ── Reads ─────────────────────────────────────────────────────────────────
val themeMode: Flow<ThemeMode> = store.data.map { prefs ->
ThemeMode.fromKey(prefs[keyTheme] ?: ThemeMode.SYSTEM.key)
}
val syncEnabled: Flow<Boolean> = store.data.map { it[keySyncEnabled] ?: true }
val syncIntervalMinutes: Flow<Int> = store.data.map { it[keySyncInterval] ?: 30 }
val notificationsEnabled: Flow<Boolean> = store.data.map { it[keyNotifications] ?: true }
// ── Writes ────────────────────────────────────────────────────────────────
suspend fun setThemeMode(mode: ThemeMode) =
store.edit { it[keyTheme] = mode.key }
suspend fun setSyncEnabled(enabled: Boolean) =
store.edit { it[keySyncEnabled] = enabled }
suspend fun setSyncInterval(minutes: Int) =
store.edit { it[keySyncInterval] = minutes }
suspend fun setNotificationsEnabled(enabled: Boolean) =
store.edit { it[keyNotifications] = enabled }
}
enum class ThemeMode(val key: String, val label: String) {
SYSTEM("SYSTEM", "Système"),
LIGHT("LIGHT", "Clair"),
DARK("DARK", "Sombre");
companion object {
fun fromKey(key: String) = entries.firstOrNull { it.key == key } ?: SYSTEM
}
}
@@ -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,
@@ -0,0 +1,37 @@
package com.planify.mobile.data.sync
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.planify.mobile.data.caldav.CalDavSyncManager
import com.planify.mobile.data.caldav.SyncResult
import com.planify.mobile.domain.repository.SourceRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
@HiltWorker
class CalDavSyncWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val sourceRepository: SourceRepository,
private val syncManager: CalDavSyncManager,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val sources = sourceRepository.getCaldavSources()
if (sources.isEmpty()) return Result.success()
var hasError = false
sources.forEach { source ->
val result = syncManager.incrementalSync(source)
if (result is SyncResult.Failure) hasError = true
}
return if (hasError) Result.retry() else Result.success()
}
companion object {
const val WORK_NAME = "caldav_periodic_sync"
}
}
@@ -0,0 +1,51 @@
package com.planify.mobile.data.sync
import android.content.Context
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SyncScheduler @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val workManager = WorkManager.getInstance(context)
fun schedule(intervalMinutes: Long = 30) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = PeriodicWorkRequestBuilder<CalDavSyncWorker>(
repeatInterval = intervalMinutes,
repeatIntervalTimeUnit = TimeUnit.MINUTES,
)
.setConstraints(constraints)
.build()
workManager.enqueueUniquePeriodicWork(
CalDavSyncWorker.WORK_NAME,
ExistingPeriodicWorkPolicy.UPDATE,
request,
)
}
fun cancel() {
workManager.cancelUniqueWork(CalDavSyncWorker.WORK_NAME)
}
fun syncNow() {
val request = androidx.work.OneTimeWorkRequestBuilder<CalDavSyncWorker>()
.setConstraints(
Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
)
.build()
workManager.enqueue(request)
}
}
@@ -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>>
} }
@@ -10,8 +10,11 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material.icons.outlined.Inbox import androidx.compose.material.icons.outlined.Inbox
import androidx.compose.material.icons.outlined.Menu import androidx.compose.material.icons.outlined.Menu
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Today import androidx.compose.material.icons.outlined.Today
import androidx.compose.material3.DrawerValue import androidx.compose.material3.DrawerValue
@@ -58,6 +61,9 @@ fun MainScreen(viewModel: DrawerViewModel = hiltViewModel()) {
Route.Inbox.path to "Inbox", Route.Inbox.path to "Inbox",
Route.Today.path to "Aujourd'hui", Route.Today.path to "Aujourd'hui",
Route.Scheduled.path to "Planifié", Route.Scheduled.path to "Planifié",
Route.Search.path to "Recherche",
Route.Filter.path to "Filtres",
Route.Settings.path to "Paramètres",
) )
val title = drawerTitles[currentRoute] val title = drawerTitles[currentRoute]
?: projects.find { "project/${it.id}" == currentRoute }?.name ?: projects.find { "project/${it.id}" == currentRoute }?.name
@@ -90,6 +96,33 @@ fun MainScreen(viewModel: DrawerViewModel = hiltViewModel()) {
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
}, },
) )
NavigationDrawerItem(
icon = { Icon(Icons.Outlined.CalendarMonth, null) },
label = { Text("Planifié") },
selected = currentRoute == Route.Scheduled.path,
onClick = {
navController.navigate(Route.Scheduled.path)
scope.launch { drawerState.close() }
},
)
NavigationDrawerItem(
icon = { Icon(Icons.Outlined.Search, null) },
label = { Text("Recherche") },
selected = currentRoute == Route.Search.path,
onClick = {
navController.navigate(Route.Search.path)
scope.launch { drawerState.close() }
},
)
NavigationDrawerItem(
icon = { Icon(Icons.Outlined.FilterList, null) },
label = { Text("Filtres") },
selected = currentRoute == Route.Filter.path,
onClick = {
navController.navigate(Route.Filter.path)
scope.launch { drawerState.close() }
},
)
HorizontalDivider(Modifier.padding(vertical = 8.dp)) HorizontalDivider(Modifier.padding(vertical = 8.dp))
Text( Text(
text = "Projets", text = "Projets",
@@ -113,8 +146,11 @@ fun MainScreen(viewModel: DrawerViewModel = hiltViewModel()) {
NavigationDrawerItem( NavigationDrawerItem(
icon = { Icon(Icons.Outlined.Settings, null) }, icon = { Icon(Icons.Outlined.Settings, null) },
label = { Text("Paramètres") }, label = { Text("Paramètres") },
selected = false, selected = currentRoute == Route.Settings.path,
onClick = { scope.launch { drawerState.close() } }, onClick = {
navController.navigate(Route.Settings.path)
scope.launch { drawerState.close() }
},
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
} }
@@ -0,0 +1,109 @@
package com.planify.mobile.ui.components
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.DragHandle
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.zIndex
import kotlinx.coroutines.launch
/** State for a reorderable LazyColumn. */
class ReorderState {
var draggedIndex by mutableStateOf<Int?>(null)
var dragOffsetY by mutableFloatStateOf(0f)
var overIndex by mutableIntStateOf(-1)
fun isDragged(index: Int) = draggedIndex == index
fun isOver(index: Int) = overIndex == index
}
@Composable
fun rememberReorderState() = remember { ReorderState() }
/**
* Modifier for a drag handle icon that drives a [ReorderState].
* Call [onReorder] when the drag ends with the new list order.
*/
@Composable
fun <T> Modifier.reorderDragHandle(
item: T,
index: Int,
items: List<T>,
state: ReorderState,
listState: LazyListState,
onReorder: (List<T>) -> Unit,
): Modifier {
val scope = rememberCoroutineScope()
return this.pointerInput(item, items) {
detectDragGesturesAfterLongPress(
onDragStart = {
state.draggedIndex = index
state.dragOffsetY = 0f
state.overIndex = index
},
onDrag = { change, dragAmount ->
change.consume()
state.dragOffsetY += dragAmount.y
val currentInfo: LazyListItemInfo? = listState.layoutInfo.visibleItemsInfo
.firstOrNull { it.index == (state.draggedIndex ?: -1) }
if (currentInfo != null) {
val currentCenter = currentInfo.offset + currentInfo.size / 2 + state.dragOffsetY.toInt()
val newOver = listState.layoutInfo.visibleItemsInfo
.minByOrNull { kotlin.math.abs(it.offset + it.size / 2 - currentCenter) }
?.index ?: state.overIndex
state.overIndex = newOver
}
},
onDragEnd = {
val from = state.draggedIndex ?: return@detectDragGesturesAfterLongPress
val to = state.overIndex
if (from != to && to >= 0 && to < items.size) {
val mutable = items.toMutableList()
val moved = mutable.removeAt(from)
mutable.add(to, moved)
scope.launch { onReorder(mutable) }
}
state.draggedIndex = null
state.dragOffsetY = 0f
state.overIndex = -1
},
onDragCancel = {
state.draggedIndex = null
state.dragOffsetY = 0f
state.overIndex = -1
},
)
}
}
/** Modifier to apply drag visual feedback (elevation + offset) to a dragged item. */
fun Modifier.draggedItemModifier(isDragged: Boolean, offsetY: Float): Modifier =
if (isDragged) this
.zIndex(1f)
.graphicsLayer { translationY = offsetY; shadowElevation = 8f }
else this
@Composable
fun DragHandleIcon(modifier: Modifier = Modifier) {
Icon(
imageVector = Icons.Outlined.DragHandle,
contentDescription = "Réordonner",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = modifier,
)
}
@@ -0,0 +1,80 @@
package com.planify.mobile.ui.filter
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.domain.model.Task
import com.planify.mobile.ui.components.EmptyState
import com.planify.mobile.ui.components.TaskRow
private val filterLabels = mapOf(
FilterType.ALL to "Toutes",
FilterType.COMPLETED to "Terminées",
FilterType.REPEATING to "Récurrentes",
FilterType.PRIORITY_1 to "Priorité urgente",
FilterType.PRIORITY_2 to "Priorité haute",
FilterType.PRIORITY_3 to "Priorité moyenne",
)
@Composable
fun FilterScreen(
initialFilter: FilterType = FilterType.ALL,
onTaskClick: (Task) -> Unit,
viewModel: FilterViewModel = hiltViewModel(),
) {
val tasks by viewModel.tasks.collectAsState()
val activeFilter by viewModel.activeFilter.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterType.entries.forEach { filter ->
FilterChip(
selected = activeFilter == filter,
onClick = { viewModel.setFilter(filter) },
label = { Text(filterLabels[filter] ?: filter.name) },
)
}
}
if (tasks.isEmpty()) {
EmptyState(
icon = Icons.Outlined.FilterList,
title = "Aucune tâche",
subtitle = "Aucune tâche ne correspond à ce filtre",
)
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(tasks, key = { it.id }) { task ->
TaskRow(
task = task,
onClick = { onTaskClick(task) },
onCheckedChange = { viewModel.toggleTask(task) },
)
}
}
}
}
}
@@ -0,0 +1,51 @@
package com.planify.mobile.ui.filter
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.planify.mobile.domain.model.Task
import com.planify.mobile.domain.repository.TaskRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
enum class FilterType { ALL, COMPLETED, REPEATING, PRIORITY_1, PRIORITY_2, PRIORITY_3 }
@HiltViewModel
class FilterViewModel @Inject constructor(
private val taskRepository: TaskRepository,
) : ViewModel() {
private val _filter = MutableStateFlow(FilterType.ALL)
@OptIn(ExperimentalCoroutinesApi::class)
val tasks = _filter.flatMapLatest { filter ->
when (filter) {
FilterType.ALL -> taskRepository.getInboxTasks().let {
// ALL = all uncompleted tasks across all projects
taskRepository.getTasksByPriority(4).let { _ ->
// Use a union approach via getRepeatingTasks as base — actually
// for ALL we use getScheduledTasks + inbox combined.
// Simplify: use priority 4 as "all" isn't perfect; provide getAll via search ""
taskRepository.searchTasks("")
}
}
FilterType.COMPLETED -> taskRepository.getCompletedTasks()
FilterType.REPEATING -> taskRepository.getRepeatingTasks()
FilterType.PRIORITY_1 -> taskRepository.getTasksByPriority(1)
FilterType.PRIORITY_2 -> taskRepository.getTasksByPriority(2)
FilterType.PRIORITY_3 -> taskRepository.getTasksByPriority(3)
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun setFilter(filter: FilterType) { _filter.value = filter }
val activeFilter get() = _filter
fun toggleTask(task: Task) {
viewModelScope.launch { taskRepository.checkTask(task.id, !task.checked) }
}
}
@@ -0,0 +1,46 @@
package com.planify.mobile.ui.label
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Label
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.domain.model.Task
import com.planify.mobile.ui.components.EmptyState
import com.planify.mobile.ui.components.TaskRow
@Composable
fun LabelScreen(
labelId: String,
onTaskClick: (Task) -> Unit,
viewModel: LabelViewModel = hiltViewModel(),
) {
LaunchedEffect(labelId) { viewModel.init(labelId) }
val tasks by viewModel.tasks.collectAsState()
if (tasks.isEmpty()) {
EmptyState(
icon = Icons.Outlined.Label,
title = "Aucune tâche",
subtitle = "Aucune tâche n'est associée à ce label",
)
return
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(tasks, key = { it.id }) { task ->
TaskRow(
task = task,
onClick = { onTaskClick(task) },
onCheckedChange = { viewModel.toggleTask(task) },
)
}
}
}
@@ -0,0 +1,52 @@
package com.planify.mobile.ui.label
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.planify.mobile.domain.model.Label
import com.planify.mobile.domain.model.Task
import com.planify.mobile.domain.repository.LabelRepository
import com.planify.mobile.domain.repository.TaskRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class LabelViewModel @Inject constructor(
private val taskRepository: TaskRepository,
private val labelRepository: LabelRepository,
) : ViewModel() {
private val _labelId = MutableStateFlow<String?>(null)
@OptIn(ExperimentalCoroutinesApi::class)
val label = _labelId.flatMapLatest { id ->
if (id == null) flowOf(null)
else labelRepository.getAllLabels().flatMapLatest { labels ->
flowOf(labels.find { it.id == id })
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
@OptIn(ExperimentalCoroutinesApi::class)
val tasks = label.flatMapLatest { lbl ->
if (lbl == null) flowOf(emptyList())
else taskRepository.getTasksByLabel(lbl.name)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun init(labelId: String) {
_labelId.value = labelId
}
fun toggleTask(task: Task) {
viewModelScope.launch { taskRepository.checkTask(task.id, !task.checked) }
}
fun deleteTask(task: Task) {
viewModelScope.launch { taskRepository.deleteTask(task.id) }
}
}
@@ -7,8 +7,13 @@ import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.planify.mobile.ui.filter.FilterScreen
import com.planify.mobile.ui.inbox.InboxScreen import com.planify.mobile.ui.inbox.InboxScreen
import com.planify.mobile.ui.label.LabelScreen
import com.planify.mobile.ui.project.ProjectScreen import com.planify.mobile.ui.project.ProjectScreen
import com.planify.mobile.ui.scheduled.ScheduledScreen
import com.planify.mobile.ui.search.SearchScreen
import com.planify.mobile.ui.settings.SettingsScreen
import com.planify.mobile.ui.today.TodayScreen import com.planify.mobile.ui.today.TodayScreen
@Composable @Composable
@@ -40,9 +45,36 @@ fun PlanifyNavHost(
val projectId = backStack.arguments?.getString("projectId") ?: return@composable val projectId = backStack.arguments?.getString("projectId") ?: return@composable
ProjectScreen( ProjectScreen(
projectId = projectId, projectId = projectId,
onTaskClick = { /* TODO #11 : ouvrir édition */ }, onTaskClick = { /* TODO: ouvrir édition */ },
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
) )
} }
composable(Route.Scheduled.path) {
ScheduledScreen(onTaskClick = { /* TODO: ouvrir édition */ })
}
composable(Route.Search.path) {
SearchScreen(onTaskClick = { /* TODO: ouvrir édition */ })
}
composable(Route.Filter.path) {
FilterScreen(onTaskClick = { /* TODO: ouvrir édition */ })
}
composable(
route = Route.Label().path,
arguments = listOf(navArgument("labelId") { type = NavType.StringType })
) { backStack ->
val labelId = backStack.arguments?.getString("labelId") ?: return@composable
LabelScreen(
labelId = labelId,
onTaskClick = { /* TODO: ouvrir édition */ },
)
}
composable(Route.Settings.path) {
SettingsScreen()
}
} }
} }
@@ -4,6 +4,8 @@ sealed class Route(val path: String) {
data object Inbox : Route("inbox") data object Inbox : Route("inbox")
data object Today : Route("today") data object Today : Route("today")
data object Scheduled : Route("scheduled") data object Scheduled : Route("scheduled")
data object Search : Route("search")
data object Filter : Route("filter")
data class Project(val projectId: String = "{projectId}") : data class Project(val projectId: String = "{projectId}") :
Route("project/{projectId}") { Route("project/{projectId}") {
fun buildRoute(id: String) = "project/$id" fun buildRoute(id: String) = "project/$id"
@@ -2,6 +2,7 @@ package com.planify.mobile.ui.project
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -9,6 +10,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FolderOpen import androidx.compose.material.icons.outlined.FolderOpen
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -21,14 +23,20 @@ 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
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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.domain.model.Task import com.planify.mobile.domain.model.Task
import com.planify.mobile.domain.model.ViewStyle import com.planify.mobile.domain.model.ViewStyle
import com.planify.mobile.ui.components.DragHandleIcon
import com.planify.mobile.ui.components.EmptyState import com.planify.mobile.ui.components.EmptyState
import com.planify.mobile.ui.components.ReorderState
import com.planify.mobile.ui.components.SectionHeader import com.planify.mobile.ui.components.SectionHeader
import com.planify.mobile.ui.components.TaskRow import com.planify.mobile.ui.components.TaskRow
import com.planify.mobile.ui.components.draggedItemModifier
import com.planify.mobile.ui.components.reorderDragHandle
import com.planify.mobile.ui.components.rememberReorderState
@Composable @Composable
fun ProjectScreen( fun ProjectScreen(
@@ -62,6 +70,7 @@ fun ProjectScreen(
}, },
onTaskClick = onTaskClick, onTaskClick = onTaskClick,
onCheckedChange = { task -> viewModel.toggleTask(task) }, onCheckedChange = { task -> viewModel.toggleTask(task) },
onReorder = { viewModel.reorderTasks(it) },
modifier = modifier, modifier = modifier,
) )
ViewStyle.BOARD -> ProjectBoardView( ViewStyle.BOARD -> ProjectBoardView(
@@ -80,9 +89,13 @@ private fun ProjectListView(
onToggleSection: (String) -> Unit, onToggleSection: (String) -> Unit,
onTaskClick: (Task) -> Unit, onTaskClick: (Task) -> Unit,
onCheckedChange: (Task) -> Unit, onCheckedChange: (Task) -> Unit,
onReorder: (List<Task>) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LazyColumn(modifier = modifier.fillMaxSize()) { val listState = rememberLazyListState()
val reorderState = rememberReorderState()
LazyColumn(state = listState, modifier = modifier.fillMaxSize()) {
state.sections.forEach { group -> state.sections.forEach { group ->
val key = group.section?.id ?: "unsectioned" val key = group.section?.id ?: "unsectioned"
val name = group.section?.name ?: "Sans section" val name = group.section?.name ?: "Sans section"
@@ -98,13 +111,34 @@ private fun ProjectListView(
} }
if (key !in collapsedSections) { if (key !in collapsedSections) {
items(group.tasks, key = { it.id }) { task -> itemsIndexed(group.tasks, key = { _, t -> t.id }) { index, task ->
Row(
modifier = Modifier
.fillMaxWidth()
.draggedItemModifier(
isDragged = reorderState.isDragged(index),
offsetY = if (reorderState.isDragged(index)) reorderState.dragOffsetY else 0f,
),
verticalAlignment = Alignment.CenterVertically,
) {
DragHandleIcon(
modifier = Modifier.reorderDragHandle(
item = task,
index = index,
items = group.tasks,
state = reorderState,
listState = listState,
onReorder = onReorder,
)
)
TaskRow( TaskRow(
task = task, task = task,
onCheckedChange = { onCheckedChange(task) }, onCheckedChange = { onCheckedChange(task) },
onClick = { onTaskClick(task) }, onClick = { onTaskClick(task) },
modifier = Modifier.weight(1f),
) )
} }
}
item { HorizontalDivider(Modifier.padding(horizontal = 16.dp)) } item { HorizontalDivider(Modifier.padding(horizontal = 16.dp)) }
} }
} }
@@ -68,4 +68,8 @@ class ProjectViewModel @Inject constructor(
fun toggleTask(task: Task) { fun toggleTask(task: Task) {
viewModelScope.launch { taskRepository.checkTask(task.id, !task.checked) } viewModelScope.launch { taskRepository.checkTask(task.id, !task.checked) }
} }
fun reorderTasks(reordered: List<Task>) {
viewModelScope.launch { taskRepository.reorderTasks(reordered.map { it.id }) }
}
} }
@@ -0,0 +1,56 @@
package com.planify.mobile.ui.scheduled
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.domain.model.Task
import com.planify.mobile.ui.components.EmptyState
import com.planify.mobile.ui.components.TaskRow
@Composable
fun ScheduledScreen(
onTaskClick: (Task) -> Unit,
viewModel: ScheduledViewModel = hiltViewModel(),
) {
val groups by viewModel.groups.collectAsState()
if (groups.isEmpty()) {
EmptyState(
icon = Icons.Outlined.CalendarMonth,
title = "Aucune tâche planifiée",
subtitle = "Les tâches avec une date d'échéance apparaîtront ici",
)
return
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
groups.forEach { group ->
item(key = group.label) {
Text(
text = group.label,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
items(group.tasks, key = { it.id }) { task ->
TaskRow(
task = task,
onClick = { onTaskClick(task) },
onCheckedChange = { viewModel.toggleTask(task) },
)
}
}
}
}
@@ -0,0 +1,60 @@
package com.planify.mobile.ui.scheduled
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.planify.mobile.domain.model.Task
import com.planify.mobile.domain.repository.TaskRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.time.LocalDate
import javax.inject.Inject
data class ScheduledGroup(val label: String, val tasks: List<Task>)
@HiltViewModel
class ScheduledViewModel @Inject constructor(
private val taskRepository: TaskRepository,
) : ViewModel() {
val groups = taskRepository.getScheduledTasks()
.map { tasks -> groupByDate(tasks) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private fun groupByDate(tasks: List<Task>): List<ScheduledGroup> {
val today = LocalDate.now()
val tomorrow = today.plusDays(1)
val endOfWeek = today.plusDays(7)
val buckets = linkedMapOf(
"Aujourd'hui" to mutableListOf<Task>(),
"Demain" to mutableListOf(),
"Cette semaine" to mutableListOf(),
"Plus tard" to mutableListOf(),
)
for (task in tasks) {
val date = runCatching { LocalDate.parse(task.dueDate?.date ?: "") }.getOrNull() ?: continue
when {
date == today -> buckets["Aujourd'hui"]!!.add(task)
date == tomorrow -> buckets["Demain"]!!.add(task)
date <= endOfWeek -> buckets["Cette semaine"]!!.add(task)
else -> buckets["Plus tard"]!!.add(task)
}
}
return buckets.entries
.filter { it.value.isNotEmpty() }
.map { ScheduledGroup(it.key, it.value) }
}
fun toggleTask(task: Task) {
viewModelScope.launch { taskRepository.checkTask(task.id, !task.checked) }
}
fun deleteTask(task: Task) {
viewModelScope.launch { taskRepository.deleteTask(task.id) }
}
}
@@ -0,0 +1,85 @@
package com.planify.mobile.ui.search
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.domain.model.Task
import com.planify.mobile.ui.components.TaskRow
@Composable
fun SearchScreen(
onTaskClick: (Task) -> Unit,
viewModel: SearchViewModel = hiltViewModel(),
) {
val query by viewModel.query.collectAsState()
val results by viewModel.results.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
OutlinedTextField(
value = query,
onValueChange = viewModel::setQuery,
placeholder = { Text("Rechercher des tâches…") },
leadingIcon = { Icon(Icons.Outlined.Search, contentDescription = null) },
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = { viewModel.setQuery("") }) {
Icon(Icons.Outlined.Close, contentDescription = "Effacer")
}
}
},
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
)
when {
query.length < 2 -> Text(
text = "Saisissez au moins 2 caractères",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
results.isEmpty() -> Text(
text = "Aucun résultat pour « $query »",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
else -> LazyColumn {
item {
Text(
text = "${results.size} résultat(s)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
items(results, key = { it.id }) { task ->
TaskRow(
task = task,
onClick = { onTaskClick(task) },
onCheckedChange = { viewModel.toggleTask(task) },
)
}
}
}
}
}
@@ -0,0 +1,40 @@
package com.planify.mobile.ui.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.planify.mobile.domain.model.Task
import com.planify.mobile.domain.repository.TaskRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class SearchViewModel @Inject constructor(
private val taskRepository: TaskRepository,
) : ViewModel() {
val query = MutableStateFlow("")
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
val results = query
.debounce(300)
.flatMapLatest { q ->
if (q.length < 2) flowOf(emptyList())
else taskRepository.searchTasks(q)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun setQuery(q: String) { query.value = q }
fun toggleTask(task: Task) {
viewModelScope.launch { taskRepository.checkTask(task.id, !task.checked) }
}
}
@@ -0,0 +1,304 @@
package com.planify.mobile.ui.settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
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
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
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
import com.planify.mobile.domain.model.Source
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel(),
) {
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
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp),
) {
// ── Apparence ───────────────────────────────────────────────────────
SectionTitle("Apparence")
ListItem(
headlineContent = { Text("Thème") },
supportingContent = {
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
ThemeMode.entries.forEachIndexed { index, mode ->
SegmentedButton(
selected = state.themeMode == mode,
onClick = { viewModel.setTheme(mode) },
shape = SegmentedButtonDefaults.itemShape(index, ThemeMode.entries.size),
label = { Text(mode.label) },
)
}
}
},
)
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
// ── Synchronisation ─────────────────────────────────────────────────
SectionTitle("Synchronisation")
ListItem(
headlineContent = { Text("Sync automatique") },
trailingContent = {
Switch(
checked = state.syncEnabled,
onCheckedChange = viewModel::setSyncEnabled,
)
},
)
if (state.syncEnabled) {
ListItem(
headlineContent = { Text("Intervalle") },
supportingContent = {
val options = listOf(15 to "15 min", 30 to "30 min", 60 to "1 h", 240 to "4 h")
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
options.forEachIndexed { index, (mins, label) ->
SegmentedButton(
selected = state.syncIntervalMinutes == mins,
onClick = { viewModel.setSyncInterval(mins) },
shape = SegmentedButtonDefaults.itemShape(index, options.size),
label = { Text(label) },
)
}
}
},
)
}
ListItem(
headlineContent = { Text("Synchroniser maintenant") },
trailingContent = {
IconButton(onClick = viewModel::syncNow) {
Icon(Icons.Outlined.Sync, contentDescription = "Sync")
}
},
)
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
// ── Notifications ────────────────────────────────────────────────────
SectionTitle("Notifications")
ListItem(
headlineContent = { Text("Rappels activés") },
trailingContent = {
Switch(
checked = state.notificationsEnabled,
onCheckedChange = viewModel::setNotificationsEnabled,
)
},
)
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
// ── Comptes CalDAV ────────────────────────────────────────────────────
SectionTitle("Comptes CalDAV")
state.caldavSources.forEach { source ->
CalDavSourceRow(source = source, onDelete = { viewModel.removeCalDavAccount(source) })
}
ListItem(
headlineContent = { Text("Ajouter un compte") },
leadingContent = { Icon(Icons.Outlined.Add, contentDescription = null) },
modifier = Modifier
.fillMaxWidth()
.let { mod ->
mod.then(
Modifier.padding(0.dp).run {
this
}
)
},
trailingContent = null,
)
Button(
onClick = { showAddAccount = true },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Icon(Icons.Outlined.Add, contentDescription = null)
Text("Ajouter un compte CalDAV", modifier = Modifier.padding(start = 8.dp))
}
if (discovery.first) {
ListItem(headlineContent = { Text("Connexion en cours…") })
}
discovery.second?.let { error ->
Text(
text = error,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
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))
}
if (showAddAccount) {
AddCalDavAccountDialog(
onDismiss = { showAddAccount = false },
onConfirm = { url, user, pwd ->
viewModel.addCalDavAccount(url, user, pwd)
showAddAccount = false
},
)
}
}
@Composable
private fun SectionTitle(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
@Composable
private fun CalDavSourceRow(source: Source, onDelete: () -> Unit) {
ListItem(
leadingContent = { Icon(Icons.Outlined.AccountCircle, contentDescription = null) },
headlineContent = { Text(source.displayName) },
supportingContent = { Text(source.caldavData?.serverUrl ?: "") },
trailingContent = {
IconButton(onClick = onDelete) {
Icon(Icons.Outlined.Delete, contentDescription = "Supprimer", tint = MaterialTheme.colorScheme.error)
}
},
)
}
@Composable
private fun AddCalDavAccountDialog(
onDismiss: () -> Unit,
onConfirm: (url: String, username: String, password: String) -> Unit,
) {
var url by remember { mutableStateOf("") }
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Ajouter un compte CalDAV") },
text = {
Column {
OutlinedTextField(
value = url,
onValueChange = { url = it },
label = { Text("URL du serveur") },
placeholder = { Text("https://example.com/caldav") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Nom d'utilisateur") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Mot de passe") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
)
}
},
confirmButton = {
Button(
onClick = { onConfirm(url.trim(), username.trim(), password) },
enabled = url.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
) { Text("Connecter") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Annuler") } },
)
}
@@ -0,0 +1,127 @@
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
import javax.inject.Inject
data class SettingsUiState(
val themeMode: ThemeMode = ThemeMode.SYSTEM,
val syncEnabled: Boolean = true,
val syncIntervalMinutes: Int = 30,
val notificationsEnabled: Boolean = true,
val caldavSources: List<Source> = emptyList(),
val discoveryInProgress: Boolean = false,
val discoveryError: String? = null,
)
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val prefs: AppPreferences,
private val sourceRepository: SourceRepository,
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(
prefs.themeMode,
prefs.syncEnabled,
prefs.syncIntervalMinutes,
prefs.notificationsEnabled,
sourceRepository.getAllSources(),
) { theme, sync, interval, notifs, sources ->
SettingsUiState(
themeMode = theme,
syncEnabled = sync,
syncIntervalMinutes = interval,
notificationsEnabled = notifs,
caldavSources = sources.filter { it.type == com.planify.mobile.domain.model.SourceType.CALDAV },
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())
private val _discoveryState = MutableStateFlow<Pair<Boolean, String?>>(false to null)
val discoveryInProgress = _discoveryState.asStateFlow()
fun setTheme(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) }
fun setSyncEnabled(enabled: Boolean) = viewModelScope.launch {
prefs.setSyncEnabled(enabled)
if (enabled) syncScheduler.schedule(uiState.value.syncIntervalMinutes.toLong())
else syncScheduler.cancel()
}
fun setSyncInterval(minutes: Int) = viewModelScope.launch {
prefs.setSyncInterval(minutes)
if (uiState.value.syncEnabled) syncScheduler.schedule(minutes.toLong())
}
fun setNotificationsEnabled(enabled: Boolean) = viewModelScope.launch {
prefs.setNotificationsEnabled(enabled)
}
fun syncNow() { syncScheduler.syncNow() }
fun addCalDavAccount(baseUrl: String, username: String, password: String) {
_discoveryState.update { true to null }
viewModelScope.launch {
when (val result = discovery.discover(baseUrl, username, password)) {
is DiscoveryResult.Success -> {
result.sources.forEach { source ->
credentialStore.savePassword(source.id, password)
sourceRepository.insertSource(source)
}
_discoveryState.update { false to null }
if (uiState.value.syncEnabled) syncScheduler.schedule()
}
is DiscoveryResult.Failure -> {
_discoveryState.update { false to result.message }
}
}
}
}
fun removeCalDavAccount(source: Source) = viewModelScope.launch {
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 }
}
@@ -8,29 +8,40 @@ import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.data.preferences.ThemeMode
private val LightColorScheme = lightColorScheme() private val LightColorScheme = lightColorScheme()
private val DarkColorScheme = darkColorScheme() private val DarkColorScheme = darkColorScheme()
@Composable @Composable
fun PlanifyTheme( fun PlanifyTheme(
darkTheme: Boolean = isSystemInDarkTheme(), viewModel: ThemeViewModel = hiltViewModel(),
dynamicColor: Boolean = true, content: @Composable () -> Unit,
content: @Composable () -> Unit
) { ) {
val colorScheme = when { val themeMode by viewModel.themeMode.collectAsState()
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { val systemDark = isSystemInDarkTheme()
val context = LocalContext.current val isDark = when (themeMode) {
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) ThemeMode.DARK -> true
ThemeMode.LIGHT -> false
ThemeMode.SYSTEM -> systemDark
} }
darkTheme -> DarkColorScheme
val colorScheme = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
isDark -> DarkColorScheme
else -> LightColorScheme else -> LightColorScheme
} }
MaterialTheme( MaterialTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = Typography, typography = Typography,
content = content content = content,
) )
} }
@@ -0,0 +1,19 @@
package com.planify.mobile.ui.theme
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.planify.mobile.data.preferences.AppPreferences
import com.planify.mobile.data.preferences.ThemeMode
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@HiltViewModel
class ThemeViewModel @Inject constructor(prefs: AppPreferences) : ViewModel() {
val themeMode = prefs.themeMode.stateIn(
viewModelScope,
SharingStarted.Eagerly,
ThemeMode.SYSTEM,
)
}
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="exports" path="exports/" />
</paths>