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.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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 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(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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="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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue