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, lang: String,
length: String, length: String,
minResults: Int = 5, minResults: Int = 5,
maxRadiusM: Int = 10_000,
): NearbyResponseDto = apiService.getNearby( ): NearbyResponseDto = apiService.getNearby(
lat = lat, lat = lat,
lon = lon, lon = lon,
@ -41,5 +42,6 @@ class PlacesRepository @Inject constructor(
lang = lang, lang = lang,
length = length, length = length,
minResults = minResults, minResults = minResults,
maxRadiusM = maxRadiusM,
) )
} }

View file

@ -1,17 +1,22 @@
package com.guidecity.app.ui.guide package com.guidecity.app.ui.guide
import androidx.compose.foundation.clickable 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.guidecity.app.data.remote.dto.NearbyPlaceDto import com.guidecity.app.data.remote.dto.NearbyPlaceDto
import com.guidecity.app.theme.Mocha
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
/** /**
@ -46,24 +51,37 @@ fun PlaceCardStack(
.collect { page -> if (page != activeIndex) onPageChanged(page) } .collect { page -> if (page != activeIndex) onPageChanged(page) }
} }
HorizontalPager( Column(modifier = modifier) {
state = pagerState, // Makes it obvious there's more than one place to swipe through,
modifier = modifier // even when only the active card is visible on screen.
.fillMaxWidth() Text(
.height(200.dp), text = "${activeIndex + 1} / ${places.size}",
) { page -> style = MaterialTheme.typography.labelLarge,
val place = places[page] color = Mocha.Subtext1,
val cardState = when {
page == activeIndex -> CardState.ACTIVE
page in doneIndices -> CardState.DONE
else -> CardState.UPCOMING
}
PlaceCard(
place = place,
state = cardState,
modifier = Modifier modifier = Modifier
.padding(12.dp) .fillMaxWidth()
.clickable { onCardClick(place.id) }, .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) },
)
}
} }
} }

View file

@ -55,11 +55,11 @@ fun MapScreen(
val permissionLauncher = rememberLauncherForActivityResult( val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions(), contract = ActivityResultContracts.RequestMultiplePermissions(),
) { results -> ) { results ->
viewModel.onPermissionResult(results.values.any { it }, onLocationResolved) viewModel.onPermissionResult(results.values.any { it })
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.checkInitialPermission(onLocationResolved) viewModel.checkInitialPermission()
} }
Scaffold( Scaffold(
@ -89,7 +89,7 @@ fun MapScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
when (uiState) { when (val state = uiState) {
MapUiState.NeedsPermission -> { MapUiState.NeedsPermission -> {
Column( Column(
modifier = Modifier modifier = Modifier
@ -132,7 +132,7 @@ fun MapScreen(
ListItem( ListItem(
headlineContent = { Text(place.name) }, headlineContent = { Text(place.name) },
modifier = Modifier.clickable { modifier = Modifier.clickable {
viewModel.selectSearchResult(place, onLocationResolved) viewModel.selectSearchResult(place)
}, },
) )
} }
@ -140,9 +140,47 @@ fun MapScreen(
} }
} }
MapUiState.ResolvingLocation, MapUiState.CheckingPermission -> { MapUiState.ResolvingLocation, MapUiState.CheckingPermission, MapUiState.CheckingNearby -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator() 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 import javax.inject.Inject
private const val TAG = "MapViewModel" private const val TAG = "MapViewModel"
private const val MAX_SEARCH_RADIUS_M = 10_000
sealed interface MapUiState { sealed interface MapUiState {
data object CheckingPermission : MapUiState data object CheckingPermission : MapUiState
data object NeedsPermission : MapUiState data object NeedsPermission : MapUiState
data object ResolvingLocation : MapUiState data object ResolvingLocation : MapUiState
data object SearchFallback : MapUiState data object SearchFallback : MapUiState
data object CheckingNearby : MapUiState
data class ReadyToStart(val placesFound: Int, val radiusM: Int) : MapUiState
data object NoPlacesNearby : MapUiState
} }
@HiltViewModel @HiltViewModel
@ -37,33 +41,33 @@ class MapViewModel @Inject constructor(
private val _searchResults = MutableStateFlow<List<PlaceListItemDto>>(emptyList()) private val _searchResults = MutableStateFlow<List<PlaceListItemDto>>(emptyList())
val searchResults: StateFlow<List<PlaceListItemDto>> = _searchResults.asStateFlow() val searchResults: StateFlow<List<PlaceListItemDto>> = _searchResults.asStateFlow()
fun checkInitialPermission(onLocationResolved: () -> Unit) { fun checkInitialPermission() {
val hasPermission = locationProvider.hasLocationPermission() val hasPermission = locationProvider.hasLocationPermission()
Log.d(TAG, "checkInitialPermission: hasPermission=$hasPermission") Log.d(TAG, "checkInitialPermission: hasPermission=$hasPermission")
if (hasPermission) { if (hasPermission) {
resolveLocation(onLocationResolved) resolveLocation()
} else { } else {
_uiState.value = MapUiState.NeedsPermission _uiState.value = MapUiState.NeedsPermission
} }
} }
fun onPermissionResult(granted: Boolean, onLocationResolved: () -> Unit) { fun onPermissionResult(granted: Boolean) {
Log.i(TAG, "onPermissionResult: granted=$granted") Log.i(TAG, "onPermissionResult: granted=$granted")
if (granted) { if (granted) {
resolveLocation(onLocationResolved) resolveLocation()
} else { } else {
_uiState.value = MapUiState.SearchFallback _uiState.value = MapUiState.SearchFallback
} }
} }
private fun resolveLocation(onLocationResolved: () -> Unit) { private fun resolveLocation() {
_uiState.value = MapUiState.ResolvingLocation _uiState.value = MapUiState.ResolvingLocation
viewModelScope.launch { viewModelScope.launch {
val location = locationProvider.getCurrentLocation() val location = locationProvider.getCurrentLocation()
if (location != null) { if (location != null) {
Log.i(TAG, "resolveLocation: got lat=${location.lat} lon=${location.lon}") Log.i(TAG, "resolveLocation: got lat=${location.lat} lon=${location.lon}")
selectedLocationHolder.set(location) selectedLocationHolder.set(location)
onLocationResolved() checkNearby(location)
} else { } else {
Log.w(TAG, "resolveLocation: location unavailable, falling back to search") Log.w(TAG, "resolveLocation: location unavailable, falling back to search")
_uiState.value = MapUiState.SearchFallback _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) { fun searchPlaces(query: String) {
if (query.isBlank()) { if (query.isBlank()) {
_searchResults.value = emptyList() _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}") Log.i(TAG, "selectSearchResult: placeId=${place.id} slug=${place.slug}")
selectedLocationHolder.set(LatLon(place.location.lat, place.location.lon)) val location = LatLon(place.location.lat, place.location.lon)
onLocationResolved() selectedLocationHolder.set(location)
checkNearby(location)
} }
} }

View file

@ -15,6 +15,10 @@
<string name="map_location_permission_rationale">guideCity нужен доступ к геолокации, чтобы найти места поблизости. Вы также можете ввести место вручную.</string> <string name="map_location_permission_rationale">guideCity нужен доступ к геолокации, чтобы найти места поблизости. Вы также можете ввести место вручную.</string>
<string name="map_grant_permission">Разрешить доступ к геолокации</string> <string name="map_grant_permission">Разрешить доступ к геолокации</string>
<string name="map_search_placeholder">Введите место для поиска…</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_no_places_nearby">Поблизости пока не найдено интересных мест.</string>
<string name="guide_retry">Повторить</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_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_grant_permission">Grant location access</string>
<string name="map_search_placeholder">Search for a place instead…</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_no_places_nearby">No places found nearby yet.</string>
<string name="guide_retry">Retry</string> <string name="guide_retry">Retry</string>