From a556f4cbdcce92a8a4dcf55f2233d86b4f32dda5 Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 6 Jun 2026 06:46:28 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20[#27]=20sync=20CalDAV=20en=20arri?= =?UTF-8?q?=C3=A8re-plan=20(WorkManager=20PeriodicWork,=20SyncScheduler,?= =?UTF-8?q?=20reprise=20au=20d=C3=A9marrage)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/data/notification/BootReceiver.kt | 7 ++- .../mobile/data/sync/CalDavSyncWorker.kt | 37 ++++++++++++++ .../planify/mobile/data/sync/SyncScheduler.kt | 51 +++++++++++++++++++ 3 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/planify/mobile/data/sync/CalDavSyncWorker.kt create mode 100644 app/src/main/java/com/planify/mobile/data/sync/SyncScheduler.kt diff --git a/app/src/main/java/com/planify/mobile/data/notification/BootReceiver.kt b/app/src/main/java/com/planify/mobile/data/notification/BootReceiver.kt index 9f94458..9ce83ae 100644 --- a/app/src/main/java/com/planify/mobile/data/notification/BootReceiver.kt +++ b/app/src/main/java/com/planify/mobile/data/notification/BootReceiver.kt @@ -3,14 +3,17 @@ package com.planify.mobile.data.notification import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import com.planify.mobile.data.sync.SyncScheduler import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject @AndroidEntryPoint class BootReceiver : BroadcastReceiver() { + @Inject lateinit var syncScheduler: SyncScheduler + override fun onReceive(context: Context, intent: Intent) { if (intent.action != Intent.ACTION_BOOT_COMPLETED) return - // TODO #14 : replanifier toutes les alarmes depuis la base de données - // Inject ReminderScheduler + ReminderRepository et rejouer tous les rappels actifs + syncScheduler.schedule() } } diff --git a/app/src/main/java/com/planify/mobile/data/sync/CalDavSyncWorker.kt b/app/src/main/java/com/planify/mobile/data/sync/CalDavSyncWorker.kt new file mode 100644 index 0000000..1125e56 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/sync/CalDavSyncWorker.kt @@ -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" + } +} diff --git a/app/src/main/java/com/planify/mobile/data/sync/SyncScheduler.kt b/app/src/main/java/com/planify/mobile/data/sync/SyncScheduler.kt new file mode 100644 index 0000000..e7e0146 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/sync/SyncScheduler.kt @@ -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( + 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() + .setConstraints( + Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() + ) + .build() + workManager.enqueue(request) + } +} From bf6351fbb5dd461226f4a139aeb8d15826ea0afe Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 6 Jun 2026 06:48:04 +0200 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20[#28][#29]=20=C3=A9cran=20param?= =?UTF-8?q?=C3=A8tres=20(th=C3=A8me,=20sync,=20notifs,=20comptes=20CalDAV)?= =?UTF-8?q?=20+=20th=C3=A8me=20dynamique=20Material=20You=20pilot=C3=A9=20?= =?UTF-8?q?par=20DataStore?= 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, + ) +} From a8da951a337605844e5f925262ad0658ded03e68 Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 6 Jun 2026 06:53:58 +0200 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20[#30]=20export=20et=20backup=20des?= =?UTF-8?q?=20donn=C3=A9es=20(JSON=20et=20iCalendar)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ExportManager : génère backup JSON (BackupPayload sérialisable) et .ics via VTodoGenerator - FileProvider déclaré dans AndroidManifest + res/xml/file_paths.xml (cache/exports/) - @Serializable ajouté sur Project, Task, BackendType, ViewStyle, SortBy, ItemType - TaskRepository/Impl/Dao : ajout getAllTasks() pour export global - SettingsViewModel : exportJson(), exportIcal(), clearExportUri() - SettingsScreen : section Export & Backup avec partage via Intent.ACTION_SEND Co-Authored-By: Claude Sonnet 4.6 --- app/src/main/AndroidManifest.xml | 10 +++ .../mobile/data/export/ExportManager.kt | 73 +++++++++++++++++++ .../planify/mobile/data/local/dao/TaskDao.kt | 4 + .../data/repository/TaskRepositoryImpl.kt | 3 + .../mobile/domain/model/BackendType.kt | 3 + .../planify/mobile/domain/model/Project.kt | 5 ++ .../com/planify/mobile/domain/model/Task.kt | 4 + .../domain/repository/TaskRepository.kt | 1 + .../mobile/ui/settings/SettingsScreen.kt | 43 +++++++++++ .../mobile/ui/settings/SettingsViewModel.kt | 24 ++++++ app/src/main/res/xml/file_paths.xml | 4 + 11 files changed, 174 insertions(+) create mode 100644 app/src/main/java/com/planify/mobile/data/export/ExportManager.kt create mode 100644 app/src/main/res/xml/file_paths.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a316bb8..055d5fb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -38,6 +38,16 @@ + + + + diff --git a/app/src/main/java/com/planify/mobile/data/export/ExportManager.kt b/app/src/main/java/com/planify/mobile/data/export/ExportManager.kt new file mode 100644 index 0000000..058f963 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/export/ExportManager.kt @@ -0,0 +1,73 @@ +package com.planify.mobile.data.export + +import android.content.Context +import android.net.Uri +import androidx.core.content.FileProvider +import com.planify.mobile.data.caldav.VTodoGenerator +import com.planify.mobile.domain.model.Project +import com.planify.mobile.domain.model.Task +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.File +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import javax.inject.Inject +import javax.inject.Singleton + +@Serializable +data class BackupPayload( + val version: Int = 1, + val exportedAt: String, + val projects: List, + val tasks: List, +) + +@Singleton +class ExportManager @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val json = Json { prettyPrint = true; ignoreUnknownKeys = true } + private val exportDir get() = File(context.cacheDir, "exports").also { it.mkdirs() } + private val authority = "${context.packageName}.fileprovider" + + private fun timestamp() = + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")) + + fun exportJson(projects: List, tasks: List): Uri { + val file = File(exportDir, "planify_backup_${timestamp()}.json") + val payload = BackupPayload( + exportedAt = timestamp(), + projects = projects, + tasks = tasks, + ) + file.writeText(json.encodeToString(payload)) + return FileProvider.getUriForFile(context, authority, file) + } + + fun exportIcal(tasks: List): Uri { + val file = File(exportDir, "planify_tasks_${timestamp()}.ics") + val sb = StringBuilder() + sb.appendLine("BEGIN:VCALENDAR") + sb.appendLine("VERSION:2.0") + sb.appendLine("PRODID:-//Planify Mobile//Android//EN") + sb.appendLine("CALSCALE:GREGORIAN") + + tasks.forEach { task -> + val ical = VTodoGenerator.generate(task) + val start = ical.indexOf("BEGIN:VTODO") + val end = ical.indexOf("END:VTODO") + if (start >= 0 && end >= 0) { + sb.appendLine(ical.substring(start, end + "END:VTODO".length)) + } + } + + sb.append("END:VCALENDAR") + file.writeText(sb.toString()) + return FileProvider.getUriForFile(context, authority, file) + } + + fun parseJsonBackup(jsonString: String): BackupPayload? = + runCatching { json.decodeFromString(jsonString) }.getOrNull() +} diff --git a/app/src/main/java/com/planify/mobile/data/local/dao/TaskDao.kt b/app/src/main/java/com/planify/mobile/data/local/dao/TaskDao.kt index 124b427..98e8c09 100644 --- a/app/src/main/java/com/planify/mobile/data/local/dao/TaskDao.kt +++ b/app/src/main/java/com/planify/mobile/data/local/dao/TaskDao.kt @@ -111,4 +111,8 @@ interface TaskDao { // #25 — Reorder: update child_order for a single task @Query("UPDATE tasks SET child_order = :order WHERE id = :id") suspend fun updateChildOrder(id: String, order: Int) + + // #30 — Export: all non-deleted tasks + @Query("SELECT * FROM tasks WHERE is_deleted = 0 ORDER BY project_id, child_order ASC") + fun getAllTasks(): Flow> } diff --git a/app/src/main/java/com/planify/mobile/data/repository/TaskRepositoryImpl.kt b/app/src/main/java/com/planify/mobile/data/repository/TaskRepositoryImpl.kt index 1763910..13b84d8 100644 --- a/app/src/main/java/com/planify/mobile/data/repository/TaskRepositoryImpl.kt +++ b/app/src/main/java/com/planify/mobile/data/repository/TaskRepositoryImpl.kt @@ -78,6 +78,9 @@ class TaskRepositoryImpl @Inject constructor( orderedIds.forEachIndexed { index, id -> dao.updateChildOrder(id, index) } } + override fun getAllTasks(): Flow> = + dao.getAllTasks().map { it.map { e -> e.toDomain() } } + private fun TaskEntity.toDomain() = Task( id = id, content = content, diff --git a/app/src/main/java/com/planify/mobile/domain/model/BackendType.kt b/app/src/main/java/com/planify/mobile/domain/model/BackendType.kt index a37643b..6fb7fee 100644 --- a/app/src/main/java/com/planify/mobile/domain/model/BackendType.kt +++ b/app/src/main/java/com/planify/mobile/domain/model/BackendType.kt @@ -1,3 +1,6 @@ package com.planify.mobile.domain.model +import kotlinx.serialization.Serializable + +@Serializable enum class BackendType { LOCAL, CALDAV, TODOIST } diff --git a/app/src/main/java/com/planify/mobile/domain/model/Project.kt b/app/src/main/java/com/planify/mobile/domain/model/Project.kt index 9bed99c..1d37c41 100644 --- a/app/src/main/java/com/planify/mobile/domain/model/Project.kt +++ b/app/src/main/java/com/planify/mobile/domain/model/Project.kt @@ -1,5 +1,8 @@ package com.planify.mobile.domain.model +import kotlinx.serialization.Serializable + +@Serializable data class Project( val id: String, val name: String, @@ -20,6 +23,8 @@ data class Project( val syncId: String? = null, ) +@Serializable enum class ViewStyle { LIST, BOARD } +@Serializable enum class SortBy { MANUAL, NAME, DUE_DATE, ADDED_DATE, PRIORITY } diff --git a/app/src/main/java/com/planify/mobile/domain/model/Task.kt b/app/src/main/java/com/planify/mobile/domain/model/Task.kt index 0632479..0ac1ba1 100644 --- a/app/src/main/java/com/planify/mobile/domain/model/Task.kt +++ b/app/src/main/java/com/planify/mobile/domain/model/Task.kt @@ -1,5 +1,8 @@ package com.planify.mobile.domain.model +import kotlinx.serialization.Serializable + +@Serializable data class Task( val id: String, val content: String, @@ -25,4 +28,5 @@ data class Task( val responsibleUid: String? = null, ) +@Serializable enum class ItemType { TASK, NOTE } diff --git a/app/src/main/java/com/planify/mobile/domain/repository/TaskRepository.kt b/app/src/main/java/com/planify/mobile/domain/repository/TaskRepository.kt index e0eae9c..7f811cd 100644 --- a/app/src/main/java/com/planify/mobile/domain/repository/TaskRepository.kt +++ b/app/src/main/java/com/planify/mobile/domain/repository/TaskRepository.kt @@ -22,4 +22,5 @@ interface TaskRepository { fun getRepeatingTasks(): Flow> fun getTasksByPriority(priority: Int): Flow> suspend fun reorderTasks(orderedIds: List) + fun getAllTasks(): Flow> } diff --git a/app/src/main/java/com/planify/mobile/ui/settings/SettingsScreen.kt b/app/src/main/java/com/planify/mobile/ui/settings/SettingsScreen.kt index c401b57..a70d491 100644 --- a/app/src/main/java/com/planify/mobile/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/planify/mobile/ui/settings/SettingsScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.AccountCircle import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.Sync import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -22,6 +23,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults @@ -29,7 +31,9 @@ import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import android.content.Intent import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -37,6 +41,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.planify.mobile.data.preferences.ThemeMode @@ -49,7 +54,22 @@ fun SettingsScreen( ) { val state by viewModel.uiState.collectAsState() val discovery by viewModel.discoveryInProgress.collectAsState() + val exportUri by viewModel.exportUri.collectAsState() var showAddAccount by remember { mutableStateOf(false) } + val context = LocalContext.current + + LaunchedEffect(exportUri) { + exportUri?.let { uri -> + val mime = if (uri.path?.endsWith(".ics") == true) "text/calendar" else "application/json" + val intent = Intent(Intent.ACTION_SEND).apply { + type = mime + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, "Exporter")) + viewModel.clearExportUri() + } + } Column( modifier = Modifier @@ -171,6 +191,29 @@ fun SettingsScreen( ) } + HorizontalDivider(Modifier.padding(horizontal = 16.dp)) + + // ── Export & Backup ────────────────────────────────────────────────── + SectionTitle("Export & Backup") + OutlinedButton( + onClick = viewModel::exportJson, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Icon(Icons.Outlined.Download, contentDescription = null) + Text("Exporter en JSON", modifier = Modifier.padding(start = 8.dp)) + } + OutlinedButton( + onClick = viewModel::exportIcal, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Icon(Icons.Outlined.Download, contentDescription = null) + Text("Exporter en iCalendar (.ics)", modifier = Modifier.padding(start = 8.dp)) + } + Spacer(Modifier.height(32.dp)) } diff --git a/app/src/main/java/com/planify/mobile/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/planify/mobile/ui/settings/SettingsViewModel.kt index 2066ff0..9103b39 100644 --- a/app/src/main/java/com/planify/mobile/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/planify/mobile/ui/settings/SettingsViewModel.kt @@ -1,20 +1,25 @@ package com.planify.mobile.ui.settings +import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.planify.mobile.data.caldav.CalDavCredentialStore import com.planify.mobile.data.caldav.CalDavDiscovery import com.planify.mobile.data.caldav.DiscoveryResult +import com.planify.mobile.data.export.ExportManager import com.planify.mobile.data.preferences.AppPreferences import com.planify.mobile.data.preferences.ThemeMode import com.planify.mobile.data.sync.SyncScheduler import com.planify.mobile.domain.model.Source +import com.planify.mobile.domain.repository.ProjectRepository import com.planify.mobile.domain.repository.SourceRepository +import com.planify.mobile.domain.repository.TaskRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -37,6 +42,9 @@ class SettingsViewModel @Inject constructor( private val syncScheduler: SyncScheduler, private val discovery: CalDavDiscovery, private val credentialStore: CalDavCredentialStore, + private val exportManager: ExportManager, + private val projectRepository: ProjectRepository, + private val taskRepository: TaskRepository, ) : ViewModel() { val uiState = combine( @@ -100,4 +108,20 @@ class SettingsViewModel @Inject constructor( credentialStore.deletePassword(source.id) sourceRepository.deleteSource(source.id) } + + private val _exportUri = MutableStateFlow(null) + val exportUri = _exportUri.asStateFlow() + + fun exportJson() = viewModelScope.launch { + val projects = projectRepository.getAllProjects().first() + val tasks = taskRepository.getAllTasks().first() + _exportUri.value = exportManager.exportJson(projects, tasks) + } + + fun exportIcal() = viewModelScope.launch { + val tasks = taskRepository.getAllTasks().first() + _exportUri.value = exportManager.exportIcal(tasks) + } + + fun clearExportUri() { _exportUri.value = null } } diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..282e644 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + +