Loading cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/BaseSearchViewModel.kt 0 → 100644 +138 −0 Original line number Diff line number Diff line /* * 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 cardinal-android/app/src/main/java/earth/maps/cardinal/ui/directions/DirectionsViewModel.kt +10 −68 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading @@ -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, Loading @@ -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) Loading Loading @@ -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 Loading @@ -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) { Loading Loading @@ -451,24 +409,10 @@ class DirectionsViewModel @Inject constructor( } } private fun performSearch(query: String) { viewModelScope.launch { isSearching = true searchError = null try { override suspend fun getSearchFocusPoint(): LatLng? { // 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 } } return fromPlace?.latLng ?: super.getSearchFocusPoint() } /** Loading @@ -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) } /** Loading cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/HomeScreen.kt +19 −9 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading @@ -85,7 +87,7 @@ fun HomeScreen( onResultPinsChange: (List<Place>) -> Unit, onSearchEvent: () -> Unit, ) { val searchQuery = viewModel.searchQuery val searchQuery = viewModel.searchQueryValue Column { SearchPanelContent( Loading @@ -99,7 +101,7 @@ fun HomeScreen( onPeekHeightChange = onPeekHeightChange, onSearchEvent = onSearchEvent, onPlaceSelected = onPlaceSelected, homeInSearchScreen = viewModel.searchQuery.text.isNotEmpty(), homeInSearchScreen = viewModel.searchQueryValue.text.isNotEmpty(), ) } } Loading @@ -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( Loading @@ -148,7 +150,7 @@ private fun SearchPanelContent( ContentBelow( homeInSearchScreen = homeInSearchScreen, geocodePlaces = geocodeResults, geocodePlaces = geocodeResults.value, viewModel = viewModel, onPlaceSelected = { place -> viewModel.onPlaceSelected(place) Loading Loading @@ -295,9 +297,17 @@ 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 { items(geocodePlaces) { Loading @@ -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), Loading cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/HomeViewModel.kt +12 −63 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -33,87 +32,39 @@ 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 a separate TextFieldValue for UI compatibility 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>> { Loading @@ -134,16 +85,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() } Loading cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/AccessibilitySettingsScreen.kt +54 −121 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -45,60 +29,28 @@ 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 Column( modifier = Modifier .fillMaxWidth() .padding( horizontal = dimensionResource(dimen.padding), vertical = dimensionResource(dimen.padding_minor) ) SettingsItem( title = stringResource(string.contrast_settings_title), description = stringResource(string.contrast_settings_help_text) ) { 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) } Loading @@ -120,31 +72,13 @@ fun AccessibilitySettingsScreen( } } 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) ) SettingsItem( title = stringResource(string.animation_speed_title), description = stringResource(string.animation_speed_help_text) ) { 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 selection val currentAnimationSpeed by viewModel.animationSpeed.collectAsState() var selectedAnimationSpeed by remember { Loading Loading @@ -172,5 +106,4 @@ fun AccessibilitySettingsScreen( } } } ) } Loading
cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/BaseSearchViewModel.kt 0 → 100644 +138 −0 Original line number Diff line number Diff line /* * 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
cardinal-android/app/src/main/java/earth/maps/cardinal/ui/directions/DirectionsViewModel.kt +10 −68 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading @@ -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, Loading @@ -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) Loading Loading @@ -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 Loading @@ -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) { Loading Loading @@ -451,24 +409,10 @@ class DirectionsViewModel @Inject constructor( } } private fun performSearch(query: String) { viewModelScope.launch { isSearching = true searchError = null try { override suspend fun getSearchFocusPoint(): LatLng? { // 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 } } return fromPlace?.latLng ?: super.getSearchFocusPoint() } /** Loading @@ -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) } /** Loading
cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/HomeScreen.kt +19 −9 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading @@ -85,7 +87,7 @@ fun HomeScreen( onResultPinsChange: (List<Place>) -> Unit, onSearchEvent: () -> Unit, ) { val searchQuery = viewModel.searchQuery val searchQuery = viewModel.searchQueryValue Column { SearchPanelContent( Loading @@ -99,7 +101,7 @@ fun HomeScreen( onPeekHeightChange = onPeekHeightChange, onSearchEvent = onSearchEvent, onPlaceSelected = onPlaceSelected, homeInSearchScreen = viewModel.searchQuery.text.isNotEmpty(), homeInSearchScreen = viewModel.searchQueryValue.text.isNotEmpty(), ) } } Loading @@ -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( Loading @@ -148,7 +150,7 @@ private fun SearchPanelContent( ContentBelow( homeInSearchScreen = homeInSearchScreen, geocodePlaces = geocodeResults, geocodePlaces = geocodeResults.value, viewModel = viewModel, onPlaceSelected = { place -> viewModel.onPlaceSelected(place) Loading Loading @@ -295,9 +297,17 @@ 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 { items(geocodePlaces) { Loading @@ -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), Loading
cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/HomeViewModel.kt +12 −63 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -33,87 +32,39 @@ 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 a separate TextFieldValue for UI compatibility 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>> { Loading @@ -134,16 +85,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() } Loading
cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/AccessibilitySettingsScreen.kt +54 −121 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -45,60 +29,28 @@ 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 Column( modifier = Modifier .fillMaxWidth() .padding( horizontal = dimensionResource(dimen.padding), vertical = dimensionResource(dimen.padding_minor) ) SettingsItem( title = stringResource(string.contrast_settings_title), description = stringResource(string.contrast_settings_help_text) ) { 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) } Loading @@ -120,31 +72,13 @@ fun AccessibilitySettingsScreen( } } 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) ) SettingsItem( title = stringResource(string.animation_speed_title), description = stringResource(string.animation_speed_help_text) ) { 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 selection val currentAnimationSpeed by viewModel.animationSpeed.collectAsState() var selectedAnimationSpeed by remember { Loading Loading @@ -172,5 +106,4 @@ fun AccessibilitySettingsScreen( } } } ) }