From cd428d0fb76d3ac5a552fa8f0c8079e01331d873 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Fri, 10 Jul 2026 07:44:12 +0000 Subject: [PATCH] Persist listened places, fix restart-on-reopen bug, add stats/reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five behavior changes from live testing feedback: 1. Narration no longer leads with the place title — the body text already opens with the name, so it was read twice. 2. "Done" is now derived from a persisted set of listened place IDs (UserPrefsDataStore.listenedPlaceIds), not an in-memory index set that got wiped on every load. A place that's ever been narrated to completion — in the guide list or the detail screen — is never auto-narrated again, including across the 60s re-scan and app restarts. Auto-advance now skips straight to the next unlistened place instead of walking sequentially. Settings has a new "reset listened places" button. 3. TtsManager is now keyed by place id: calling speak() for the place that's already playing just re-attaches the onDone callback instead of restarting via QUEUE_FLUSH. Fixes opening a place's detail screen while the guide list is already narrating it restarting playback from the beginning. Marking a place "listened" now also lives in TtsManager itself (on natural onDone, not onError/onStop), so it's correct regardless of which screen was driving playback. 4. Settings now shows "Listened: X of Y (Z%)" against the total place count for the city. 5. Search radius changed from 10km to 2km (client default in PlacesRepository/MapViewModel, and the backend's own default for consistency) — but the result count is uncapped, same as before; every place within the radius is returned regardless of how many that is. Caught a real bug while testing the "skip listened" change: the new listenedPlaceIds collector ran in a separate coroutine that hadn't necessarily delivered its first value before the initial loadNearby() call, so freshly-loaded listened state could be missed on cold start. Fixed by awaiting listenedPlaceIds.first() synchronously before the first load, with a separate .drop(1) collector for later changes (e.g. the reset button). Also hardened GuideViewModelTest with try/finally around viewModelScope.cancel() — a failing assertion was skipping cleanup and turning into a 5+ minute hang instead of a fast failure, since the 60s re-scan loop was never cancelled. Verified: testDebugUnitTest passes (4/4), assembleDebug produces a working APK, sent to Telegram. Backend pytest still passes. Co-Authored-By: Claude Sonnet 5 --- .../data/local/prefs/UserPrefsDataStore.kt | 24 ++++++ .../app/data/repository/PlacesRepository.kt | 7 +- .../java/com/guidecity/app/tts/TtsManager.kt | 48 +++++++++-- .../app/ui/detail/PlaceDetailViewModel.kt | 9 ++- .../guidecity/app/ui/guide/GuideViewModel.kt | 58 +++++++++---- .../com/guidecity/app/ui/map/MapViewModel.kt | 2 +- .../app/ui/settings/SettingsScreen.kt | 27 +++++++ .../app/ui/settings/SettingsViewModel.kt | 30 +++++++ .../java/com/guidecity/app/util/Narration.kt | 12 +-- .../app/src/main/res/values-ru/strings.xml | 5 +- android/app/src/main/res/values/strings.xml | 5 +- .../app/ui/guide/GuideViewModelTest.kt | 81 ++++++++++++++----- backend/app/api/routes/nearby.py | 2 +- 13 files changed, 254 insertions(+), 56 deletions(-) diff --git a/android/app/src/main/java/com/guidecity/app/data/local/prefs/UserPrefsDataStore.kt b/android/app/src/main/java/com/guidecity/app/data/local/prefs/UserPrefsDataStore.kt index 4a1c6cc..c096b27 100644 --- a/android/app/src/main/java/com/guidecity/app/data/local/prefs/UserPrefsDataStore.kt +++ b/android/app/src/main/java/com/guidecity/app/data/local/prefs/UserPrefsDataStore.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow @@ -20,6 +21,7 @@ class UserPrefsDataStore @Inject constructor( private object Keys { val ONBOARDING_SEEN = booleanPreferencesKey("onboarding_seen") val CONTENT_LENGTH = stringPreferencesKey("content_length_pref") + val LISTENED_PLACE_IDS = stringSetPreferencesKey("listened_place_ids") } val onboardingSeen: Flow = @@ -38,4 +40,26 @@ class UserPrefsDataStore @Inject constructor( suspend fun setContentLength(length: ContentLength) { context.dataStore.edit { it[Keys.CONTENT_LENGTH] = length.name } } + + /** + * Places that have already been narrated to completion — persisted so a + * card is never auto-read twice, even across the 60s re-scan or an app + * restart. See [markPlaceListened] / [resetListenedPlaces]. + */ + val listenedPlaceIds: Flow> = + context.dataStore.data.map { prefs -> + (prefs[Keys.LISTENED_PLACE_IDS] ?: emptySet()).mapNotNull { it.toIntOrNull() }.toSet() + } + + suspend fun markPlaceListened(placeId: Int) { + context.dataStore.edit { prefs -> + val current = prefs[Keys.LISTENED_PLACE_IDS] ?: emptySet() + prefs[Keys.LISTENED_PLACE_IDS] = current + placeId.toString() + } + } + + /** Settings screen "reset" button — forgets all listened places so they get read again. */ + suspend fun resetListenedPlaces() { + context.dataStore.edit { prefs -> prefs[Keys.LISTENED_PLACE_IDS] = emptySet() } + } } diff --git a/android/app/src/main/java/com/guidecity/app/data/repository/PlacesRepository.kt b/android/app/src/main/java/com/guidecity/app/data/repository/PlacesRepository.kt index 1aa7934..0479444 100644 --- a/android/app/src/main/java/com/guidecity/app/data/repository/PlacesRepository.kt +++ b/android/app/src/main/java/com/guidecity/app/data/repository/PlacesRepository.kt @@ -20,9 +20,14 @@ class PlacesRepository @Inject constructor( suspend fun searchPlaces(query: String, citySlug: String?, lang: String): List = apiService.searchPlaces(query, citySlug, lang) + /** Full place listing for a city — used for "listened X of Y" stats, not paginated. */ + suspend fun getCityPlaces(citySlug: String, lang: String): List = + apiService.getCityPlaces(citySlug = citySlug, lang = lang) + /** * Fetches nearby places sorted by distance, iteratively expanding the * search radius server-side until at least [minResults] are found. + * Every match within the radius is returned — there's no cap on count. * * `speed`/`heading`-aware filtering is a reserved v2 extension on the * backend (see nearby_search.py); this client doesn't send them yet. @@ -34,7 +39,7 @@ class PlacesRepository @Inject constructor( lang: String, length: String, minResults: Int = 5, - maxRadiusM: Int = 10_000, + maxRadiusM: Int = 2_000, ): NearbyResponseDto = apiService.getNearby( lat = lat, lon = lon, diff --git a/android/app/src/main/java/com/guidecity/app/tts/TtsManager.kt b/android/app/src/main/java/com/guidecity/app/tts/TtsManager.kt index 6cb6bac..e49358f 100644 --- a/android/app/src/main/java/com/guidecity/app/tts/TtsManager.kt +++ b/android/app/src/main/java/com/guidecity/app/tts/TtsManager.kt @@ -4,7 +4,13 @@ import android.content.Context import android.speech.tts.TextToSpeech import android.speech.tts.UtteranceProgressListener import android.util.Log +import com.guidecity.app.data.local.prefs.UserPrefsDataStore import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import java.util.Locale import java.util.UUID import javax.inject.Inject @@ -17,18 +23,30 @@ private const val TAG = "TtsManager" * the app's "local voice narration" requirement without a custom ML model. * * A single app-wide instance is shared by the guide screen and the place - * detail screen (both injected via Hilt) so only one narration plays at a - * time; calling [speak] naturally interrupts whatever was playing before via - * [TextToSpeech.QUEUE_FLUSH]. + * detail screen (both injected via Hilt), keyed by place id: + * - calling [speak] for a *different* key naturally interrupts whatever was + * playing before via [TextToSpeech.QUEUE_FLUSH]. + * - calling [speak] for the place that's *already* playing does NOT + * restart it — e.g. opening a place's detail screen while the guide list + * is already narrating it just re-attaches [onDone], it doesn't reset + * playback to the beginning. + * - when an utterance finishes naturally (not interrupted/muted), the + * place is persisted as listened via [UserPrefsDataStore], so it's never + * auto-narrated again — regardless of which screen was driving playback. */ @Singleton class TtsManager @Inject constructor( @ApplicationContext context: Context, + private val userPrefsDataStore: UserPrefsDataStore, ) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var isReady = false private var pendingUtterance: Pair Unit)?>? = null private var onDoneCallback: (() -> Unit)? = null private var lastSpokenText: String? = null + private var currentPlaceId: Int? = null + private var isCurrentlySpeaking = false var isMuted: Boolean = false private set @@ -50,16 +68,22 @@ class TtsManager @Inject constructor( object : UtteranceProgressListener() { override fun onStart(utteranceId: String?) { Log.d(TAG, "utterance started: $utteranceId") + isCurrentlySpeaking = true } override fun onDone(utteranceId: String?) { Log.d(TAG, "utterance done: $utteranceId") + isCurrentlySpeaking = false + currentPlaceId?.let { placeId -> + scope.launch { userPrefsDataStore.markPlaceListened(placeId) } + } onDoneCallback?.invoke() } @Deprecated("Deprecated in Java, but still the callback the platform invokes") override fun onError(utteranceId: String?) { Log.e(TAG, "utterance error: $utteranceId") + isCurrentlySpeaking = false } }, ) @@ -70,9 +94,20 @@ class TtsManager @Inject constructor( Log.d(TAG, "setLanguage($locale) -> $result") } - /** Speaks [text], calling [onDone] on the main thread once narration finishes. */ - fun speak(text: String, onDone: (() -> Unit)? = null) { - Log.d(TAG, "speak() called, muted=$isMuted ready=$isReady length=${text.length}") + /** + * Speaks [text] for [placeId], calling [onDone] once narration finishes. + * If [placeId] is already the one currently playing, this just + * re-attaches [onDone] without restarting playback. + */ + fun speak(placeId: Int, text: String, onDone: (() -> Unit)? = null) { + if (placeId == currentPlaceId && isCurrentlySpeaking) { + Log.d(TAG, "speak(): placeId=$placeId already playing, attaching onDone without restart") + onDoneCallback = onDone + return + } + + Log.d(TAG, "speak() called for placeId=$placeId, muted=$isMuted ready=$isReady length=${text.length}") + currentPlaceId = placeId lastSpokenText = text onDoneCallback = onDone if (isMuted) return @@ -113,5 +148,6 @@ class TtsManager @Inject constructor( fun shutdown() { Log.d(TAG, "shutdown()") tts.shutdown() + scope.cancel() } } 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 a925802..c2b3656 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 @@ -40,7 +40,7 @@ data class PlaceDetailUiState( * 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 + * progression, and 2km 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. */ @@ -80,7 +80,6 @@ class PlaceDetailViewModel @Inject constructor( lang = "ru", length = length, minResults = 5, - maxRadiusM = 10_000, ) }.onFailure { Log.e(TAG, "getNearby failed", it) }.getOrNull() } @@ -140,8 +139,10 @@ class PlaceDetailViewModel @Inject constructor( 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() } + // If this exact place is already playing (e.g. opened while the guide + // list is narrating it), TtsManager won't restart it from scratch. + val text = buildNarrationText(place.distanceM, place.content.body) + ttsManager.speak(place.id, text) { onNarrationDone() } } private fun onNarrationDone() { 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 015686b..154fb10 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 @@ -16,6 +16,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.first import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -39,6 +40,11 @@ data class GuideUiState( * re-sorts by fresh distance (TODO(v2): factor in the user's speed/heading * once the backend's reserved `speed`/`heading` nearby params are used, to * auto-pick a shorter content length when moving fast through a dense area). + * + * "Done" is derived from [UserPrefsDataStore.listenedPlaceIds] — a place + * that was ever narrated to completion (in this screen or the detail + * screen) is never auto-narrated again, even across re-scans or an app + * restart, until the user resets it from Settings. */ @HiltViewModel class GuideViewModel @Inject constructor( @@ -54,15 +60,35 @@ class GuideViewModel @Inject constructor( private var contentLength: ContentLength = ContentLength.MEDIUM private var rescanStarted = false + private var listenedIds: Set = emptySet() init { viewModelScope.launch { contentLength = userPrefsDataStore.contentLength.first() ?: ContentLength.MEDIUM - Log.i(TAG, "init: contentLength=$contentLength") + // Must be populated before the first loadNearby(), so places already + // listened in a previous session are correctly skipped from the start. + listenedIds = userPrefsDataStore.listenedPlaceIds.first() + Log.i(TAG, "init: contentLength=$contentLength, ${listenedIds.size} place(s) already listened") ttsManager.setLanguage(Locale("ru")) loadNearby(startNarration = true) startRescanLoop() } + // React to *later* changes only (e.g. the Settings "reset" button) — + // the initial value was already consumed above via .first(). + viewModelScope.launch { + userPrefsDataStore.listenedPlaceIds.drop(1).collect { ids -> + Log.d(TAG, "listenedPlaceIds changed: ${ids.size} total") + listenedIds = ids + recomputeDoneIndices() + } + } + } + + private fun recomputeDoneIndices() { + val state = _uiState.value + if (state.places.isEmpty()) return + val doneIdx = state.places.indices.filter { state.places[it].id in listenedIds }.toSet() + _uiState.value = state.copy(doneIndices = doneIdx) } private fun startRescanLoop() { @@ -98,14 +124,17 @@ class GuideViewModel @Inject constructor( ) }.onSuccess { response -> Log.i(TAG, "loadNearby success: ${response.places.size} place(s), radius=${response.searchRadiusM}m") + val places = response.places + val doneIdx = places.indices.filter { places[it].id in listenedIds }.toSet() + val firstUnlistened = places.indices.firstOrNull { it !in doneIdx } ?: 0 _uiState.value = _uiState.value.copy( isLoading = false, - places = response.places, - activeIndex = 0, - doneIndices = emptySet(), + places = places, + activeIndex = firstUnlistened, + doneIndices = doneIdx, errorMessage = null, ) - if (startNarration) { + if (startNarration && firstUnlistened !in doneIdx) { narrateActive() } }.onFailure { error -> @@ -130,23 +159,22 @@ 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}") - val text = buildNarrationText(place.content.title, place.distanceM, place.content.body) - ttsManager.speak(text) { onNarrationDone() } + val text = buildNarrationText(place.distanceM, place.content.body) + ttsManager.speak(place.id, text) { onNarrationDone() } } + /** Advances to the next place that hasn't been listened to yet, skipping any already-done ones. */ private fun onNarrationDone() { val state = _uiState.value - val nextIndex = state.activeIndex + 1 + val newDone = state.doneIndices + state.activeIndex + val nextIndex = state.places.indices.firstOrNull { it !in newDone } Log.d(TAG, "onNarrationDone: finished index=${state.activeIndex}, nextIndex=$nextIndex") - if (nextIndex >= state.places.size) { - Log.i(TAG, "onNarrationDone: reached end of nearby list") - _uiState.value = state.copy(doneIndices = state.doneIndices + state.activeIndex) + if (nextIndex == null) { + Log.i(TAG, "onNarrationDone: no more unlistened places") + _uiState.value = state.copy(doneIndices = newDone) return } - _uiState.value = state.copy( - doneIndices = state.doneIndices + state.activeIndex, - activeIndex = nextIndex, - ) + _uiState.value = state.copy(doneIndices = newDone, activeIndex = nextIndex) narrateActive() } 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 f478c58..d815ceb 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 @@ -17,7 +17,7 @@ import kotlinx.coroutines.launch import javax.inject.Inject private const val TAG = "MapViewModel" -private const val MAX_SEARCH_RADIUS_M = 10_000 +private const val MAX_SEARCH_RADIUS_M = 2_000 sealed interface MapUiState { data object CheckingPermission : MapUiState diff --git a/android/app/src/main/java/com/guidecity/app/ui/settings/SettingsScreen.kt b/android/app/src/main/java/com/guidecity/app/ui/settings/SettingsScreen.kt index b8aa6c5..9b58fb0 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/settings/SettingsScreen.kt @@ -2,12 +2,16 @@ package com.guidecity.app.ui.settings import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.selection.selectable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -32,6 +36,7 @@ fun SettingsScreen( viewModel: SettingsViewModel = hiltViewModel(), ) { val selected by viewModel.contentLength.collectAsState() + val stats by viewModel.listenedStats.collectAsState() Scaffold( topBar = { @@ -71,6 +76,28 @@ fun SettingsScreen( isSelected = selected == ContentLength.LONG, onSelect = { viewModel.setContentLength(ContentLength.LONG) }, ) + + Spacer(modifier = Modifier.height(24.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.settings_stats_title), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource( + R.string.settings_stats_progress, + stats.listened, + stats.total, + stats.percent, + ), + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { viewModel.resetListenedPlaces() }) { + Text(stringResource(R.string.settings_reset_listened)) + } } } } diff --git a/android/app/src/main/java/com/guidecity/app/ui/settings/SettingsViewModel.kt b/android/app/src/main/java/com/guidecity/app/ui/settings/SettingsViewModel.kt index d0d7969..bfb8b33 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/settings/SettingsViewModel.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/settings/SettingsViewModel.kt @@ -5,24 +5,54 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.guidecity.app.data.local.prefs.ContentLength import com.guidecity.app.data.local.prefs.UserPrefsDataStore +import com.guidecity.app.data.repository.PlacesRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject private const val TAG = "SettingsViewModel" +data class ListenedStats(val listened: Int, val total: Int) { + val percent: Int get() = if (total == 0) 0 else (listened * 100) / total +} + @HiltViewModel class SettingsViewModel @Inject constructor( private val userPrefsDataStore: UserPrefsDataStore, + private val placesRepository: PlacesRepository, ) : ViewModel() { val contentLength: StateFlow = userPrefsDataStore.contentLength .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + private val _totalPlaces = MutableStateFlow(0) + + val listenedStats: StateFlow = combine( + userPrefsDataStore.listenedPlaceIds, + _totalPlaces, + ) { listenedIds, total -> ListenedStats(listened = listenedIds.size, total = total) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListenedStats(0, 0)) + + init { + viewModelScope.launch { + runCatching { placesRepository.getCityPlaces(citySlug = "moscow", lang = "ru") } + .onSuccess { _totalPlaces.value = it.size } + .onFailure { Log.e(TAG, "failed to load total place count", it) } + } + } + fun setContentLength(length: ContentLength) { Log.i(TAG, "setContentLength: $length") viewModelScope.launch { userPrefsDataStore.setContentLength(length) } } + + fun resetListenedPlaces() { + Log.i(TAG, "resetListenedPlaces") + viewModelScope.launch { userPrefsDataStore.resetListenedPlaces() } + } } 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 index 3c3ff46..78571c1 100644 --- a/android/app/src/main/java/com/guidecity/app/util/Narration.kt +++ b/android/app/src/main/java/com/guidecity/app/util/Narration.kt @@ -11,13 +11,15 @@ fun formatDistanceForNarration(distanceM: Double): String = } /** - * 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 + * Builds the narration body prefixed with distance from the user. The + * place's title is deliberately NOT included — the body text itself + * already opens with the name in practice, so reading it twice was + * redundant. 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 = +fun buildNarrationText(distanceM: Double, body: String): String = if (distanceM < 0) { - "$title. $body" + body } else { - "$title. Расстояние: ${formatDistanceForNarration(distanceM)}. $body" + "Расстояние: ${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 85052ba..a2cb758 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -18,7 +18,7 @@ Ищем места поблизости… Найдено мест поблизости: %1$d (в радиусе %2$d м) Начать - В радиусе 10 км ничего не нашлось. + В радиусе 2 км ничего не нашлось. Показать список Показать карту @@ -35,6 +35,9 @@ Настройки Подробность рассказа + Прогресс прослушивания + Прослушано: %1$d из %2$d (%3$d%%) + Сбросить прослушанные места Место не найдено. diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index e6a9176..d0a074d 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -18,7 +18,7 @@ Looking for places nearby… Found %1$d place(s) nearby (within %2$d m) Start - Nothing found within 10 km of here. + Nothing found within 2 km of here. Show list Show map @@ -35,6 +35,9 @@ Settings Content length + Listening progress + Listened: %1$d of %2$d (%3$d%%) + Reset listened places Place not found. diff --git a/android/app/src/test/java/com/guidecity/app/ui/guide/GuideViewModelTest.kt b/android/app/src/test/java/com/guidecity/app/ui/guide/GuideViewModelTest.kt index aca0ecc..a77b124 100644 --- a/android/app/src/test/java/com/guidecity/app/ui/guide/GuideViewModelTest.kt +++ b/android/app/src/test/java/com/guidecity/app/ui/guide/GuideViewModelTest.kt @@ -34,6 +34,13 @@ import java.io.IOException * regression test for a real bug we hit: on a failed nearby-search call, the * screen used to stay stuck on the loading spinner forever because the * early-return path never cleared `isLoading`. See GuideViewModel.loadNearby. + * + * Every test cancels the ViewModel's scope in a `finally` block. GuideViewModel + * starts an infinite 60s re-scan loop (`while (isActive) { delay(60_000); ... }`); + * under UnconfinedTestDispatcher, runTest's implicit final drain will hang + * forever trying to advance that loop unless it's cancelled first — and if an + * assertion throws before an un-guarded cancel() call, you get a 5+ minute + * test hang instead of a clean failure. Learned this the hard way once. */ class GuideViewModelTest { @@ -54,6 +61,7 @@ class GuideViewModelTest { private fun buildViewModel( placesRepository: PlacesRepository, ttsManager: TtsManager = mockk(relaxed = true), + listenedIds: Set = emptySet(), ): GuideViewModel { val locationProvider = mockk(relaxed = true) @@ -62,6 +70,7 @@ class GuideViewModelTest { val userPrefsDataStore = mockk() every { userPrefsDataStore.contentLength } returns flowOf(ContentLength.MEDIUM) + every { userPrefsDataStore.listenedPlaceIds } returns flowOf(listenedIds) return GuideViewModel( placesRepository = placesRepository, @@ -82,14 +91,15 @@ class GuideViewModelTest { val ttsManager = mockk(relaxed = true) val viewModel = buildViewModel(placesRepository, ttsManager) - - val state = viewModel.uiState.value - assertEquals(false, state.isLoading) - assertEquals(2, state.places.size) - assertNull(state.errorMessage) - verify { ttsManager.speak(any(), any()) } - - viewModel.viewModelScope.cancel() + try { + val state = viewModel.uiState.value + assertEquals(false, state.isLoading) + assertEquals(2, state.places.size) + assertNull(state.errorMessage) + verify { ttsManager.speak(any(), any(), any()) } + } finally { + viewModel.viewModelScope.cancel() + } } @Test @@ -100,13 +110,40 @@ class GuideViewModelTest { } throws IOException("Cleartext HTTP traffic not permitted") val viewModel = buildViewModel(placesRepository) + try { + val state = viewModel.uiState.value + assertEquals(false, state.isLoading) + assertTrue(state.places.isEmpty()) + assertNotNull(state.errorMessage) + } finally { + viewModel.viewModelScope.cancel() + } + } - val state = viewModel.uiState.value - assertEquals(false, state.isLoading) - assertTrue(state.places.isEmpty()) - assertNotNull(state.errorMessage) + @Test + fun `places already listened are marked done and skipped, not re-narrated`() = runTest { + val placesRepository = mockk() + val response = NearbyResponseDto( + searchRadiusM = 300, + count = 3, + places = listOf(fakePlace(1), fakePlace(2), fakePlace(3)), + ) + coEvery { + placesRepository.getNearby(lat = any(), lon = any(), citySlug = any(), lang = any(), length = any()) + } returns response - viewModel.viewModelScope.cancel() + val ttsManager = mockk(relaxed = true) + // place id 1 (index 0) was already listened in a previous session + val viewModel = buildViewModel(placesRepository, ttsManager, listenedIds = setOf(1)) + try { + val state = viewModel.uiState.value + assertTrue(0 in state.doneIndices) + assertEquals(1, state.activeIndex) + verify(exactly = 0) { ttsManager.speak(1, any(), any()) } + verify { ttsManager.speak(2, any(), any()) } + } finally { + viewModel.viewModelScope.cancel() + } } @Test @@ -118,15 +155,17 @@ class GuideViewModelTest { } throws IOException("network down") andThen response val viewModel = buildViewModel(placesRepository) - assertNotNull(viewModel.uiState.value.errorMessage) + try { + assertNotNull(viewModel.uiState.value.errorMessage) - viewModel.retry() + viewModel.retry() - val state = viewModel.uiState.value - assertEquals(false, state.isLoading) - assertNull(state.errorMessage) - assertEquals(1, state.places.size) - - viewModel.viewModelScope.cancel() + val state = viewModel.uiState.value + assertEquals(false, state.isLoading) + assertNull(state.errorMessage) + assertEquals(1, state.places.size) + } finally { + viewModel.viewModelScope.cancel() + } } } diff --git a/backend/app/api/routes/nearby.py b/backend/app/api/routes/nearby.py index 68f6e39..cdd85ed 100644 --- a/backend/app/api/routes/nearby.py +++ b/backend/app/api/routes/nearby.py @@ -25,7 +25,7 @@ def nearby( min_results: int = Query(default=5, ge=1, le=50), initial_radius_m: int = Query(default=300, ge=50, le=5000), step_m: int = Query(default=200, ge=50, le=5000), - max_radius_m: int = Query(default=5000, ge=100, le=20000), + max_radius_m: int = Query(default=2000, ge=100, le=20000), speed: float | None = Query( default=None, description="Reserved for v2: user's speed in m/s. Currently accepted but ignored.",