diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/MainActivity.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/MainActivity.kt index 59f6fd3..a0f2edf 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/MainActivity.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/MainActivity.kt @@ -4,18 +4,31 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import com.vaultdrop.mobile.domain.ThemePreferenceStore import com.vaultdrop.mobile.ui.navigation.VaultDropApp import com.vaultdrop.mobile.ui.theme.VaultDropTheme import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import kotlinx.coroutines.launch @AndroidEntryPoint class MainActivity : ComponentActivity() { + @Inject + lateinit var themePreferenceStore: ThemePreferenceStore + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() + + lifecycleScope.launch { themePreferenceStore.load() } + setContent { - VaultDropTheme { + val theme by themePreferenceStore.theme.collectAsStateWithLifecycle() + VaultDropTheme(theme = theme) { VaultDropApp() } } diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/PrefKeys.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/PrefKeys.kt index fff660e..795faea 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/PrefKeys.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/PrefKeys.kt @@ -5,4 +5,5 @@ object PrefKeys { const val DEVICE_USER_ID = "device_user_id" const val ACTIVE_USER_ID = "active_user_id" const val SYNC_MODE = "sync_mode" + const val THEME = "theme" } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/ThemePreference.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/ThemePreference.kt new file mode 100644 index 0000000..c6c0156 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/ThemePreference.kt @@ -0,0 +1,17 @@ +package com.vaultdrop.mobile.domain + +/** + * Préférence d'affichage de la page Réglages, persistée dans `user_preferences`. + * `SYSTEM` (défaut) = suivre le thème du système ; `LIGHT`/`DARK` = forcer. + */ +enum class ThemePreference(val storedValue: String) { + SYSTEM("system"), + LIGHT("light"), + DARK("dark"), + ; + + companion object { + fun fromStored(value: String?): ThemePreference = + entries.firstOrNull { it.storedValue == value } ?: SYSTEM + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/ThemePreferenceStore.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/ThemePreferenceStore.kt new file mode 100644 index 0000000..c5d3c65 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/ThemePreferenceStore.kt @@ -0,0 +1,40 @@ +package com.vaultdrop.mobile.domain + +import com.vaultdrop.mobile.data.local.dao.UserPreferenceDao +import com.vaultdrop.mobile.data.local.entity.UserPreferenceEntity +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Préférence de thème (clair/sombre) : valeur par défaut SYSTEM, chargée une + * fois depuis `user_preferences` puis exposée en [StateFlow]. La source le + * thread-safe : écritures et lectures passent par le DAO Room (IO). + */ +@Singleton +class ThemePreferenceStore @Inject constructor( + private val userPreferenceDao: UserPreferenceDao, +) { + + private val loaded = AtomicBoolean(false) + + private val _theme = MutableStateFlow(ThemePreference.SYSTEM) + val theme: StateFlow = _theme.asStateFlow() + + /** Charge la valeur persistée au boot (idempotent). */ + suspend fun load() { + if (!loaded.compareAndSet(false, true)) return + val stored = userPreferenceDao.getValue(PrefKeys.THEME) + _theme.value = ThemePreference.fromStored(stored) + } + + suspend fun set(theme: ThemePreference) { + _theme.value = theme + userPreferenceDao.upsert( + UserPreferenceEntity(PrefKeys.THEME, theme.storedValue, System.currentTimeMillis()), + ) + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/settings/SettingsScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/settings/SettingsScreen.kt index e063c46..472af8f 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/settings/SettingsScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/settings/SettingsScreen.kt @@ -1,17 +1,31 @@ package com.vaultdrop.mobile.ui.settings -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.isSystemInDarkTheme +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.material.icons.Icons +import androidx.compose.material.icons.filled.DarkMode import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vaultdrop.mobile.R +import com.vaultdrop.mobile.domain.ThemePreference import com.vaultdrop.mobile.ui.navigation.FloatingNavBar import com.vaultdrop.mobile.ui.navigation.NavTab @@ -20,7 +34,15 @@ import com.vaultdrop.mobile.ui.navigation.NavTab fun SettingsScreen( selectedTab: NavTab, onTabSelected: (NavTab) -> Unit, + viewModel: SettingsViewModel = hiltViewModel(), ) { + val theme by viewModel.theme.collectAsStateWithLifecycle() + val darkNow = when (theme) { + ThemePreference.DARK -> true + ThemePreference.LIGHT -> false + ThemePreference.SYSTEM -> isSystemInDarkTheme() + } + Scaffold( topBar = { TopAppBar(title = { Text(stringResource(R.string.settings)) }) @@ -29,14 +51,62 @@ fun SettingsScreen( FloatingNavBar(selected = selectedTab, onSelect = onTabSelected) }, ) { padding -> - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding), ) { - Text( - text = stringResource(R.string.settings), - textAlign = TextAlign.Center, + HorizontalDivider() + SettingsRow( + icon = { Icon(Icons.Filled.DarkMode, contentDescription = null) }, + title = stringResource(R.string.settings_dark_mode), + subtitle = stringResource( + if (darkNow) R.string.settings_dark_mode_on + else R.string.settings_dark_mode_off, + ), + trailing = { + Switch( + checked = darkNow, + onCheckedChange = { on -> + viewModel.setTheme(if (on) ThemePreference.DARK else ThemePreference.LIGHT) + }, + ) + }, ) } } } + +@Composable +private fun SettingsRow( + icon: @Composable () -> Unit, + title: String, + subtitle: String, + trailing: @Composable () -> Unit, +) { + androidx.compose.foundation.layout.Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + icon() + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + trailing() + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/settings/SettingsViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..1a4a52c --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/settings/SettingsViewModel.kt @@ -0,0 +1,26 @@ +package com.vaultdrop.mobile.ui.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vaultdrop.mobile.domain.ThemePreference +import com.vaultdrop.mobile.domain.ThemePreferenceStore +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val themePreferenceStore: ThemePreferenceStore, +) : ViewModel() { + + val theme: StateFlow = themePreferenceStore.theme + + init { + viewModelScope.launch { themePreferenceStore.load() } + } + + fun setTheme(theme: ThemePreference) { + viewModelScope.launch { themePreferenceStore.set(theme) } + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/theme/Theme.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/theme/Theme.kt index 6cd0c1f..d0a36a2 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/theme/Theme.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/theme/Theme.kt @@ -4,6 +4,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import com.vaultdrop.mobile.domain.ThemePreference private val LightColors = lightColorScheme( primary = BrandBlue, @@ -17,6 +18,19 @@ private val DarkColors = darkColorScheme( onPrimary = androidx.compose.ui.graphics.Color.Black, ) +@Composable +fun VaultDropTheme( + theme: ThemePreference, + content: @Composable () -> Unit, +) { + val darkTheme = when (theme) { + ThemePreference.DARK -> true + ThemePreference.LIGHT -> false + ThemePreference.SYSTEM -> isSystemInDarkTheme() + } + VaultDropTheme(darkTheme = darkTheme, content = content) +} + @Composable fun VaultDropTheme( darkTheme: Boolean = isSystemInDarkTheme(), diff --git a/mobile-kotlin/app/src/main/res/values-en/strings.xml b/mobile-kotlin/app/src/main/res/values-en/strings.xml index 22ab478..fc96623 100644 --- a/mobile-kotlin/app/src/main/res/values-en/strings.xml +++ b/mobile-kotlin/app/src/main/res/values-en/strings.xml @@ -27,6 +27,9 @@ Search Settings Settings + Dark mode + Dark theme active + Light theme active Search diff --git a/mobile-kotlin/app/src/main/res/values/strings.xml b/mobile-kotlin/app/src/main/res/values/strings.xml index 0412064..f824cff 100644 --- a/mobile-kotlin/app/src/main/res/values/strings.xml +++ b/mobile-kotlin/app/src/main/res/values/strings.xml @@ -28,6 +28,9 @@ Recherche Réglages Réglages + Mode sombre + Thème sombre actif + Thème clair actif Recherche