Restructure guide flow: list overview, swipe in detail, real map markers

Three UX fixes:

1. The placeholder map was always rendered with userLocation=null and
   places=emptyList() — MapScreen never passed the actually-resolved
   location/nearby list to DgisMapView, so it only ever showed the
   "waiting for location" placeholder text. MapViewModel now exposes
   resolvedLocation and nearbyPlaces, wired through to the map. (Note:
   this is still the Canvas-based placeholder, not the real 2GIS SDK —
   that integration is still pending, as flagged since the skeleton
   was first built.)

2. GuideScreen's Tinder-style pager only showed one card at a time,
   which read as "it only found one place" even with 5 nearby. Swapped
   it for a plain scrollable list (auto-scrolls to the active/narrating
   item); tapping an item opens PlaceDetailScreen, which now owns the
   swipe-between-places interaction instead. PlaceDetailViewModel
   fetches the same nearby-ordered list GuideViewModel uses (falling
   back to a single non-swipeable place when opened from
   Favorites/search for something outside that list).

3. Narration now leads with the place's name and distance from the
   user before the body text (util/Narration.kt), in both the guide
   list and the detail swipe view.

Verified: testDebugUnitTest passes, 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 21:08:19 +00:00
parent e220863451
commit 7f1a6d51fc
10 changed files with 235 additions and 144 deletions

View file

@ -4,6 +4,8 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -20,8 +22,10 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -29,6 +33,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.guidecity.app.R import com.guidecity.app.R
import com.guidecity.app.theme.GuideStackColors import com.guidecity.app.theme.GuideStackColors
import kotlinx.coroutines.flow.distinctUntilChanged
@Composable @Composable
fun PlaceDetailScreen( fun PlaceDetailScreen(
@ -38,11 +43,12 @@ fun PlaceDetailScreen(
) { ) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val isFavorite by viewModel.isFavorite.collectAsState() val isFavorite by viewModel.isFavorite.collectAsState()
val activePlace = state.places.getOrNull(state.activeIndex)
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(state.place?.content?.title.orEmpty()) }, title = { Text(activePlace?.content?.title.orEmpty()) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = null) Icon(Icons.Filled.ArrowBack, contentDescription = null)
@ -79,7 +85,6 @@ fun PlaceDetailScreen(
.fillMaxSize() .fillMaxSize()
.padding(padding), .padding(padding),
) { ) {
val place = state.place
when { when {
state.isLoading -> { state.isLoading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@ -87,20 +92,43 @@ fun PlaceDetailScreen(
} }
} }
place == null -> { state.errorMessage != null || state.places.isEmpty() -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = stringResource(R.string.place_not_found)) Text(text = stringResource(R.string.place_not_found))
} }
} }
else -> { else -> {
Column( // Swiping here moves between nearby places, one full-content
modifier = Modifier // page at a time; the overview list (GuideScreen) only
.fillMaxSize() // scrolls, tapping into an item is what brings you here.
.verticalScroll(rememberScrollState()) val pagerState = rememberPagerState(initialPage = state.activeIndex) { state.places.size }
.padding(16.dp),
) { LaunchedEffect(state.activeIndex) {
Text(text = place.content.body, style = MaterialTheme.typography.bodyLarge) if (pagerState.currentPage != state.activeIndex) {
pagerState.animateScrollToPage(state.activeIndex)
}
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }
.distinctUntilChanged()
.collect { page -> viewModel.setActiveIndex(page) }
}
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
) { page ->
val place = state.places[page]
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
Text(text = place.content.body, style = MaterialTheme.typography.bodyLarge)
}
} }
} }
} }

View file

