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.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
@ -20,6 +21,7 @@ class UserPrefsDataStore @Inject constructor(
|
||||||
private object Keys {
|
private object Keys {
|
||||||
val ONBOARDING_SEEN = booleanPreferencesKey("onboarding_seen")
|
val ONBOARDING_SEEN = booleanPreferencesKey("onboarding_seen")
|
||||||
val CONTENT_LENGTH = stringPreferencesKey("content_length_pref")
|
val CONTENT_LENGTH = stringPreferencesKey("content_length_pref")
|
||||||
|
val LISTENED_PLACE_IDS = stringSetPreferencesKey("listened_place_ids")
|
||||||
}
|
}
|
||||||
|
|
||||||
val onboardingSeen: Flow<Boolean> =
|
val onboardingSeen: Flow<Boolean> =
|
||||||
|
|
@ -38,4 +40,26 @@ class UserPrefsDataStore @Inject constructor(
|
||||||
suspend fun setContentLength(length: ContentLength) {
|
suspend fun setContentLength(length: ContentLength) {
|
||||||
context.dataStore.edit { it[Keys.CONTENT_LENGTH] = length.name }
|
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> =
|
suspend fun searchPlaces(query: String, citySlug: String?, lang: String): List<PlaceListItemDto> =
|
||||||
apiService.searchPlaces(query, citySlug, lang)
|
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
|
* Fetches nearby places sorted by distance, iteratively expanding the
|
||||||
* search radius server-side until at least [minResults] are found.
|
* 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
|
* `speed`/`heading`-aware filtering is a reserved v2 extension on the
|
||||||
* backend (see nearby_search.py); this client doesn't send them yet.
|
* backend (see nearby_search.py); this client doesn't send them yet.
|
||||||
|
|
@ -34,7 +39,7 @@ class PlacesRepository @Inject constructor(
|
||||||
lang: String,
|
lang: String,
|
||||||
length: String,
|
length: String,
|
||||||
minResults: Int = 5,
|
minResults: Int = 5,
|
||||||
maxRadiusM: Int = 10_000,
|
maxRadiusM: Int = 2_000,
|
||||||
): NearbyResponseDto = apiService.getNearby(
|
): NearbyResponseDto = apiService.getNearby(
|
||||||
lat = lat,
|
lat = lat,
|
||||||
lon = lon,
|
lon = lon,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,13 @@ import android.content.Context
|
||||||
import android.speech.tts.TextToSpeech
|
import android.speech.tts.TextToSpeech
|
||||||
import android.speech.tts.UtteranceProgressListener
|
import android.speech.tts.UtteranceProgressListener
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.guidecity.app.data.local.prefs.UserPrefsDataStore
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
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.Locale
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
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.
|
* 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
|
* 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
|
* detail screen (both injected via Hilt), keyed by place id:
|
||||||
* time; calling [speak] naturally interrupts whatever was playing before via
|
* - calling [speak] for a *different* key naturally interrupts whatever was
|
||||||
* [TextToSpeech.QUEUE_FLUSH].
|
* 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
|
@Singleton
|
||||||
class TtsManager @Inject constructor(
|
class TtsManager @Inject constructor(
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
|
private val userPrefsDataStore: UserPrefsDataStore,
|
||||||
) {
|
) {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
private var isReady = false
|
private var isReady = false
|
||||||
private var pendingUtterance: Pair<String, (() -> Unit)?>? = null
|
private var pendingUtterance: Pair<String, (() -> Unit)?>? = null
|
||||||
private var onDoneCallback: (() -> Unit)? = null
|
private var onDoneCallback: (() -> Unit)? = null
|
||||||
private var lastSpokenText: String? = null
|
private var lastSpokenText: String? = null
|
||||||
|
private var currentPlaceId: Int? = null
|
||||||
|
private var isCurrentlySpeaking = false
|
||||||
|
|
||||||
var isMuted: Boolean = false
|
var isMuted: Boolean = false
|
||||||
private set
|
private set
|
||||||
|
|
@ -50,16 +68,22 @@ class TtsManager @Inject constructor(
|
||||||
object : UtteranceProgressListener() {
|
object : UtteranceProgressListener() {
|
||||||
override fun onStart(utteranceId: String?) {
|
override fun onStart(utteranceId: String?) {
|
||||||
Log.d(TAG, "utterance started: $utteranceId")
|
Log.d(TAG, "utterance started: $utteranceId")
|
||||||
|
isCurrentlySpeaking = true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDone(utteranceId: String?) {
|
override fun onDone(utteranceId: String?) {
|
||||||
Log.d(TAG, "utterance done: $utteranceId")
|
Log.d(TAG, "utterance done: $utteranceId")
|
||||||
|
isCurrentlySpeaking = false
|
||||||
|
currentPlaceId?.let { placeId ->
|
||||||
|
scope.launch { userPrefsDataStore.markPlaceListened(placeId) }
|
||||||
|
}
|
||||||
onDoneCallback?.invoke()
|
onDoneCallback?.invoke()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Deprecated("Deprecated in Java, but still the callback the platform invokes")
|
@Deprecated("Deprecated in Java, but still the callback the platform invokes")
|
||||||
override fun onError(utteranceId: String?) {
|
override fun onError(utteranceId: String?) {
|
||||||
Log.e(TAG, "utterance error: $utteranceId")
|
Log.e(TAG, "utterance error: $utteranceId")
|
||||||
|
isCurrentlySpeaking = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -70,9 +94,20 @@ class TtsManager @Inject constructor(
|
||||||
Log.d(TAG, "setLanguage($locale) -> $result")
|
Log.d(TAG, "setLanguage($locale) -> $result")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Speaks [text], calling [onDone] on the main thread once narration finishes. */
|
/**
|
||||||
fun speak(text: String, onDone: (() -> Unit)? = null) {
|
* Speaks [text] for [placeId], calling [onDone] once narration finishes.
|
||||||
Log.d(TAG, "speak() called, muted=$isMuted ready=$isReady length=${text.length}")
|
* 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
|
lastSpokenText = text
|
||||||
onDoneCallback = onDone
|
onDoneCallback = onDone
|
||||||
if (isMuted) return
|
if (isMuted) return
|
||||||
|
|
@ -113,5 +148,6 @@ class TtsManager @Inject constructor(
|
||||||
fun shutdown() {
|
fun shutdown() {
|
||||||
Log.d(TAG, "shutdown()")
|
Log.d(TAG, "shutdown()")
|
||||||
tts.shutdown()
|
tts.shutdown()
|
||||||
|
scope.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ data class PlaceDetailUiState(
|
||||||
* Shows one place's full content with narration, and — when the requested
|
* 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
|
* 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
|
* 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
|
* 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.
|
* to a single, non-swipeable place fetched directly by id.
|
||||||
*/
|
*/
|
||||||
|
|
@ -80,7 +80,6 @@ class PlaceDetailViewModel @Inject constructor(
|
||||||
lang = "ru",
|
lang = "ru",
|
||||||
length = length,
|
length = length,
|
||||||
minResults = 5,
|
minResults = 5,
|
||||||
maxRadiusM = 10_000,
|
|
||||||
)
|
)
|
||||||
}.onFailure { Log.e(TAG, "getNearby failed", it) }.getOrNull()
|
}.onFailure { Log.e(TAG, "getNearby failed", it) }.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
@ -140,8 +139,10 @@ class PlaceDetailViewModel @Inject constructor(
|
||||||
private fun narrateActive() {
|
private fun narrateActive() {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
val place = state.places.getOrNull(state.activeIndex) ?: return
|
val place = state.places.getOrNull(state.activeIndex) ?: return
|
||||||
val text = buildNarrationText(place.content.title, place.distanceM, place.content.body)
|
// If this exact place is already playing (e.g. opened while the guide
|
||||||
ttsManager.speak(text) { onNarrationDone() }
|
// 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() {
|
private fun onNarrationDone() {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
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
|
* 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
|
* 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).
|
* 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
|
@HiltViewModel
|
||||||
class GuideViewModel @Inject constructor(
|
class GuideViewModel @Inject constructor(
|
||||||
|
|
@ -54,15 +60,35 @@ class GuideViewModel @Inject constructor(
|
||||||
|
|
||||||
private var contentLength: ContentLength = ContentLength.MEDIUM
|
private var contentLength: ContentLength = ContentLength.MEDIUM
|
||||||
private var rescanStarted = false
|
private var rescanStarted = false
|
||||||
|
private var listenedIds: Set<Int> = emptySet()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
contentLength = userPrefsDataStore.contentLength.first() ?: ContentLength.MEDIUM
|
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"))
|
ttsManager.setLanguage(Locale("ru"))
|
||||||
loadNearby(startNarration = true)
|
loadNearby(startNarration = true)
|
||||||
startRescanLoop()
|
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() {
|
private fun startRescanLoop() {
|
||||||
|
|
@ -98,14 +124,17 @@ class GuideViewModel @Inject constructor(
|
||||||
)
|
)
|
||||||
}.onSuccess { response ->
|
}.onSuccess { response ->
|
||||||
Log.i(TAG, "loadNearby success: ${response.places.size} place(s), radius=${response.searchRadiusM}m")
|
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(
|
_uiState.value = _uiState.value.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
places = response.places,
|
places = places,
|
||||||
activeIndex = 0,
|
activeIndex = firstUnlistened,
|
||||||
doneIndices = emptySet(),
|
doneIndices = doneIdx,
|
||||||
errorMessage = null,
|
errorMessage = null,
|
||||||
)
|
)
|
||||||
if (startNarration) {
|
if (startNarration && firstUnlistened !in doneIdx) {
|
||||||
narrateActive()
|
narrateActive()
|
||||||
}
|
}
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
|
|
@ -130,23 +159,22 @@ 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}")
|
||||||
val text = buildNarrationText(place.content.title, place.distanceM, place.content.body)
|
val text = buildNarrationText(place.distanceM, place.content.body)
|
||||||
ttsManager.speak(text) { onNarrationDone() }
|
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() {
|
private fun onNarrationDone() {
|
||||||
val state = _uiState.value
|
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")
|
Log.d(TAG, "onNarrationDone: finished index=${state.activeIndex}, nextIndex=$nextIndex")
|
||||||
if (nextIndex >= state.places.size) {
|
if (nextIndex == null) {
|
||||||
Log.i(TAG, "onNarrationDone: reached end of nearby list")
|
Log.i(TAG, "onNarrationDone: no more unlistened places")
|
||||||
_uiState.value = state.copy(doneIndices = state.doneIndices + state.activeIndex)
|
_uiState.value = state.copy(doneIndices = newDone)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_uiState.value = state.copy(
|
_uiState.value = state.copy(doneIndices = newDone, activeIndex = nextIndex)
|
||||||
doneIndices = state.doneIndices + state.activeIndex,
|
|
||||||
activeIndex = nextIndex,
|
|
||||||
)
|
|
||||||
narrateActive()
|
narrateActive()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val TAG = "MapViewModel"
|
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 {
|
sealed interface MapUiState {
|
||||||
data object CheckingPermission : 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.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.selection.selectable
|
import androidx.compose.foundation.selection.selectable
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
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.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
|
@ -32,6 +36,7 @@ fun SettingsScreen(
|
||||||
viewModel: SettingsViewModel = hiltViewModel(),
|
viewModel: SettingsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val selected by viewModel.contentLength.collectAsState()
|
val selected by viewModel.contentLength.collectAsState()
|
||||||
|
val stats by viewModel.listenedStats.collectAsState()
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
|
|
@ -71,6 +76,28 @@ fun SettingsScreen(
|
||||||
isSelected = selected == ContentLength.LONG,
|
isSelected = selected == ContentLength.LONG,
|
||||||
onSelect = { viewModel.setContentLength(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 androidx.lifecycle.viewModelScope
|
||||||
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.repository.PlacesRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.stateIn
|
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 = "SettingsViewModel"
|
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
|
@HiltViewModel
|
||||||
class SettingsViewModel @Inject constructor(
|
class SettingsViewModel @Inject constructor(
|
||||||
private val userPrefsDataStore: UserPrefsDataStore,
|
private val userPrefsDataStore: UserPrefsDataStore,
|
||||||
|
private val placesRepository: PlacesRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val contentLength: StateFlow<ContentLength?> = userPrefsDataStore.contentLength
|
val contentLength: StateFlow<ContentLength?> = userPrefsDataStore.contentLength
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
.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) {
|
fun setContentLength(length: ContentLength) {
|
||||||
Log.i(TAG, "setContentLength: $length")
|
Log.i(TAG, "setContentLength: $length")
|
||||||
viewModelScope.launch { userPrefsDataStore.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
|
* Builds the narration body prefixed with distance from the user. The
|
||||||
* the user. Pass a negative [distanceM] (no real distance known — e.g. a
|
* 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.
|
* 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) {
|
if (distanceM < 0) {
|
||||||
"$title. $body"
|
body
|
||||||
} else {
|
} else {
|
||||||
"$title. Расстояние: ${formatDistanceForNarration(distanceM)}. $body"
|
"Расстояние: ${formatDistanceForNarration(distanceM)}. $body"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
<string name="map_checking_nearby">Ищем места поблизости…</string>
|
<string name="map_checking_nearby">Ищем места поблизости…</string>
|
||||||
<string name="map_places_found">Найдено мест поблизости: %1$d (в радиусе %2$d м)</string>
|
<string name="map_places_found">Найдено мест поблизости: %1$d (в радиусе %2$d м)</string>
|
||||||
<string name="map_start">Начать</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_list">Показать список</string>
|
||||||
<string name="map_show_map">Показать карту</string>
|
<string name="map_show_map">Показать карту</string>
|
||||||
|
|
||||||
|
|
@ -35,6 +35,9 @@
|
||||||
|
|
||||||
<string name="settings_title">Настройки</string>
|
<string name="settings_title">Настройки</string>
|
||||||
<string name="settings_content_length_label">Подробность рассказа</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>
|
<string name="place_not_found">Место не найдено.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
<string name="map_checking_nearby">Looking for places nearby…</string>
|
<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_places_found">Found %1$d place(s) nearby (within %2$d m)</string>
|
||||||
<string name="map_start">Start</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_list">Show list</string>
|
||||||
<string name="map_show_map">Show map</string>
|
<string name="map_show_map">Show map</string>
|
||||||
|
|
||||||
|
|
@ -35,6 +35,9 @@
|
||||||
|
|
||||||
<string name="settings_title">Settings</string>
|
<string name="settings_title">Settings</string>
|
||||||
<string name="settings_content_length_label">Content length</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>
|
<string name="place_not_found">Place not found.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,13 @@ import java.io.IOException
|
||||||
* regression test for a real bug we hit: on a failed nearby-search call, the
|
* 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
|
* screen used to stay stuck on the loading spinner forever because the
|
||||||
* early-return path never cleared `isLoading`. See GuideViewModel.loadNearby.
|
* 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 {
|
class GuideViewModelTest {
|
||||||
|
|
||||||
|
|
@ -54,6 +61,7 @@ class GuideViewModelTest {
|
||||||
private fun buildViewModel(
|
private fun buildViewModel(
|
||||||
placesRepository: PlacesRepository,
|
placesRepository: PlacesRepository,
|
||||||
ttsManager: TtsManager = mockk(relaxed = true),
|
ttsManager: TtsManager = mockk(relaxed = true),
|
||||||
|
listenedIds: Set<Int> = emptySet(),
|
||||||
): GuideViewModel {
|
): GuideViewModel {
|
||||||
val locationProvider = mockk<LocationProvider>(relaxed = true)
|
val locationProvider = mockk<LocationProvider>(relaxed = true)
|
||||||
|
|
||||||
|
|
@ -62,6 +70,7 @@ class GuideViewModelTest {
|
||||||
|
|
||||||
val userPrefsDataStore = mockk<UserPrefsDataStore>()
|
val userPrefsDataStore = mockk<UserPrefsDataStore>()
|
||||||
every { userPrefsDataStore.contentLength } returns flowOf(ContentLength.MEDIUM)
|
every { userPrefsDataStore.contentLength } returns flowOf(ContentLength.MEDIUM)
|
||||||
|
every { userPrefsDataStore.listenedPlaceIds } returns flowOf(listenedIds)
|
||||||
|
|
||||||
return GuideViewModel(
|
return GuideViewModel(
|
||||||
placesRepository = placesRepository,
|
placesRepository = placesRepository,
|
||||||
|
|
@ -82,15 +91,16 @@ class GuideViewModelTest {
|
||||||
|
|
||||||
val ttsManager = mockk<TtsManager>(relaxed = true)
|
val ttsManager = mockk<TtsManager>(relaxed = true)
|
||||||
val viewModel = buildViewModel(placesRepository, ttsManager)
|
val viewModel = buildViewModel(placesRepository, ttsManager)
|
||||||
|
try {
|
||||||
val state = viewModel.uiState.value
|
val state = viewModel.uiState.value
|
||||||
assertEquals(false, state.isLoading)
|
assertEquals(false, state.isLoading)
|
||||||
assertEquals(2, state.places.size)
|
assertEquals(2, state.places.size)
|
||||||
assertNull(state.errorMessage)
|
assertNull(state.errorMessage)
|
||||||
verify { ttsManager.speak(any(), any()) }
|
verify { ttsManager.speak(any(), any(), any()) }
|
||||||
|
} finally {
|
||||||
viewModel.viewModelScope.cancel()
|
viewModel.viewModelScope.cancel()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `failed load clears the spinner and surfaces an error instead of hanging forever`() = runTest {
|
fun `failed load clears the spinner and surfaces an error instead of hanging forever`() = runTest {
|
||||||
|
|
@ -100,14 +110,41 @@ class GuideViewModelTest {
|
||||||
} throws IOException("Cleartext HTTP traffic not permitted")
|
} throws IOException("Cleartext HTTP traffic not permitted")
|
||||||
|
|
||||||
val viewModel = buildViewModel(placesRepository)
|
val viewModel = buildViewModel(placesRepository)
|
||||||
|
try {
|
||||||
val state = viewModel.uiState.value
|
val state = viewModel.uiState.value
|
||||||
assertEquals(false, state.isLoading)
|
assertEquals(false, state.isLoading)
|
||||||
assertTrue(state.places.isEmpty())
|
assertTrue(state.places.isEmpty())
|
||||||
assertNotNull(state.errorMessage)
|
assertNotNull(state.errorMessage)
|
||||||
|
} finally {
|
||||||
viewModel.viewModelScope.cancel()
|
viewModel.viewModelScope.cancel()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
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
|
@Test
|
||||||
fun `retry after a failure can succeed and clears the error`() = runTest {
|
fun `retry after a failure can succeed and clears the error`() = runTest {
|
||||||
|
|
@ -118,6 +155,7 @@ class GuideViewModelTest {
|
||||||
} throws IOException("network down") andThen response
|
} throws IOException("network down") andThen response
|
||||||
|
|
||||||
val viewModel = buildViewModel(placesRepository)
|
val viewModel = buildViewModel(placesRepository)
|
||||||
|
try {
|
||||||
assertNotNull(viewModel.uiState.value.errorMessage)
|
assertNotNull(viewModel.uiState.value.errorMessage)
|
||||||
|
|
||||||
viewModel.retry()
|
viewModel.retry()
|
||||||
|
|
@ -126,7 +164,8 @@ class GuideViewModelTest {
|
||||||
assertEquals(false, state.isLoading)
|
assertEquals(false, state.isLoading)
|
||||||
assertNull(state.errorMessage)
|
assertNull(state.errorMessage)
|
||||||
assertEquals(1, state.places.size)
|
assertEquals(1, state.places.size)
|
||||||
|
} finally {
|
||||||
viewModel.viewModelScope.cancel()
|
viewModel.viewModelScope.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ def nearby(
|
||||||
min_results: int = Query(default=5, ge=1, le=50),
|
min_results: int = Query(default=5, ge=1, le=50),
|
||||||
initial_radius_m: int = Query(default=300, ge=50, le=5000),
|
initial_radius_m: int = Query(default=300, ge=50, le=5000),
|
||||||
step_m: int = Query(default=200, 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(
|
speed: float | None = Query(
|
||||||
default=None,
|
default=None,
|
||||||
description="Reserved for v2: user's speed in m/s. Currently accepted but ignored.",
|
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