Compare commits
12 Commits
8827c85c82
...
8fce0f2578
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fce0f2578 | |||
| 47aa839e3a | |||
| ee1dac46cb | |||
| d3e9ad4753 | |||
| 289ff97698 | |||
| 1316c6555b | |||
| 5d1c69484a | |||
| 5fc6c8a3d4 | |||
| 1146b146c0 | |||
| 86aab6c3da | |||
| 7deeb23f33 | |||
| 84e40d12c8 |
@@ -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,12 +111,33 @@ 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 ->
|
||||||
TaskRow(
|
Row(
|
||||||
task = task,
|
modifier = Modifier
|
||||||
onCheckedChange = { onCheckedChange(task) },
|
.fillMaxWidth()
|
||||||
onClick = { onTaskClick(task) },
|
.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(
|
||||||
|
task = task,
|
||||||
|
onCheckedChange = { onCheckedChange(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 themeMode by viewModel.themeMode.collectAsState()
|
||||||
|
val systemDark = isSystemInDarkTheme()
|
||||||
|
val isDark = when (themeMode) {
|
||||||
|
ThemeMode.DARK -> true
|
||||||
|
ThemeMode.LIGHT -> false
|
||||||
|
ThemeMode.SYSTEM -> systemDark
|
||||||
|
}
|
||||||
|
|
||||||
val colorScheme = when {
|
val colorScheme = when {
|
||||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
}
|
}
|
||||||
darkTheme -> DarkColorScheme
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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