@ -7,35 +7,42 @@ import androidx.lifecycle.viewModelScope
import com.guidecity.app.data.local.db.FavoritePlaceEntity import com.guidecity.app.data.local.db.FavoritePlaceEntity
import com.guidecity.app.data.local.prefs.ContentLength import com.guidecity.app.data.local.prefs.ContentLength
import com.guidecity.app.data.local.prefs.UserPrefsDataStore import com.guidecity.app.data.local.prefs.UserPrefsDataStore
import com.guidecity.app.data.remote.dto.PlaceDetailDto import com.guidecity.app.data.remote.dto.NearbyPlaceDto
import com.guidecity.app.data.repository.FavoritesRepository import com.guidecity.app.data.repository.FavoritesRepository
import com.guidecity.app.data.repository.PlacesRepository import com.guidecity.app.data.repository.PlacesRepository
import com.guidecity.app.location.LocationProvider
import com.guidecity.app.location.SelectedLocationHolder
import com.guidecity.app.tts.TtsManager import com.guidecity.app.tts.TtsManager
import com.guidecity.app.util.buildNarrationText
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
private const val TAG = "PlaceDetailViewModel" private const val TAG = "PlaceDetailViewModel"
/** Sentinel meaning "no real distance known" — the single-place fallback path uses this. */
private const val UNKNOWN_DISTANCE_M = -1.0
data class PlaceDetailUiState( data class PlaceDetailUiState(
val isLoading: Boolean = true, val isLoading: Boolean = true,
val place: PlaceDetailDto? = null, val places: List<NearbyPlaceDto> = emptyList(),
val activeIndex: Int = 0,
val isMuted: Boolean = false, val isMuted: Boolean = false,
val errorMessage: String? = null,
) )
/** /**
* Self-contained per-place narrator: works whether reached from the guide * Shows one place's full content with narration, and when the requested
* card stack, favorites, or search. On narration completion the user goes * place is part of the user's current nearby list lets them swipe to the
* back manually auto-advancing straight to the *next* nearby place's * other nearby places too, reusing the same ordering, active/narration
* detail (as in the original spec) would need this screen to share * progression, and 10km search cap as the guide list screen. When reached
* GuideViewModel's ordered list, which only exists when arriving from the * from Favorites/search for a place that isn't in that list, it falls back
* guide screen; left as a follow-up once that shared-state wiring is added. * to a single, non-swipeable place fetched directly by id.
*/ */
@HiltViewModel @HiltViewModel
class PlaceDetailViewModel @Inject constructor( class PlaceDetailViewModel @Inject constructor(
@ -43,30 +50,117 @@ class PlaceDetailViewModel @Inject constructor(
private val placesRepository: PlacesRepository, private val placesRepository: PlacesRepository,
private val favoritesRepository: FavoritesRepository, private val favoritesRepository: FavoritesRepository,
private val userPrefsDataStore: UserPrefsDataStore, private val userPrefsDataStore: UserPrefsDataStore,
private val selectedLocationHolder: SelectedLocationHolder,
private val locationProvider: LocationProvider,
private val ttsManager: TtsManager, private val ttsManager: TtsManager,
) : ViewModel() { ) : ViewModel() {
private val placeId: Int = checkNotNull(savedStateHandle.get<String>("placeId")).toInt() private val initialPlaceId: Int = checkNotNull(savedStateHandle.get<String>("placeId")).toInt()
private val _uiState = MutableStateFlow(PlaceDetailUiState()) private val _uiState = MutableStateFlow(PlaceDetailUiState())
val uiState: StateFlow<PlaceDetailUiState> = _uiState.asStateFlow() val uiState: StateFlow<PlaceDetailUiState> = _uiState.asStateFlow()
val isFavorite: StateFlow<Boolean> = favoritesRepository.isFavorite(placeId) private val _isFavorite = MutableStateFlow(false)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) val isFavorite: StateFlow<Boolean> = _isFavorite.asStateFlow()
private var favoriteObserveJob: Job? = null
init { init {
Log.d(TAG, "init: placeId=$placeId") Log.d(TAG, "init: placeId=$initialPlaceId")
viewModelScope.launch { viewModelScope.launch {
val length = (userPrefsDataStore.contentLength.first() ?: ContentLength.MEDIUM).toApiValue() val length = (userPrefsDataStore.contentLength.first() ?: ContentLength.MEDIUM).toApiValue()
val place = runCatching { placesRepository.getPlace(placeId, lang = "ru", length = length) } val location = selectedLocationHolder.location.value ?: locationProvider.getCurrentLocation()
.onFailure { Log.e(TAG, "getPlace($placeId) failed", it) }
.getOrNull() val nearby = location?.let { loc ->
Log.d(TAG, "loaded place=${place?.slug}") runCatching {
_uiState.value = _uiState.value.copy(isLoading = false, place = place) placesRepository.getNearby(
place?.let { ttsManager.speak(it.content.body) } lat = loc.lat,
lon = loc.lon,
citySlug = "moscow",
lang = "ru",
length = length,
minResults = 5,
maxRadiusM = 10_000,
)
}.onFailure { Log.e(TAG, "getNearby failed", it) }.getOrNull()
}
val indexInNearby = nearby?.places?.indexOfFirst { it.id == initialPlaceId } ?: -1
if (nearby != null && indexInNearby >= 0) {
Log.i(TAG, "opened as part of nearby list, index=$indexInNearby of ${nearby.places.size}")
_uiState.value = _uiState.value.copy(
isLoading = false,
places = nearby.places,
activeIndex = indexInNearby,
)
observeFavorite(initialPlaceId)
narrateActive()
} else {
loadSinglePlaceFallback(length)
}
} }
} }
private fun loadSinglePlaceFallback(length: String) {
viewModelScope.launch {
val place = runCatching { placesRepository.getPlace(initialPlaceId, lang = "ru", length = length) }
.onFailure { Log.e(TAG, "getPlace($initialPlaceId) failed", it) }
.getOrNull()
if (place == null) {
_uiState.value = _uiState.value.copy(isLoading = false, errorMessage = "not_found")
return@launch
}
val asSingleEntry = NearbyPlaceDto(
id = place.id,
slug = place.slug,
category = place.category,
location = place.location,
address = place.address,
district = place.district,
builtYear = place.builtYear,
architectBuilder = place.architectBuilder,
architecturalStyle = place.architecturalStyle,
content = place.content,
distanceM = UNKNOWN_DISTANCE_M,
)
Log.i(TAG, "loaded as standalone fallback (not in current nearby list): ${place.slug}")
_uiState.value = _uiState.value.copy(isLoading = false, places = listOf(asSingleEntry), activeIndex = 0)
observeFavorite(place.id)
narrateActive()
}
}
private fun observeFavorite(placeId: Int) {
favoriteObserveJob?.cancel()
favoriteObserveJob = viewModelScope.launch {
favoritesRepository.isFavorite(placeId).collect { _isFavorite.value = it }
}
}
private fun narrateActive() {
val state = _uiState.value
val place = state.places.getOrNull(state.activeIndex) ?: return
val text = buildNarrationText(place.content.title, place.distanceM, place.content.body)
ttsManager.speak(text) { onNarrationDone() }
}
private fun onNarrationDone() {
val state = _uiState.value
val nextIndex = state.activeIndex + 1
if (nextIndex >= state.places.size) return
setActiveIndex(nextIndex)
}
/** Called both on TTS auto-advance and on manual swipe. */
fun setActiveIndex(index: Int) {
val state = _uiState.value
if (index !in state.places.indices || index == state.activeIndex) return
Log.d(TAG, "setActiveIndex: ${state.activeIndex} -> $index")
_uiState.value = state.copy(activeIndex = index)
observeFavorite(state.places[index].id)
narrateActive()
}
fun toggleMute() { fun toggleMute() {
if (ttsManager.isMuted) ttsManager.unmute() else ttsManager.mute() if (ttsManager.isMuted) ttsManager.unmute() else ttsManager.mute()
Log.d(TAG, "toggleMute: isMuted=${ttsManager.isMuted}") Log.d(TAG, "toggleMute: isMuted=${ttsManager.isMuted}")
@ -74,15 +168,16 @@ class PlaceDetailViewModel @Inject constructor(
} }
fun toggleFavorite() { fun toggleFavorite() {
val place = _uiState.value.place ?: return val state = _uiState.value
Log.d(TAG, "toggleFavorite: placeId=$placeId currentlyFavorite=${isFavorite.value}") val place = state.places.getOrNull(state.activeIndex) ?: return
Log.d(TAG, "toggleFavorite: placeId=${place.id} currentlyFavorite=${isFavorite.value}")
viewModelScope.launch { viewModelScope.launch {
if (isFavorite.value) { if (isFavorite.value) {
favoritesRepository.removeFavorite(placeId) favoritesRepository.removeFavorite(place.id)
} else { } else {
favoritesRepository.addFavorite( favoritesRepository.addFavorite(
FavoritePlaceEntity( FavoritePlaceEntity(
placeId = placeId, placeId = place.id,
slug = place.slug, slug = place.slug,
name = place.content.title, name = place.content.title,
category = place.category, category = place.category,

View file

@ -1,12 +1,14 @@
package com.guidecity.app.ui.guide package com.guidecity.app.ui.guide
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
@ -16,10 +18,12 @@ import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -28,6 +32,7 @@ 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.hilt.navigation.compose.hiltViewModel
import com.guidecity.app.R import com.guidecity.app.R
import com.guidecity.app.theme.Mocha
@Composable @Composable
fun GuideScreen( fun GuideScreen(
@ -37,6 +42,11 @@ fun GuideScreen(
viewModel: GuideViewModel = hiltViewModel(), viewModel: GuideViewModel = hiltViewModel(),
) { ) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val listState = rememberLazyListState()
LaunchedEffect(state.activeIndex) {
listState.animateScrollToItem(state.activeIndex)
}
Scaffold( Scaffold(
topBar = { topBar = {
@ -76,7 +86,6 @@ fun GuideScreen(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text(text = state.errorMessage.orEmpty()) Text(text = state.errorMessage.orEmpty())
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.retry() }) { Button(onClick = { viewModel.retry() }) {
Text(text = stringResource(R.string.guide_retry)) Text(text = stringResource(R.string.guide_retry))
} }
@ -96,14 +105,30 @@ fun GuideScreen(
} }
else -> { else -> {
PlaceCardStack( Column(modifier = Modifier.fillMaxSize()) {
places = state.places, Text(
activeIndex = state.activeIndex, text = stringResource(R.string.guide_places_count, state.places.size),
doneIndices = state.doneIndices, style = MaterialTheme.typography.labelLarge,
onCardClick = onOpenDetail, color = Mocha.Subtext1,
onPageChanged = { viewModel.setActiveIndex(it) }, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
modifier = Modifier.fillMaxSize(), )
) LazyColumn(state = listState) {
itemsIndexed(state.places, key = { _, place -> place.id }) { index, place ->
val cardState = when {
index == state.activeIndex -> CardState.ACTIVE
index in state.doneIndices -> CardState.DONE
else -> CardState.UPCOMING
}
PlaceCard(
place = place,
state = cardState,
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 6.dp)
.clickable { onOpenDetail(place.id) },
)
}
}
}
} }
} }
} }

View file

@ -10,6 +10,7 @@ import com.guidecity.app.data.repository.PlacesRepository
import com.guidecity.app.location.LocationProvider import com.guidecity.app.location.LocationProvider
import com.guidecity.app.location.SelectedLocationHolder import com.guidecity.app.location.SelectedLocationHolder
import com.guidecity.app.tts.TtsManager import com.guidecity.app.tts.TtsManager
import com.guidecity.app.util.buildNarrationText
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -129,7 +130,8 @@ class GuideViewModel @Inject constructor(
val state = _uiState.value val state = _uiState.value
val place = state.places.getOrNull(state.activeIndex) ?: return val place = state.places.getOrNull(state.activeIndex) ?: return
Log.d(TAG, "narrateActive: index=${state.activeIndex} placeId=${place.id} slug=${place.slug}") Log.d(TAG, "narrateActive: index=${state.activeIndex} placeId=${place.id} slug=${place.slug}")
ttsManager.speak(place.content.body) { onNarrationDone() } val text = buildNarrationText(place.content.title, place.distanceM, place.content.body)
ttsManager.speak(text) { onNarrationDone() }
} }
private fun onNarrationDone() { private fun onNarrationDone() {
@ -148,15 +150,6 @@ class GuideViewModel @Inject constructor(
narrateActive() narrateActive()
} }
/** User manually swiped to a different card; doesn't force-complete skipped ones. */
fun setActiveIndex(index: Int) {
val state = _uiState.value
if (index !in state.places.indices || index == state.activeIndex) return
Log.d(TAG, "setActiveIndex: ${state.activeIndex} -> $index (manual swipe)")
_uiState.value = state.copy(activeIndex = index)
narrateActive()
}
fun toggleMute() { fun toggleMute() {
if (ttsManager.isMuted) ttsManager.unmute() else ttsManager.mute() if (ttsManager.isMuted) ttsManager.unmute() else ttsManager.mute()
Log.d(TAG, "toggleMute: isMuted=${ttsManager.isMuted}") Log.d(TAG, "toggleMute: isMuted=${ttsManager.isMuted}")

View file

@ -1,87 +0,0 @@
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
/**
* Tinder-style swipeable stack of place preview cards, ordered by distance.
* Swiping changes which card is "active" ([onPageChanged]); tapping a card
* opens its full detail screen ([onCardClick]). Done cards stay in the
* pager (dimmed via [CardState.DONE]) rather than being removed, so the
* user can still swipe back to review one that already finished narrating.
*/
@Composable
fun PlaceCardStack(
places: List<NearbyPlaceDto>,
activeIndex: Int,
doneIndices: Set<Int>,
onCardClick: (placeId: Int) -> Unit,
onPageChanged: (index: Int) -> Unit,
modifier: Modifier = Modifier,
) {
if (places.isEmpty()) return
val pagerState = rememberPagerState(initialPage = activeIndex) { places.size }
LaunchedEffect(activeIndex) {
if (pagerState.currentPage != activeIndex) {
pagerState.animateScrollToPage(activeIndex)
}
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }
.distinctUntilChanged()
.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
.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

@ -50,6 +50,8 @@ fun MapScreen(
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val searchResults by viewModel.searchResults.collectAsState() val searchResults by viewModel.searchResults.collectAsState()
val resolvedLocation by viewModel.resolvedLocation.collectAsState()
val nearbyPlaces by viewModel.nearbyPlaces.collectAsState()
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
val permissionLauncher = rememberLauncherForActivityResult( val permissionLauncher = rememberLauncherForActivityResult(
@ -83,8 +85,8 @@ fun MapScreen(
.padding(padding), .padding(padding),
) { ) {
DgisMapView( DgisMapView(
userLocation = null, userLocation = resolvedLocation,
places = emptyList(), places = nearbyPlaces,
onPlaceClick = {}, onPlaceClick = {},
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )

View file

@ -3,6 +3,7 @@ package com.guidecity.app.ui.map
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.guidecity.app.data.remote.dto.NearbyPlaceDto
import com.guidecity.app.data.remote.dto.PlaceListItemDto import com.guidecity.app.data.remote.dto.PlaceListItemDto
import com.guidecity.app.data.repository.PlacesRepository import com.guidecity.app.data.repository.PlacesRepository
import com.guidecity.app.location.LatLon import com.guidecity.app.location.LatLon
@ -41,6 +42,12 @@ 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()
private val _resolvedLocation = MutableStateFlow<LatLon?>(null)
val resolvedLocation: StateFlow<LatLon?> = _resolvedLocation.asStateFlow()
private val _nearbyPlaces = MutableStateFlow<List<NearbyPlaceDto>>(emptyList())
val nearbyPlaces: StateFlow<List<NearbyPlaceDto>> = _nearbyPlaces.asStateFlow()
fun checkInitialPermission() { fun checkInitialPermission() {
val hasPermission = locationProvider.hasLocationPermission() val hasPermission = locationProvider.hasLocationPermission()
Log.d(TAG, "checkInitialPermission: hasPermission=$hasPermission") Log.d(TAG, "checkInitialPermission: hasPermission=$hasPermission")
@ -66,6 +73,7 @@ class MapViewModel @Inject constructor(
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}")
_resolvedLocation.value = location
selectedLocationHolder.set(location) selectedLocationHolder.set(location)
checkNearby(location) checkNearby(location)
} else { } else {
@ -96,6 +104,7 @@ class MapViewModel @Inject constructor(
) )
}.onSuccess { response -> }.onSuccess { response ->
Log.i(TAG, "checkNearby: count=${response.count} radius=${response.searchRadiusM}m") Log.i(TAG, "checkNearby: count=${response.count} radius=${response.searchRadiusM}m")
_nearbyPlaces.value = response.places
_uiState.value = if (response.count > 0) { _uiState.value = if (response.count > 0) {
MapUiState.ReadyToStart(placesFound = response.count, radiusM = response.searchRadiusM) MapUiState.ReadyToStart(placesFound = response.count, radiusM = response.searchRadiusM)
} else { } else {
@ -130,6 +139,7 @@ class MapViewModel @Inject constructor(
fun selectSearchResult(place: PlaceListItemDto) { fun selectSearchResult(place: PlaceListItemDto) {
Log.i(TAG, "selectSearchResult: placeId=${place.id} slug=${place.slug}") Log.i(TAG, "selectSearchResult: placeId=${place.id} slug=${place.slug}")
val location = LatLon(place.location.lat, place.location.lon) val location = LatLon(place.location.lat, place.location.lon)
_resolvedLocation.value = location
selectedLocationHolder.set(location) selectedLocationHolder.set(location)
checkNearby(location) checkNearby(location)
} }

View file

@ -0,0 +1,23 @@
package com.guidecity.app.util
import kotlin.math.roundToInt
/** Formats a distance in meters for spoken narration (Russian only, matching the app's current hardcoded lang="ru"). */
fun formatDistanceForNarration(distanceM: Double): String =
if (distanceM >= 1000) {
"%.1f км".format(distanceM / 1000)
} else {
"${distanceM.roundToInt()} метров"
}
/**
* Prefixes the narration body with the place's name and its distance from
* the user. Pass a negative [distanceM] (no real distance known e.g. a
* place opened standalone from Favorites/search) to omit the distance clause.
*/
fun buildNarrationText(title: String, distanceM: Double, body: String): String =
if (distanceM < 0) {
"$title. $body"
} else {
"$title. Расстояние: ${formatDistanceForNarration(distanceM)}. $body"
}

View file

@ -21,6 +21,7 @@
<string name="map_no_places_nearby">В радиусе 10 км ничего не нашлось.</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_places_count">Мест поблизости: %1$d</string>
<string name="guide_retry">Повторить</string> <string name="guide_retry">Повторить</string>
<string name="guide_favorite_add">Добавить в избранное</string> <string name="guide_favorite_add">Добавить в избранное</string>
<string name="guide_favorite_remove">Убрать из избранного</string> <string name="guide_favorite_remove">Убрать из избранного</string>

View file

@ -21,6 +21,7 @@
<string name="map_no_places_nearby">Nothing found within 10 km of here.</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_places_count">%1$d place(s) nearby</string>
<string name="guide_retry">Retry</string> <string name="guide_retry">Retry</string>
<string name="guide_favorite_add">Add to favorites</string> <string name="guide_favorite_add">Add to favorites</string>
<string name="guide_favorite_remove">Remove from favorites</string> <string name="guide_favorite_remove">Remove from favorites</string>