diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/MainActivity.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/MainActivity.kt index b38c8bdca5b91af9c08765806065945cd804abba..405e2451d0c723ddcdbf88285f8f40f7eb04846f 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/MainActivity.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/MainActivity.kt @@ -31,6 +31,7 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -54,6 +55,7 @@ import earth.maps.cardinal.tileserver.PermissionRequest import earth.maps.cardinal.tileserver.PermissionRequestManager import earth.maps.cardinal.ui.core.AppContent import earth.maps.cardinal.ui.core.MapViewModel +import earth.maps.cardinal.ui.settings.ThemeModePromptBottomSheet import earth.maps.cardinal.ui.theme.AppTheme import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -226,7 +228,12 @@ class MainActivity : ComponentActivity() { setContent { val contrastLevel by appPreferenceRepository.contrastLevel.collectAsState() - AppTheme(contrastLevel = contrastLevel) { + val themeMode by appPreferenceRepository.themeMode.collectAsState() + val hasPromptedThemeMode by appPreferenceRepository.hasPromptedThemeMode.collectAsState() + val systemInDarkTheme = isSystemInDarkTheme() + val darkTheme = themeMode.shouldUseDarkTheme(systemInDarkTheme) + + AppTheme(darkTheme = darkTheme, contrastLevel = contrastLevel) { val mapViewModel: MapViewModel = hiltViewModel() val navController = rememberNavController() @@ -246,6 +253,7 @@ class MainActivity : ComponentActivity() { hasLocationPermission = hasLocationPermission, routeRepository = routeRepository, appPreferenceRepository = appPreferenceRepository, + useDarkTheme = darkTheme, onRequestNotificationPermission = { requestNotificationPermission() }, hasNotificationPermission = hasNotificationPermission, showLocationPermissionDialog = showLocationPermissionDialog, @@ -259,6 +267,19 @@ class MainActivity : ComponentActivity() { requestLocationPermission() } ) + + if (systemInDarkTheme && !hasPromptedThemeMode) { + ThemeModePromptBottomSheet( + initialThemeMode = themeMode, + onDismiss = { + appPreferenceRepository.setHasPromptedThemeMode(true) + }, + onSave = { selectedThemeMode -> + appPreferenceRepository.setThemeMode(selectedThemeMode) + appPreferenceRepository.setHasPromptedThemeMode(true) + } + ) + } } } } diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/AppPreferenceRepository.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/AppPreferenceRepository.kt index a9ad61ad96f1f73e930a440bb58ef0effdf4afd3..c8b41f3ceb4e5f933c1a2ce6857ba3b4b0f12f7c 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/AppPreferenceRepository.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/AppPreferenceRepository.kt @@ -57,6 +57,9 @@ class AppPreferenceRepository @Inject constructor( private val _distanceUnit = MutableStateFlow(AppPreferences.DISTANCE_UNIT_METRIC) val distanceUnit: StateFlow = _distanceUnit.asStateFlow() + private val _themeMode = MutableStateFlow(ThemeMode.SYSTEM) + val themeMode: StateFlow = _themeMode.asStateFlow() + private val _allowTransitInOfflineMode = MutableStateFlow(true) val allowTransitInOfflineMode: StateFlow = _allowTransitInOfflineMode.asStateFlow() @@ -76,6 +79,9 @@ class AppPreferenceRepository @Inject constructor( private val _hasPromptedLocation = MutableStateFlow(appPreferences.loadHasPromptedLocation()) val hasPromptedLocation: StateFlow = _hasPromptedLocation.asStateFlow() + private val _hasPromptedThemeMode = MutableStateFlow(appPreferences.loadHasPromptedThemeMode()) + val hasPromptedThemeMode: StateFlow = _hasPromptedThemeMode.asStateFlow() + // Pelias API configuration private val _peliasApiConfig = MutableStateFlow( ApiConfiguration( @@ -99,6 +105,7 @@ class AppPreferenceRepository @Inject constructor( loadAnimationSpeed() loadOfflineMode() loadDistanceUnit() + loadThemeMode() loadAllowTransitInOfflineMode() loadContinuousLocationTracking() loadShowZoomFabs() @@ -155,6 +162,18 @@ class AppPreferenceRepository @Inject constructor( _distanceUnit.value = distanceUnit } + fun setThemeMode(themeMode: ThemeMode) { + _themeMode.value = themeMode + viewModelScope.launch { + appPreferences.saveThemeMode(themeMode) + } + } + + private fun loadThemeMode() { + val themeMode = appPreferences.loadThemeMode() + _themeMode.value = themeMode + } + private fun loadAllowTransitInOfflineMode() { val allowTransitInOfflineMode = appPreferences.loadAllowTransitInOfflineMode() _allowTransitInOfflineMode.value = allowTransitInOfflineMode @@ -222,6 +241,13 @@ class AppPreferenceRepository @Inject constructor( } } + fun setHasPromptedThemeMode(hasPrompted: Boolean) { + _hasPromptedThemeMode.value = hasPrompted + viewModelScope.launch { + appPreferences.saveHasPromptedThemeMode(hasPrompted) + } + } + private fun loadApiConfigurations() { // Load Pelias configuration val peliasBaseUrl = appPreferences.loadPeliasBaseUrl() diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/AppPreferences.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/AppPreferences.kt index 0c8d07d1bda9b87e6007ee844577ac59571c69ab..041beb50294450e7d38544a2cf1a37850021f8c8 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/AppPreferences.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/AppPreferences.kt @@ -46,8 +46,10 @@ class AppPreferences(private val context: Context) { private const val KEY_LAST_ROUTING_MODE = "last_routing_mode" private const val KEY_USE_24_HOUR_FORMAT = "use_24_hour_format" - + private const val KEY_HAS_PROMPTED_LOCATION = "has_prompted_location" + private const val KEY_THEME_MODE = "theme_mode" + private const val KEY_HAS_PROMPTED_THEME_MODE = "has_prompted_theme_mode" // API configuration keys private const val KEY_PELIAS_BASE_URL = "pelias_base_url" @@ -75,6 +77,7 @@ class AppPreferences(private val context: Context) { // Distance unit constants const val DISTANCE_UNIT_METRIC = 0 const val DISTANCE_UNIT_IMPERIAL = 1 + } /** @@ -305,6 +308,38 @@ class AppPreferences(private val context: Context) { return prefs.getBoolean(KEY_HAS_PROMPTED_LOCATION, false) } + fun saveThemeMode(themeMode: ThemeMode) { + prefs.edit { + putString(KEY_THEME_MODE, themeMode.preferenceValue) + } + } + + fun loadThemeMode(): ThemeMode { + val themeMode = runCatching { + prefs.getString(KEY_THEME_MODE, null) + }.getOrNull() + + if (themeMode != null) { + return ThemeMode.fromPreferenceValue(themeMode) + } + + val legacyThemeMode = runCatching { + prefs.getInt(KEY_THEME_MODE, -1) + }.getOrNull() + + return legacyThemeMode?.let(ThemeMode::fromLegacyPreferenceValue) ?: ThemeMode.SYSTEM + } + + fun saveHasPromptedThemeMode(hasPrompted: Boolean) { + prefs.edit { + putBoolean(KEY_HAS_PROMPTED_THEME_MODE, hasPrompted) + } + } + + fun loadHasPromptedThemeMode(): Boolean { + return prefs.getBoolean(KEY_HAS_PROMPTED_THEME_MODE, false) + } + /** * Gets the default distance unit based on the system locale. * Returns imperial for countries that use imperial system (US, Liberia, Myanmar), diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/ThemeMode.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/ThemeMode.kt new file mode 100644 index 0000000000000000000000000000000000000000..4c47652ea9033e0d99135778ab7f7b2d4abd84ae --- /dev/null +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/ThemeMode.kt @@ -0,0 +1,43 @@ +/* + * Cardinal Maps + * Copyright (C) 2026 Cardinal Maps Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package earth.maps.cardinal.data + +enum class ThemeMode(val preferenceValue: String) { + LIGHT("light"), + DARK("dark"), + SYSTEM("system"); + + fun shouldUseDarkTheme(systemInDarkTheme: Boolean): Boolean { + return when (this) { + LIGHT -> false + DARK -> true + SYSTEM -> systemInDarkTheme + } + } + + companion object { + fun fromPreferenceValue(value: String?): ThemeMode { + return entries.firstOrNull { it.preferenceValue == value } ?: SYSTEM + } + + fun fromLegacyPreferenceValue(value: Int): ThemeMode? { + return entries.getOrNull(value) + } + } +} diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/AppContent.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/AppContent.kt index 93aeaa35721d07af371220db6c15f238f418459c..f35064114f47d84ec709d8b06c83b2c1c83cc98f 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/AppContent.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/AppContent.kt @@ -150,6 +150,7 @@ fun AppContent( hasNotificationPermission: Boolean, routeRepository: RouteRepository, appPreferenceRepository: AppPreferenceRepository, + useDarkTheme: Boolean, showLocationPermissionDialog: Boolean = false, onDismissLocationDialog: () -> Unit = {}, onAcceptLocationDialog: () -> Unit = {}, @@ -178,7 +179,8 @@ fun AppContent( droppedPinName = droppedPinName, onRequestLocationPermission = onRequestLocationPermission, hasLocationPermission = hasLocationPermission, - appPreferenceRepository = appPreferenceRepository + appPreferenceRepository = appPreferenceRepository, + useDarkTheme = useDarkTheme ) NavHost( @@ -346,7 +348,7 @@ fun AppContent( } composable(Screen.TURN_BY_TURN) { backStackEntry -> - TurnByTurnRoute(state, routeRepository, port, backStackEntry) + TurnByTurnRoute(state, routeRepository, useDarkTheme, port, backStackEntry) } } @@ -391,7 +393,8 @@ private fun MapViewContainer( droppedPinName: String, onRequestLocationPermission: () -> Unit, hasLocationPermission: Boolean, - appPreferenceRepository: AppPreferenceRepository + appPreferenceRepository: AppPreferenceRepository, + useDarkTheme: Boolean ) { Box( modifier = Modifier @@ -452,6 +455,7 @@ private fun MapViewContainer( cameraState = state.cameraState, mapPins = state.mapPins, appPreferences = appPreferenceRepository, + useDarkTheme = useDarkTheme, selectedOfflineArea = state.selectedOfflineArea, currentRoute = state.currentRoute, allRoutes = state.allRoutes, @@ -1095,6 +1099,7 @@ private fun TransitItineraryDetailRoute( private fun TurnByTurnRoute( state: AppContentState, routeRepository: RouteRepository, + useDarkTheme: Boolean, port: Int?, backStackEntry: NavBackStackEntry ) { @@ -1120,6 +1125,7 @@ private fun TurnByTurnRoute( port = port, mode = routingMode, route = ferrostarRoute, + useDarkTheme = useDarkTheme, ) } } diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapView.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapView.kt index f0b88860513c8479f37591742024dc49a0f6f348..4e7038f5ab8db6d7f979b4c57b6e5cc98860ae00 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapView.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapView.kt @@ -19,7 +19,6 @@ package earth.maps.cardinal.ui.core import android.content.Context -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -118,6 +117,7 @@ fun MapView( fabInsets: PaddingValues, cameraState: CameraState, appPreferences: AppPreferenceRepository, + useDarkTheme: Boolean, selectedOfflineArea: OfflineArea? = null, currentRoute: Route? = null, allRoutes: List, @@ -129,7 +129,7 @@ fun MapView( val pinFeatures = mapPins.map { mapViewModel.createFeatureFromPlace(it) } rememberCoroutineScope() - val styleVariant = if (isSystemInDarkTheme()) "dark" else "light" + val styleVariant = if (useDarkTheme) "dark" else "light" // Load saved viewport on initial composition LaunchedEffect(Unit) { @@ -186,7 +186,7 @@ fun MapView( val location by mapViewModel.locationFlow.collectAsState() val sensorHeading by mapViewModel.heading.collectAsState() val savedPlaces by mapViewModel.savedPlacesFlow.collectAsState(FeatureCollection()) - FavoritesLayer(savedPlaces, mapPins, isSystemInDarkTheme()) + FavoritesLayer(savedPlaces, mapPins, useDarkTheme) OfflineBoundsLayer(selectedOfflineArea) @@ -194,7 +194,7 @@ fun MapView( TransitLayer(currentTransitItinerary) - PinsLayer(pinFeatures, isSystemInDarkTheme()) + PinsLayer(pinFeatures, useDarkTheme) location?.let { LocationPuck(it, sensorHeading) } } @@ -226,7 +226,7 @@ fun MapView( private fun FavoritesLayer( savedPlaces: FeatureCollection>, activeMarkers: List, - isSystemInDarkTheme: Boolean + useDarkTheme: Boolean ) { val textColor = MaterialTheme.colorScheme.onSurface val activeMarkerIds = activeMarkers.mapNotNull { it.id } @@ -235,7 +235,7 @@ private fun FavoritesLayer( source = rememberGeoJsonSource(GeoJsonData.JsonString(Json.encodeToString(savedPlaces))), iconAllowOverlap = const(true), iconImage = image( - if (isSystemInDarkTheme) { + if (useDarkTheme) { painterResource(drawable.ic_stars_dark) } else { painterResource(drawable.ic_stars_light) @@ -524,14 +524,14 @@ private fun TransitLayer(currentTransitItinerary: Itinerary?) { } @Composable -private fun PinsLayer(pinFeatures: List>>, isSystemInDarkTheme: Boolean) { +private fun PinsLayer(pinFeatures: List>>, useDarkTheme: Boolean) { SymbolLayer( id = "map_pins", source = rememberGeoJsonSource(GeoJsonData.JsonString(Json.encodeToString(FeatureCollection(features = pinFeatures)))), iconAllowOverlap = const(true), iconAnchor = const(SymbolAnchor.Bottom), iconImage = image( - if (isSystemInDarkTheme) { + if (useDarkTheme) { painterResource(drawable.map_pin_dark) } else { painterResource(drawable.map_pin_light) diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/navigation/TurnByTurnNavigationScreen.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/navigation/TurnByTurnNavigationScreen.kt index a1b57e2b36600947d2fb62a8bf91b58d654d86fa..a5cd56622b4aca1769d94ab6748d913e1f35ee7d 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/navigation/TurnByTurnNavigationScreen.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/navigation/TurnByTurnNavigationScreen.kt @@ -18,7 +18,6 @@ package earth.maps.cardinal.ui.navigation -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme @@ -50,7 +49,8 @@ fun KeepScreenOn() { fun TurnByTurnNavigationScreen( port: Int, mode: RoutingMode, - route: Route? + route: Route?, + useDarkTheme: Boolean ) { // Inject the ViewModel using Hilt val turnByTurnViewModel: TurnByTurnNavigationViewModel = hiltViewModel() @@ -103,7 +103,7 @@ fun TurnByTurnNavigationScreen( val viewModel = remember { DefaultNavigationViewModel(ferrostarCore = ferrostarCore) } // Determine the style URL based on theme - val styleVariant = if (isSystemInDarkTheme()) "dark" else "light" + val styleVariant = if (useDarkTheme) "dark" else "light" val styleUrl = "http://127.0.0.1:$port/style_$styleVariant.json" // Only display the navigation view if we have a route diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/AccessibilitySettingsScreen.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/AccessibilitySettingsScreen.kt index 217aba55ab95800f80fbd2e908d5fdc2ec24bfd4..cb91763bc768b3bc8bd631e0552910ec7ceae0d3 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/AccessibilitySettingsScreen.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/AccessibilitySettingsScreen.kt @@ -46,6 +46,31 @@ fun AccessibilitySettingsScreen( Column(modifier = Modifier.padding(padding)) { SettingsDivider() SettingsScrollableContent() { + // Theme Mode Settings Item + SettingsItem( + title = stringResource(string.theme_mode_settings_title), + description = stringResource(string.theme_mode_settings_help_text) + ) { + val currentThemeMode by viewModel.themeMode.collectAsState() + var selectedThemeMode by remember { + mutableStateOf(currentThemeMode) + } + + LaunchedEffect(currentThemeMode) { + selectedThemeMode = currentThemeMode + } + + PreferenceOption( + selectedValue = selectedThemeMode, + options = themeModeOptions + ) { newValue -> + selectedThemeMode = newValue + viewModel.setThemeMode(newValue) + } + } + + SettingsDivider() + // Contrast Settings Item SettingsItem( title = stringResource(string.contrast_settings_title), diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/SettingsViewModel.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/SettingsViewModel.kt index 00741f01ab6874e0d4093c71ab4a94cd82742e83..f46629832cc9165a8779f43ab6cf428f39a0ba63 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/SettingsViewModel.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/SettingsViewModel.kt @@ -29,6 +29,7 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import earth.maps.cardinal.data.AppPreferenceRepository +import earth.maps.cardinal.data.ThemeMode import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @@ -100,6 +101,12 @@ class SettingsViewModel @Inject constructor( 0 ) + val themeMode = appPreferenceRepository.themeMode.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + appPreferenceRepository.themeMode.value + ) + fun setOfflineMode(enabled: Boolean) { appPreferenceRepository.setOfflineMode(enabled) } @@ -148,6 +155,11 @@ class SettingsViewModel @Inject constructor( appPreferenceRepository.setDistanceUnit(distanceUnit) } + fun setThemeMode(themeMode: ThemeMode) { + appPreferenceRepository.setThemeMode(themeMode) + appPreferenceRepository.setHasPromptedThemeMode(true) + } + fun getVersionName(): String? { try { val pInfo: PackageInfo = diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/ThemeModePromptBottomSheet.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/ThemeModePromptBottomSheet.kt new file mode 100644 index 0000000000000000000000000000000000000000..fede0a8e54a4635f9db2ebc494607d1f87dc495b --- /dev/null +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/ThemeModePromptBottomSheet.kt @@ -0,0 +1,132 @@ +/* + * Cardinal Maps + * Copyright (C) 2026 Cardinal Maps Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package earth.maps.cardinal.ui.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import earth.maps.cardinal.R.drawable +import earth.maps.cardinal.R.string +import earth.maps.cardinal.data.ThemeMode + +internal val themeModeOptions = listOf( + ThemeMode.LIGHT to string.theme_mode_light, + ThemeMode.DARK to string.theme_mode_dark, + ThemeMode.SYSTEM to string.theme_mode_system +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ThemeModePromptBottomSheet( + initialThemeMode: ThemeMode, + onDismiss: () -> Unit, + onSave: (ThemeMode) -> Unit +) { + var selectedThemeMode by rememberSaveable(initialThemeMode) { + mutableStateOf(initialThemeMode) + } + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + dragHandle = { BottomSheetDefaults.DragHandle() } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + painter = painterResource(drawable.ic_theme_mode), + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + Text( + text = stringResource( + string.theme_mode_dialog_title, + stringResource(string.app_name_long) + ), + style = MaterialTheme.typography.titleLarge + ) + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource( + string.theme_mode_dialog_message, + stringResource(string.app_name_long) + ), + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + PreferenceOption( + selectedValue = selectedThemeMode, + options = themeModeOptions, + onOptionSelected = { selectedThemeMode = it } + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + TextButton( + modifier = Modifier.weight(1f), + onClick = onDismiss + ) { + Text(text = stringResource(string.not_now)) + } + Button( + modifier = Modifier.weight(1f), + onClick = { onSave(selectedThemeMode) } + ) { + Text(text = stringResource(string.save)) + } + } + } + } +} diff --git a/cardinal-android/app/src/main/res/drawable/ic_theme_mode.xml b/cardinal-android/app/src/main/res/drawable/ic_theme_mode.xml new file mode 100644 index 0000000000000000000000000000000000000000..8e6f519c1abf93a2b3dfb2e8c1f601f2bc3e894d --- /dev/null +++ b/cardinal-android/app/src/main/res/drawable/ic_theme_mode.xml @@ -0,0 +1,10 @@ + + + + diff --git a/cardinal-android/app/src/main/res/values/strings.xml b/cardinal-android/app/src/main/res/values/strings.xml index 08f8feeff73a088240790f46483098ef3efed0d0..2d294eb26c7868d6915e35f3aa4fab2755fa50cd 100644 --- a/cardinal-android/app/src/main/res/values/strings.xml +++ b/cardinal-android/app/src/main/res/values/strings.xml @@ -77,6 +77,15 @@ Normal Fast + + New! %1$s is in dark theme + Use %1$s in: + Theme + Choose how the app uses light and dark mode + Always in light theme + Always in dark theme + Same as device theme + Download current viewport Downloading Downloading %1$s diff --git a/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/settings/SettingsViewModelTest.kt b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/settings/SettingsViewModelTest.kt index 8444652520e5d42ec3710c66f3312484a5d8040a..f751620b432e8972730256c68f7314731decc0e0 100644 --- a/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/settings/SettingsViewModelTest.kt +++ b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/settings/SettingsViewModelTest.kt @@ -7,6 +7,7 @@ import android.content.pm.PackageManager import androidx.core.net.toUri import earth.maps.cardinal.data.ApiConfiguration import earth.maps.cardinal.data.AppPreferenceRepository +import earth.maps.cardinal.data.ThemeMode import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -52,6 +53,7 @@ class SettingsViewModelTest { every { mockAppPreferenceRepository.showZoomFabs } returns MutableStateFlow(true) every { mockAppPreferenceRepository.use24HourFormat } returns MutableStateFlow(false) every { mockAppPreferenceRepository.distanceUnit } returns MutableStateFlow(0) + every { mockAppPreferenceRepository.themeMode } returns MutableStateFlow(ThemeMode.SYSTEM) viewModel = SettingsViewModel( context = context, @@ -331,6 +333,14 @@ class SettingsViewModelTest { verify { mockAppPreferenceRepository.setDistanceUnit(2) } } + @Test + fun `setThemeMode should save theme mode and mark prompt handled`() = runTest { + viewModel.setThemeMode(ThemeMode.LIGHT) + + verify { mockAppPreferenceRepository.setThemeMode(ThemeMode.LIGHT) } + verify { mockAppPreferenceRepository.setHasPromptedThemeMode(true) } + } + @Test fun `getVersionName should execute without crashing`() { // Test that the method executes without throwing an exception @@ -338,4 +348,4 @@ class SettingsViewModelTest { viewModel.getVersionName() // If we reach here, the method executed successfully } -} \ No newline at end of file +}