diff --git a/android/app/src/main/java/com/guidecity/app/ui/detail/PlaceDetailScreen.kt b/android/app/src/main/java/com/guidecity/app/ui/detail/PlaceDetailScreen.kt index 0c77b9c..1666322 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/detail/PlaceDetailScreen.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/detail/PlaceDetailScreen.kt @@ -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) + } } } } diff --git a/android/app/src/main/java/com/guidecity/app/ui/detail/PlaceDetailViewModel.kt b/android/app/src/main/java/com/guidecity/app/ui/detail/PlaceDetailViewModel.kt index 3c62735..a925802 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/detail/PlaceDetailViewModel.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/detail/PlaceDetailViewModel.kt @@ -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 = 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("placeId")).toInt() + private val initialPlaceId: Int = checkNotNull(savedStateHandle.get("placeId")).toInt() private val _uiState = MutableStateFlow(PlaceDetailUiState()) val uiState: StateFlow = _uiState.asStateFlow() - val isFavorite: StateFlow = favoritesRepository.isFavorite(placeId) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + private val _isFavorite = MutableStateFlow(false) + val isFavorite: StateFlow = _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, diff --git a/android/app/src/main/java/com/guidecity/app/ui/guide/GuideScreen.kt b/android/app/src/main/java/com/guidecity/app/ui/guide/GuideScreen.kt index 830fe10..e294b68 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/guide/GuideScreen.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/guide/GuideScreen.kt @@ -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) }, + ) + } + } + } } } } diff --git a/android/app/src/main/java/com/guidecity/app/ui/guide/GuideViewModel.kt b/android/app/src/main/java/com/guidecity/app/ui/guide/GuideViewModel.kt index 8f5d7e9..015686b 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/guide/GuideViewModel.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/guide/GuideViewModel.kt @@ -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}") diff --git a/android/app/src/main/java/com/guidecity/app/ui/guide/PlaceCardStack.kt b/android/app/src/main/java/com/guidecity/app/ui/guide/PlaceCardStack.kt deleted file mode 100644 index f682604..0000000 --- a/android/app/src/main/java/com/guidecity/app/ui/guide/PlaceCardStack.kt +++ /dev/null @@ -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, - activeIndex: Int, - doneIndices: Set, - 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) }, - ) - } - } -} diff --git a/android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt b/android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt index 55b3bc4..fa3ba59 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt @@ -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(), ) diff --git a/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt b/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt index 3cc1a05..f478c58 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt @@ -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>(emptyList()) val searchResults: StateFlow> = _searchResults.asStateFlow() + private val _resolvedLocation = MutableStateFlow(null) + val resolvedLocation: StateFlow = _resolvedLocation.asStateFlow() + + private val _nearbyPlaces = MutableStateFlow>(emptyList()) + val nearbyPlaces: StateFlow> = _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) } diff --git a/android/app/src/main/java/com/guidecity/app/util/Narration.kt b/android/app/src/main/java/com/guidecity/app/util/Narration.kt new file mode 100644 index 0000000..3c3ff46 --- /dev/null +++ b/android/app/src/main/java/com/guidecity/app/util/Narration.kt @@ -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" + } diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 19c9a19..a5cbcb3 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -21,6 +21,7 @@ В радиусе 10 км ничего не нашлось. Поблизости пока не найдено интересных мест. + Мест поблизости: %1$d Повторить Добавить в избранное Убрать из избранного diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 2dfb5aa..9fce130 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -21,6 +21,7 @@ Nothing found within 10 km of here. No places found nearby yet. + %1$d place(s) nearby Retry Add to favorites Remove from favorites