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:
parent
531fab801d
commit
e220863451
6 changed files with 136 additions and 32 deletions
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,9 +51,21 @@ fun PlaceCardStack(
|
||||||
.collect { page -> if (page != activeIndex) onPageChanged(page) }
|
.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(
|
HorizontalPager(
|
||||||
state = pagerState,
|
state = pagerState,
|
||||||
modifier = modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(200.dp),
|
.height(200.dp),
|
||||||
) { page ->
|
) { page ->
|
||||||
|
|
@ -66,4 +83,5 @@ fun PlaceCardStack(
|
||||||
.clickable { onCardClick(place.id) },
|
.clickable { onCardClick(place.id) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue