No description
Find a file
vrubelroman cd428d0fb7 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>
2026-07-10 07:44:12 +00:00
android Persist listened places, fix restart-on-reopen bug, add stats/reset 2026-07-10 07:44:12 +00:00
backend Persist listened places, fix restart-on-reopen bug, add stats/reset 2026-07-10 07:44:12 +00:00
scripts Add Telegram notification script for sending build artifacts 2026-07-09 20:17:07 +00:00
.env.example Add structured logging throughout the backend 2026-07-09 20:10:10 +00:00
.gitignore Replace 2GIS MapKit with OpenStreetMap (osmdroid) — no key needed 2026-07-09 21:46:47 +00:00
.telegram.env.example Add Telegram notification script for sending build artifacts 2026-07-09 20:17:07 +00:00
docker-compose.yml Add restart: unless-stopped to all docker-compose services 2026-07-09 20:44:36 +00:00
README.md Replace 2GIS MapKit with OpenStreetMap (osmdroid) — no key needed 2026-07-09 21:46:47 +00:00

guideCity

Android walking city-guide app. Detects the user's location, finds nearby landmarks, and narrates historical/architectural information about them by voice while showing a swipeable card stack. Starting city: Moscow.

Architecture

                 ┌────────────────────┐
                 │   Android app      │  Kotlin + Jetpack Compose
                 │  (android/)        │  OSM map, on-device TTS,
                 └─────────┬──────────┘  location, Room favorites
                           │ HTTPS / REST (JSON)
                 ┌─────────▼──────────┐
                 │   FastAPI backend   │  Python, SQLAlchemy, Alembic
                 │   (backend/)        │  Dockerized
                 └─────────┬──────────┘
                           │ SQL
                 ┌─────────▼──────────┐
                 │ PostgreSQL+PostGIS  │  cities / places / place_content
                 └────────────────────┘
  • backend/ — FastAPI service exposing city/place/nearby-search endpoints, backed by PostgreSQL+PostGIS. See backend/ for details.
  • android/ — Kotlin/Compose app skeleton (MVVM, Hilt, Retrofit, Room, DataStore, OpenStreetMap via osmdroid, on-device TextToSpeech).

Backend quickstart

cp .env.example .env
docker compose up -d db
docker compose exec api alembic upgrade head   # (once api image is built: docker compose up -d --build api adminer first)
docker compose exec api python -m seed.seed_loader --file seed/moscow_gorky_park.yaml
docker compose exec api python -m seed.seed_loader --file seed/moscow_red_square.yaml
docker compose exec api python -m seed.seed_loader --file seed/moscow_city.yaml
curl http://localhost:8000/api/v1/health
curl "http://localhost:8000/api/v1/nearby?lat=55.7525&lon=37.6231"

Swagger UI: http://localhost:8000/docs Adminer (DB inspection): http://localhost:8080

The api service binds 0.0.0.0:8000 (see docker-compose.yml), so it's also reachable from other devices on the same LAN at http://<this-machine's-LAN-IP>:8000/ — no extra config needed, just make sure nothing (firewall, VPN) blocks port 8000 on that interface.

It's also reverse-proxied behind nginx at https://guidetest.vrubel.xyz/, which works from anywhere (not just the local network) and is the preferred API_BASE_URL for the Android app during this test phase.

Android quickstart

cp android/local.properties.example android/local.properties
# fill in sdk.dir (and API_BASE_URL if not using the default)
cd android && ./gradlew :app:assembleDebug

Point the app's API base URL (API_BASE_URL in local.properties) at https://guidetest.vrubel.xyz/ (works from anywhere), http://10.0.2.2:8000/ for the emulator, or your machine's LAN IP for a physical device on the same Wi-Fi/LAN.

Run unit tests before building — ./gradlew testDebugUnitTest, then ./gradlew :app:assembleDebug.

Map

Uses OpenStreetMap tiles via osmdroid (map/CityMapView.kt) — free, no API key or account needed, works out of the box. (An earlier iteration tried the native 2GIS MapKit SDK instead; dropped because getting a working mobile-SDK key from 2GIS turned out to be a sales-mediated B2B process, not a quick self-serve signup — see git history if that's ever worth revisiting.)

Notes / current scope

This is iteration 1: Moscow only (Gorky Park, Red Square area, Moscow-City), no user accounts (favorites are local/Room-only), no speed/heading-aware auto content-length selection yet (reserved API params exist, unused). Nearby-search radius caps at 10km.