Add map summary screen (place count + Start button) before the guide

After resolving location, the map screen now checks how many places
are nearby (same server-side iterative radius expansion the guide
screen uses) and shows "Found N places within Rm" with a Start button,
instead of jumping straight into narration. The search cap is raised
to 10km (was 5km) — if nothing turns up within that, a "nothing found"
message is shown instead.

This also addresses a UX report: the guide screen's Tinder-style card
stack only shows one card at a time by design, which read as "it only
found one place" even when 5 were actually there. Added a "1 / 5"
position indicator to the card stack so it's clear there's more to
swipe through, and the new summary screen surfaces the real count
upfront regardless.

Verified: ./gradlew testDebugUnitTest passes, :app:assembleDebug
produces a working APK, sent to Telegram.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
vrubelroman 2026-07-09 20:58:17 +00:00
parent 531fab801d
commit e220863451
6 changed files with 136 additions and 32 deletions

View file

@ -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,
)
}

View file

@ -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,9 +51,21 @@ fun PlaceCardStack(
.collect { page -> if (page != activeIndex) onPageChanged(page) }
}
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
.fillMaxWidth()
.padding(top = 8.dp),
)
HorizontalPager(
state = pagerState,
modifier = modifier
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
) { page ->
@ -67,3 +84,4 @@ fun PlaceCardStack(
)
}
}
}

View file

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

View file

@ -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<List<PlaceListItemDto>>(emptyList())
val searchResults: StateFlow<List<PlaceListItemDto>> = _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)
}
}

View file

@ -15,6 +15,10 @@
<string name="map_location_permission_rationale">guideCity нужен доступ к геолокации, чтобы найти места поблизости. Вы также можете ввести место вручную.</string>
<string name="map_grant_permission">Разрешить доступ к геолокации</string>
<string name="map_search_placeholder">Введите место для поиска…</string>
<string name="map_checking_nearby">Ищем места поблизости…</string>
<string name="map_places_found">Найдено мест поблизости: %1$d (в радиусе %2$d м)</string>
<string name="map_start">Начать</string>
<string name="map_no_places_nearby">В радиусе 10 км ничего не нашлось.</string>
<string name="guide_no_places_nearby">Поблизости пока не найдено интересных мест.</string>
<string name="guide_retry">Повторить</string>

View file

@ -15,6 +15,10 @@
<string name="map_location_permission_rationale">guideCity needs your location to find nearby places. You can also search for a place by name instead.</string>
<string name="map_grant_permission">Grant location access</string>
<string name="map_search_placeholder">Search for a place instead…</string>
<string name="map_checking_nearby">Looking for places nearby…</string>
<string name="map_places_found">Found %1$d place(s) nearby (within %2$d m)</string>
<string name="map_start">Start</string>
<string name="map_no_places_nearby">Nothing found within 10 km of here.</string>
<string name="guide_no_places_nearby">No places found nearby yet.</string>
<string name="guide_retry">Retry</string>