feat: [#28][#29] écran paramètres (thème, sync, notifs, comptes CalDAV) + thème dynamique Material You piloté par DataStore

This commit is contained in:
2026-06-06 06:48:04 +02:00
parent a556f4cbdc
commit bf6351fbb5
7 changed files with 476 additions and 9 deletions
@@ -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
}
}
@@ -63,6 +63,7 @@ fun MainScreen(viewModel: DrawerViewModel = hiltViewModel()) {
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]
?: projects.find { "project/${it.id}" == currentRoute }?.name
@@ -145,8 +146,11 @@ fun MainScreen(viewModel: DrawerViewModel = hiltViewModel()) {
NavigationDrawerItem(
icon = { Icon(Icons.Outlined.Settings, null) },
label = { Text("Paramètres") },
selected = false,
onClick = { scope.launch { drawerState.close() } },
selected = currentRoute == Route.Settings.path,
onClick = {
navController.navigate(Route.Settings.path)
scope.launch { drawerState.close() }
},
)
Spacer(Modifier.height(8.dp))
}
@@ -13,6 +13,7 @@ import com.planify.mobile.ui.label.LabelScreen
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
@Composable
@@ -71,5 +72,9 @@ fun PlanifyNavHost(
onTaskClick = { /* TODO: ouvrir édition */ },
)
}
composable(Route.Settings.path) {
SettingsScreen()
}
}
}
@@ -0,0 +1,261 @@
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.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.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 androidx.compose.runtime.Composable
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.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()
var showAddAccount by remember { mutableStateOf(false) }
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),
)
}
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,103 @@
package com.planify.mobile.ui.settings
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.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.SourceRepository
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.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,
) : 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)
}
}
@@ -8,29 +8,40 @@ import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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 DarkColorScheme = darkColorScheme()
@Composable
fun PlanifyTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
viewModel: ThemeViewModel = hiltViewModel(),
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 {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
isDark -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
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,
)
}