diff --git a/android/app/src/main/java/com/guidecity/app/data/repository/PlacesRepository.kt b/android/app/src/main/java/com/guidecity/app/data/repository/PlacesRepository.kt index 1890d6e..1aa7934 100644 --- a/android/app/src/main/java/com/guidecity/app/data/repository/PlacesRepository.kt +++ b/android/app/src/main/java/com/guidecity/app/data/repository/PlacesRepository.kt @@ -34,6 +34,7 @@ class PlacesRepository @Inject constructor( lang: String, length: String, minResults: Int = 5, + maxRadiusM: Int = 10_000, ): NearbyResponseDto = apiService.getNearby( lat = lat, lon = lon, @@ -41,5 +42,6 @@ class PlacesRepository @Inject constructor( lang = lang, length = length, minResults = minResults, + maxRadiusM = maxRadiusM, ) } diff --git a/android/app/src/main/java/com/guidecity/app/ui/guide/PlaceCardStack.kt b/android/app/src/main/java/com/guidecity/app/ui/guide/PlaceCardStack.kt index 381ba67..f682604 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/guide/PlaceCardStack.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/guide/PlaceCardStack.kt @@ -1,17 +1,22 @@ package com.guidecity.app.ui.guide import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.guidecity.app.data.remote.dto.NearbyPlaceDto +import com.guidecity.app.theme.Mocha import kotlinx.coroutines.flow.distinctUntilChanged /** @@ -46,24 +51,37 @@ fun PlaceCardStack( .collect { page -> if (page != activeIndex) onPageChanged(page) } } - HorizontalPager( - state = pagerState, - modifier = modifier - .fillMaxWidth() - .height(200.dp), - ) { page -> - val place = places[page] - val cardState = when { - page == activeIndex -> CardState.ACTIVE - page in doneIndices -> CardState.DONE - else -> CardState.UPCOMING - } - PlaceCard( - place = place, - state = cardState, + Column(modifier = modifier) { + // Makes it obvious there's more than one place to swipe through, + // even when only the active card is visible on screen. + Text( + text = "${activeIndex + 1} / ${places.size}", + style = MaterialTheme.typography.labelLarge, + color = Mocha.Subtext1, modifier = Modifier - .padding(12.dp) - .clickable { onCardClick(place.id) }, + .fillMaxWidth() + .padding(top = 8.dp), ) + + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + ) { page -> + val place = places[page] + val cardState = when { + page == activeIndex -> CardState.ACTIVE + page in doneIndices -> CardState.DONE + else -> CardState.UPCOMING + } + PlaceCard( + place = place, + state = cardState, + modifier = Modifier + .padding(12.dp) + .clickable { onCardClick(place.id) }, + ) + } } } diff --git a/android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt b/android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt index 7888355..55b3bc4 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt @@ -55,11 +55,11 @@ fun MapScreen( val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestMultiplePermissions(), ) { results -> - viewModel.onPermissionResult(results.values.any { it }, onLocationResolved) + viewModel.onPermissionResult(results.values.any { it }) } LaunchedEffect(Unit) { - viewModel.checkInitialPermission(onLocationResolved) + viewModel.checkInitialPermission() } Scaffold( @@ -89,7 +89,7 @@ fun MapScreen( modifier = Modifier.fillMaxSize(), ) - when (uiState) { + when (val state = uiState) { MapUiState.NeedsPermission -> { Column( modifier = Modifier @@ -132,7 +132,7 @@ fun MapScreen( ListItem( headlineContent = { Text(place.name) }, modifier = Modifier.clickable { - viewModel.selectSearchResult(place, onLocationResolved) + viewModel.selectSearchResult(place) }, ) } @@ -140,9 +140,47 @@ fun MapScreen( } } - MapUiState.ResolvingLocation, MapUiState.CheckingPermission -> { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + MapUiState.ResolvingLocation, MapUiState.CheckingPermission, MapUiState.CheckingNearby -> { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { CircularProgressIndicator() + if (state == MapUiState.CheckingNearby) { + Spacer(modifier = Modifier.height(12.dp)) + Text(stringResource(R.string.map_checking_nearby)) + } + } + } + + MapUiState.NoPlacesNearby -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(stringResource(R.string.map_no_places_nearby)) + } + } + + is MapUiState.ReadyToStart -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.Bottom, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.map_places_found, state.placesFound, state.radiusM), + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onLocationResolved) { + Text(stringResource(R.string.map_start)) + } } } } diff --git a/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt b/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt index 83e4287..3cc1a05 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt @@ -16,12 +16,16 @@ import kotlinx.coroutines.launch import javax.inject.Inject private const val TAG = "MapViewModel" +private const val MAX_SEARCH_RADIUS_M = 10_000 sealed interface MapUiState { data object CheckingPermission : MapUiState data object NeedsPermission : MapUiState data object ResolvingLocation : MapUiState data object SearchFallback : MapUiState + data object CheckingNearby : MapUiState + data class ReadyToStart(val placesFound: Int, val radiusM: Int) : MapUiState + data object NoPlacesNearby : MapUiState } @HiltViewModel @@ -37,33 +41,33 @@ class MapViewModel @Inject constructor( private val _searchResults = MutableStateFlow>(emptyList()) val searchResults: StateFlow> = _searchResults.asStateFlow() - fun checkInitialPermission(onLocationResolved: () -> Unit) { + fun checkInitialPermission() { val hasPermission = locationProvider.hasLocationPermission() Log.d(TAG, "checkInitialPermission: hasPermission=$hasPermission") if (hasPermission) { - resolveLocation(onLocationResolved) + resolveLocation() } else { _uiState.value = MapUiState.NeedsPermission } } - fun onPermissionResult(granted: Boolean, onLocationResolved: () -> Unit) { + fun onPermissionResult(granted: Boolean) { Log.i(TAG, "onPermissionResult: granted=$granted") if (granted) { - resolveLocation(onLocationResolved) + resolveLocation() } else { _uiState.value = MapUiState.SearchFallback } } - private fun resolveLocation(onLocationResolved: () -> Unit) { + private fun resolveLocation() { _uiState.value = MapUiState.ResolvingLocation viewModelScope.launch { val location = locationProvider.getCurrentLocation() if (location != null) { Log.i(TAG, "resolveLocation: got lat=${location.lat} lon=${location.lon}") selectedLocationHolder.set(location) - onLocationResolved() + checkNearby(location) } else { Log.w(TAG, "resolveLocation: location unavailable, falling back to search") _uiState.value = MapUiState.SearchFallback @@ -71,6 +75,39 @@ class MapViewModel @Inject constructor( } } + /** + * Counts places within an expanding radius (server-side, same mechanism + * as the guide screen's own fetch) up to [MAX_SEARCH_RADIUS_M], so the + * user can see how many places were found — and at what radius — before + * committing to start the guide. + */ + private fun checkNearby(location: LatLon) { + _uiState.value = MapUiState.CheckingNearby + viewModelScope.launch { + runCatching { + placesRepository.getNearby( + lat = location.lat, + lon = location.lon, + citySlug = "moscow", + lang = "ru", + length = "short", + minResults = 5, + maxRadiusM = MAX_SEARCH_RADIUS_M, + ) + }.onSuccess { response -> + Log.i(TAG, "checkNearby: count=${response.count} radius=${response.searchRadiusM}m") + _uiState.value = if (response.count > 0) { + MapUiState.ReadyToStart(placesFound = response.count, radiusM = response.searchRadiusM) + } else { + MapUiState.NoPlacesNearby + } + }.onFailure { error -> + Log.e(TAG, "checkNearby failed", error) + _uiState.value = MapUiState.NoPlacesNearby + } + } + } + fun searchPlaces(query: String) { if (query.isBlank()) { _searchResults.value = emptyList() @@ -90,9 +127,10 @@ class MapViewModel @Inject constructor( } } - fun selectSearchResult(place: PlaceListItemDto, onLocationResolved: () -> Unit) { + fun selectSearchResult(place: PlaceListItemDto) { Log.i(TAG, "selectSearchResult: placeId=${place.id} slug=${place.slug}") - selectedLocationHolder.set(LatLon(place.location.lat, place.location.lon)) - onLocationResolved() + val location = LatLon(place.location.lat, place.location.lon) + selectedLocationHolder.set(location) + checkNearby(location) } } diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index d3d07e8..19c9a19 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -15,6 +15,10 @@ guideCity нужен доступ к геолокации, чтобы найти места поблизости. Вы также можете ввести место вручную. Разрешить доступ к геолокации Введите место для поиска… + Ищем места поблизости… + Найдено мест поблизости: %1$d (в радиусе %2$d м) + Начать + В радиусе 10 км ничего не нашлось. Поблизости пока не найдено интересных мест. Повторить diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index bdae1cf..2dfb5aa 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -15,6 +15,10 @@ guideCity needs your location to find nearby places. You can also search for a place by name instead. Grant location access Search for a place instead… + Looking for places nearby… + Found %1$d place(s) nearby (within %2$d m) + Start + Nothing found within 10 km of here. No places found nearby yet. Retry