Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Skip to content
Commits on Source (3)
/*
* Cardinal Maps
* Copyright (C) 2025 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 <https://www.gnu.org/licenses/>.
*/
package earth.maps.cardinal.ui.core
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import earth.maps.cardinal.data.LatLng
import earth.maps.cardinal.data.Place
import earth.maps.cardinal.data.ViewportRepository
import earth.maps.cardinal.data.room.RecentSearchRepository
import earth.maps.cardinal.geocoding.GeocodingService
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
/**
* Base ViewModel that provides common search functionality
* to reduce duplication across ViewModels that need search capabilities
*/
@OptIn(FlowPreview::class)
abstract class BaseSearchViewModel(
protected val geocodingService: GeocodingService,
protected val viewportRepository: ViewportRepository,
protected val recentSearchRepository: RecentSearchRepository
) : ViewModel() {
// Search query flow for debouncing
private val _searchQueryFlow = MutableStateFlow("")
protected val searchQueryFlow: StateFlow<String> = _searchQueryFlow.asStateFlow()
var searchQuery by mutableStateOf("")
protected set
val geocodeResults = mutableStateOf<List<Place>>(emptyList())
var isSearching by mutableStateOf(false)
protected set
var searchError by mutableStateOf<String?>(null)
protected set
init {
// Set up debounced search
searchQueryFlow
.debounce(300) // 300ms delay
.distinctUntilChanged()
.onEach { query ->
if (query.isNotEmpty()) {
performSearch(query)
} else {
// Clear results when query is empty
geocodeResults.value = emptyList()
searchError = null
}
}
.launchIn(viewModelScope)
}
/**
* Updates the search query and triggers debounced search
*/
fun updateSearchQuery(query: String) {
searchQuery = query
_searchQueryFlow.value = query
}
/**
* Performs the actual search operation
* Can be overridden by subclasses to provide custom focus point logic
*/
protected open fun performSearch(query: String) {
viewModelScope.launch {
isSearching = true
searchError = null
try {
// Get focus point for viewport biasing - subclasses can override this
val focusPoint = getSearchFocusPoint()
geocodeResults.value = geocodingService.geocode(query, focusPoint)
isSearching = false
} catch (e: Exception) {
// Handle error
searchError = e.message ?: "An error occurred during search"
geocodeResults.value = emptyList()
isSearching = false
}
}
}
/**
* Determines the focus point for search viewport biasing
* Subclasses can override this to provide custom logic
*/
protected open suspend fun getSearchFocusPoint(): LatLng? {
return viewportRepository.viewportCenter.value
}
/**
* Adds a place to recent searches
*/
protected fun addRecentSearch(place: Place) {
viewModelScope.launch {
recentSearchRepository.addRecentSearch(place)
}
}
/**
* Called when a search result is selected
* Subclasses can override this to provide custom behavior
*/
open fun onPlaceSelected(place: Place) {
addRecentSearch(place)
}
}
\ No newline at end of file
......@@ -22,7 +22,6 @@ import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavController
import dagger.hilt.android.lifecycle.HiltViewModel
......@@ -45,19 +44,14 @@ import earth.maps.cardinal.geocoding.GeocodingService
import earth.maps.cardinal.routing.FerrostarWrapperRepository
import earth.maps.cardinal.routing.RouteRepository
import earth.maps.cardinal.transit.TransitousService
import earth.maps.cardinal.ui.core.BaseSearchViewModel
import earth.maps.cardinal.ui.core.NavigationUtils
import earth.maps.cardinal.ui.core.Screen
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import uniffi.ferrostar.GeographicCoordinate
......@@ -68,12 +62,11 @@ import java.time.Instant
import javax.inject.Inject
import kotlin.time.ExperimentalTime
@OptIn(FlowPreview::class)
@HiltViewModel
class DirectionsViewModel @Inject constructor(
private val geocodingService: GeocodingService,
geocodingService: GeocodingService,
private val ferrostarWrapperRepository: FerrostarWrapperRepository,
private val viewportRepository: ViewportRepository,
viewportRepository: ViewportRepository,
private val placeDao: SavedPlaceDao,
private val savedPlaceRepository: SavedPlaceRepository,
private val locationRepository: LocationRepository,
......@@ -81,25 +74,10 @@ class DirectionsViewModel @Inject constructor(
private val routeRepository: RouteRepository,
private val appPreferenceRepository: AppPreferenceRepository,
private val transitousService: TransitousService,
private val recentSearchRepository: RecentSearchRepository,
recentSearchRepository: RecentSearchRepository,
private val routeStateRepository: RouteStateRepository,
private val planStateRepository: PlanStateRepository,
) : ViewModel() {
// Search query flow for debouncing
private val _searchQueryFlow = MutableStateFlow("")
private val searchQueryFlow: StateFlow<String> = _searchQueryFlow.asStateFlow()
var searchQuery by mutableStateOf("")
private set
val geocodeResults = mutableStateOf<List<Place>>(emptyList())
var isSearching by mutableStateOf(false)
private set
var searchError by mutableStateOf<String?>(null)
private set
) : BaseSearchViewModel(geocodingService, viewportRepository, recentSearchRepository) {
// Directions state
var fromPlace by mutableStateOf<Place?>(null)
......@@ -128,22 +106,6 @@ class DirectionsViewModel @Inject constructor(
private var haveManuallySetDeparture: Boolean = false
init {
// Set up debounced search
searchQueryFlow
.debounce(300) // 300ms delay
.distinctUntilChanged()
.onEach { query ->
if (query.isNotEmpty()) {
performSearch(query)
} else {
// Clear results when query is empty
geocodeResults.value = emptyList()
searchError = null
}
}
.launchIn(viewModelScope)
}
suspend fun initializeRoutingMode() {
// Set initial routing mode from preferences
......@@ -154,10 +116,6 @@ class DirectionsViewModel @Inject constructor(
initializeDefaultProfileForMode(selectedRoutingMode)
}
fun updateSearchQuery(query: String) {
searchQuery = query
_searchQueryFlow.value = query
}
suspend fun initializeDeparture() {
if (!appPreferenceRepository.continuousLocationTracking.value) {
......@@ -451,24 +409,10 @@ class DirectionsViewModel @Inject constructor(
}
}
private fun performSearch(query: String) {
viewModelScope.launch {
isSearching = true
searchError = null
try {
// Use fromPlace as focus point for viewport biasing if available,
// otherwise fall back to current viewport center
val focusPoint = fromPlace?.latLng ?: viewportRepository.viewportCenter.value
geocodeResults.value = geocodingService.geocode(query, focusPoint)
isSearching = false
} catch (e: Exception) {
// Handle error
searchError = e.message ?: "An error occurred during search"
geocodeResults.value = emptyList()
isSearching = false
}
}
override suspend fun getSearchFocusPoint(): LatLng? {
// Use fromPlace as focus point for viewport biasing if available,
// otherwise fall back to current viewport center
return fromPlace?.latLng ?: super.getSearchFocusPoint()
}
/**
......@@ -488,9 +432,7 @@ class DirectionsViewModel @Inject constructor(
* Adds the place to recent searches.
*/
fun onPlaceSelectedFromSearch(place: Place) {
viewModelScope.launch {
recentSearchRepository.addRecentSearch(place)
}
addRecentSearch(place)
}
/**
......
......@@ -47,6 +47,7 @@ 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
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
......@@ -70,6 +71,7 @@ import earth.maps.cardinal.R.string
import earth.maps.cardinal.data.AddressFormatter
import earth.maps.cardinal.data.GeocodeResult
import earth.maps.cardinal.data.Place
import earth.maps.cardinal.data.room.RecentSearch
import earth.maps.cardinal.ui.core.TOOLBAR_HEIGHT_DP
import earth.maps.cardinal.ui.place.SearchResultItem
import kotlinx.coroutines.launch
......@@ -85,7 +87,7 @@ fun HomeScreen(
onResultPinsChange: (List<Place>) -> Unit,
onSearchEvent: () -> Unit,
) {
val searchQuery = viewModel.searchQuery
val searchQuery = viewModel.searchQueryValue
Column {
SearchPanelContent(
......@@ -99,7 +101,7 @@ fun HomeScreen(
onPeekHeightChange = onPeekHeightChange,
onSearchEvent = onSearchEvent,
onPlaceSelected = onPlaceSelected,
homeInSearchScreen = viewModel.searchQuery.text.isNotEmpty(),
homeInSearchScreen = viewModel.searchQueryValue.text.isNotEmpty(),
)
}
}
......@@ -117,11 +119,11 @@ private fun SearchPanelContent(
homeInSearchScreen: Boolean,
) {
val addressFormatter = remember { AddressFormatter() }
val pinnedPlaces by viewModel.pinnedPlaces().collectAsState(emptyList())
val geocodeResults by viewModel.geocodeResults.collectAsState(emptyList())
val pinnedPlaces by viewModel.pinnedPlaces().collectAsState(initial = emptyList<Place>())
val geocodeResults = viewModel.geocodeResults
LaunchedEffect(geocodeResults) {
onResultPinsChange(geocodeResults)
LaunchedEffect(geocodeResults.value) {
onResultPinsChange(geocodeResults.value)
}
Column(
......@@ -148,7 +150,7 @@ private fun SearchPanelContent(
ContentBelow(
homeInSearchScreen = homeInSearchScreen,
geocodePlaces = geocodeResults,
geocodePlaces = geocodeResults.value,
viewModel = viewModel,
onPlaceSelected = { place ->
viewModel.onPlaceSelected(place)
......@@ -295,8 +297,16 @@ private fun ContentBelow(
onPlaceSelected: (Place) -> Unit,
addressFormatter: AddressFormatter,
) {
val recentSearches by viewModel.recentSearches().collectAsState(emptyList())
val recentSearches = remember { mutableStateOf<List<RecentSearch>>(emptyList()) }
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(Unit) {
coroutineScope.launch {
viewModel.recentSearches().collect { searches ->
recentSearches.value = searches
}
}
}
if (homeInSearchScreen) {
LazyColumn {
......@@ -312,7 +322,7 @@ private fun ContentBelow(
} else {
LazyColumn {
// Show recent searches if any.
items(recentSearches) { recentSearch ->
items(recentSearches.value) { recentSearch ->
SearchResultItem(
addressFormatter = addressFormatter,
place = viewModel.searchToPlace(recentSearch),
......
......@@ -22,7 +22,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import earth.maps.cardinal.data.LocationRepository
......@@ -33,87 +32,40 @@ import earth.maps.cardinal.data.room.RecentSearchRepository
import earth.maps.cardinal.data.room.SavedPlaceDao
import earth.maps.cardinal.data.room.SavedPlaceRepository
import earth.maps.cardinal.geocoding.GeocodingService
import kotlinx.coroutines.FlowPreview
import earth.maps.cardinal.ui.core.BaseSearchViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@OptIn(FlowPreview::class)
@HiltViewModel
class HomeViewModel @Inject constructor(
private val placeDao: SavedPlaceDao,
private val geocodingService: GeocodingService,
private val viewportRepository: ViewportRepository,
geocodingService: GeocodingService,
viewportRepository: ViewportRepository,
private val locationRepository: LocationRepository,
private val savedPlaceRepository: SavedPlaceRepository,
private val recentSearchRepository: RecentSearchRepository,
) : ViewModel() {
recentSearchRepository: RecentSearchRepository,
) : BaseSearchViewModel(geocodingService, viewportRepository, recentSearchRepository) {
// Whether the home screen is in a search state.
private val _searchExpanded = MutableStateFlow(false)
val searchExpanded: Flow<Boolean> = _searchExpanded
// Search query flow for debouncing
private val _searchQueryFlow = MutableStateFlow("")
private val searchQueryFlow: StateFlow<String> = _searchQueryFlow.asStateFlow()
var searchQuery by mutableStateOf(
// Keep selection state separately from the base viewmodel so that we can preserve it when the
// user hits the back button to return to the search screen.
var searchQueryValue by mutableStateOf(
TextFieldValue()
)
val geocodeResults = MutableStateFlow<List<Place>>(emptyList())
var isSearching by mutableStateOf(false)
private set
var searchError by mutableStateOf<String?>(null)
private set
init {
// Set up debounced search
searchQueryFlow.debounce(300) // 300ms delay
.distinctUntilChanged().onEach { query ->
if (query.isNotEmpty()) {
performSearch(query)
} else {
// Clear results when query is empty
geocodeResults.value = emptyList()
searchError = null
}
}.launchIn(viewModelScope)
}
fun updateSearchQuery(query: TextFieldValue) {
searchQuery = query
_searchQueryFlow.value = query.text
}
private fun performSearch(query: String) {
viewModelScope.launch {
isSearching = true
searchError = null
try {
// Use current viewport center as focus point for viewport biasing
val focusPoint = viewportRepository.viewportCenter.value
geocodeResults.value = geocodingService.geocode(query, focusPoint)
isSearching = false
} catch (e: Exception) {
// Handle error
searchError = e.message ?: "An error occurred during search"
geocodeResults.value = emptyList()
isSearching = false
}
}
searchQueryValue = query
updateSearchQuery(query.text)
}
fun pinnedPlaces(): Flow<List<Place>> {
......@@ -134,16 +86,14 @@ class HomeViewModel @Inject constructor(
* Called when a search result is selected/tapped.
* Adds the place to recent searches.
*/
fun onPlaceSelected(place: Place) {
viewModelScope.launch {
recentSearchRepository.addRecentSearch(place)
}
override fun onPlaceSelected(place: Place) {
addRecentSearch(place)
}
/**
* Gets recent searches.
*/
fun recentSearches(): Flow<List<RecentSearch>> {
suspend fun recentSearches(): Flow<List<RecentSearch>> {
return recentSearchRepository.getRecentSearches()
}
......
......@@ -18,24 +18,8 @@
package earth.maps.cardinal.ui.settings
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
......@@ -45,132 +29,81 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import earth.maps.cardinal.R.dimen
import earth.maps.cardinal.R.string
import earth.maps.cardinal.data.AppPreferences
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccessibilitySettingsScreen(
viewModel: SettingsViewModel = hiltViewModel<SettingsViewModel>(),
onDismiss: () -> Unit
) {
val snackBarHostState = remember { SnackbarHostState() }
Scaffold(
snackbarHost = { SnackbarHost(snackBarHostState) },
contentWindowInsets = WindowInsets.safeDrawing,
topBar = {
TopAppBar(title = {
Text(
text = stringResource(string.accessibility_settings_title),
style = MaterialTheme.typography.headlineMedium,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
)
})
},
content = { padding ->
Box(modifier = Modifier.padding(padding)) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
SettingsScreenScaffold(
title = stringResource(string.accessibility_settings_title),
onDismiss = onDismiss
) { padding ->
Column(modifier = Modifier.padding(padding)) {
SettingsDivider()
SettingsScrollableContent() {
// Contrast Settings Item
SettingsItem(
title = stringResource(string.contrast_settings_title),
description = stringResource(string.contrast_settings_help_text)
) {
// Contrast Settings Item
Column(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = dimensionResource(dimen.padding),
vertical = dimensionResource(dimen.padding_minor)
)
) {
Text(
text = stringResource(string.contrast_settings_title),
style = MaterialTheme.typography.titleMedium
)
Text(
text = stringResource(string.contrast_settings_help_text),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Contrast level selection
val currentContrastLevel by viewModel.contrastLevel.collectAsState()
var selectedContrastLevel by remember { mutableStateOf(currentContrastLevel) }
// Contrast level selection
val currentContrastLevel by viewModel.contrastLevel.collectAsState()
var selectedContrastLevel by remember { mutableStateOf(currentContrastLevel) }
// Update selected state when preference changes from outside
LaunchedEffect(currentContrastLevel) {
selectedContrastLevel = currentContrastLevel
}
// Update selected state when preference changes from outside
LaunchedEffect(currentContrastLevel) {
selectedContrastLevel = currentContrastLevel
}
PreferenceOption(
selectedValue = selectedContrastLevel, options = listOf(
AppPreferences.CONTRAST_LEVEL_STANDARD to string.contrast_standard,
AppPreferences.CONTRAST_LEVEL_MEDIUM to string.contrast_medium,
AppPreferences.CONTRAST_LEVEL_HIGH to string.contrast_high
)
) { newValue ->
selectedContrastLevel = newValue
viewModel.setContrastLevel(newValue)
}
PreferenceOption(
selectedValue = selectedContrastLevel, options = listOf(
AppPreferences.CONTRAST_LEVEL_STANDARD to string.contrast_standard,
AppPreferences.CONTRAST_LEVEL_MEDIUM to string.contrast_medium,
AppPreferences.CONTRAST_LEVEL_HIGH to string.contrast_high
)
) { newValue ->
selectedContrastLevel = newValue
viewModel.setContrastLevel(newValue)
}
}
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
thickness = DividerDefaults.Thickness,
color = MaterialTheme.colorScheme.outlineVariant
)
SettingsDivider()
// Animation Speed Settings Item
Column(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = dimensionResource(dimen.padding),
vertical = dimensionResource(dimen.padding_minor)
)
) {
Text(
text = stringResource(string.animation_speed_title),
style = MaterialTheme.typography.titleMedium
)
Text(
text = stringResource(string.animation_speed_help_text),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
// Animation Speed Settings Item
SettingsItem(
title = stringResource(string.animation_speed_title),
description = stringResource(string.animation_speed_help_text)
) {
// Animation speed selection
val currentAnimationSpeed by viewModel.animationSpeed.collectAsState()
var selectedAnimationSpeed by remember {
mutableIntStateOf(
currentAnimationSpeed
)
}
// Animation speed selection
val currentAnimationSpeed by viewModel.animationSpeed.collectAsState()
var selectedAnimationSpeed by remember {
mutableIntStateOf(
currentAnimationSpeed
)
}
// Update selected state when preference changes from outside
LaunchedEffect(currentAnimationSpeed) {
selectedAnimationSpeed = currentAnimationSpeed
}
// Update selected state when preference changes from outside
LaunchedEffect(currentAnimationSpeed) {
selectedAnimationSpeed = currentAnimationSpeed
}
PreferenceOption(
selectedValue = selectedAnimationSpeed, options = listOf(
AppPreferences.ANIMATION_SPEED_SLOW to string.animation_speed_slow,
AppPreferences.ANIMATION_SPEED_NORMAL to string.animation_speed_normal,
AppPreferences.ANIMATION_SPEED_FAST to string.animation_speed_fast
)
) { newValue ->
selectedAnimationSpeed = newValue
viewModel.setAnimationSpeed(newValue)
}
PreferenceOption(
selectedValue = selectedAnimationSpeed, options = listOf(
AppPreferences.ANIMATION_SPEED_SLOW to string.animation_speed_slow,
AppPreferences.ANIMATION_SPEED_NORMAL to string.animation_speed_normal,
AppPreferences.ANIMATION_SPEED_FAST to string.animation_speed_fast
)
) { newValue ->
selectedAnimationSpeed = newValue
viewModel.setAnimationSpeed(newValue)
}
}
}
}
)
}
}
......@@ -18,31 +18,11 @@
package earth.maps.cardinal.ui.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
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.layout.safeDrawing
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
......@@ -50,220 +30,125 @@ 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.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import earth.maps.cardinal.R.dimen
import earth.maps.cardinal.R.string
import earth.maps.cardinal.ui.core.TOOLBAR_HEIGHT_DP
@Composable
private fun ContinuousLocationTrackingSetting(viewModel: SettingsViewModel) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = dimensionResource(dimen.padding),
vertical = dimensionResource(dimen.padding_minor)
)
) {
Text(
text = stringResource(string.continuous_location_tracking_disabled_title),
style = MaterialTheme.typography.titleMedium
)
Text(
text = stringResource(string.continuous_location_tracking_disabled_help_text),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
fun AdvancedSettingsScreen(
viewModel: SettingsViewModel = hiltViewModel<SettingsViewModel>()
) {
SettingsScreenScaffold(
title = stringResource(string.advanced_settings_title)
) { padding ->
Column(modifier = Modifier.padding(padding)) {
SettingsDivider()
SettingsScrollableContent {
StatefulSwitchSetting(
title = stringResource(string.continuous_location_tracking_disabled_title),
description = stringResource(string.continuous_location_tracking_disabled_help_text),
stateFlow = viewModel.continuousLocationTracking,
onStateChanged = viewModel::setContinuousLocationTrackingEnabled
)
val continuousLocationTracking by viewModel.continuousLocationTracking.collectAsState()
SettingsDivider()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (continuousLocationTracking) stringResource(string.enabled) else stringResource(
string.disabled
), style = MaterialTheme.typography.bodyMedium
)
Switch(
checked = continuousLocationTracking,
onCheckedChange = { newValue ->
viewModel.setContinuousLocationTrackingEnabled(newValue)
}
)
}
}
}
StatefulSwitchSetting(
title = stringResource(string.show_zoom_fabs_title),
description = stringResource(string.show_zoom_fabs_help_text),
stateFlow = viewModel.showZoomFabs,
onStateChanged = viewModel::setShowZoomFabsEnabled
)
@Composable
private fun ShowZoomFabsSetting(viewModel: SettingsViewModel) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = dimensionResource(dimen.padding),
vertical = dimensionResource(dimen.padding_minor)
)
) {
Text(
text = stringResource(string.show_zoom_fabs_title),
style = MaterialTheme.typography.titleMedium
)
Text(
text = stringResource(string.show_zoom_fabs_help_text),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
SettingsDivider()
val showZoomFabs by viewModel.showZoomFabs.collectAsState()
// Time format setting with custom text
TimeFormatSetting(viewModel)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (showZoomFabs) stringResource(string.enabled) else stringResource(
string.disabled
), style = MaterialTheme.typography.bodyMedium
)
Switch(
checked = showZoomFabs,
onCheckedChange = { newValue ->
viewModel.setShowZoomFabsEnabled(newValue)
}
)
SettingsDivider()
// Distance unit setting with custom text
DistanceUnitSetting(viewModel)
SettingsDivider()
// API Configuration settings
PeliasBaseUrlSetting(viewModel)
SettingsDivider()
PeliasApiKeySetting(viewModel)
SettingsDivider()
ValhallaBaseUrlSetting(viewModel)
SettingsDivider()
ValhallaApiKeySetting(viewModel)
}
}
}
}
@Composable
private fun TimeFormatSetting(viewModel: SettingsViewModel) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = dimensionResource(dimen.padding),
vertical = dimensionResource(dimen.padding_minor)
)
) {
Text(
text = stringResource(string.time_format_title),
style = MaterialTheme.typography.titleMedium
)
Text(
text = stringResource(string.time_format_help_text),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
val use24HourFormat by viewModel.use24HourFormat.collectAsState()
var isChecked by remember { mutableStateOf(use24HourFormat) }
val use24HourFormat by viewModel.use24HourFormat.collectAsState()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
val formatText = if (use24HourFormat) {
"24\u2011hour"
} else {
"12\u2011hour"
}
Text(
text = formatText,
style = MaterialTheme.typography.bodyMedium
)
Switch(
checked = use24HourFormat,
onCheckedChange = { newValue ->
viewModel.setUse24HourFormat(newValue)
}
)
}
// Update selected state when preference changes from outside
LaunchedEffect(use24HourFormat) {
isChecked = use24HourFormat
}
SwitchSetting(
title = stringResource(string.time_format_title),
description = stringResource(string.time_format_help_text),
isChecked = isChecked,
onCheckedChange = { newValue ->
isChecked = newValue
viewModel.setUse24HourFormat(newValue)
},
enabledText = "24\u2011hour",
disabledText = "12\u2011hour"
)
}
@Composable
private fun DistanceUnitSetting(viewModel: SettingsViewModel) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = dimensionResource(dimen.padding),
vertical = dimensionResource(dimen.padding_minor)
)
) {
Text(
text = stringResource(string.distance_unit_title),
style = MaterialTheme.typography.titleMedium
)
Text(
text = stringResource(string.distance_unit_help_text),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
val distanceUnit by viewModel.distanceUnit.collectAsState()
val isMetric = distanceUnit == 0
val distanceUnit by viewModel.distanceUnit.collectAsState()
val isMetric = distanceUnit == 0
var isChecked by remember { mutableStateOf(isMetric) }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
val unitText = if (isMetric) {
stringResource(string.metric)
} else {
stringResource(string.imperial)
}
Text(
text = unitText,
style = MaterialTheme.typography.bodyMedium
)
Switch(
checked = isMetric,
onCheckedChange = { newValue ->
val newUnit = if (newValue) 0 else 1
viewModel.setDistanceUnit(newUnit)
}
)
}
// Update selected state when preference changes from outside
LaunchedEffect(distanceUnit) {
isChecked = isMetric
}
SwitchSetting(
title = stringResource(string.distance_unit_title),
description = stringResource(string.distance_unit_help_text),
isChecked = isChecked,
onCheckedChange = { newValue ->
isChecked = newValue
val newUnit = if (newValue) 0 else 1
viewModel.setDistanceUnit(newUnit)
},
enabledText = stringResource(string.metric),
disabledText = stringResource(string.imperial)
)
}
@Composable
private fun PeliasBaseUrlSetting(viewModel: SettingsViewModel) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = dimensionResource(dimen.padding),
vertical = dimensionResource(dimen.padding_minor)
)
SettingsItem(
title = stringResource(string.pelias_base_url_title),
description = ""
) {
Text(
text = stringResource(string.pelias_base_url_title),
style = MaterialTheme.typography.titleMedium
)
val currentPeliasConfig by viewModel.peliasApiConfig.collectAsState()
var peliasBaseUrl by remember { mutableStateOf(currentPeliasConfig.baseUrl) }
......@@ -289,19 +174,10 @@ private fun PeliasBaseUrlSetting(viewModel: SettingsViewModel) {
@Composable
private fun PeliasApiKeySetting(viewModel: SettingsViewModel) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = dimensionResource(dimen.padding),
vertical = dimensionResource(dimen.padding_minor)
)
SettingsItem(
title = stringResource(string.pelias_api_key_title),
description = ""
) {
Text(
text = stringResource(string.pelias_api_key_title),
style = MaterialTheme.typography.titleMedium
)
val currentPeliasConfig by viewModel.peliasApiConfig.collectAsState()
var peliasApiKey by remember {
mutableStateOf(
......@@ -332,19 +208,10 @@ private fun PeliasApiKeySetting(viewModel: SettingsViewModel) {
@Composable
private fun ValhallaBaseUrlSetting(viewModel: SettingsViewModel) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = dimensionResource(dimen.padding),
vertical = dimensionResource(dimen.padding_minor)
)
SettingsItem(
title = stringResource(string.valhalla_base_url_title),
description = ""
) {
Text(
text = stringResource(string.valhalla_base_url_title),
style = MaterialTheme.typography.titleMedium
)
val currentValhallaConfig by viewModel.valhallaApiConfig.collectAsState()
var valhallaBaseUrl by remember { mutableStateOf(currentValhallaConfig.baseUrl) }
......@@ -370,19 +237,10 @@ private fun ValhallaBaseUrlSetting(viewModel: SettingsViewModel) {
@Composable
private fun ValhallaApiKeySetting(viewModel: SettingsViewModel) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = dimensionResource(dimen.padding),
vertical = dimensionResource(dimen.padding_minor)
)
SettingsItem(
title = stringResource(string.valhalla_api_key_title),
description = ""
) {
Text(
text = stringResource(string.valhalla_api_key_title),
style = MaterialTheme.typography.titleMedium
)
val currentValhallaConfig by viewModel.valhallaApiConfig.collectAsState()
var valhallaApiKey by remember {
mutableStateOf(
......@@ -410,108 +268,3 @@ private fun ValhallaApiKeySetting(viewModel: SettingsViewModel) {
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AdvancedSettingsScreen(
viewModel: SettingsViewModel = hiltViewModel<SettingsViewModel>()
) {
val snackBarHostState = remember { SnackbarHostState() }
Scaffold(
snackbarHost = { SnackbarHost(snackBarHostState) },
contentWindowInsets = WindowInsets.safeDrawing,
topBar = {
TopAppBar(title = {
Text(
text = stringResource(string.advanced_settings_title),
style = MaterialTheme.typography.headlineMedium,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
)
})
},
content = { padding ->
Box(modifier = Modifier.padding(padding)) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
thickness = DividerDefaults.Thickness,
color = MaterialTheme.colorScheme.outlineVariant
)
ContinuousLocationTrackingSetting(viewModel)
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
thickness = DividerDefaults.Thickness,
color = MaterialTheme.colorScheme.outlineVariant
)
ShowZoomFabsSetting(viewModel)
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
thickness = DividerDefaults.Thickness,
color = MaterialTheme.colorScheme.outlineVariant
)
TimeFormatSetting(viewModel)
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
thickness = DividerDefaults.Thickness,
color = MaterialTheme.colorScheme.outlineVariant
)
DistanceUnitSetting(viewModel)
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
thickness = DividerDefaults.Thickness,
color = MaterialTheme.colorScheme.outlineVariant
)
// NEW SETTINGS GO HERE.
PeliasBaseUrlSetting(viewModel)
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
thickness = DividerDefaults.Thickness,
color = MaterialTheme.colorScheme.outlineVariant
)
PeliasApiKeySetting(viewModel)
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
thickness = DividerDefaults.Thickness,
color = MaterialTheme.colorScheme.outlineVariant
)
ValhallaBaseUrlSetting(viewModel)
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
thickness = DividerDefaults.Thickness,
color = MaterialTheme.colorScheme.outlineVariant
)
ValhallaApiKeySetting(viewModel)
Spacer(
modifier = Modifier
.fillMaxWidth()
.height(TOOLBAR_HEIGHT_DP)
)
}
}
}
)
}
......@@ -18,212 +18,56 @@
package earth.maps.cardinal.ui.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DividerDefaults
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.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
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
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import earth.maps.cardinal.R.dimen
import earth.maps.cardinal.R.drawable
import earth.maps.cardinal.R.string
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PrivacySettingsScreen(
viewModel: SettingsViewModel = hiltViewModel<SettingsViewModel>(),
onDismiss: () -> Unit,
onNavigateToOfflineAreas: () -> Unit,
) {
Scaffold(
snackbarHost = { SnackbarHost(remember { SnackbarHostState() }) },
contentWindowInsets = WindowInsets.safeDrawing,
topBar = {
TopAppBar(title = {
Text(
text = stringResource(string.privacy_settings_title),
style = MaterialTheme.typography.headlineMedium,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
SettingsScreenScaffold(
title = stringResource(string.privacy_settings_title),
onDismiss = onDismiss
) { padding ->
Column(modifier = Modifier.padding(padding)) {
SettingsDivider()
SettingsScrollableContent {
// Offline Areas Settings Item
SettingsItem(
title = stringResource(string.offline_areas_title),
description = stringResource(string.offline_areas_help_text),
iconResId = drawable.cloud_download_24dp,
onClick = onNavigateToOfflineAreas
)
})
},
content = { padding ->
Box(modifier = Modifier.padding(padding)) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
// Offline Areas Settings Item
Column(
modifier = Modifier
.fillMaxWidth()
.clickable { onNavigateToOfflineAreas() }
.padding(
horizontal = dimensionResource(dimen.padding),
vertical = dimensionResource(dimen.padding_minor)
)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(string.offline_areas_title),
style = MaterialTheme.typography.titleMedium
)
Icon(
painter = painterResource(drawable.cloud_download_24dp),
contentDescription = null
)
}
Text(
text = stringResource(string.offline_areas_help_text),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
thickness = DividerDefaults.Thickness,
color = MaterialTheme.colorScheme.outlineVariant
)
SettingsDivider()
// Offline Mode Settings Item
Column(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = dimensionResource(dimen.padding),
vertical = dimensionResource(dimen.padding_minor)
)
) {
Text(
text = stringResource(string.offline_mode_title),
style = MaterialTheme.typography.titleMedium
)
Text(
text = stringResource(string.offline_mode_help_text),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Offline mode toggle
val currentOfflineMode by viewModel.offlineMode.collectAsState()
var isOfflineModeEnabled by remember { mutableStateOf(currentOfflineMode) }
// Update selected state when preference changes from outside
LaunchedEffect(currentOfflineMode) {
isOfflineModeEnabled = currentOfflineMode
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (isOfflineModeEnabled) stringResource(string.enabled) else stringResource(
string.disabled
), style = MaterialTheme.typography.bodyMedium
)
Switch(
checked = isOfflineModeEnabled, onCheckedChange = { newValue ->
isOfflineModeEnabled = newValue
viewModel.setOfflineMode(newValue)
})
}
// Allow transit in offline mode toggle
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
) {
Text(
text = stringResource(string.allow_transit_in_offline_mode_title),
style = MaterialTheme.typography.titleMedium
)
Text(
text = stringResource(string.allow_transit_in_offline_mode_help_text),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
val currentAllowTransitInOfflineMode by viewModel.allowTransitInOfflineMode.collectAsState()
var isAllowTransitInOfflineModeEnabled by remember {
mutableStateOf(
currentAllowTransitInOfflineMode
)
}
// Update selected state when preference changes from outside
LaunchedEffect(currentAllowTransitInOfflineMode) {
isAllowTransitInOfflineModeEnabled =
currentAllowTransitInOfflineMode
}
StatefulSwitchSetting(
title = stringResource(string.offline_mode_title),
description = stringResource(string.offline_mode_help_text),
stateFlow = viewModel.offlineMode,
onStateChanged = viewModel::setOfflineMode
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (isAllowTransitInOfflineModeEnabled) stringResource(
string.enabled
) else stringResource(
string.disabled
), style = MaterialTheme.typography.bodyMedium
)
Switch(
checked = isAllowTransitInOfflineModeEnabled,
onCheckedChange = { newValue ->
isAllowTransitInOfflineModeEnabled = newValue
viewModel.setAllowTransitInOfflineMode(newValue)
})
}
}
}
}
SettingsDivider()
StatefulSwitchSetting(
title = stringResource(string.allow_transit_in_offline_mode_title),
description = stringResource(string.allow_transit_in_offline_mode_help_text),
stateFlow = viewModel.allowTransitInOfflineMode,
onStateChanged = viewModel::setAllowTransitInOfflineMode
)
}
}
)
}
}
......@@ -22,29 +22,21 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
......@@ -71,48 +63,33 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoutingProfilesScreen(
viewModel: RoutingProfilesViewModel = hiltViewModel(), navController: NavController
viewModel: RoutingProfilesViewModel = hiltViewModel(),
navController: NavController
) {
val allProfiles by viewModel.allProfiles.collectAsState()
val snackBarHostState = remember { SnackbarHostState() }
Scaffold(
snackbarHost = { SnackbarHost(snackBarHostState) },
contentWindowInsets = WindowInsets.safeDrawing,
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(string.routing_profiles),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
}
)
},
content = { padding ->
Box(modifier = Modifier.padding(padding)) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(dimensionResource(dimen.padding))
) {
SettingsScreenScaffold(
title = stringResource(string.routing_profiles)
) { padding ->
Column(modifier = Modifier.padding(padding)) {
SettingsDivider()
Box {
SettingsScrollableContent {
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = dimensionResource(dimen.padding))
modifier = Modifier.padding(dimensionResource(dimen.padding))
) {
if (allProfiles.isEmpty()) {
Text(
text = stringResource(string.no_routing_profiles_configured_yet)
)
}
// Group profiles by routing mode
RoutingMode.entries.forEach { mode ->
val modeProfiles = allProfiles.filter { it.routingMode == mode.value }
val modeProfiles =
allProfiles.filter { it.routingMode == mode.value }
if (modeProfiles.isNotEmpty()) {
RoutingModeSection(
......@@ -124,7 +101,8 @@ fun RoutingProfilesScreen(
},
onDelete = { profile ->
viewModel.deleteProfile(profile.id)
})
}
)
}
}
}
......@@ -155,7 +133,7 @@ fun RoutingProfilesScreen(
}
}
}
)
}
}
@Composable
......
/*
* Cardinal Maps
* Copyright (C) 2025 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 <https://www.gnu.org/licenses/>.
*/
package earth.maps.cardinal.ui.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
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.layout.safeDrawing
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
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
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import earth.maps.cardinal.R.dimen
import earth.maps.cardinal.R.drawable
import earth.maps.cardinal.R.string
import earth.maps.cardinal.ui.core.TOOLBAR_HEIGHT_DP
/**
* Common preference option component with radio buttons
*/
@Composable
fun <T> PreferenceOption(
selectedValue: T,
options: List<Pair<T, Int>>,
onOptionSelected: (T) -> Unit
) {
Column {
options.forEach { (value, labelResId) ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
onOptionSelected(value)
}
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selectedValue == value,
onClick = {
onOptionSelected(value)
}
)
Text(
text = stringResource(labelResId),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(start = 8.dp)
)
}
}
}
}
/**
* Common settings screen scaffold with top bar
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreenScaffold(
title: String,
navController: NavController? = null,
onDismiss: (() -> Unit)? = null,
showCloseButton: Boolean = false,
content: @Composable (paddingValues: PaddingValues) -> Unit
) {
val snackBarHostState = remember { SnackbarHostState() }
Scaffold(
snackbarHost = { SnackbarHost(snackBarHostState) },
contentWindowInsets = WindowInsets.safeDrawing,
topBar = {
TopAppBar(title = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = title,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
if (showCloseButton) {
Spacer(modifier = Modifier.fillMaxWidth())
IconButton(onClick = {
navController?.popBackStack() ?: onDismiss?.invoke()
}) {
Icon(
painter = painterResource(drawable.ic_close),
contentDescription = stringResource(string.close)
)
}
}
}
})
},
content = content
)
}
/**
* Common settings item with title, description, and optional icon
*/
@Composable
fun SettingsItem(
title: String,
description: String,
iconResId: Int? = null,
onClick: (() -> Unit)? = null,
content: @Composable (() -> Unit)? = null
) {
Column(
modifier = Modifier
.fillMaxWidth()
.let { modifier ->
if (onClick != null) {
modifier.clickable { onClick() }
} else {
modifier
}
}
.padding(
horizontal = dimensionResource(dimen.padding),
vertical = dimensionResource(dimen.padding_minor)
)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium
)
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (iconResId != null) {
Icon(
painter = painterResource(iconResId),
contentDescription = null
)
}
}
content?.invoke()
}
}
/**
* Common settings divider
*/
@Composable
fun SettingsDivider() {
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
thickness = DividerDefaults.Thickness,
color = MaterialTheme.colorScheme.outlineVariant
)
}
/**
* Common switch setting component
*/
@Composable
fun SwitchSetting(
title: String,
description: String,
isChecked: Boolean,
onCheckedChange: (Boolean) -> Unit,
enabledText: String = stringResource(string.enabled),
disabledText: String = stringResource(string.disabled)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = dimensionResource(dimen.padding),
vertical = dimensionResource(dimen.padding_minor)
)
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium
)
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (isChecked) enabledText else disabledText,
style = MaterialTheme.typography.bodyMedium
)
Switch(
checked = isChecked,
onCheckedChange = onCheckedChange
)
}
}
}
/**
* Common switch setting with state management
*/
@Composable
fun StatefulSwitchSetting(
title: String,
description: String,
stateFlow: kotlinx.coroutines.flow.StateFlow<Boolean>,
onStateChanged: (Boolean) -> Unit,
enabledText: String = stringResource(string.enabled),
disabledText: String = stringResource(string.disabled)
) {
val currentState by stateFlow.collectAsState()
var isChecked by remember { mutableStateOf(currentState) }
// Update selected state when preference changes from outside
LaunchedEffect(currentState) {
isChecked = currentState
}
SwitchSetting(
title = title,
description = description,
isChecked = isChecked,
onCheckedChange = { newValue ->
isChecked = newValue
onStateChanged(newValue)
},
enabledText = enabledText,
disabledText = disabledText
)
}
/**
* Common scrollable content wrapper for settings screens
*/
@Composable
fun SettingsScrollableContent(
content: @Composable () -> Unit,
) {
Box {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
content()
// Add bottom padding to ensure proper spacing
Spacer(
modifier = Modifier
.fillMaxWidth()
.height(TOOLBAR_HEIGHT_DP)
)
}
}
}
\ No newline at end of file
......@@ -18,44 +18,20 @@
package earth.maps.cardinal.ui.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import earth.maps.cardinal.R.dimen
import earth.maps.cardinal.R.drawable
......@@ -63,248 +39,82 @@ import earth.maps.cardinal.R.string
import earth.maps.cardinal.ui.core.NavigationUtils
import earth.maps.cardinal.ui.core.Screen
@Composable
fun <T> PreferenceOption(
selectedValue: T, options: List<Pair<T, Int>>, onOptionSelected: (T) -> Unit
fun SettingsScreen(
navController: NavController,
viewModel: SettingsViewModel,
) {
Column {
options.forEach { (value, labelResId) ->
SettingsScreenScaffold(
title = stringResource(string.app_name_long),
navController = navController,
showCloseButton = true
) { padding ->
Column(modifier = Modifier.padding(padding)) {
// Custom title with version
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
onOptionSelected(value)
}
.padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = selectedValue == value, onClick = {
onOptionSelected(value)
})
.padding(horizontal = dimensionResource(dimen.padding)),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(labelResId),
modifier = Modifier.padding(start = dimensionResource(dimen.padding)),
text = viewModel.getVersionName() ?: "v?.?.?",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(start = 8.dp)
fontStyle = FontStyle.Italic
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
navController: NavController,
viewModel: SettingsViewModel,
) {
Scaffold(
snackbarHost = { SnackbarHost(remember { SnackbarHostState() }) },
contentWindowInsets = WindowInsets.safeDrawing,
topBar = {
TopAppBar(title = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(string.app_name_long),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Text(
modifier = Modifier.padding(start = dimensionResource(dimen.padding)),
text = viewModel.getVersionName() ?: "v?.?.?",
style = MaterialTheme.typography.bodyMedium,
fontStyle = FontStyle.Italic
)
Spacer(modifier = Modifier.fillMaxWidth())
IconButton(onClick = {
navController.popBackStack()
}) {
Icon(
painter = painterResource(drawable.ic_close),
contentDescription = stringResource(string.close)
)
}
}
})
},
content = { padding ->
Box(modifier = Modifier.padding(padding)) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
thickness = DividerDefaults.Thickness,
color = MaterialTheme.colorScheme.outlineVariant
)
SettingsDivider()
SettingsScrollableContent {
// Routing Profiles Settings Item
Column(
modifier = Modifier
.fillMaxWidth()
.clickable {
NavigationUtils.navigate(
navController,
Screen.RoutingProfiles
)
}
.padding(
horizontal = dimensionResource(dimen.padding),
vertical = dimensionResource(dimen.padding_minor)
)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(string.routing_profiles),
style = MaterialTheme.typography.titleMedium
)
Icon(
painter = painterResource(drawable.commute_icon),
contentDescription = null
)
}
Text(
text = stringResource(string.create_and_manage_custom_routing_profiles),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Routing Profiles Settings Item
SettingsItem(
title = stringResource(string.routing_profiles),
description = stringResource(string.create_and_manage_custom_routing_profiles),
iconResId = drawable.commute_icon,
onClick = {
NavigationUtils.navigate(navController, Screen.RoutingProfiles)
}
)
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
thickness = DividerDefaults.Thickness,
color = MaterialTheme.colorScheme.outlineVariant
)
SettingsDivider()
// Privacy Settings Item
Column(
modifier = Modifier
.fillMaxWidth()
.clickable {
NavigationUtils.navigate(
navController,
Screen.OfflineSettings
)
}
.padding(
horizontal = dimensionResource(dimen.padding),
vertical = dimensionResource(dimen.padding_minor)
)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(string.privacy_settings_title),
style = MaterialTheme.typography.titleMedium
)
Icon(
painter = painterResource(drawable.ic_offline),
contentDescription = null
)
}
Text(
text = stringResource(string.privacy_settings_summary),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Privacy Settings Item
SettingsItem(
title = stringResource(string.privacy_settings_title),
description = stringResource(string.privacy_settings_summary),
iconResId = drawable.ic_offline,
onClick = {
NavigationUtils.navigate(navController, Screen.OfflineSettings)
}
)
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
thickness = DividerDefaults.Thickness,
color = MaterialTheme.colorScheme.outlineVariant
)
SettingsDivider()
// Accessibility Settings Item
Column(
modifier = Modifier
.fillMaxWidth()
.clickable {
NavigationUtils.navigate(
navController,
Screen.AccessibilitySettings
)
}
.padding(
horizontal = dimensionResource(dimen.padding),
vertical = dimensionResource(dimen.padding_minor)
)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(string.accessibility_settings_title),
style = MaterialTheme.typography.titleMedium
)
Icon(
painter = painterResource(drawable.ic_accessiblity_settings),
contentDescription = null
)
}
Text(
text = stringResource(string.accessibility_settings_summary),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Accessibility Settings Item
SettingsItem(
title = stringResource(string.accessibility_settings_title),
description = stringResource(string.accessibility_settings_summary),
iconResId = drawable.ic_accessiblity_settings,
onClick = {
NavigationUtils.navigate(navController, Screen.AccessibilitySettings)
}
)
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
thickness = DividerDefaults.Thickness,
color = MaterialTheme.colorScheme.outlineVariant
)
SettingsDivider()
// Advanced Settings Item
Column(
modifier = Modifier
.fillMaxWidth()
.clickable {
NavigationUtils.navigate(
navController,
Screen.AdvancedSettings
)
}
.padding(
horizontal = dimensionResource(dimen.padding),
vertical = dimensionResource(dimen.padding_minor)
)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(string.advanced_settings_title),
style = MaterialTheme.typography.titleMedium
)
Icon(
painter = painterResource(drawable.ic_settings),
contentDescription = null
)
}
Text(
text = stringResource(string.advanced_settings_summary),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Advanced Settings Item
SettingsItem(
title = stringResource(string.advanced_settings_title),
description = stringResource(string.advanced_settings_summary),
iconResId = drawable.ic_settings,
onClick = {
NavigationUtils.navigate(navController, Screen.AdvancedSettings)
}
// Add some bottom padding to ensure proper spacing
Box(modifier = Modifier.padding(bottom = 8.dp))
}
)
}
})
}
}
}
......@@ -126,7 +126,7 @@ class HomeViewModelTest {
@Test
fun `initial state should be correct`() {
assertEquals(TextFieldValue(), viewModel.searchQuery)
assertEquals(TextFieldValue(), viewModel.searchQueryValue)
assertEquals(emptyList<Place>(), viewModel.geocodeResults.value)
assertFalse(viewModel.isSearching)
assertNull(viewModel.searchError)
......@@ -138,7 +138,7 @@ class HomeViewModelTest {
viewModel.updateSearchQuery(newQuery)
assertEquals(newQuery, viewModel.searchQuery)
assertEquals(newQuery, viewModel.searchQueryValue)
}
@Test
......