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

Commit 794ce684 authored by Ellen Poe's avatar Ellen Poe
Browse files

Merge branch 'ellenhp/jscpd_refactor' into 'main'

Reduce code duplication

See merge request e/os/cardinal!33
parents 3480a090 f1232722
Loading
Loading
Loading
Loading
Loading
+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
+10 −68
Original line number Diff line number Diff line
@@ -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 {
    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()
    }

    /**
@@ -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)
    }

    /**
+19 −9
Original line number Diff line number Diff line
@@ -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,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) {
@@ -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),
+13 −63
Original line number Diff line number Diff line
@@ -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()
    }

+54 −121
Original line number Diff line number Diff line
@@ -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,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) }
@@ -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 {
@@ -172,5 +106,4 @@ fun AccessibilitySettingsScreen(
            }
        }
    }
    )
}
Loading