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:
parent
e220863451
commit
7f1a6d51fc
10 changed files with 235 additions and 144 deletions
|
|
@ -4,6 +4,8 @@ import androidx.compose.foundation.layout.Box
|
|||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
|
|
@ -20,8 +22,10 @@ import androidx.compose.material3.Scaffold
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
|
@ -29,6 +33,7 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.guidecity.app.R
|
||||
import com.guidecity.app.theme.GuideStackColors
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
@Composable
|
||||
fun PlaceDetailScreen(
|
||||
|
|
@ -38,11 +43,12 @@ fun PlaceDetailScreen(
|
|||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val isFavorite by viewModel.isFavorite.collectAsState()
|
||||
val activePlace = state.places.getOrNull(state.activeIndex)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(state.place?.content?.title.orEmpty()) },
|
||||
title = { Text(activePlace?.content?.title.orEmpty()) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Filled.ArrowBack, contentDescription = null)
|
||||
|
|
@ -79,7 +85,6 @@ fun PlaceDetailScreen(
|
|||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) {
|
||||
val place = state.place
|
||||
when {
|
||||
state.isLoading -> {
|
||||
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) {
|
||||
Text(text = stringResource(R.string.place_not_found))
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(text = place.content.body, style = MaterialTheme.typography.bodyLarge)
|
||||
// Swiping here moves between nearby places, one full-content
|
||||
// page at a time; the overview list (GuideScreen) only
|
||||
// scrolls, tapping into an item is what brings you here.
|
||||
val pagerState = rememberPagerState(initialPage = state.activeIndex) { state.places.size }
|
||||
|
||||
LaunchedEffect(state.activeIndex) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,35 +7,42 @@ import androidx.lifecycle.viewModelScope
|
|||
import com.guidecity.app.data.local.db.FavoritePlaceEntity
|
||||
import com.guidecity.app.data.local.prefs.ContentLength
|
||||
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.PlacesRepository
|
||||
import com.guidecity.app.location.LocationProvider
|
||||
import com.guidecity.app.location.SelectedLocationHolder
|
||||
import com.guidecity.app.tts.TtsManager
|
||||
import com.guidecity.app.util.buildNarrationText
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
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(
|
||||
val isLoading: Boolean = true,
|
||||
val place: PlaceDetailDto? = null,
|
||||
val places: List<NearbyPlaceDto> = emptyList(),
|
||||
val activeIndex: Int = 0,
|
||||
val isMuted: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Self-contained per-place narrator: works whether reached from the guide
|
||||
* card stack, favorites, or search. On narration completion the user goes
|
||||
* back manually — auto-advancing straight to the *next* nearby place's
|
||||
* detail (as in the original spec) would need this screen to share
|
||||
* GuideViewModel's ordered list, which only exists when arriving from the
|
||||
* guide screen; left as a follow-up once that shared-state wiring is added.
|
||||
* Shows one place's full content with narration, and — when the requested
|
||||
* place is part of the user's current nearby list — lets them swipe to the
|
||||
* other nearby places too, reusing the same ordering, active/narration
|
||||
* progression, and 10km search cap as the guide list screen. When reached
|
||||
* from Favorites/search for a place that isn't in that list, it falls back
|
||||
* to a single, non-swipeable place fetched directly by id.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class PlaceDetailViewModel @Inject constructor(
|
||||
|
|
@ -43,30 +50,117 @@ class PlaceDetailViewModel @Inject constructor(
|
|||
private val placesRepository: PlacesRepository,
|
||||
private val favoritesRepository: FavoritesRepository,
|
||||
private val userPrefsDataStore: UserPrefsDataStore,
|
||||
private val selectedLocationHolder: SelectedLocationHolder,
|
||||
private val locationProvider: LocationProvider,
|
||||
private val ttsManager: TtsManager,
|
||||
) : 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())
|
||||
val uiState: StateFlow<PlaceDetailUiState> = _uiState.asStateFlow()
|
||||
|
||||
val isFavorite: StateFlow<Boolean> = favoritesRepository.isFavorite(placeId)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
private val _isFavorite = MutableStateFlow(false)
|
||||
val isFavorite: StateFlow<Boolean> = _isFavorite.asStateFlow()
|
||||
|
||||
private var favoriteObserveJob: Job? = null
|
||||
|
||||
init {
|
||||
Log.d(TAG, "init: placeId=$placeId")
|
||||
Log.d(TAG, "init: placeId=$initialPlaceId")
|
||||
viewModelScope.launch {
|
||||
val length = (userPrefsDataStore.contentLength.first() ?: ContentLength.MEDIUM).toApiValue()
|
||||
val place = runCatching { placesRepository.getPlace(placeId, lang = "ru", length = length) }
|
||||
.onFailure { Log.e(TAG, "getPlace($placeId) failed", it) }
|
||||
.getOrNull()
|
||||
Log.d(TAG, "loaded place=${place?.slug}")
|
||||
_uiState.value = _uiState.value.copy(isLoading = false, place = place)
|
||||
place?.let { ttsManager.speak(it.content.body) }
|
||||
val location = selectedLocationHolder.location.value ?: locationProvider.getCurrentLocation()
|
||||
|
||||
val nearby = location?.let { loc ->
|
||||
runCatching {
|
||||
placesRepository.getNearby(
|
||||
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() {
|
||||
if (ttsManager.isMuted) ttsManager.unmute() else ttsManager.mute()
|
||||
Log.d(TAG, "toggleMute: isMuted=${ttsManager.isMuted}")
|
||||
|
|
@ -74,15 +168,16 @@ class PlaceDetailViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun toggleFavorite() {
|
||||
val place = _uiState.value.place ?: return
|
||||
Log.d(TAG, "toggleFavorite: placeId=$placeId currentlyFavorite=${isFavorite.value}")
|
||||
val state = _uiState.value
|
||||
val place = state.places.getOrNull(state.activeIndex) ?: return
|
||||
Log.d(TAG, "toggleFavorite: placeId=${place.id} currentlyFavorite=${isFavorite.value}")
|
||||
viewModelScope.launch {
|
||||
if (isFavorite.value) {
|
||||
favoritesRepository.removeFavorite(placeId)
|
||||
favoritesRepository.removeFavorite(place.id)
|
||||
} else {
|
||||
favoritesRepository.addFavorite(
|
||||
FavoritePlaceEntity(
|
||||
placeId = placeId,
|
||||
placeId = place.id,
|
||||
slug = place.slug,
|
||||
name = place.content.title,
|
||||
category = place.category,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
package com.guidecity.app.ui.guide
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
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.filled.Favorite
|
||||
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.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
|
|
@ -28,6 +32,7 @@ import androidx.compose.ui.res.stringResource
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.guidecity.app.R
|
||||
import com.guidecity.app.theme.Mocha
|
||||
|
||||
@Composable
|
||||
fun GuideScreen(
|
||||
|
|
@ -37,6 +42,11 @@ fun GuideScreen(
|
|||
viewModel: GuideViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(state.activeIndex) {
|
||||
listState.animateScrollToItem(state.activeIndex)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
|
|
@ -76,7 +86,6 @@ fun GuideScreen(
|
|||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(text = state.errorMessage.orEmpty())
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Button(onClick = { viewModel.retry() }) {
|
||||
Text(text = stringResource(R.string.guide_retry))
|
||||
}
|
||||
|
|
@ -96,14 +105,30 @@ fun GuideScreen(
|
|||
}
|
||||
|
||||
else -> {
|
||||
PlaceCardStack(
|
||||
places = state.places,
|
||||
activeIndex = state.activeIndex,
|
||||
doneIndices = state.doneIndices,
|
||||
onCardClick = onOpenDetail,
|
||||
onPageChanged = { viewModel.setActiveIndex(it) },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Text(
|
||||
text = stringResource(R.string.guide_places_count, state.places.size),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = Mocha.Subtext1,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ 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.util.buildNarrationText
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
|
@ -129,7 +130,8 @@ class GuideViewModel @Inject constructor(
|
|||
val state = _uiState.value
|
||||
val place = state.places.getOrNull(state.activeIndex) ?: return
|
||||
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() {
|
||||
|
|
@ -148,15 +150,6 @@ class GuideViewModel @Inject constructor(
|
|||
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() {
|
||||
if (ttsManager.isMuted) ttsManager.unmute() else ttsManager.mute()
|
||||
Log.d(TAG, "toggleMute: isMuted=${ttsManager.isMuted}")
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -50,6 +50,8 @@ fun MapScreen(
|
|||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val searchResults by viewModel.searchResults.collectAsState()
|
||||
val resolvedLocation by viewModel.resolvedLocation.collectAsState()
|
||||
val nearbyPlaces by viewModel.nearbyPlaces.collectAsState()
|
||||
var query by remember { mutableStateOf("") }
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
|
|
@ -83,8 +85,8 @@ fun MapScreen(
|
|||
.padding(padding),
|
||||
) {
|
||||
DgisMapView(
|
||||
userLocation = null,
|
||||
places = emptyList(),
|
||||
userLocation = resolvedLocation,
|
||||
places = nearbyPlaces,
|
||||
onPlaceClick = {},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.guidecity.app.ui.map
|
|||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
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.repository.PlacesRepository
|
||||
import com.guidecity.app.location.LatLon
|
||||
|
|
@ -41,6 +42,12 @@ class MapViewModel @Inject constructor(
|
|||
private val _searchResults = MutableStateFlow<List<PlaceListItemDto>>(emptyList())
|
||||
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() {
|
||||
val hasPermission = locationProvider.hasLocationPermission()
|
||||
Log.d(TAG, "checkInitialPermission: hasPermission=$hasPermission")
|
||||
|
|
@ -66,6 +73,7 @@ class MapViewModel @Inject constructor(
|
|||
val location = locationProvider.getCurrentLocation()
|
||||
if (location != null) {
|
||||
Log.i(TAG, "resolveLocation: got lat=${location.lat} lon=${location.lon}")
|
||||
_resolvedLocation.value = location
|
||||
selectedLocationHolder.set(location)
|
||||
checkNearby(location)
|
||||
} else {
|
||||
|
|
@ -96,6 +104,7 @@ class MapViewModel @Inject constructor(
|
|||
)
|
||||
}.onSuccess { response ->
|
||||
Log.i(TAG, "checkNearby: count=${response.count} radius=${response.searchRadiusM}m")
|
||||
_nearbyPlaces.value = response.places
|
||||
_uiState.value = if (response.count > 0) {
|
||||
MapUiState.ReadyToStart(placesFound = response.count, radiusM = response.searchRadiusM)
|
||||
} else {
|
||||
|
|
@ -130,6 +139,7 @@ class MapViewModel @Inject constructor(
|
|||
fun selectSearchResult(place: PlaceListItemDto) {
|
||||
Log.i(TAG, "selectSearchResult: placeId=${place.id} slug=${place.slug}")
|
||||
val location = LatLon(place.location.lat, place.location.lon)
|
||||
_resolvedLocation.value = location
|
||||
selectedLocationHolder.set(location)
|
||||
checkNearby(location)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@
|
|||
<string name="map_no_places_nearby">В радиусе 10 км ничего не нашлось.</string>
|
||||
|
||||
<string name="guide_no_places_nearby">Поблизости пока не найдено интересных мест.</string>
|
||||
<string name="guide_places_count">Мест поблизости: %1$d</string>
|
||||
<string name="guide_retry">Повторить</string>
|
||||
<string name="guide_favorite_add">Добавить в избранное</string>
|
||||
<string name="guide_favorite_remove">Убрать из избранного</string>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
<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_places_count">%1$d place(s) nearby</string>
|
||||
<string name="guide_retry">Retry</string>
|
||||
<string name="guide_favorite_add">Add to favorites</string>
|
||||
<string name="guide_favorite_remove">Remove from favorites</string>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue