Persist listened places, fix restart-on-reopen bug, add stats/reset
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 <noreply@anthropic.com>
This commit is contained in:
parent
f6735db46f
commit
cd428d0fb7
13 changed files with 254 additions and 56 deletions
|
|
@ -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<Boolean> =
|
||||
|
|
@ -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<Set<Int>> =
|
||||
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() }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,9 +20,14 @@ class PlacesRepository @Inject constructor(
|
|||
suspend fun searchPlaces(query: String, citySlug: String?, lang: String): List<PlaceListItemDto> =
|
||||
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<PlaceListItemDto> =
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<String, (() -> 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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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<Int> = 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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ContentLength?> = userPrefsDataStore.contentLength
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
private val _totalPlaces = MutableStateFlow(0)
|
||||
|
||||
val listenedStats: StateFlow<ListenedStats> = 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() }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
<string name="map_checking_nearby">Ищем места поблизости…</string>
|
||||
<string name="map_places_found">Найдено мест поблизости: %1$d (в радиусе %2$d м)</string>
|
||||
<string name="map_start">Начать</string>
|
||||
<string name="map_no_places_nearby">В радиусе 10 км ничего не нашлось.</string>
|
||||
<string name="map_no_places_nearby">В радиусе 2 км ничего не нашлось.</string>
|
||||
<string name="map_show_list">Показать список</string>
|
||||
<string name="map_show_map">Показать карту</string>
|
||||
|
||||
|
|
@ -35,6 +35,9 @@
|
|||
|
||||
<string name="settings_title">Настройки</string>
|
||||
<string name="settings_content_length_label">Подробность рассказа</string>
|
||||
<string name="settings_stats_title">Прогресс прослушивания</string>
|
||||
<string name="settings_stats_progress">Прослушано: %1$d из %2$d (%3$d%%)</string>
|
||||
<string name="settings_reset_listened">Сбросить прослушанные места</string>
|
||||
|
||||
<string name="place_not_found">Место не найдено.</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
<string name="map_checking_nearby">Looking for places nearby…</string>
|
||||
<string name="map_places_found">Found %1$d place(s) nearby (within %2$d m)</string>
|
||||
<string name="map_start">Start</string>
|
||||
<string name="map_no_places_nearby">Nothing found within 10 km of here.</string>
|
||||
<string name="map_no_places_nearby">Nothing found within 2 km of here.</string>
|
||||
<string name="map_show_list">Show list</string>
|
||||
<string name="map_show_map">Show map</string>
|
||||
|
||||
|
|
@ -35,6 +35,9 @@
|
|||
|
||||
<string name="settings_title">Settings</string>
|
||||
<string name="settings_content_length_label">Content length</string>
|
||||
<string name="settings_stats_title">Listening progress</string>
|
||||
<string name="settings_stats_progress">Listened: %1$d of %2$d (%3$d%%)</string>
|
||||
<string name="settings_reset_listened">Reset listened places</string>
|
||||
|
||||
<string name="place_not_found">Place not found.</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -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<Int> = emptySet(),
|
||||
): GuideViewModel {
|
||||
val locationProvider = mockk<LocationProvider>(relaxed = true)
|
||||
|
||||
|
|
@ -62,6 +70,7 @@ class GuideViewModelTest {
|
|||
|
||||
val userPrefsDataStore = mockk<UserPrefsDataStore>()
|
||||
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<TtsManager>(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<PlacesRepository>()
|
||||
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<TtsManager>(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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue