From bf6351fbb5dd461226f4a139aeb8d15826ea0afe Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 6 Jun 2026 06:48:04 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20[#28][#29]=20=C3=A9cran=20param=C3=A8tr?= =?UTF-8?q?es=20(th=C3=A8me,=20sync,=20notifs,=20comptes=20CalDAV)=20+=20t?= =?UTF-8?q?h=C3=A8me=20dynamique=20Material=20You=20pilot=C3=A9=20par=20Da?= =?UTF-8?q?taStore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/data/preferences/AppPreferences.kt | 64 +++++ .../java/com/planify/mobile/ui/MainScreen.kt | 8 +- .../mobile/ui/navigation/PlanifyNavHost.kt | 5 + .../mobile/ui/settings/SettingsScreen.kt | 261 ++++++++++++++++++ .../mobile/ui/settings/SettingsViewModel.kt | 103 +++++++ .../java/com/planify/mobile/ui/theme/Theme.kt | 25 +- .../planify/mobile/ui/theme/ThemeViewModel.kt | 19 ++ 7 files changed, 476 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/com/planify/mobile/data/preferences/AppPreferences.kt create mode 100644 app/src/main/java/com/planify/mobile/ui/settings/SettingsScreen.kt create mode 100644 app/src/main/java/com/planify/mobile/ui/settings/SettingsViewModel.kt create mode 100644 app/src/main/java/com/planify/mobile/ui/theme/ThemeViewModel.kt diff --git a/app/src/main/java/com/planify/mobile/data/preferences/AppPreferences.kt b/app/src/main/java/com/planify/mobile/data/preferences/AppPreferences.kt new file mode 100644 index 0000000..65d8900 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/preferences/AppPreferences.kt @@ -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 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 = store.data.map { prefs -> + ThemeMode.fromKey(prefs[keyTheme] ?: ThemeMode.SYSTEM.key) + } + + val syncEnabled: Flow = store.data.map { it[keySyncEnabled] ?: true } + + val syncIntervalMinutes: Flow = store.data.map { it[keySyncInterval] ?: 30 } + + val notificationsEnabled: Flow = 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 + } +} diff --git a/app/src/main/java/com/planify/mobile/ui/MainScreen.kt b/app/src/main/java/com/planify/mobile/ui/MainScreen.kt index 0f4413d..015860b 100644 --- a/app/src/main/java/com/planify/mobile/ui/MainScreen.kt +++ b/app/src/main/java/com/planify/mobile/ui/MainScreen.kt @@ -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)) } diff --git a/app/src/main/java/com/planify/mobile/ui/navigation/PlanifyNavHost.kt b/app/src/main/java/com/planify/mobile/ui/navigation/PlanifyNavHost.kt index e5c982f..5d589cd 100644 --- a/app/src/main/java/com/planify/mobile/ui/navigation/PlanifyNavHost.kt +++ b/app/src/main/java/com/planify/mobile/ui/navigation/PlanifyNavHost.kt @@ -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() + } } } diff --git a/app/src/main/java/com/planify/mobile/ui/settings/SettingsScreen.kt b/app/src/main/java/com/planify/mobile/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..c401b57 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/settings/SettingsScreen.kt @@ -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") } }, + ) +} diff --git a/app/src/main/java/com/planify/mobile/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/planify/mobile/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..2066ff0 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/settings/SettingsViewModel.kt @@ -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 = 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>(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) + } +} diff --git a/app/src/main/java/com/planify/mobile/ui/theme/Theme.kt b/app/src/main/java/com/planify/mobile/ui/theme/Theme.kt index 594ef8e..ac4803d 100644 --- a/app/src/main/java/com/planify/mobile/ui/theme/Theme.kt +++ b/app/src/main/java/com/planify/mobile/ui/theme/Theme.kt @@ -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, ) } diff --git a/app/src/main/java/com/planify/mobile/ui/theme/ThemeViewModel.kt b/app/src/main/java/com/planify/mobile/ui/theme/ThemeViewModel.kt new file mode 100644 index 0000000..7e3f079 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/theme/ThemeViewModel.kt @@ -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, + ) +}