Fix infinite spinner on guide screen and allow cleartext HTTP in debug

Two bugs combined to cause this: (1) Android blocks plain-HTTP traffic
by default since API 28, so every call to our HTTP-only local backend
was throwing and being silently swallowed; (2) GuideViewModel's
loadNearby() returned early on that exception without ever setting
isLoading = false, so the spinner never cleared and no error showed.

Fix: debug-only manifest override (src/debug/AndroidManifest.xml)
enables cleartext traffic for local dev against the Docker backend;
GuideViewModel now always resolves isLoading and surfaces a
errorMessage + retry() the UI can act on instead of hanging forever.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
vrubelroman 2026-07-09 19:51:58 +00:00
parent 3f5a857c5a
commit 188bf01d6e
5 changed files with 60 additions and 12 deletions

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!--
Debug builds only: allow plain-HTTP traffic so the app can talk to the
local Docker backend (http://<lan-ip>:8000) during development.
Android blocks cleartext traffic by default since API 28 — release
builds intentionally do NOT get this override.
-->
<application android:usesCleartextTraffic="true" />
</manifest>

View file

@ -1,13 +1,18 @@
package com.guidecity.app.ui.guide package com.guidecity.app.ui.guide
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.VolumeOff import androidx.compose.material.icons.filled.VolumeOff
import androidx.compose.material.icons.filled.VolumeUp import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@ -20,6 +25,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.guidecity.app.R import com.guidecity.app.R
@ -61,6 +67,22 @@ fun GuideScreen(
.padding(padding), .padding(padding),
) { ) {
when { when {
state.errorMessage != null -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = state.errorMessage.orEmpty())
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.retry() }) {
Text(text = stringResource(R.string.guide_retry))
}
}
}
state.isLoading -> { state.isLoading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator() CircularProgressIndicator()

View file

@ -26,6 +26,7 @@ data class GuideUiState(
val activeIndex: Int = 0, val activeIndex: Int = 0,
val doneIndices: Set<Int> = emptySet(), val doneIndices: Set<Int> = emptySet(),
val isMuted: Boolean = false, val isMuted: Boolean = false,
val errorMessage: String? = null,
) )
/** /**
@ -73,11 +74,11 @@ class GuideViewModel @Inject constructor(
private suspend fun loadNearby(startNarration: Boolean) { private suspend fun loadNearby(startNarration: Boolean) {
val location = selectedLocationHolder.location.value ?: locationProvider.getCurrentLocation() val location = selectedLocationHolder.location.value ?: locationProvider.getCurrentLocation()
if (location == null) { if (location == null) {
_uiState.value = _uiState.value.copy(isLoading = false) _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = "Location unavailable")
return return
} }
val response = runCatching { runCatching {
placesRepository.getNearby( placesRepository.getNearby(
lat = location.lat, lat = location.lat,
lon = location.lon, lon = location.lon,
@ -85,18 +86,31 @@ class GuideViewModel @Inject constructor(
lang = "ru", lang = "ru",
length = contentLength.toApiValue(), length = contentLength.toApiValue(),
) )
}.getOrNull() ?: return }.onSuccess { response ->
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
isLoading = false, isLoading = false,
places = response.places, places = response.places,
activeIndex = 0, activeIndex = 0,
doneIndices = emptySet(), doneIndices = emptySet(),
errorMessage = null,
) )
if (startNarration) { if (startNarration) {
narrateActive() narrateActive()
} }
}.onFailure { error ->
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = error.message ?: "Network error",
)
}
}
/** Retries after a failed load (e.g. tap a "retry" button shown on error). */
fun retry() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
loadNearby(startNarration = true)
}
} }
private fun narrateActive() { private fun narrateActive() {

View file

@ -17,6 +17,7 @@
<string name="map_search_placeholder">Введите место для поиска…</string> <string name="map_search_placeholder">Введите место для поиска…</string>
<string name="guide_no_places_nearby">Поблизости пока не найдено интересных мест.</string> <string name="guide_no_places_nearby">Поблизости пока не найдено интересных мест.</string>
<string name="guide_retry">Повторить</string>
<string name="guide_favorite_add">Добавить в избранное</string> <string name="guide_favorite_add">Добавить в избранное</string>
<string name="guide_favorite_remove">Убрать из избранного</string> <string name="guide_favorite_remove">Убрать из избранного</string>
<string name="guide_mute">Выключить озвучку</string> <string name="guide_mute">Выключить озвучку</string>

View file

@ -17,6 +17,7 @@
<string name="map_search_placeholder">Search for a place instead…</string> <string name="map_search_placeholder">Search for a place instead…</string>
<string name="guide_no_places_nearby">No places found nearby yet.</string> <string name="guide_no_places_nearby">No places found nearby yet.</string>
<string name="guide_retry">Retry</string>
<string name="guide_favorite_add">Add to favorites</string> <string name="guide_favorite_add">Add to favorites</string>
<string name="guide_favorite_remove">Remove from favorites</string> <string name="guide_favorite_remove">Remove from favorites</string>
<string name="guide_mute">Mute narration</string> <string name="guide_mute">Mute narration</string>