diff --git a/cardinal-android/app/build.gradle.kts b/cardinal-android/app/build.gradle.kts index 720ac115dfda12a64222a0ef828365f87982c6de..695483a5e66373a617578605ffee10c453eb850f 100644 --- a/cardinal-android/app/build.gradle.kts +++ b/cardinal-android/app/build.gradle.kts @@ -214,6 +214,11 @@ dependencies { implementation(libs.hilt.navigation.compose) testImplementation(libs.junit) testImplementation(libs.assertj.core) + testImplementation(libs.hilt.android.testing) + kspTest(libs.hilt.android.compiler) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockk) + testImplementation(libs.robolectric) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/GeocodeResult.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/GeocodeResult.kt index 5c373caa9102e02b7565ecd30ab354edeeb73851..8d2b1880598ed173390edf05fefed825fa9c554e 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/GeocodeResult.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/GeocodeResult.kt @@ -18,7 +18,7 @@ package earth.maps.cardinal.data -import earth.maps.cardinal.ui.home.generatePlaceId +import kotlin.math.abs data class GeocodeResult( val latitude: Double, @@ -26,7 +26,25 @@ data class GeocodeResult( val displayName: String, val properties: Map, val address: Address? = null, -) +) { + companion object { + /** + * Generate a unique ID for a place based on its properties. + * This ensures that each search result gets a consistent but unique ID. + */ + fun generatePlaceId(result: GeocodeResult): Int { + // Create a string representation of the unique properties + val uniqueString = buildString { + append(result.latitude) + append(result.longitude) + append(result.displayName) + } + + // Generate a hash code and ensure it's positive + return abs(uniqueString.hashCode()) + } + } +} data class Address( val houseNumber: String? = null, @@ -45,7 +63,3 @@ data class Address( fun Address.format(formatter: AddressFormatter): String? { return formatter.format(this) } - -fun deduplicateSearchResults(results: List): List { - return results.distinctBy { generatePlaceId(it) } -} diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/di/GeocodingModule.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/di/GeocodingModule.kt index 510559f7eaa4929388620ebb0a4adae389d735bc..bf2d2c36bbaba8334a52da8ed23bfd9a7c01ae5e 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/di/GeocodingModule.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/di/GeocodingModule.kt @@ -25,6 +25,7 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import earth.maps.cardinal.data.AppPreferenceRepository +import earth.maps.cardinal.data.LocationRepository import earth.maps.cardinal.geocoding.GeocodingService import earth.maps.cardinal.geocoding.MultiplexedGeocodingService import earth.maps.cardinal.geocoding.OfflineGeocodingService @@ -42,31 +43,39 @@ object GeocodingModule { @Singleton fun provideGeocodingService( appPreferenceRepository: AppPreferenceRepository, + locationRepository: LocationRepository, @ApplicationContext context: Context ): GeocodingService { - val onlineService = providePeliasGeocodingService(appPreferenceRepository) - val offlineService = provideOfflineGeocodingService(context) + val onlineService = providePeliasGeocodingService(appPreferenceRepository, locationRepository) + val offlineService = provideOfflineGeocodingService(context, locationRepository) return MultiplexedGeocodingService( - appPreferenceRepository, - onlineService, - offlineService + appPreferenceRepository = appPreferenceRepository, + onlineGeocodingService = onlineService, + offlineGeocodingService = offlineService, + locationRepository = locationRepository ) } @Provides @Singleton - fun providePeliasGeocodingService(appPreferenceRepository: AppPreferenceRepository): PeliasGeocodingService { - return PeliasGeocodingService(appPreferenceRepository) + fun providePeliasGeocodingService( + appPreferenceRepository: AppPreferenceRepository, + locationRepository: LocationRepository + ): PeliasGeocodingService { + return PeliasGeocodingService(appPreferenceRepository, locationRepository) } @Provides @Singleton - fun provideOfflineGeocodingService(@ApplicationContext context: Context): OfflineGeocodingService { + fun provideOfflineGeocodingService( + @ApplicationContext context: Context, + locationRepository: LocationRepository + ): OfflineGeocodingService { val globalGeocodingService = geocodingService if (globalGeocodingService != null) { return globalGeocodingService } else { - val newGeocoder = OfflineGeocodingService(context) + val newGeocoder = OfflineGeocodingService(context, locationRepository) geocodingService = newGeocoder return newGeocoder } @@ -74,7 +83,10 @@ object GeocodingModule { @Provides @Singleton - fun provideTileProcessor(@ApplicationContext context: Context): TileProcessor { - return provideOfflineGeocodingService(context) + fun provideTileProcessor( + @ApplicationContext context: Context, + locationRepository: LocationRepository + ): TileProcessor { + return provideOfflineGeocodingService(context, locationRepository) } } diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/geocoding/GeocodingService.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/geocoding/GeocodingService.kt index 1d940dd3bbccbef9ce4e6e64cc567823675b2ee9..5763df4b02ed034ee6c654799c7037573eadd9d1 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/geocoding/GeocodingService.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/geocoding/GeocodingService.kt @@ -19,31 +19,85 @@ package earth.maps.cardinal.geocoding import earth.maps.cardinal.data.GeocodeResult +import earth.maps.cardinal.data.GeocodeResult.Companion.generatePlaceId import earth.maps.cardinal.data.LatLng +import earth.maps.cardinal.data.Place +import earth.maps.cardinal.data.LocationRepository import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +abstract class GeocodingService(private val locationRepository: LocationRepository) { -interface GeocodingService { /** - * Geocode a query string to find matching locations + * Geocode a query string to find matching locations, returning Place objects. * @param query The search query (e.g., address, place name) * @param focusPoint Optional focus point for viewport biasing - * @return Flow of geocoding results + * @return Flow of Place objects */ - suspend fun geocode(query: String, focusPoint: LatLng? = null): Flow> + suspend fun geocode(query: String, focusPoint: LatLng? = null): Flow> { + return convertResultsToPlaces(geocodeRaw(query, focusPoint)) + } /** - * Reverse geocode coordinates to find address information + * Reverse geocode coordinates to find address information, returning Place objects. * @param latitude The latitude coordinate * @param longitude The longitude coordinate - * @return Flow of geocoding results + * @return Flow of Place objects */ - suspend fun reverseGeocode(latitude: Double, longitude: Double): Flow> + suspend fun reverseGeocode(latitude: Double, longitude: Double): Flow> { + return convertResultsToPlaces(reverseGeocodeRaw(latitude, longitude)) + } /** - * Find nearby places around a given point + * Find nearby places around a given point, returning Place objects. * @param latitude The latitude coordinate * @param longitude The longitude coordinate - * @return Flow of nearby places + * @return Flow of Place objects + */ + suspend fun nearby(latitude: Double, longitude: Double): Flow> { + return convertResultsToPlaces(nearbyRaw(latitude, longitude)) + } + + /** + * Converts a Flow of GeocodeResult to a Flow of Place, including deduplication. + * @param resultsFlow The Flow of GeocodeResult to convert. + * @return Flow of Place objects. */ - suspend fun nearby(latitude: Double, longitude: Double): Flow> + protected open suspend fun convertResultsToPlaces(resultsFlow: Flow>): Flow> { + return resultsFlow.map { results -> + // Deduplicate based on GeocodeResult before converting to Place + val deduplicatedResults = deduplicateSearchResults(results) + deduplicatedResults.map { geocodeResult -> + locationRepository.createSearchResultPlace(geocodeResult) + } + } + } + + /** + * Geocode a query string to find matching locations, returning raw GeocodeResult objects. + * @param query The search query (e.g., address, place name) + * @param focusPoint Optional focus point for viewport biasing + * @return Flow of raw geocoding results + */ + abstract suspend fun geocodeRaw(query: String, focusPoint: LatLng? = null): Flow> + + /** + * Reverse geocode coordinates to find address information, returning raw GeocodeResult objects. + * @param latitude The latitude coordinate + * @param longitude The longitude coordinate + * @return Flow of raw geocoding results + */ + abstract suspend fun reverseGeocodeRaw(latitude: Double, longitude: Double): Flow> + + /** + * Find nearby places around a given point, returning raw GeocodeResult objects. + * @param latitude The latitude coordinate + * @param longitude The longitude coordinate + * @return Flow of raw nearby places + */ + abstract suspend fun nearbyRaw(latitude: Double, longitude: Double): Flow> +} + +fun deduplicateSearchResults(results: List): List { + return results.distinctBy { generatePlaceId(it) } } diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/geocoding/MultiplexedGeocodingService.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/geocoding/MultiplexedGeocodingService.kt index 79a4604263eb65095d9c8da700ab15e2a16a5c00..72406ee4beb21b6faff2bf39d4d921694cb5c070 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/geocoding/MultiplexedGeocodingService.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/geocoding/MultiplexedGeocodingService.kt @@ -21,38 +21,40 @@ package earth.maps.cardinal.geocoding import earth.maps.cardinal.data.AppPreferenceRepository import earth.maps.cardinal.data.GeocodeResult import earth.maps.cardinal.data.LatLng +import earth.maps.cardinal.data.LocationRepository import kotlinx.coroutines.flow.Flow class MultiplexedGeocodingService( private val appPreferenceRepository: AppPreferenceRepository, private val onlineGeocodingService: GeocodingService, - private val offlineGeocodingService: GeocodingService -) : GeocodingService { + private val offlineGeocodingService: GeocodingService, + locationRepository: LocationRepository, +) : GeocodingService(locationRepository) { - override suspend fun geocode(query: String, focusPoint: LatLng?): Flow> { + override suspend fun geocodeRaw(query: String, focusPoint: LatLng?): Flow> { return if (appPreferenceRepository.offlineMode.value) { - offlineGeocodingService.geocode(query, focusPoint) + offlineGeocodingService.geocodeRaw(query, focusPoint) } else { - onlineGeocodingService.geocode(query, focusPoint) + onlineGeocodingService.geocodeRaw(query, focusPoint) } } - override suspend fun reverseGeocode( + override suspend fun reverseGeocodeRaw( latitude: Double, longitude: Double ): Flow> { return if (appPreferenceRepository.offlineMode.value) { - offlineGeocodingService.reverseGeocode(latitude, longitude) + offlineGeocodingService.reverseGeocodeRaw(latitude, longitude) } else { - onlineGeocodingService.reverseGeocode(latitude, longitude) + onlineGeocodingService.reverseGeocodeRaw(latitude, longitude) } } - override suspend fun nearby(latitude: Double, longitude: Double): Flow> { + override suspend fun nearbyRaw(latitude: Double, longitude: Double): Flow> { return if (appPreferenceRepository.offlineMode.value) { - offlineGeocodingService.nearby(latitude, longitude) + offlineGeocodingService.nearbyRaw(latitude, longitude) } else { - onlineGeocodingService.nearby(latitude, longitude) + onlineGeocodingService.nearbyRaw(latitude, longitude) } } } diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/geocoding/OfflineGeocodingService.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/geocoding/OfflineGeocodingService.kt index 571f7b4c4cc78729e4fc5389ffcadb24b946ef5b..4139c187c0037c568f48d5b470f5f3227a260240 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/geocoding/OfflineGeocodingService.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/geocoding/OfflineGeocodingService.kt @@ -24,16 +24,20 @@ import earth.maps.cardinal.R import earth.maps.cardinal.data.Address import earth.maps.cardinal.data.GeocodeResult import earth.maps.cardinal.data.LatLng +import earth.maps.cardinal.data.LocationRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import uniffi.cardinal_geocoder.newAirmailIndex import java.io.File -class OfflineGeocodingService(private val context: Context) : GeocodingService, TileProcessor { +class OfflineGeocodingService( + private val context: Context, + locationRepository: LocationRepository +) : GeocodingService(locationRepository), TileProcessor { private val geocoderDir = File(context.filesDir, "geocoder").apply { mkdirs() } private val airmailIndex = newAirmailIndex("en", geocoderDir.absolutePath) - override suspend fun geocode(query: String, focusPoint: LatLng?): Flow> = + override suspend fun geocodeRaw(query: String, focusPoint: LatLng?): Flow> = flow { try { val results = airmailIndex.searchPhrase(query) @@ -52,14 +56,14 @@ class OfflineGeocodingService(private val context: Context) : GeocodingService, } } - override suspend fun reverseGeocode( + override suspend fun reverseGeocodeRaw( latitude: Double, longitude: Double ): Flow> = flow { emit(emptyList()) } - override suspend fun nearby(latitude: Double, longitude: Double): Flow> = + override suspend fun nearbyRaw(latitude: Double, longitude: Double): Flow> = flow { emit(emptyList()) } diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/geocoding/PeliasGeocodingService.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/geocoding/PeliasGeocodingService.kt index b93ca88e559e998816dffeb8df7a94d77900d3b6..aa831062c3700a9047c99d2fe6005d73b1284eb4 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/geocoding/PeliasGeocodingService.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/geocoding/PeliasGeocodingService.kt @@ -23,6 +23,7 @@ import earth.maps.cardinal.data.Address import earth.maps.cardinal.data.AppPreferenceRepository import earth.maps.cardinal.data.GeocodeResult import earth.maps.cardinal.data.LatLng +import earth.maps.cardinal.data.LocationRepository import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.engine.android.Android @@ -44,8 +45,11 @@ import kotlinx.serialization.json.jsonPrimitive private const val TAG = "PeliasGeocoding" -class PeliasGeocodingService(private val appPreferenceRepository: AppPreferenceRepository) : - GeocodingService { +class PeliasGeocodingService( + private val appPreferenceRepository: AppPreferenceRepository, + locationRepository: LocationRepository +) : + GeocodingService(locationRepository) { private val client = HttpClient(Android) { install(ContentNegotiation) { json(Json { @@ -56,7 +60,7 @@ class PeliasGeocodingService(private val appPreferenceRepository: AppPreferenceR install(Logging) } - override suspend fun geocode(query: String, focusPoint: LatLng?): Flow> = + override suspend fun geocodeRaw(query: String, focusPoint: LatLng?): Flow> = flow { try { Log.d(TAG, "Geocoding query: $query, focusPoint: $focusPoint") @@ -88,7 +92,7 @@ class PeliasGeocodingService(private val appPreferenceRepository: AppPreferenceR } } - override suspend fun reverseGeocode( + override suspend fun reverseGeocodeRaw( latitude: Double, longitude: Double ): Flow> = flow { @@ -119,7 +123,7 @@ class PeliasGeocodingService(private val appPreferenceRepository: AppPreferenceR } } - override suspend fun nearby(latitude: Double, longitude: Double): Flow> = + override suspend fun nearbyRaw(latitude: Double, longitude: Double): Flow> = flow { try { Log.d(TAG, "Nearby: $latitude, $longitude") diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/directions/DirectionsScreen.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/directions/DirectionsScreen.kt index 1f847c1f1a30c9cf4c08348606a2dc7a9ccbae6c..6b3b914dc94ed3dc28360cf7febdb3fdadeee9a1 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/directions/DirectionsScreen.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/directions/DirectionsScreen.kt @@ -66,7 +66,6 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import earth.maps.cardinal.R.dimen import earth.maps.cardinal.R.drawable @@ -75,7 +74,6 @@ import earth.maps.cardinal.data.AppPreferenceRepository import earth.maps.cardinal.data.GeoUtils import earth.maps.cardinal.data.Place import earth.maps.cardinal.data.RoutingMode -import earth.maps.cardinal.data.deduplicateSearchResults import earth.maps.cardinal.data.room.RoutingProfile import earth.maps.cardinal.ui.core.NavigationUtils import earth.maps.cardinal.ui.core.Screen @@ -632,8 +630,7 @@ private fun SearchResultsContent( fieldFocusState: FieldFocusState ) { SearchResults( - viewModel = hiltViewModel(), - geocodeResults = deduplicateSearchResults(viewModel.geocodeResults.value), + places = viewModel.geocodeResults.value, onPlaceSelected = { place -> updatePlaceForField(viewModel, fieldFocusState, place) onFieldFocusStateChange(FieldFocusState.NONE) diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/directions/DirectionsViewModel.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/directions/DirectionsViewModel.kt index 44ec17045b3feb2c52eb568bf9780a594791462a..bc332c7899a8643a8a6f7a0b17769ccd59ff04b5 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/directions/DirectionsViewModel.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/directions/DirectionsViewModel.kt @@ -18,20 +18,15 @@ package earth.maps.cardinal.ui.directions -import android.Manifest -import android.content.Context -import android.content.pm.PackageManager import android.util.Log import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.NavController import dagger.hilt.android.lifecycle.HiltViewModel import earth.maps.cardinal.data.AppPreferenceRepository -import earth.maps.cardinal.data.GeocodeResult import earth.maps.cardinal.data.LatLng import earth.maps.cardinal.data.LocationRepository import earth.maps.cardinal.data.Place @@ -108,7 +103,7 @@ class DirectionsViewModel @Inject constructor( var searchQuery by mutableStateOf("") private set - val geocodeResults = mutableStateOf>(emptyList()) + val geocodeResults = mutableStateOf>(emptyList()) var isSearching by mutableStateOf(false) private set @@ -516,28 +511,6 @@ class DirectionsViewModel @Inject constructor( } } - /** - * Checks if location permissions are granted before attempting to get location. - * This method should be called before any location-related operations. - */ - fun hasLocationPermission(context: Context): Boolean { - return ContextCompat.checkSelfPermission( - context, - Manifest.permission.ACCESS_FINE_LOCATION - ) == PackageManager.PERMISSION_GRANTED || - ContextCompat.checkSelfPermission( - context, - Manifest.permission.ACCESS_COARSE_LOCATION - ) == PackageManager.PERMISSION_GRANTED - } - - /** - * Creates a "My Location" Place object with the given coordinates. - */ - fun createMyLocationPlace(latLng: LatLng): Place { - return locationRepository.createMyLocationPlace(latLng) - } - /** * Called when a search result is selected from directions search. * Adds the place to recent searches. diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/HomeScreen.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/HomeScreen.kt index 66909228ec4ff505f0f45f1b5591b25f7e7309ba..ec7d0141a3aeceb12ea9a7bb60c0155aac9d97e9 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/HomeScreen.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/HomeScreen.kt @@ -118,10 +118,10 @@ private fun SearchPanelContent( ) { val addressFormatter = remember { AddressFormatter() } val pinnedPlaces by viewModel.pinnedPlaces().collectAsState(emptyList()) - val geocodePlaces by viewModel.geocodePlaces.collectAsState(emptyList()) + val geocodeResults by viewModel.geocodeResults.collectAsState(emptyList()) - LaunchedEffect(geocodePlaces) { - onResultPinsChange(geocodePlaces) + LaunchedEffect(geocodeResults) { + onResultPinsChange(geocodeResults) } Column( @@ -148,7 +148,7 @@ private fun SearchPanelContent( ContentBelow( homeInSearchScreen = homeInSearchScreen, - geocodePlaces = geocodePlaces, + geocodePlaces = geocodeResults, viewModel = viewModel, onPlaceSelected = { place -> viewModel.onPlaceSelected(place) @@ -347,19 +347,3 @@ fun NavigationIcon( } } } - -/** - * Generate a unique ID for a place based on its properties. - * This ensures that each search result gets a consistent but unique ID. - */ -fun generatePlaceId(result: GeocodeResult): Int { - // Create a string representation of the unique properties - val uniqueString = buildString { - append(result.latitude) - append(result.longitude) - append(result.displayName) - } - - // Generate a hash code and ensure it's positive - return abs(uniqueString.hashCode()) -} diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/HomeViewModel.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/HomeViewModel.kt index 2da9cb922766a7265057239cddd8856d470eeda7..788480eb7671bf06bef351ca3acc1d336a5f3184 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/HomeViewModel.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/HomeViewModel.kt @@ -29,7 +29,6 @@ import earth.maps.cardinal.data.GeocodeResult import earth.maps.cardinal.data.LocationRepository import earth.maps.cardinal.data.Place import earth.maps.cardinal.data.ViewportRepository -import earth.maps.cardinal.data.deduplicateSearchResults import earth.maps.cardinal.data.room.RecentSearch import earth.maps.cardinal.data.room.RecentSearchRepository import earth.maps.cardinal.data.room.SavedPlaceDao @@ -72,9 +71,7 @@ class HomeViewModel @Inject constructor( TextFieldValue() ) - val geocodeResults = MutableStateFlow>(emptyList()) - - val geocodePlaces = geocodeResults.map { list -> list.map { geocodeResultToPlace(it) } } + val geocodeResults = MutableStateFlow>(emptyList()) var isSearching by mutableStateOf(false) private set @@ -101,9 +98,6 @@ class HomeViewModel @Inject constructor( _searchQueryFlow.value = query.text } - fun geocodeResultToPlace(result: GeocodeResult): Place { - return locationRepository.createSearchResultPlace(result) - } private fun performSearch(query: String) { viewModelScope.launch { @@ -113,7 +107,7 @@ class HomeViewModel @Inject constructor( // Use current viewport center as focus point for viewport biasing val focusPoint = viewportRepository.viewportCenter.value geocodingService.geocode(query, focusPoint).collect { results -> - geocodeResults.value = deduplicateSearchResults(results) + geocodeResults.value = results isSearching = false } } catch (e: Exception) { diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/NearbyViewModel.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/NearbyViewModel.kt index 9dd7791369379d6c4bd5f266ad86bff43770b07a..1c782ab974fcdffce802e35583dd642334332259 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/NearbyViewModel.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/NearbyViewModel.kt @@ -94,8 +94,7 @@ class NearbyViewModel @Inject constructor( _error.value = null try { geocodingService.nearby(latitude, longitude).collect { results -> - _nearbyResults.value = - results.map { locationRepository.createSearchResultPlace(it) } + _nearbyResults.value = results _isLoading.value = false } } catch (e: Exception) { diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/place/SearchResults.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/place/SearchResults.kt index 7cb1a36ebf095a520533dbc588645f9c93d3ef21..157bdb7d5d79893f91e5e0f6e9db19e75cdcc540 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/place/SearchResults.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/place/SearchResults.kt @@ -46,21 +46,18 @@ import earth.maps.cardinal.R import earth.maps.cardinal.R.dimen import earth.maps.cardinal.R.drawable import earth.maps.cardinal.data.AddressFormatter -import earth.maps.cardinal.data.GeocodeResult import earth.maps.cardinal.data.Place import earth.maps.cardinal.data.format @Composable fun SearchResults( - viewModel: SearchResultsViewModel, - geocodeResults: List, + places: List, onPlaceSelected: (Place) -> Unit, modifier: Modifier = Modifier ) { val addressFormatter = remember { AddressFormatter() } LazyColumn(modifier = modifier) { - items(geocodeResults) { result -> - val place = viewModel.generatePlace(result) + items(places) { place -> SearchResultItem( addressFormatter = addressFormatter, place = place, @@ -140,4 +137,3 @@ fun SearchResultItem( } } } - diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/place/SearchResultsViewModel.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/place/SearchResultsViewModel.kt deleted file mode 100644 index 6cea11780916a69373f78928bf1989b0383073d3..0000000000000000000000000000000000000000 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/place/SearchResultsViewModel.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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 . - */ - -package earth.maps.cardinal.ui.place - -import androidx.lifecycle.ViewModel -import dagger.hilt.android.lifecycle.HiltViewModel -import earth.maps.cardinal.data.GeocodeResult -import earth.maps.cardinal.data.LocationRepository -import earth.maps.cardinal.data.Place -import javax.inject.Inject - -@HiltViewModel -class SearchResultsViewModel @Inject constructor( - private val locationRepository: LocationRepository -) : ViewModel() { - fun generatePlace(geocodeResult: GeocodeResult): Place { - return locationRepository.createSearchResultPlace(geocodeResult) - } -} \ No newline at end of file diff --git a/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/core/MapViewModelTest.kt b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/core/MapViewModelTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..6175d18be1c0117e3b2528d0fdcf509f0121a70e --- /dev/null +++ b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/core/MapViewModelTest.kt @@ -0,0 +1,239 @@ +package earth.maps.cardinal.ui.core + +import android.content.Context +import androidx.compose.ui.unit.dp +import earth.maps.cardinal.data.LocationRepository +import earth.maps.cardinal.data.OrientationRepository +import earth.maps.cardinal.data.Place +import earth.maps.cardinal.data.ViewportPreferences +import earth.maps.cardinal.data.ViewportRepository +import earth.maps.cardinal.data.room.SavedPlace +import earth.maps.cardinal.geocoding.OfflineGeocodingService +import io.github.dellisd.spatialk.geojson.Position +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.maplibre.compose.camera.CameraPosition +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +@OptIn(ExperimentalCoroutinesApi::class) +class MapViewModelTest { + + private lateinit var context: Context + private lateinit var viewModel: MapViewModel + + private val mockViewportPreferences = mockk(relaxed = true) + private val mockViewportRepository = mockk(relaxed = true) + private val mockLocationRepository = mockk() + private val mockOrientationRepository = mockk() + private val mockOfflineGeocodingService = mockk() + private val mockPlaceDao = mockk() + + @Before + fun setup() { + context = Robolectric.buildActivity(androidx.activity.ComponentActivity::class.java).get() + + // Setup default repository behaviors + every { mockLocationRepository.isLocating } returns MutableStateFlow(false) + every { mockLocationRepository.locationFlow } returns MutableStateFlow(null) + coEvery { mockLocationRepository.getCurrentLocation(any()) } returns null + every { mockLocationRepository.createSearchResultPlace(any()) } returns Place( + name = "Test Place", latLng = earth.maps.cardinal.data.LatLng(0.0, 0.0), address = null + ) + + every { mockOrientationRepository.azimuth } returns MutableStateFlow(0f) + + every { mockPlaceDao.getAllPlacesAsFlow() } returns flowOf(emptyList()) + + viewModel = MapViewModel( + context = context, + viewportPreferences = mockViewportPreferences, + viewportRepository = mockViewportRepository, + locationRepository = mockLocationRepository, + orientationRepository = mockOrientationRepository, + offlineGeocodingService = mockOfflineGeocodingService, + placeDao = mockPlaceDao + ) + + // Initialize screen dimensions + viewModel.peekHeight = 100.dp + viewModel.screenHeight = 800.dp + viewModel.screenWidth = 600.dp + } + + @Test + fun `saveViewport should call viewportPreferences saveViewport`() = runTest { + val cameraPosition = CameraPosition( + target = Position(0.0, 0.0), zoom = 10.0 + ) + + viewModel.saveViewport(cameraPosition) + + verify { mockViewportPreferences.saveViewport(cameraPosition) } + verify { mockViewportRepository.updateViewportCenter(cameraPosition) } + } + + @Test + fun `loadViewport should return saved viewport from preferences`() = runTest { + val expectedCameraPosition = CameraPosition( + target = Position(1.0, 1.0), zoom = 15.0 + ) + + every { mockViewportPreferences.loadViewport() } returns expectedCameraPosition + + val result = viewModel.loadViewport() + + assertThat(result).isEqualTo(expectedCameraPosition) + } + + @Test + fun `loadViewport should return null when no viewport is saved`() = runTest { + every { mockViewportPreferences.loadViewport() } returns null + + val result = viewModel.loadViewport() + + assertThat(result).isNull() + } + + @Test + fun `updateViewportCenter should call viewportRepository updateViewportCenter`() = runTest { + val cameraPosition = CameraPosition( + target = Position(2.0, 2.0), zoom = 12.0 + ) + + viewModel.updateViewportCenter(cameraPosition) + + verify { mockViewportRepository.updateViewportCenter(cameraPosition) } + } + + @Test + fun `markLocationRequestPending should update hasPendingLocationRequest to true`() = runTest { + viewModel.markLocationRequestPending() + + assertThat(viewModel.hasPendingLocationRequest.first()).isTrue() + } + + @Test + fun `isLocating should reflect LocationRepository's isLocating state`() = runTest { + val expectedLocatingState = MutableStateFlow(false) + every { mockLocationRepository.isLocating } returns expectedLocatingState + + // Re-initialize viewModel to use the new mock + viewModel = MapViewModel( + context = context, + viewportPreferences = mockViewportPreferences, + viewportRepository = mockViewportRepository, + locationRepository = mockLocationRepository, + orientationRepository = mockOrientationRepository, + offlineGeocodingService = mockOfflineGeocodingService, + placeDao = mockPlaceDao + ) + + assertThat(viewModel.isLocating.first()).isFalse() + + expectedLocatingState.value = true + assertThat(viewModel.isLocating.first()).isTrue() + + expectedLocatingState.value = false + assertThat(viewModel.isLocating.first()).isFalse() + } + + @Test + fun `locationFlow should reflect LocationRepository's locationFlow state`() = runTest { + val expectedLocation = android.location.Location("test").apply { + latitude = 37.7749 + longitude = -122.4194 + } + val expectedLocationFlow = MutableStateFlow(expectedLocation) + every { mockLocationRepository.locationFlow } returns expectedLocationFlow + + // Re-initialize viewModel to use the new mock + viewModel = MapViewModel( + context = context, + viewportPreferences = mockViewportPreferences, + viewportRepository = mockViewportRepository, + locationRepository = mockLocationRepository, + orientationRepository = mockOrientationRepository, + offlineGeocodingService = mockOfflineGeocodingService, + placeDao = mockPlaceDao + ) + + assertThat(viewModel.locationFlow.first()).isEqualTo(expectedLocation) + + val newLocation = android.location.Location("test").apply { + latitude = 34.0522 + longitude = -118.2437 + } + expectedLocationFlow.value = newLocation + assertThat(viewModel.locationFlow.first()).isEqualTo(newLocation) + } + + @Test + fun `heading should reflect OrientationRepository's azimuth state`() = runTest { + val expectedHeading = 45.0f + val expectedHeadingFlow = MutableStateFlow(expectedHeading) + every { mockOrientationRepository.azimuth } returns expectedHeadingFlow + + // Re-initialize viewModel to use the new mock + viewModel = MapViewModel( + context = context, + viewportPreferences = mockViewportPreferences, + viewportRepository = mockViewportRepository, + locationRepository = mockLocationRepository, + orientationRepository = mockOrientationRepository, + offlineGeocodingService = mockOfflineGeocodingService, + placeDao = mockPlaceDao + ) + + assertThat(viewModel.heading.first()).isEqualTo(expectedHeading) + + val newHeading = 90.0f + expectedHeadingFlow.value = newHeading + assertThat(viewModel.heading.first()).isEqualTo(newHeading) + } + + @Test + fun `savedPlacesFlow should reflect PlaceDao's data`() = runTest { + val savedPlace = SavedPlace( + id = "1", + placeId = 0, + name = "Test Place", + type = "Point of Interest", + icon = "default_icon", + latitude = 37.7749, + longitude = -122.4194, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + val expectedPlacesFlow = flowOf(listOf(savedPlace)) + every { mockPlaceDao.getAllPlacesAsFlow() } returns expectedPlacesFlow + + // Re-initialize viewModel to use the new mock + viewModel = MapViewModel( + context = context, + viewportPreferences = mockViewportPreferences, + viewportRepository = mockViewportRepository, + locationRepository = mockLocationRepository, + orientationRepository = mockOrientationRepository, + offlineGeocodingService = mockOfflineGeocodingService, + placeDao = mockPlaceDao + ) + + val featureCollection = viewModel.savedPlacesFlow.first() + assertThat(featureCollection.features).hasSize(1) + assertThat(featureCollection.features.first().properties["name"].toString()).isEqualTo("\"Test Place\"") + } + +} diff --git a/cardinal-android/gradle/libs.versions.toml b/cardinal-android/gradle/libs.versions.toml index c2fb016abc996b6f48bd9634d767805d4491d4c9..5473b5539f663cf66529c6a0fef9bb7216ca1fda 100644 --- a/cardinal-android/gradle/libs.versions.toml +++ b/cardinal-android/gradle/libs.versions.toml @@ -16,6 +16,7 @@ lifecycleRuntimeKtx = "2.9.3" activityCompose = "1.10.1" composeBom = "2025.08.01" maplibreCompose = "0.11.1" +robolectric = "4.16" room = "2.7.2" navigation = "2.9.3" gson = "2.13.1" @@ -29,6 +30,9 @@ ferrostar = "0.41.0" okhttp3 = "5.1.0" material3 = "1.5.0-alpha04" detekt = "2.0.0-alpha.0" +mockk = "1.13.16" +kotlinxCoroutinesTest = "1.10.2" +hiltAndroidTesting = "2.57.1" [libraries] androidaddressformatter = { module = "com.github.woheller69:AndroidAddressFormatter", version.ref = "androidaddressformatter" } @@ -61,6 +65,7 @@ ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } maplibre-compose = { module = "org.maplibre.compose:maplibre-compose", version.ref = "maplibreCompose" } maplibre-compose-material3 = { module = "org.maplibre.compose:maplibre-compose-material3", version.ref = "maplibreCompose" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } @@ -76,6 +81,9 @@ ferrostar-core = { group = "com.stadiamaps.ferrostar", name = "core", version.re ferrostar-maplibreui = { group = "com.stadiamaps.ferrostar", name = "maplibreui", version.ref = "ferrostar" } ferrostar-composeui = { group = "com.stadiamaps.ferrostar", name = "composeui", version.ref = "ferrostar" } okhttp3 = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp3" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } +hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hiltAndroidTesting" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -84,4 +92,4 @@ kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "ko ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hiltPlugin" } cargo-ndk = { id = "com.github.willir.rust.cargo-ndk-android", version.ref = "cargo-ndk" } -detekt = { id = "dev.detekt", version.ref = "detekt" } \ No newline at end of file +detekt = { id = "dev.detekt", version.ref = "detekt" }