Compare commits

...

10 commits

Author SHA1 Message Date
vrubelroman
37edc96d50 Replace 2GIS MapKit with OpenStreetMap (osmdroid) — no key needed
Getting a working mobile-SDK key from 2GIS turned out to require a
sales-mediated B2B process (Platform Manager subscription, demo keys
explicitly excluded from mobile SDK use per their own docs), not a
quick self-serve signup. Swapped to osmdroid instead: free, no API
key or account, works immediately.

CityMapView (replacing DgisMapView/DgisSdkProvider) wraps osmdroid's
View-based MapView via AndroidView, forwarding lifecycle events, with
markers for the user's location and each nearby place. Configured
required OSM tile-usage-policy user agent + app-private tile cache in
GuideCityApp. Removed the now-unused DGIS_API_KEY plumbing and the
arm64-only ABI filter (osmdroid has no heavy native libs, so it's not
needed) — APK is back down to ~19.6MB from ~69MB.

Verified: testDebugUnitTest passes, assembleDebug produces a working
APK, sent via the Telegram bot (previously blocked by its 50MB limit
with the 2GIS build).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 21:46:47 +00:00
vrubelroman
7c4d5727e4 Wire up the real 2GIS MapKit SDK (was a placeholder)
Added the actual ru.dgis.sdk:sdk-map + compose-map dependencies (from
https://artifactory.2gis.dev/sdk-maven-release) instead of the Canvas
placeholder guess. Verified the exact API surface by downloading the
AARs directly and inspecting classes with javap (DGis.initialize,
MapOptions, MapComposableState/MapComposable, CameraPosition/GeoPoint)
rather than relying on possibly-stale docs.

DgisSdkProvider lazily calls DGis.initialize() once per process,
catching failure so a missing key doesn't crash the app; DgisMapView
renders the real MapComposable when that succeeds, falling back to
the old placeholder canvas otherwise.

Important finding: the existing DGIS_API_KEY (2GIS's public REST/JS
API key format) does NOT work with this native SDK. It requires a
separate dgissdk.key file issued per-app-package from dev.2gis.com,
placed in app/src/main/assets/ (gitignored). Documented in README.
Without it the app still runs fine on the placeholder map.

Also restricted ndk.abiFilters to arm64-v8a — the SDK's native libs
otherwise balloon the APK from ~19MB to ~190MB.

Verified: testDebugUnitTest passes, assembleDebug produces a working
(arm64-only, ~69MB) APK. Not runtime-verified against a real map key
or device — no emulator/device available in this environment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 21:28:52 +00:00
vrubelroman
7f1a6d51fc Restructure guide flow: list overview, swipe in detail, real map markers
Three UX fixes:

1. The placeholder map was always rendered with userLocation=null and
   places=emptyList() — MapScreen never passed the actually-resolved
   location/nearby list to DgisMapView, so it only ever showed the
   "waiting for location" placeholder text. MapViewModel now exposes
   resolvedLocation and nearbyPlaces, wired through to the map. (Note:
   this is still the Canvas-based placeholder, not the real 2GIS SDK —
   that integration is still pending, as flagged since the skeleton
   was first built.)

2. GuideScreen's Tinder-style pager only showed one card at a time,
   which read as "it only found one place" even with 5 nearby. Swapped
   it for a plain scrollable list (auto-scrolls to the active/narrating
   item); tapping an item opens PlaceDetailScreen, which now owns the
   swipe-between-places interaction instead. PlaceDetailViewModel
   fetches the same nearby-ordered list GuideViewModel uses (falling
   back to a single non-swipeable place when opened from
   Favorites/search for something outside that list).

3. Narration now leads with the place's name and distance from the
   user before the body text (util/Narration.kt), in both the guide
   list and the detail swipe view.

Verified: testDebugUnitTest passes, assembleDebug produces a working
APK, sent to Telegram.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 21:08:19 +00:00
vrubelroman
e220863451 Add map summary screen (place count + Start button) before the guide
After resolving location, the map screen now checks how many places
are nearby (same server-side iterative radius expansion the guide
screen uses) and shows "Found N places within Rm" with a Start button,
instead of jumping straight into narration. The search cap is raised
to 10km (was 5km) — if nothing turns up within that, a "nothing found"
message is shown instead.

This also addresses a UX report: the guide screen's Tinder-style card
stack only shows one card at a time by design, which read as "it only
found one place" even when 5 were actually there. Added a "1 / 5"
position indicator to the card stack so it's clear there's more to
swipe through, and the new summary screen surfaces the real count
upfront regardless.

Verified: ./gradlew testDebugUnitTest passes, :app:assembleDebug
produces a working APK, sent to Telegram.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 20:58:17 +00:00
vrubelroman
531fab801d Add restart: unless-stopped to all docker-compose services
Root cause of the 502: the host's disk filled up (down to ~511MB
free), which appears to have restarted the Docker daemon. Every other
service on this shared host came back up automatically (they all have
restart policies); guideCity's three containers didn't and stayed
down. Freed ~3.2GB via `docker builder prune` + `docker image prune`
(safe: build cache and dangling images only, no running containers
touched) and brought the stack back up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 20:44:36 +00:00
vrubelroman
2901950520 Add 5 real places near Solntsevo (Domostroitelnaya 14) for live testing
Metro Solntsevo, the district's central park/pond (Bogdanova St),
Meshchersky forest park, the Levenson dacha (a Shekhtel-designed 1900
Art Nouveau landmark), and the Peredelkino writers' village — all
researched with real coordinates/history, in RU/EN x short/medium/long.

Verified against the live nearby endpoint: all 5 are found from the
user's actual coordinates (55.662303, 37.425486), radius expanding to
5100m to cover the farthest (Peredelkino village, ~5012m out).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 20:26:00 +00:00
vrubelroman
bb80307479 Add Telegram notification script for sending build artifacts
scripts/notify_telegram.sh sends a file (e.g. the debug APK) to a
Telegram chat via Bot API sendDocument. Reads TELEGRAM_BOT_TOKEN and
TELEGRAM_CHAT_ID from .telegram.env (gitignored, real credentials
never committed) or the environment. Verified end-to-end: sent the
current debug APK and confirmed delivery.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 20:17:07 +00:00
vrubelroman
0cc8d9b767 Point mobile app at nginx-fronted https://guidetest.vrubel.xyz/
Verified reachable end-to-end (health/nearby endpoints respond
correctly through the reverse proxy) and set as the preferred
API_BASE_URL for testing from a physical device on any network,
ahead of the LAN-IP and emulator-loopback fallbacks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 20:10:40 +00:00
vrubelroman
6e90956b3e Add logging throughout the Android app and unit tests
Log.d/i/w/e calls covering location resolution, TTS lifecycle and
errors, nearby/search network calls and their failure paths, and
screen-level state transitions. OkHttp's logging interceptor now
routes through Log (tag "OkHttp") instead of println for consistent
filtering. Enabled testOptions.unitTests.isReturnDefaultValues so
android.util.Log calls don't crash plain JVM unit tests.

Added mockk + kotlinx-coroutines-test and a GuideViewModelTest suite
covering the load-success, load-failure, and retry paths — including
a regression test for the infinite-spinner bug (isLoading must clear
and errorMessage must be set on a failed nearby-search call, not left
hanging). Verified: ./gradlew testDebugUnitTest passes (4/4).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 20:10:31 +00:00
vrubelroman
be67902b45 Add structured logging throughout the backend
Request-level logging middleware (method/path/status/duration),
per-module loggers in routes/services (nearby search radius
expansion, 404s, name search), LOG_LEVEL setting wired through
docker-compose, and seed_loader converted from print() to logging.

Verified: pytest passes, and manually confirmed log lines appear for
both successful and 404 requests via `docker compose logs api`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 20:10:10 +00:00
44 changed files with 1162 additions and 272 deletions

View file

@ -1,3 +1,4 @@
POSTGRES_USER=guidecity POSTGRES_USER=guidecity
POSTGRES_PASSWORD=changeme POSTGRES_PASSWORD=changeme
POSTGRES_DB=guidecity POSTGRES_DB=guidecity
LOG_LEVEL=INFO

9
.telegram.env.example Normal file
View file

@ -0,0 +1,9 @@
# Copy to .telegram.env (gitignored) and fill in real values.
# Used by scripts/notify_telegram.sh to send build artifacts (e.g. the APK)
# to a Telegram chat after a successful build.
TELEGRAM_BOT_TOKEN=123456:ABC-DEF...
# Numeric chat id of the person/group the bot should message. A bot can't
# message you first — send it any message (e.g. /start) once, then run
# `curl "https://api.telegram.org/bot<token>/getUpdates"` to find your chat id.
TELEGRAM_CHAT_ID=123456789

View file

@ -9,7 +9,7 @@ voice while showing a swipeable card stack. Starting city: Moscow.
``` ```
┌────────────────────┐ ┌────────────────────┐
│ Android app │ Kotlin + Jetpack Compose │ Android app │ Kotlin + Jetpack Compose
│ (android/) │ 2GIS map, on-device TTS, │ (android/) │ OSM map, on-device TTS,
└─────────┬──────────┘ location, Room favorites └─────────┬──────────┘ location, Room favorites
│ HTTPS / REST (JSON) │ HTTPS / REST (JSON)
┌─────────▼──────────┐ ┌─────────▼──────────┐
@ -25,7 +25,7 @@ voice while showing a swipeable card stack. Starting city: Moscow.
- `backend/` — FastAPI service exposing city/place/nearby-search endpoints, - `backend/` — FastAPI service exposing city/place/nearby-search endpoints,
backed by PostgreSQL+PostGIS. See `backend/` for details. backed by PostgreSQL+PostGIS. See `backend/` for details.
- `android/` — Kotlin/Compose app skeleton (MVVM, Hilt, Retrofit, Room, - `android/` — Kotlin/Compose app skeleton (MVVM, Hilt, Retrofit, Room,
DataStore, 2GIS MapKit, on-device TextToSpeech). DataStore, OpenStreetMap via osmdroid, on-device TextToSpeech).
## Backend quickstart ## Backend quickstart
@ -48,24 +48,38 @@ also reachable from other devices on the same LAN at
`http://<this-machine's-LAN-IP>:8000/` — no extra config needed, just make `http://<this-machine's-LAN-IP>:8000/` — no extra config needed, just make
sure nothing (firewall, VPN) blocks port 8000 on that interface. 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 ## Android quickstart
```bash ```bash
cp android/local.properties.example android/local.properties cp android/local.properties.example android/local.properties
# fill in sdk.dir and DGIS_API_KEY (test key: b4df01a8-61db-4cb9-8286-7e069495987d) # fill in sdk.dir (and API_BASE_URL if not using the default)
cd android && ./gradlew :app:assembleDebug cd android && ./gradlew :app:assembleDebug
``` ```
Point the app's API base URL (`API_BASE_URL` in `local.properties`) at Point the app's API base URL (`API_BASE_URL` in `local.properties`) at
`http://10.0.2.2:8000/` for the emulator, or your machine's LAN IP `https://guidetest.vrubel.xyz/` (works from anywhere), `http://10.0.2.2:8000/`
(e.g. `http://192.168.8.173:8000/`) for a physical device on the same for the emulator, or your machine's LAN IP for a physical device on the same
Wi-Fi/LAN. Wi-Fi/LAN.
Run unit tests before building — `./gradlew testDebugUnitTest`, then
`./gradlew :app:assembleDebug`.
## Map
Uses OpenStreetMap tiles via [osmdroid](https://github.com/osmdroid/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 ## Notes / current scope
This is iteration 1: Moscow only (Gorky Park, Red Square area, Moscow-City), 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 no user accounts (favorites are local/Room-only), no speed/heading-aware
auto content-length selection yet (reserved API params exist, unused). auto content-length selection yet (reserved API params exist, unused).
Nearby-search radius caps at 10km.
The 2GIS API key above is used directly via `local.properties`/`BuildConfig`
for development convenience — don't ship it as-is in a public repo.

View file

@ -32,11 +32,6 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField(
"String",
"DGIS_API_KEY",
"\"${localProp("DGIS_API_KEY", "")}\"",
)
buildConfigField( buildConfigField(
"String", "String",
"API_BASE_URL", "API_BASE_URL",
@ -67,6 +62,14 @@ android {
compose = true compose = true
buildConfig = true buildConfig = true
} }
testOptions {
unitTests {
// Production code calls android.util.Log for observability; without this,
// the Android stub jar throws on every such call in plain JVM unit tests.
isReturnDefaultValues = true
}
}
} }
dependencies { dependencies {
@ -102,11 +105,12 @@ dependencies {
implementation(libs.play.services.location) implementation(libs.play.services.location)
implementation(libs.kotlinx.coroutines.play.services) implementation(libs.kotlinx.coroutines.play.services)
// 2GIS MapKit SDK: add once the exact Maven coordinates/repository are // OpenStreetMap tiles via osmdroid — free, no API key/registration needed.
// confirmed from https://docs.2gis.com/ — see map/DgisMapView.kt for the implementation(libs.osmdroid.android)
// isolated integration point in the meantime.
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))

View file

@ -1,7 +1,32 @@
package com.guidecity.app package com.guidecity.app
import android.app.Application import android.app.Application
import android.util.Log
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import org.osmdroid.config.Configuration
import java.io.File
private const val TAG = "GuideCityApp"
@HiltAndroidApp @HiltAndroidApp
class GuideCityApp : Application() class GuideCityApp : Application() {
override fun onCreate() {
super.onCreate()
Log.i(TAG, "Application created (debug=${BuildConfig.DEBUG}, apiBaseUrl=${BuildConfig.API_BASE_URL})")
configureOsmdroid()
}
/**
* OpenStreetMap's tile usage policy requires a distinct user agent per
* app. Cache paths point at app-private storage so no storage
* permission is needed.
*/
private fun configureOsmdroid() {
val osmdroidDir = File(cacheDir, "osmdroid")
Configuration.getInstance().apply {
userAgentValue = BuildConfig.APPLICATION_ID
osmdroidBasePath = osmdroidDir
osmdroidTileCache = File(osmdroidDir, "tiles")
}
}
}

View file

@ -1,6 +1,7 @@
package com.guidecity.app package com.guidecity.app
import android.os.Bundle import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
@ -8,10 +9,13 @@ import com.guidecity.app.navigation.GuideCityNavHost
import com.guidecity.app.theme.GuideCityTheme import com.guidecity.app.theme.GuideCityTheme
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
private const val TAG = "MainActivity"
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Log.d(TAG, "onCreate")
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
GuideCityTheme { GuideCityTheme {

View file

@ -1,5 +1,6 @@
package com.guidecity.app.data.remote package com.guidecity.app.data.remote
import android.util.Log
import com.guidecity.app.BuildConfig import com.guidecity.app.BuildConfig
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
@ -8,6 +9,8 @@ import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory import retrofit2.converter.kotlinx.serialization.asConverterFactory
private const val TAG = "OkHttp"
object RetrofitClient { object RetrofitClient {
private val json = Json { private val json = Json {
@ -18,7 +21,7 @@ object RetrofitClient {
private val okHttpClient: OkHttpClient by lazy { private val okHttpClient: OkHttpClient by lazy {
OkHttpClient.Builder() OkHttpClient.Builder()
.addInterceptor( .addInterceptor(
HttpLoggingInterceptor().apply { HttpLoggingInterceptor { message -> Log.d(TAG, message) }.apply {
level = if (BuildConfig.DEBUG) { level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY HttpLoggingInterceptor.Level.BODY
} else { } else {
@ -30,6 +33,7 @@ object RetrofitClient {
} }
val apiService: ApiService by lazy { val apiService: ApiService by lazy {
Log.i(TAG, "creating Retrofit client, baseUrl=${BuildConfig.API_BASE_URL}")
Retrofit.Builder() Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL) .baseUrl(BuildConfig.API_BASE_URL)
.client(okHttpClient) .client(okHttpClient)

View file

@ -34,6 +34,7 @@ class PlacesRepository @Inject constructor(
lang: String, lang: String,
length: String, length: String,
minResults: Int = 5, minResults: Int = 5,
maxRadiusM: Int = 10_000,
): NearbyResponseDto = apiService.getNearby( ): NearbyResponseDto = apiService.getNearby(
lat = lat, lat = lat,
lon = lon, lon = lon,
@ -41,5 +42,6 @@ class PlacesRepository @Inject constructor(
lang = lang, lang = lang,
length = length, length = length,
minResults = minResults, minResults = minResults,
maxRadiusM = maxRadiusM,
) )
} }

View file

@ -4,6 +4,7 @@ import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationServices import com.google.android.gms.location.LocationServices
@ -13,6 +14,8 @@ import kotlinx.coroutines.tasks.await
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
private const val TAG = "LocationProvider"
data class LatLon(val lat: Double, val lon: Double) data class LatLon(val lat: Double, val lon: Double)
@Singleton @Singleton
@ -26,18 +29,34 @@ class LocationProvider @Inject constructor(
fun hasLocationPermission(): Boolean { fun hasLocationPermission(): Boolean {
val fine = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) val fine = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
val coarse = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) val coarse = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
return fine == PackageManager.PERMISSION_GRANTED || coarse == PackageManager.PERMISSION_GRANTED val granted = fine == PackageManager.PERMISSION_GRANTED || coarse == PackageManager.PERMISSION_GRANTED
Log.d(TAG, "hasLocationPermission: fine=$fine coarse=$coarse -> $granted")
return granted
} }
/** Returns null if permission isn't granted or no location could be resolved. */ /** Returns null if permission isn't granted or no location could be resolved. */
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
suspend fun getCurrentLocation(): LatLon? { suspend fun getCurrentLocation(): LatLon? {
if (!hasLocationPermission()) return null if (!hasLocationPermission()) {
Log.w(TAG, "getCurrentLocation: permission not granted, returning null")
return null
}
return try {
val current = fusedClient val current = fusedClient
.getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, null) .getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, null)
.await() .await()
val location = current ?: fusedClient.lastLocation.await() val location = current ?: fusedClient.lastLocation.await()
return location?.let { LatLon(it.latitude, it.longitude) } if (location == null) {
Log.w(TAG, "getCurrentLocation: no location available from fused client (current or last)")
null
} else {
Log.i(TAG, "getCurrentLocation: resolved lat=${location.latitude} lon=${location.longitude}")
LatLon(location.latitude, location.longitude)
}
} catch (e: Exception) {
Log.e(TAG, "getCurrentLocation failed", e)
null
}
} }
} }

View file

@ -0,0 +1,113 @@
package com.guidecity.app.map
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import com.guidecity.app.data.remote.dto.NearbyPlaceDto
import com.guidecity.app.location.LatLon
import com.guidecity.app.theme.Mocha
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker
/**
* OpenStreetMap tiles via osmdroid free, no API key or registration
* needed (unlike the 2GIS native MapKit SDK, which requires a paid/sales
* process for a mobile SDK key; see git history if that's ever revisited).
*
* Wraps osmdroid's View-based [MapView] via [AndroidView] since it has no
* native Compose API, forwarding lifecycle events so tile loading/caching
* behaves correctly.
*/
@Composable
fun CityMapView(
userLocation: LatLon?,
places: List<NearbyPlaceDto>,
onPlaceClick: (placeId: Int) -> Unit,
modifier: Modifier = Modifier,
) {
if (userLocation == null) {
Box(
modifier = modifier
.fillMaxSize()
.background(Mocha.Mantle),
contentAlignment = Alignment.Center,
) {
Text(
text = "Waiting for location…",
color = Mocha.Subtext1,
style = MaterialTheme.typography.bodyLarge,
)
}
return
}
val lifecycleOwner = LocalLifecycleOwner.current
var mapViewRef by remember { mutableStateOf<MapView?>(null) }
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> mapViewRef?.onResume()
Lifecycle.Event.ON_PAUSE -> mapViewRef?.onPause()
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
mapViewRef?.onDetach()
}
}
AndroidView(
modifier = modifier.fillMaxSize(),
factory = { context ->
MapView(context).apply {
setMultiTouchControls(true)
controller.setZoom(15.0)
mapViewRef = this
}
},
update = { mapView ->
mapView.controller.setCenter(GeoPoint(userLocation.lat, userLocation.lon))
mapView.overlays.clear()
mapView.overlays.add(
Marker(mapView).apply {
position = GeoPoint(userLocation.lat, userLocation.lon)
title = "Вы здесь"
},
)
places.forEach { place ->
mapView.overlays.add(
Marker(mapView).apply {
position = GeoPoint(place.location.lat, place.location.lon)
title = place.content.title
setOnMarkerClickListener { _, _ ->
onPlaceClick(place.id)
true
}
},
)
}
mapView.invalidate()
},
)
}

View file

@ -1,87 +0,0 @@
package com.guidecity.app.map
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
import com.guidecity.app.data.remote.dto.NearbyPlaceDto
import com.guidecity.app.location.LatLon
import com.guidecity.app.theme.Mocha
import kotlin.math.cos
/**
* Isolated integration point for the 2GIS MapKit SDK.
*
* This currently renders a lightweight placeholder (user location centered,
* nearby places plotted by relative lat/lon offset) so the rest of the app
* permission flow, nearby fetch, navigation to place detail is fully
* functional and demoable before the real SDK is wired in.
*
* To integrate the real map: replace the Canvas placeholder below with the
* 2GIS MapKit view (see android/build.gradle.kts and settings.gradle.kts for
* the pending dependency/repository TODOs the exact Maven coordinates need
* confirming from https://docs.2gis.com/), keeping this function's signature
* so callers (MapScreen, GuideScreen) don't need to change.
*/
@Composable
fun DgisMapView(
userLocation: LatLon?,
places: List<NearbyPlaceDto>,
onPlaceClick: (placeId: Int) -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxSize()
.background(Mocha.Mantle),
contentAlignment = Alignment.Center,
) {
if (userLocation == null) {
Text(
text = "Map placeholder — waiting for location",
color = Mocha.Subtext1,
style = MaterialTheme.typography.bodyLarge,
)
return@Box
}
Canvas(modifier = Modifier.fillMaxSize()) {
val centerX = size.width / 2f
val centerY = size.height / 2f
val metersPerDegreeLat = 111_320.0
val metersPerDegreeLon = 111_320.0 * cos(Math.toRadians(userLocation.lat))
val pixelsPerMeter = 0.6f
fun offsetFor(lat: Double, lon: Double): Offset {
val dyMeters = (lat - userLocation.lat) * metersPerDegreeLat
val dxMeters = (lon - userLocation.lon) * metersPerDegreeLon
return Offset(
x = centerX + (dxMeters * pixelsPerMeter).toFloat(),
y = centerY - (dyMeters * pixelsPerMeter).toFloat(),
)
}
// User location marker.
drawCircle(color = Mocha.Blue, radius = 14f, center = Offset(centerX, centerY))
drawCircle(
color = Mocha.Blue,
radius = 22f,
center = Offset(centerX, centerY),
style = Stroke(width = 3f),
)
places.forEach { place ->
val point = offsetFor(place.location.lat, place.location.lon)
drawCircle(color = Mocha.Peach, radius = 10f, center = point)
}
}
}
}

View file

@ -3,12 +3,15 @@ package com.guidecity.app.tts
import android.content.Context 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 dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.Locale import java.util.Locale
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
private const val TAG = "TtsManager"
/** /**
* Wraps Android's built-in, on-device [TextToSpeech] engine this satisfies * Wraps Android's built-in, on-device [TextToSpeech] engine this satisfies
* the app's "local voice narration" requirement without a custom ML model. * the app's "local voice narration" requirement without a custom ML model.
@ -32,29 +35,44 @@ class TtsManager @Inject constructor(
private val tts: TextToSpeech = TextToSpeech(context) { status -> private val tts: TextToSpeech = TextToSpeech(context) { status ->
isReady = status == TextToSpeech.SUCCESS isReady = status == TextToSpeech.SUCCESS
pendingUtterance?.let { (text, onDone) -> speakInternal(text, onDone) } if (!isReady) {
Log.e(TAG, "TextToSpeech engine init failed, status=$status")
} else {
Log.i(TAG, "TextToSpeech engine ready")
}
pendingUtterance?.let { (text, onDone) ->
Log.d(TAG, "flushing pending utterance queued before engine was ready")
speakInternal(text, onDone)
}
pendingUtterance = null pendingUtterance = null
}.apply { }.apply {
setOnUtteranceProgressListener( setOnUtteranceProgressListener(
object : UtteranceProgressListener() { object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) = Unit override fun onStart(utteranceId: String?) {
Log.d(TAG, "utterance started: $utteranceId")
}
override fun onDone(utteranceId: String?) { override fun onDone(utteranceId: String?) {
Log.d(TAG, "utterance done: $utteranceId")
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?) = Unit override fun onError(utteranceId: String?) {
Log.e(TAG, "utterance error: $utteranceId")
}
}, },
) )
} }
fun setLanguage(locale: Locale) { fun setLanguage(locale: Locale) {
tts.language = locale val result = tts.setLanguage(locale)
Log.d(TAG, "setLanguage($locale) -> $result")
} }
/** Speaks [text], calling [onDone] on the main thread once narration finishes. */ /** Speaks [text], calling [onDone] on the main thread once narration finishes. */
fun speak(text: String, onDone: (() -> Unit)? = null) { fun speak(text: String, onDone: (() -> Unit)? = null) {
Log.d(TAG, "speak() called, muted=$isMuted ready=$isReady length=${text.length}")
lastSpokenText = text lastSpokenText = text
onDoneCallback = onDone onDoneCallback = onDone
if (isMuted) return if (isMuted) return
@ -67,26 +85,33 @@ class TtsManager @Inject constructor(
private fun speakInternal(text: String, onDone: (() -> Unit)?) { private fun speakInternal(text: String, onDone: (() -> Unit)?) {
onDoneCallback = onDone onDoneCallback = onDone
tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, UUID.randomUUID().toString()) val result = tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, UUID.randomUUID().toString())
if (result != TextToSpeech.SUCCESS) {
Log.e(TAG, "tts.speak() returned error code $result")
}
} }
/** Stops narration and suppresses further [speak] calls until [unmute]. */ /** Stops narration and suppresses further [speak] calls until [unmute]. */
fun mute() { fun mute() {
Log.d(TAG, "mute()")
isMuted = true isMuted = true
tts.stop() tts.stop()
} }
/** Resumes narration from the start of the last spoken text. */ /** Resumes narration from the start of the last spoken text. */
fun unmute() { fun unmute() {
Log.d(TAG, "unmute()")
isMuted = false isMuted = false
lastSpokenText?.let { speakInternal(it, onDoneCallback) } lastSpokenText?.let { speakInternal(it, onDoneCallback) }
} }
fun stop() { fun stop() {
Log.d(TAG, "stop()")
tts.stop() tts.stop()
} }
fun shutdown() { fun shutdown() {
Log.d(TAG, "shutdown()")
tts.shutdown() tts.shutdown()
} }
} }

View file

@ -4,6 +4,8 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -20,8 +22,10 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.snapshotFlow
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
@ -29,6 +33,7 @@ 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
import com.guidecity.app.theme.GuideStackColors import com.guidecity.app.theme.GuideStackColors
import kotlinx.coroutines.flow.distinctUntilChanged
@Composable @Composable
fun PlaceDetailScreen( fun PlaceDetailScreen(
@ -38,11 +43,12 @@ fun PlaceDetailScreen(
) { ) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val isFavorite by viewModel.isFavorite.collectAsState() val isFavorite by viewModel.isFavorite.collectAsState()
val activePlace = state.places.getOrNull(state.activeIndex)
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(state.place?.content?.title.orEmpty()) }, title = { Text(activePlace?.content?.title.orEmpty()) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = null) Icon(Icons.Filled.ArrowBack, contentDescription = null)
@ -79,7 +85,6 @@ fun PlaceDetailScreen(
.fillMaxSize() .fillMaxSize()
.padding(padding), .padding(padding),
) { ) {
val place = state.place
when { when {
state.isLoading -> { state.isLoading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@ -87,13 +92,35 @@ fun PlaceDetailScreen(
} }
} }
place == null -> { state.errorMessage != null || state.places.isEmpty() -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = stringResource(R.string.place_not_found)) Text(text = stringResource(R.string.place_not_found))
} }
} }
else -> { else -> {
// Swiping here moves between nearby places, one full-content
// page at a time; the overview list (GuideScreen) only
// scrolls, tapping into an item is what brings you here.
val pagerState = rememberPagerState(initialPage = state.activeIndex) { state.places.size }
LaunchedEffect(state.activeIndex) {
if (pagerState.currentPage != state.activeIndex) {
pagerState.animateScrollToPage(state.activeIndex)
}
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }
.distinctUntilChanged()
.collect { page -> viewModel.setActiveIndex(page) }
}
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
) { page ->
val place = state.places[page]
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -106,4 +133,5 @@ fun PlaceDetailScreen(
} }
} }
} }
}
} }

View file

@ -1,38 +1,48 @@
package com.guidecity.app.ui.detail package com.guidecity.app.ui.detail
import android.util.Log
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.guidecity.app.data.local.db.FavoritePlaceEntity import com.guidecity.app.data.local.db.FavoritePlaceEntity
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.remote.dto.PlaceDetailDto import com.guidecity.app.data.remote.dto.NearbyPlaceDto
import com.guidecity.app.data.repository.FavoritesRepository import com.guidecity.app.data.repository.FavoritesRepository
import com.guidecity.app.data.repository.PlacesRepository import com.guidecity.app.data.repository.PlacesRepository
import com.guidecity.app.location.LocationProvider
import com.guidecity.app.location.SelectedLocationHolder
import com.guidecity.app.tts.TtsManager import com.guidecity.app.tts.TtsManager
import com.guidecity.app.util.buildNarrationText
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
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 = "PlaceDetailViewModel"
/** Sentinel meaning "no real distance known" — the single-place fallback path uses this. */
private const val UNKNOWN_DISTANCE_M = -1.0
data class PlaceDetailUiState( data class PlaceDetailUiState(
val isLoading: Boolean = true, val isLoading: Boolean = true,
val place: PlaceDetailDto? = null, val places: List<NearbyPlaceDto> = emptyList(),
val activeIndex: Int = 0,
val isMuted: Boolean = false, val isMuted: Boolean = false,
val errorMessage: String? = null,
) )
/** /**
* Self-contained per-place narrator: works whether reached from the guide * Shows one place's full content with narration, and when the requested
* card stack, favorites, or search. On narration completion the user goes * place is part of the user's current nearby list lets them swipe to the
* back manually auto-advancing straight to the *next* nearby place's * other nearby places too, reusing the same ordering, active/narration
* detail (as in the original spec) would need this screen to share * progression, and 10km search cap as the guide list screen. When reached
* GuideViewModel's ordered list, which only exists when arriving from the * from Favorites/search for a place that isn't in that list, it falls back
* guide screen; left as a follow-up once that shared-state wiring is added. * to a single, non-swipeable place fetched directly by id.
*/ */
@HiltViewModel @HiltViewModel
class PlaceDetailViewModel @Inject constructor( class PlaceDetailViewModel @Inject constructor(
@ -40,40 +50,134 @@ class PlaceDetailViewModel @Inject constructor(
private val placesRepository: PlacesRepository, private val placesRepository: PlacesRepository,
private val favoritesRepository: FavoritesRepository, private val favoritesRepository: FavoritesRepository,
private val userPrefsDataStore: UserPrefsDataStore, private val userPrefsDataStore: UserPrefsDataStore,
private val selectedLocationHolder: SelectedLocationHolder,
private val locationProvider: LocationProvider,
private val ttsManager: TtsManager, private val ttsManager: TtsManager,
) : ViewModel() { ) : ViewModel() {
private val placeId: Int = checkNotNull(savedStateHandle.get<String>("placeId")).toInt() private val initialPlaceId: Int = checkNotNull(savedStateHandle.get<String>("placeId")).toInt()
private val _uiState = MutableStateFlow(PlaceDetailUiState()) private val _uiState = MutableStateFlow(PlaceDetailUiState())
val uiState: StateFlow<PlaceDetailUiState> = _uiState.asStateFlow() val uiState: StateFlow<PlaceDetailUiState> = _uiState.asStateFlow()
val isFavorite: StateFlow<Boolean> = favoritesRepository.isFavorite(placeId) private val _isFavorite = MutableStateFlow(false)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) val isFavorite: StateFlow<Boolean> = _isFavorite.asStateFlow()
private var favoriteObserveJob: Job? = null
init { init {
Log.d(TAG, "init: placeId=$initialPlaceId")
viewModelScope.launch { viewModelScope.launch {
val length = (userPrefsDataStore.contentLength.first() ?: ContentLength.MEDIUM).toApiValue() val length = (userPrefsDataStore.contentLength.first() ?: ContentLength.MEDIUM).toApiValue()
val place = runCatching { placesRepository.getPlace(placeId, lang = "ru", length = length) }.getOrNull() val location = selectedLocationHolder.location.value ?: locationProvider.getCurrentLocation()
_uiState.value = _uiState.value.copy(isLoading = false, place = place)
place?.let { ttsManager.speak(it.content.body) } val nearby = location?.let { loc ->
runCatching {
placesRepository.getNearby(
lat = loc.lat,
lon = loc.lon,
citySlug = "moscow",
lang = "ru",
length = length,
minResults = 5,
maxRadiusM = 10_000,
)
}.onFailure { Log.e(TAG, "getNearby failed", it) }.getOrNull()
} }
val indexInNearby = nearby?.places?.indexOfFirst { it.id == initialPlaceId } ?: -1
if (nearby != null && indexInNearby >= 0) {
Log.i(TAG, "opened as part of nearby list, index=$indexInNearby of ${nearby.places.size}")
_uiState.value = _uiState.value.copy(
isLoading = false,
places = nearby.places,
activeIndex = indexInNearby,
)
observeFavorite(initialPlaceId)
narrateActive()
} else {
loadSinglePlaceFallback(length)
}
}
}
private fun loadSinglePlaceFallback(length: String) {
viewModelScope.launch {
val place = runCatching { placesRepository.getPlace(initialPlaceId, lang = "ru", length = length) }
.onFailure { Log.e(TAG, "getPlace($initialPlaceId) failed", it) }
.getOrNull()
if (place == null) {
_uiState.value = _uiState.value.copy(isLoading = false, errorMessage = "not_found")
return@launch
}
val asSingleEntry = NearbyPlaceDto(
id = place.id,
slug = place.slug,
category = place.category,
location = place.location,
address = place.address,
district = place.district,
builtYear = place.builtYear,
architectBuilder = place.architectBuilder,
architecturalStyle = place.architecturalStyle,
content = place.content,
distanceM = UNKNOWN_DISTANCE_M,
)
Log.i(TAG, "loaded as standalone fallback (not in current nearby list): ${place.slug}")
_uiState.value = _uiState.value.copy(isLoading = false, places = listOf(asSingleEntry), activeIndex = 0)
observeFavorite(place.id)
narrateActive()
}
}
private fun observeFavorite(placeId: Int) {
favoriteObserveJob?.cancel()
favoriteObserveJob = viewModelScope.launch {
favoritesRepository.isFavorite(placeId).collect { _isFavorite.value = it }
}
}
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() }
}
private fun onNarrationDone() {
val state = _uiState.value
val nextIndex = state.activeIndex + 1
if (nextIndex >= state.places.size) return
setActiveIndex(nextIndex)
}
/** Called both on TTS auto-advance and on manual swipe. */
fun setActiveIndex(index: Int) {
val state = _uiState.value
if (index !in state.places.indices || index == state.activeIndex) return
Log.d(TAG, "setActiveIndex: ${state.activeIndex} -> $index")
_uiState.value = state.copy(activeIndex = index)
observeFavorite(state.places[index].id)
narrateActive()
} }
fun toggleMute() { fun toggleMute() {
if (ttsManager.isMuted) ttsManager.unmute() else ttsManager.mute() if (ttsManager.isMuted) ttsManager.unmute() else ttsManager.mute()
Log.d(TAG, "toggleMute: isMuted=${ttsManager.isMuted}")
_uiState.value = _uiState.value.copy(isMuted = ttsManager.isMuted) _uiState.value = _uiState.value.copy(isMuted = ttsManager.isMuted)
} }
fun toggleFavorite() { fun toggleFavorite() {
val place = _uiState.value.place ?: return val state = _uiState.value
val place = state.places.getOrNull(state.activeIndex) ?: return
Log.d(TAG, "toggleFavorite: placeId=${place.id} currentlyFavorite=${isFavorite.value}")
viewModelScope.launch { viewModelScope.launch {
if (isFavorite.value) { if (isFavorite.value) {
favoritesRepository.removeFavorite(placeId) favoritesRepository.removeFavorite(place.id)
} else { } else {
favoritesRepository.addFavorite( favoritesRepository.addFavorite(
FavoritePlaceEntity( FavoritePlaceEntity(
placeId = placeId, placeId = place.id,
slug = place.slug, slug = place.slug,
name = place.content.title, name = place.content.title,
category = place.category, category = place.category,

View file

@ -1,5 +1,6 @@
package com.guidecity.app.ui.favorites package com.guidecity.app.ui.favorites
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.guidecity.app.data.local.db.FavoritePlaceEntity import com.guidecity.app.data.local.db.FavoritePlaceEntity
@ -7,13 +8,17 @@ import com.guidecity.app.data.repository.FavoritesRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject import javax.inject.Inject
private const val TAG = "FavoritesViewModel"
@HiltViewModel @HiltViewModel
class FavoritesViewModel @Inject constructor( class FavoritesViewModel @Inject constructor(
favoritesRepository: FavoritesRepository, favoritesRepository: FavoritesRepository,
) : ViewModel() { ) : ViewModel() {
val favorites: StateFlow<List<FavoritePlaceEntity>> = favoritesRepository.observeFavorites() val favorites: StateFlow<List<FavoritePlaceEntity>> = favoritesRepository.observeFavorites()
.onEach { Log.d(TAG, "favorites updated: ${it.size} item(s)") }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
} }

View file

@ -1,12 +1,14 @@
package com.guidecity.app.ui.guide package com.guidecity.app.ui.guide
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement 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.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.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
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
@ -16,10 +18,12 @@ 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
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -28,6 +32,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp 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
import com.guidecity.app.theme.Mocha
@Composable @Composable
fun GuideScreen( fun GuideScreen(
@ -37,6 +42,11 @@ fun GuideScreen(
viewModel: GuideViewModel = hiltViewModel(), viewModel: GuideViewModel = hiltViewModel(),
) { ) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val listState = rememberLazyListState()
LaunchedEffect(state.activeIndex) {
listState.animateScrollToItem(state.activeIndex)
}
Scaffold( Scaffold(
topBar = { topBar = {
@ -76,7 +86,6 @@ fun GuideScreen(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text(text = state.errorMessage.orEmpty()) Text(text = state.errorMessage.orEmpty())
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.retry() }) { Button(onClick = { viewModel.retry() }) {
Text(text = stringResource(R.string.guide_retry)) Text(text = stringResource(R.string.guide_retry))
} }
@ -96,16 +105,32 @@ fun GuideScreen(
} }
else -> { else -> {
PlaceCardStack( Column(modifier = Modifier.fillMaxSize()) {
places = state.places, Text(
activeIndex = state.activeIndex, text = stringResource(R.string.guide_places_count, state.places.size),
doneIndices = state.doneIndices, style = MaterialTheme.typography.labelLarge,
onCardClick = onOpenDetail, color = Mocha.Subtext1,
onPageChanged = { viewModel.setActiveIndex(it) }, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
modifier = Modifier.fillMaxSize(), )
LazyColumn(state = listState) {
itemsIndexed(state.places, key = { _, place -> place.id }) { index, place ->
val cardState = when {
index == state.activeIndex -> CardState.ACTIVE
index in state.doneIndices -> CardState.DONE
else -> CardState.UPCOMING
}
PlaceCard(
place = place,
state = cardState,
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 6.dp)
.clickable { onOpenDetail(place.id) },
) )
} }
} }
} }
} }
}
}
}
} }

View file

@ -1,5 +1,6 @@
package com.guidecity.app.ui.guide package com.guidecity.app.ui.guide
import android.util.Log
import androidx.lifecycle.ViewModel 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
@ -9,6 +10,7 @@ import com.guidecity.app.data.repository.PlacesRepository
import com.guidecity.app.location.LocationProvider import com.guidecity.app.location.LocationProvider
import com.guidecity.app.location.SelectedLocationHolder import com.guidecity.app.location.SelectedLocationHolder
import com.guidecity.app.tts.TtsManager import com.guidecity.app.tts.TtsManager
import com.guidecity.app.util.buildNarrationText
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -20,6 +22,8 @@ import kotlinx.coroutines.launch
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
private const val TAG = "GuideViewModel"
data class GuideUiState( data class GuideUiState(
val isLoading: Boolean = true, val isLoading: Boolean = true,
val places: List<NearbyPlaceDto> = emptyList(), val places: List<NearbyPlaceDto> = emptyList(),
@ -54,6 +58,7 @@ class GuideViewModel @Inject constructor(
init { init {
viewModelScope.launch { viewModelScope.launch {
contentLength = userPrefsDataStore.contentLength.first() ?: ContentLength.MEDIUM contentLength = userPrefsDataStore.contentLength.first() ?: ContentLength.MEDIUM
Log.i(TAG, "init: contentLength=$contentLength")
ttsManager.setLanguage(Locale("ru")) ttsManager.setLanguage(Locale("ru"))
loadNearby(startNarration = true) loadNearby(startNarration = true)
startRescanLoop() startRescanLoop()
@ -63,9 +68,11 @@ class GuideViewModel @Inject constructor(
private fun startRescanLoop() { private fun startRescanLoop() {
if (rescanStarted) return if (rescanStarted) return
rescanStarted = true rescanStarted = true
Log.d(TAG, "starting 60s re-scan loop")
viewModelScope.launch { viewModelScope.launch {
while (isActive) { while (isActive) {
delay(60_000) delay(60_000)
Log.d(TAG, "re-scan tick")
loadNearby(startNarration = false) loadNearby(startNarration = false)
} }
} }
@ -74,10 +81,13 @@ 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) {
Log.w(TAG, "loadNearby: no location available")
_uiState.value = _uiState.value.copy(isLoading = false, errorMessage = "Location unavailable") _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = "Location unavailable")
return return
} }
Log.d(TAG, "loadNearby: lat=${location.lat} lon=${location.lon} length=${contentLength.toApiValue()}")
runCatching { runCatching {
placesRepository.getNearby( placesRepository.getNearby(
lat = location.lat, lat = location.lat,
@ -87,6 +97,7 @@ class GuideViewModel @Inject constructor(
length = contentLength.toApiValue(), length = contentLength.toApiValue(),
) )
}.onSuccess { response -> }.onSuccess { response ->
Log.i(TAG, "loadNearby success: ${response.places.size} place(s), radius=${response.searchRadiusM}m")
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
isLoading = false, isLoading = false,
places = response.places, places = response.places,
@ -98,6 +109,7 @@ class GuideViewModel @Inject constructor(
narrateActive() narrateActive()
} }
}.onFailure { error -> }.onFailure { error ->
Log.e(TAG, "loadNearby failed", error)
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
isLoading = false, isLoading = false,
errorMessage = error.message ?: "Network error", errorMessage = error.message ?: "Network error",
@ -107,6 +119,7 @@ class GuideViewModel @Inject constructor(
/** Retries after a failed load (e.g. tap a "retry" button shown on error). */ /** Retries after a failed load (e.g. tap a "retry" button shown on error). */
fun retry() { fun retry() {
Log.d(TAG, "retry() called")
viewModelScope.launch { viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
loadNearby(startNarration = true) loadNearby(startNarration = true)
@ -116,13 +129,17 @@ class GuideViewModel @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
ttsManager.speak(place.content.body) { onNarrationDone() } 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() }
} }
private fun onNarrationDone() { private fun onNarrationDone() {
val state = _uiState.value val state = _uiState.value
val nextIndex = state.activeIndex + 1 val nextIndex = state.activeIndex + 1
Log.d(TAG, "onNarrationDone: finished index=${state.activeIndex}, nextIndex=$nextIndex")
if (nextIndex >= state.places.size) { if (nextIndex >= state.places.size) {
Log.i(TAG, "onNarrationDone: reached end of nearby list")
_uiState.value = state.copy(doneIndices = state.doneIndices + state.activeIndex) _uiState.value = state.copy(doneIndices = state.doneIndices + state.activeIndex)
return return
} }
@ -133,21 +150,15 @@ class GuideViewModel @Inject constructor(
narrateActive() narrateActive()
} }
/** User manually swiped to a different card; doesn't force-complete skipped ones. */
fun setActiveIndex(index: Int) {
val state = _uiState.value
if (index !in state.places.indices || index == state.activeIndex) return
_uiState.value = state.copy(activeIndex = index)
narrateActive()
}
fun toggleMute() { fun toggleMute() {
if (ttsManager.isMuted) ttsManager.unmute() else ttsManager.mute() if (ttsManager.isMuted) ttsManager.unmute() else ttsManager.mute()
Log.d(TAG, "toggleMute: isMuted=${ttsManager.isMuted}")
_uiState.value = _uiState.value.copy(isMuted = ttsManager.isMuted) _uiState.value = _uiState.value.copy(isMuted = ttsManager.isMuted)
} }
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
Log.d(TAG, "onCleared")
ttsManager.stop() ttsManager.stop()
} }
} }

View file

@ -1,69 +0,0 @@
package com.guidecity.app.ui.guide
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.guidecity.app.data.remote.dto.NearbyPlaceDto
import kotlinx.coroutines.flow.distinctUntilChanged
/**
* Tinder-style swipeable stack of place preview cards, ordered by distance.
* Swiping changes which card is "active" ([onPageChanged]); tapping a card
* opens its full detail screen ([onCardClick]). Done cards stay in the
* pager (dimmed via [CardState.DONE]) rather than being removed, so the
* user can still swipe back to review one that already finished narrating.
*/
@Composable
fun PlaceCardStack(
places: List<NearbyPlaceDto>,
activeIndex: Int,
doneIndices: Set<Int>,
onCardClick: (placeId: Int) -> Unit,
onPageChanged: (index: Int) -> Unit,
modifier: Modifier = Modifier,
) {
if (places.isEmpty()) return
val pagerState = rememberPagerState(initialPage = activeIndex) { places.size }
LaunchedEffect(activeIndex) {
if (pagerState.currentPage != activeIndex) {
pagerState.animateScrollToPage(activeIndex)
}
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }
.distinctUntilChanged()
.collect { page -> if (page != activeIndex) onPageChanged(page) }
}
HorizontalPager(
state = pagerState,
modifier = modifier
.fillMaxWidth()
.height(200.dp),
) { page ->
val place = places[page]
val cardState = when {
page == activeIndex -> CardState.ACTIVE
page in doneIndices -> CardState.DONE
else -> CardState.UPCOMING
}
PlaceCard(
place = place,
state = cardState,
modifier = Modifier
.padding(12.dp)
.clickable { onCardClick(place.id) },
)
}
}

View file

@ -39,7 +39,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp 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
import com.guidecity.app.map.DgisMapView import com.guidecity.app.map.CityMapView
@Composable @Composable
fun MapScreen( fun MapScreen(
@ -50,16 +50,18 @@ fun MapScreen(
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val searchResults by viewModel.searchResults.collectAsState() val searchResults by viewModel.searchResults.collectAsState()
val resolvedLocation by viewModel.resolvedLocation.collectAsState()
val nearbyPlaces by viewModel.nearbyPlaces.collectAsState()
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
val permissionLauncher = rememberLauncherForActivityResult( val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions(), contract = ActivityResultContracts.RequestMultiplePermissions(),
) { results -> ) { results ->
viewModel.onPermissionResult(results.values.any { it }, onLocationResolved) viewModel.onPermissionResult(results.values.any { it })
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.checkInitialPermission(onLocationResolved) viewModel.checkInitialPermission()
} }
Scaffold( Scaffold(
@ -82,14 +84,14 @@ fun MapScreen(
.fillMaxSize() .fillMaxSize()
.padding(padding), .padding(padding),
) { ) {
DgisMapView( CityMapView(
userLocation = null, userLocation = resolvedLocation,
places = emptyList(), places = nearbyPlaces,
onPlaceClick = {}, onPlaceClick = {},
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
when (uiState) { when (val state = uiState) {
MapUiState.NeedsPermission -> { MapUiState.NeedsPermission -> {
Column( Column(
modifier = Modifier modifier = Modifier
@ -132,7 +134,7 @@ fun MapScreen(
ListItem( ListItem(
headlineContent = { Text(place.name) }, headlineContent = { Text(place.name) },
modifier = Modifier.clickable { modifier = Modifier.clickable {
viewModel.selectSearchResult(place, onLocationResolved) viewModel.selectSearchResult(place)
}, },
) )
} }
@ -140,9 +142,47 @@ fun MapScreen(
} }
} }
MapUiState.ResolvingLocation, MapUiState.CheckingPermission -> { MapUiState.ResolvingLocation, MapUiState.CheckingPermission, MapUiState.CheckingNearby -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator() CircularProgressIndicator()
if (state == MapUiState.CheckingNearby) {
Spacer(modifier = Modifier.height(12.dp))
Text(stringResource(R.string.map_checking_nearby))
}
}
}
MapUiState.NoPlacesNearby -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(stringResource(R.string.map_no_places_nearby))
}
}
is MapUiState.ReadyToStart -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Bottom,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.map_places_found, state.placesFound, state.radiusM),
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onLocationResolved) {
Text(stringResource(R.string.map_start))
}
} }
} }
} }

View file

@ -1,7 +1,9 @@
package com.guidecity.app.ui.map package com.guidecity.app.ui.map
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.guidecity.app.data.remote.dto.NearbyPlaceDto
import com.guidecity.app.data.remote.dto.PlaceListItemDto import com.guidecity.app.data.remote.dto.PlaceListItemDto
import com.guidecity.app.data.repository.PlacesRepository import com.guidecity.app.data.repository.PlacesRepository
import com.guidecity.app.location.LatLon import com.guidecity.app.location.LatLon
@ -14,11 +16,17 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
private const val TAG = "MapViewModel"
private const val MAX_SEARCH_RADIUS_M = 10_000
sealed interface MapUiState { sealed interface MapUiState {
data object CheckingPermission : MapUiState data object CheckingPermission : MapUiState
data object NeedsPermission : MapUiState data object NeedsPermission : MapUiState
data object ResolvingLocation : MapUiState data object ResolvingLocation : MapUiState
data object SearchFallback : MapUiState data object SearchFallback : MapUiState
data object CheckingNearby : MapUiState
data class ReadyToStart(val placesFound: Int, val radiusM: Int) : MapUiState
data object NoPlacesNearby : MapUiState
} }
@HiltViewModel @HiltViewModel
@ -34,49 +42,105 @@ class MapViewModel @Inject constructor(
private val _searchResults = MutableStateFlow<List<PlaceListItemDto>>(emptyList()) private val _searchResults = MutableStateFlow<List<PlaceListItemDto>>(emptyList())
val searchResults: StateFlow<List<PlaceListItemDto>> = _searchResults.asStateFlow() val searchResults: StateFlow<List<PlaceListItemDto>> = _searchResults.asStateFlow()
fun checkInitialPermission(onLocationResolved: () -> Unit) { private val _resolvedLocation = MutableStateFlow<LatLon?>(null)
if (locationProvider.hasLocationPermission()) { val resolvedLocation: StateFlow<LatLon?> = _resolvedLocation.asStateFlow()
resolveLocation(onLocationResolved)
private val _nearbyPlaces = MutableStateFlow<List<NearbyPlaceDto>>(emptyList())
val nearbyPlaces: StateFlow<List<NearbyPlaceDto>> = _nearbyPlaces.asStateFlow()
fun checkInitialPermission() {
val hasPermission = locationProvider.hasLocationPermission()
Log.d(TAG, "checkInitialPermission: hasPermission=$hasPermission")
if (hasPermission) {
resolveLocation()
} else { } else {
_uiState.value = MapUiState.NeedsPermission _uiState.value = MapUiState.NeedsPermission
} }
} }
fun onPermissionResult(granted: Boolean, onLocationResolved: () -> Unit) { fun onPermissionResult(granted: Boolean) {
Log.i(TAG, "onPermissionResult: granted=$granted")
if (granted) { if (granted) {
resolveLocation(onLocationResolved) resolveLocation()
} else { } else {
_uiState.value = MapUiState.SearchFallback _uiState.value = MapUiState.SearchFallback
} }
} }
private fun resolveLocation(onLocationResolved: () -> Unit) { private fun resolveLocation() {
_uiState.value = MapUiState.ResolvingLocation _uiState.value = MapUiState.ResolvingLocation
viewModelScope.launch { viewModelScope.launch {
val location = locationProvider.getCurrentLocation() val location = locationProvider.getCurrentLocation()
if (location != null) { if (location != null) {
Log.i(TAG, "resolveLocation: got lat=${location.lat} lon=${location.lon}")
_resolvedLocation.value = location
selectedLocationHolder.set(location) selectedLocationHolder.set(location)
onLocationResolved() checkNearby(location)
} else { } else {
Log.w(TAG, "resolveLocation: location unavailable, falling back to search")
_uiState.value = MapUiState.SearchFallback _uiState.value = MapUiState.SearchFallback
} }
} }
} }
/**
* Counts places within an expanding radius (server-side, same mechanism
* as the guide screen's own fetch) up to [MAX_SEARCH_RADIUS_M], so the
* user can see how many places were found and at what radius before
* committing to start the guide.
*/
private fun checkNearby(location: LatLon) {
_uiState.value = MapUiState.CheckingNearby
viewModelScope.launch {
runCatching {
placesRepository.getNearby(
lat = location.lat,
lon = location.lon,
citySlug = "moscow",
lang = "ru",
length = "short",
minResults = 5,
maxRadiusM = MAX_SEARCH_RADIUS_M,
)
}.onSuccess { response ->
Log.i(TAG, "checkNearby: count=${response.count} radius=${response.searchRadiusM}m")
_nearbyPlaces.value = response.places
_uiState.value = if (response.count > 0) {
MapUiState.ReadyToStart(placesFound = response.count, radiusM = response.searchRadiusM)
} else {
MapUiState.NoPlacesNearby
}
}.onFailure { error ->
Log.e(TAG, "checkNearby failed", error)
_uiState.value = MapUiState.NoPlacesNearby
}
}
}
fun searchPlaces(query: String) { fun searchPlaces(query: String) {
if (query.isBlank()) { if (query.isBlank()) {
_searchResults.value = emptyList() _searchResults.value = emptyList()
return return
} }
Log.d(TAG, "searchPlaces: query=$query")
viewModelScope.launch { viewModelScope.launch {
_searchResults.value = runCatching { runCatching {
placesRepository.searchPlaces(query, citySlug = "moscow", lang = "ru") placesRepository.searchPlaces(query, citySlug = "moscow", lang = "ru")
}.getOrDefault(emptyList()) }.onSuccess { results ->
Log.d(TAG, "searchPlaces: ${results.size} result(s) for query=$query")
_searchResults.value = results
}.onFailure { error ->
Log.e(TAG, "searchPlaces failed for query=$query", error)
_searchResults.value = emptyList()
}
} }
} }
fun selectSearchResult(place: PlaceListItemDto, onLocationResolved: () -> Unit) { fun selectSearchResult(place: PlaceListItemDto) {
selectedLocationHolder.set(LatLon(place.location.lat, place.location.lon)) Log.i(TAG, "selectSearchResult: placeId=${place.id} slug=${place.slug}")
onLocationResolved() val location = LatLon(place.location.lat, place.location.lon)
_resolvedLocation.value = location
selectedLocationHolder.set(location)
checkNearby(location)
} }
} }

View file

@ -1,5 +1,6 @@
package com.guidecity.app.ui.onboarding package com.guidecity.app.ui.onboarding
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.guidecity.app.data.local.prefs.UserPrefsDataStore import com.guidecity.app.data.local.prefs.UserPrefsDataStore
@ -7,12 +8,15 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
private const val TAG = "OnboardingViewModel"
@HiltViewModel @HiltViewModel
class OnboardingViewModel @Inject constructor( class OnboardingViewModel @Inject constructor(
private val userPrefsDataStore: UserPrefsDataStore, private val userPrefsDataStore: UserPrefsDataStore,
) : ViewModel() { ) : ViewModel() {
fun markOnboardingSeen(onDone: () -> Unit) { fun markOnboardingSeen(onDone: () -> Unit) {
viewModelScope.launch { viewModelScope.launch {
Log.i(TAG, "markOnboardingSeen")
userPrefsDataStore.setOnboardingSeen(true) userPrefsDataStore.setOnboardingSeen(true)
onDone() onDone()
} }

View file

@ -1,5 +1,6 @@
package com.guidecity.app.ui.preference package com.guidecity.app.ui.preference
import android.util.Log
import androidx.lifecycle.ViewModel 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
@ -11,6 +12,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
private const val TAG = "ContentLengthViewModel"
@HiltViewModel @HiltViewModel
class ContentLengthViewModel @Inject constructor( class ContentLengthViewModel @Inject constructor(
private val userPrefsDataStore: UserPrefsDataStore, private val userPrefsDataStore: UserPrefsDataStore,
@ -20,11 +23,13 @@ class ContentLengthViewModel @Inject constructor(
val selected: StateFlow<ContentLength> = _selected.asStateFlow() val selected: StateFlow<ContentLength> = _selected.asStateFlow()
fun select(length: ContentLength) { fun select(length: ContentLength) {
Log.d(TAG, "select: $length")
_selected.value = length _selected.value = length
} }
fun confirmSelection(onDone: () -> Unit) { fun confirmSelection(onDone: () -> Unit) {
viewModelScope.launch { viewModelScope.launch {
Log.i(TAG, "confirmSelection: ${_selected.value}")
userPrefsDataStore.setContentLength(_selected.value) userPrefsDataStore.setContentLength(_selected.value)
onDone() onDone()
} }

View file

@ -1,5 +1,6 @@
package com.guidecity.app.ui.settings package com.guidecity.app.ui.settings
import android.util.Log
import androidx.lifecycle.ViewModel 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
@ -11,6 +12,8 @@ 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"
@HiltViewModel @HiltViewModel
class SettingsViewModel @Inject constructor( class SettingsViewModel @Inject constructor(
private val userPrefsDataStore: UserPrefsDataStore, private val userPrefsDataStore: UserPrefsDataStore,
@ -19,6 +22,7 @@ class SettingsViewModel @Inject constructor(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
fun setContentLength(length: ContentLength) { fun setContentLength(length: ContentLength) {
Log.i(TAG, "setContentLength: $length")
viewModelScope.launch { userPrefsDataStore.setContentLength(length) } viewModelScope.launch { userPrefsDataStore.setContentLength(length) }
} }
} }

View file

@ -0,0 +1,23 @@
package com.guidecity.app.util
import kotlin.math.roundToInt
/** Formats a distance in meters for spoken narration (Russian only, matching the app's current hardcoded lang="ru"). */
fun formatDistanceForNarration(distanceM: Double): String =
if (distanceM >= 1000) {
"%.1f км".format(distanceM / 1000)
} else {
"${distanceM.roundToInt()} метров"
}
/**
* 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
* place opened standalone from Favorites/search) to omit the distance clause.
*/
fun buildNarrationText(title: String, distanceM: Double, body: String): String =
if (distanceM < 0) {
"$title. $body"
} else {
"$title. Расстояние: ${formatDistanceForNarration(distanceM)}. $body"
}

View file

@ -15,8 +15,13 @@
<string name="map_location_permission_rationale">guideCity нужен доступ к геолокации, чтобы найти места поблизости. Вы также можете ввести место вручную.</string> <string name="map_location_permission_rationale">guideCity нужен доступ к геолокации, чтобы найти места поблизости. Вы также можете ввести место вручную.</string>
<string name="map_grant_permission">Разрешить доступ к геолокации</string> <string name="map_grant_permission">Разрешить доступ к геолокации</string>
<string name="map_search_placeholder">Введите место для поиска…</string> <string name="map_search_placeholder">Введите место для поиска…</string>
<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="guide_no_places_nearby">Поблизости пока не найдено интересных мест.</string> <string name="guide_no_places_nearby">Поблизости пока не найдено интересных мест.</string>
<string name="guide_places_count">Мест поблизости: %1$d</string>
<string name="guide_retry">Повторить</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>

View file

@ -15,8 +15,13 @@
<string name="map_location_permission_rationale">guideCity needs your location to find nearby places. You can also search for a place by name instead.</string> <string name="map_location_permission_rationale">guideCity needs your location to find nearby places. You can also search for a place by name instead.</string>
<string name="map_grant_permission">Grant location access</string> <string name="map_grant_permission">Grant location access</string>
<string name="map_search_placeholder">Search for a place instead…</string> <string name="map_search_placeholder">Search for a place instead…</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_start">Start</string>
<string name="map_no_places_nearby">Nothing found within 10 km of here.</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_places_count">%1$d place(s) nearby</string>
<string name="guide_retry">Retry</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>

View file

@ -0,0 +1,24 @@
package com.guidecity.app
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description
/** Swaps `Dispatchers.Main` for a test dispatcher so `viewModelScope` coroutines run synchronously. */
@ExperimentalCoroutinesApi
class MainDispatcherRule(
private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}

View file

@ -0,0 +1,14 @@
package com.guidecity.app.data.local.prefs
import org.junit.Assert.assertEquals
import org.junit.Test
class ContentLengthTest {
@Test
fun `toApiValue lowercases the enum name`() {
assertEquals("short", ContentLength.SHORT.toApiValue())
assertEquals("medium", ContentLength.MEDIUM.toApiValue())
assertEquals("long", ContentLength.LONG.toApiValue())
}
}

View file

@ -0,0 +1,132 @@
package com.guidecity.app.ui.guide
import androidx.lifecycle.viewModelScope
import com.guidecity.app.MainDispatcherRule
import com.guidecity.app.data.local.prefs.ContentLength
import com.guidecity.app.data.local.prefs.UserPrefsDataStore
import com.guidecity.app.data.remote.dto.ContentDto
import com.guidecity.app.data.remote.dto.LatLonDto
import com.guidecity.app.data.remote.dto.NearbyPlaceDto
import com.guidecity.app.data.remote.dto.NearbyResponseDto
import com.guidecity.app.data.repository.PlacesRepository
import com.guidecity.app.location.LatLon
import com.guidecity.app.location.LocationProvider
import com.guidecity.app.location.SelectedLocationHolder
import com.guidecity.app.tts.TtsManager
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import java.io.IOException
/**
* Covers the loadNearby() success/failure/retry paths in particular a
* 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.
*/
class GuideViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val testLocation = LatLon(lat = 55.75, lon = 37.62)
private fun fakePlace(id: Int): NearbyPlaceDto = NearbyPlaceDto(
id = id,
slug = "place-$id",
category = "monument",
location = LatLonDto(lat = 55.75, lon = 37.62),
content = ContentDto(language = "ru", length = "medium", title = "Title $id", body = "Body $id"),
distanceM = 10.0 * id,
)
private fun buildViewModel(
placesRepository: PlacesRepository,
ttsManager: TtsManager = mockk(relaxed = true),
): GuideViewModel {
val locationProvider = mockk<LocationProvider>(relaxed = true)
val selectedLocationHolder = mockk<SelectedLocationHolder>()
every { selectedLocationHolder.location } returns MutableStateFlow(testLocation)
val userPrefsDataStore = mockk<UserPrefsDataStore>()
every { userPrefsDataStore.contentLength } returns flowOf(ContentLength.MEDIUM)
return GuideViewModel(
placesRepository = placesRepository,
locationProvider = locationProvider,
selectedLocationHolder = selectedLocationHolder,
userPrefsDataStore = userPrefsDataStore,
ttsManager = ttsManager,
)
}
@Test
fun `successful load populates places and starts narration`() = runTest {
val placesRepository = mockk<PlacesRepository>()
val response = NearbyResponseDto(searchRadiusM = 300, count = 2, places = listOf(fakePlace(1), fakePlace(2)))
coEvery {
placesRepository.getNearby(lat = any(), lon = any(), citySlug = any(), lang = any(), length = any())
} returns response
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()
}
@Test
fun `failed load clears the spinner and surfaces an error instead of hanging forever`() = runTest {
val placesRepository = mockk<PlacesRepository>()
coEvery {
placesRepository.getNearby(lat = any(), lon = any(), citySlug = any(), lang = any(), length = any())
} throws IOException("Cleartext HTTP traffic not permitted")
val viewModel = buildViewModel(placesRepository)
val state = viewModel.uiState.value
assertEquals(false, state.isLoading)
assertTrue(state.places.isEmpty())
assertNotNull(state.errorMessage)
viewModel.viewModelScope.cancel()
}
@Test
fun `retry after a failure can succeed and clears the error`() = runTest {
val placesRepository = mockk<PlacesRepository>()
val response = NearbyResponseDto(searchRadiusM = 300, count = 1, places = listOf(fakePlace(1)))
coEvery {
placesRepository.getNearby(lat = any(), lon = any(), citySlug = any(), lang = any(), length = any())
} throws IOException("network down") andThen response
val viewModel = buildViewModel(placesRepository)
assertNotNull(viewModel.uiState.value.errorMessage)
viewModel.retry()
val state = viewModel.uiState.value
assertEquals(false, state.isLoading)
assertNull(state.errorMessage)
assertEquals(1, state.places.size)
viewModel.viewModelScope.cancel()
}
}

View file

@ -19,6 +19,9 @@ ksp = "2.0.20-1.0.25"
junit = "4.13.2" junit = "4.13.2"
androidxTestExtJunit = "1.2.1" androidxTestExtJunit = "1.2.1"
espressoCore = "3.6.1" espressoCore = "3.6.1"
mockk = "1.13.12"
kotlinxCoroutinesTest = "1.8.1"
osmdroid = "6.1.20"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -57,6 +60,10 @@ androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }
osmdroid-android = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }

View file

@ -2,11 +2,10 @@
sdk.dir=/path/to/your/Android/Sdk sdk.dir=/path/to/your/Android/Sdk
# Test key provided for development; do not ship this as-is in a public repo. # Base URL of the guideCity backend API. Options, in order of preference:
DGIS_API_KEY=b4df01a8-61db-4cb9-8286-7e069495987d # 1. Nginx-fronted HTTPS domain (works from anywhere, not just the LAN):
API_BASE_URL=https://guidetest.vrubel.xyz/
# Base URL of the guideCity backend API. # 2. Emulator -> host loopback: http://10.0.2.2:8000/
# Emulator -> host loopback: http://10.0.2.2:8000/ # 3. Physical device on the same Wi-Fi/LAN -> your machine's LAN IP, e.g. http://192.168.1.50:8000/
# Physical device on the same Wi-Fi/LAN -> your machine's LAN IP, e.g. http://192.168.1.50:8000/ # (backend already binds 0.0.0.0:8000 via docker-compose, so it's reachable on the LAN IP with
# (backend already binds 0.0.0.0:8000 via docker-compose, so it's reachable on the LAN IP with no extra config) # no extra config; cleartext HTTP is allowed in debug builds only, see app/src/debug/AndroidManifest.xml)
API_BASE_URL=http://192.168.8.173:8000/

View file

@ -11,9 +11,6 @@ dependencyResolutionManagement {
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
// TODO: add the 2GIS MapKit maven repository here once its exact URL is
// confirmed from the 2GIS developer portal (see android/README section
// on the map SDK integration point).
} }
} }

View file

@ -1,3 +1,5 @@
import logging
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from geoalchemy2 import Geometry from geoalchemy2 import Geometry
from sqlalchemy import cast, func, select from sqlalchemy import cast, func, select
@ -8,6 +10,8 @@ from app.models.city import City
from app.schemas.city import CityOut from app.schemas.city import CityOut
from app.schemas.common import LatLon, LocalizedName from app.schemas.common import LatLon, LocalizedName
logger = logging.getLogger("guidecity.cities")
router = APIRouter(tags=["cities"]) router = APIRouter(tags=["cities"])
@ -31,4 +35,5 @@ def list_cities(db: Session = Depends(get_db)) -> list[CityOut]:
center=center, center=center,
) )
) )
logger.debug("listed %d cities", len(out))
return out return out

View file

@ -1,3 +1,5 @@
import logging
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -8,6 +10,8 @@ from app.schemas.nearby import NearbyPlace, NearbyResponse
from app.schemas.place import ContentOut from app.schemas.place import ContentOut
from app.services.nearby_search import find_nearby from app.services.nearby_search import find_nearby
logger = logging.getLogger("guidecity.nearby")
router = APIRouter(tags=["nearby"]) router = APIRouter(tags=["nearby"])
@ -32,6 +36,9 @@ def nearby(
), ),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> NearbyResponse: ) -> NearbyResponse:
if speed is not None or heading is not None:
logger.debug("nearby called with reserved v2 params speed=%s heading=%s (ignored)", speed, heading)
result = find_nearby( result = find_nearby(
db, db,
lat=lat, lat=lat,

View file

@ -1,3 +1,5 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from geoalchemy2 import Geometry from geoalchemy2 import Geometry
from sqlalchemy import and_, cast, func, select from sqlalchemy import and_, cast, func, select
@ -13,6 +15,8 @@ from app.schemas.common import LatLon
from app.schemas.place import ContentOut, PlaceDetail, PlaceListItem from app.schemas.place import ContentOut, PlaceDetail, PlaceListItem
from app.services.geocoding import search_places from app.services.geocoding import search_places
logger = logging.getLogger("guidecity.places")
router = APIRouter(tags=["places"]) router = APIRouter(tags=["places"])
@ -56,6 +60,7 @@ def get_place(
) )
row = db.execute(stmt).first() row = db.execute(stmt).first()
if row is None: if row is None:
logger.warning("place_id=%d not found for lang=%s length=%s", place_id, lang, length)
raise HTTPException(status_code=404, detail="Place not found") raise HTTPException(status_code=404, detail="Place not found")
place, content, lon, lat = row place, content, lon, lat = row
@ -84,6 +89,7 @@ def list_city_places(
) -> list[PlaceListItem]: ) -> list[PlaceListItem]:
city = db.scalar(select(City).where(City.slug == city_slug)) city = db.scalar(select(City).where(City.slug == city_slug))
if city is None: if city is None:
logger.warning("city_slug=%r not found", city_slug)
raise HTTPException(status_code=404, detail="City not found") raise HTTPException(status_code=404, detail="City not found")
stmt = ( stmt = (
@ -111,6 +117,7 @@ def list_city_places(
stmt = stmt.where(PlaceContent.title.ilike(f"%{q}%")) stmt = stmt.where(PlaceContent.title.ilike(f"%{q}%"))
rows = db.execute(stmt).all() rows = db.execute(stmt).all()
logger.debug("city_slug=%r listing -> %d place(s)", city_slug, len(rows))
return [ return [
PlaceListItem(id=p.id, slug=p.slug, category=p.category, name=pc.title, location=LatLon(lat=lat, lon=lon)) PlaceListItem(id=p.id, slug=p.slug, category=p.category, name=pc.title, location=LatLon(lat=lat, lon=lon))
for p, pc, lon, lat in rows for p, pc, lon, lat in rows

View file

@ -6,6 +6,7 @@ class Settings(BaseSettings):
database_url: str = "postgresql+psycopg://guidecity:guidecity@localhost:5432/guidecity" database_url: str = "postgresql+psycopg://guidecity:guidecity@localhost:5432/guidecity"
api_v1_prefix: str = "/api/v1" api_v1_prefix: str = "/api/v1"
log_level: str = "INFO"
settings = Settings() settings = Settings()

View file

@ -1,4 +1,11 @@
import logging import logging
import time
from collections.abc import Awaitable, Callable
from starlette.requests import Request
from starlette.responses import Response
request_logger = logging.getLogger("guidecity.http")
def configure_logging(level: int = logging.INFO) -> None: def configure_logging(level: int = logging.INFO) -> None:
@ -6,3 +13,25 @@ def configure_logging(level: int = logging.INFO) -> None:
level=level, level=level,
format="%(asctime)s %(levelname)s %(name)s %(message)s", format="%(asctime)s %(levelname)s %(name)s %(message)s",
) )
async def log_requests(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
"""Logs method/path/status/duration for every request handled by the app."""
start = time.perf_counter()
try:
response = await call_next(request)
except Exception:
duration_ms = (time.perf_counter() - start) * 1000
request_logger.exception(
"%s %s -> unhandled exception (%.1fms)", request.method, request.url.path, duration_ms
)
raise
duration_ms = (time.perf_counter() - start) * 1000
request_logger.info(
"%s %s -> %d (%.1fms)", request.method, request.url.path, response.status_code, duration_ms
)
return response

View file

@ -1,9 +1,26 @@
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from app.api.routes import cities, health, nearby, places from app.api.routes import cities, health, nearby, places
from app.config import settings from app.config import settings
from app.core.logging import configure_logging, log_requests
app = FastAPI(title="guideCity API", version="0.1.0") configure_logging(level=getattr(logging, settings.log_level.upper(), logging.INFO))
logger = logging.getLogger("guidecity")
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
logger.info("guideCity API startup complete (log_level=%s)", settings.log_level)
yield
logger.info("guideCity API shutting down")
app = FastAPI(title="guideCity API", version="0.1.0", lifespan=lifespan)
app.middleware("http")(log_requests)
app.include_router(health.router, prefix=settings.api_v1_prefix) app.include_router(health.router, prefix=settings.api_v1_prefix)
app.include_router(cities.router, prefix=settings.api_v1_prefix) app.include_router(cities.router, prefix=settings.api_v1_prefix)

View file

@ -4,6 +4,8 @@ Used when the user denies location permission and types a place of interest,
or when 2GIS's own geocoding API isn't wired into a given client flow yet. or when 2GIS's own geocoding API isn't wired into a given client flow yet.
""" """
import logging
from geoalchemy2 import Geometry from geoalchemy2 import Geometry
from sqlalchemy import and_, cast, func, select from sqlalchemy import and_, cast, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -13,6 +15,8 @@ from app.models.enums import ContentLanguage, ContentLength
from app.models.place import Place from app.models.place import Place
from app.models.place_content import PlaceContent from app.models.place_content import PlaceContent
logger = logging.getLogger("guidecity.geocoding")
def search_places( def search_places(
db: Session, db: Session,
@ -44,4 +48,6 @@ def search_places(
if city_slug is not None: if city_slug is not None:
stmt = stmt.join(City, City.id == Place.city_id).where(City.slug == city_slug) stmt = stmt.join(City, City.id == Place.city_id).where(City.slug == city_slug)
return db.execute(stmt).all() results = db.execute(stmt).all()
logger.info("name search q=%r city_slug=%s lang=%s -> %d result(s)", q, city_slug, language, len(results))
return results

View file

@ -10,6 +10,7 @@ results toward the user's direction of travel and to pick a shorter content
Not implemented in this iteration callers should not pass them yet. Not implemented in this iteration callers should not pass them yet.
""" """
import logging
from dataclasses import dataclass from dataclasses import dataclass
from geoalchemy2 import Geometry from geoalchemy2 import Geometry
@ -21,6 +22,8 @@ from app.models.enums import ContentLanguage, ContentLength
from app.models.place import Place from app.models.place import Place
from app.models.place_content import PlaceContent from app.models.place_content import PlaceContent
logger = logging.getLogger("guidecity.nearby_search")
@dataclass @dataclass
class NearbyResult: class NearbyResult:
@ -44,6 +47,14 @@ def find_nearby(
city_id: int | None = None city_id: int | None = None
if city_slug is not None: if city_slug is not None:
city_id = db.scalar(select(City.id).where(City.slug == city_slug)) city_id = db.scalar(select(City.id).where(City.slug == city_slug))
if city_id is None:
logger.warning("city_slug=%r not found; searching across all cities", city_slug)
logger.debug(
"nearby search starting: lat=%.5f lon=%.5f city_slug=%s lang=%s length=%s "
"min_results=%d initial_radius_m=%d step_m=%d max_radius_m=%d",
lat, lon, city_slug, language, length, min_results, initial_radius_m, step_m, max_radius_m,
)
origin = func.ST_GeogFromText(f"SRID=4326;POINT({lon} {lat})") origin = func.ST_GeogFromText(f"SRID=4326;POINT({lon} {lat})")
distance_expr = func.ST_Distance(Place.location, origin) distance_expr = func.ST_Distance(Place.location, origin)
@ -75,8 +86,16 @@ def find_nearby(
while True: while True:
stmt = base_stmt.where(func.ST_DWithin(Place.location, origin, radius)) stmt = base_stmt.where(func.ST_DWithin(Place.location, origin, radius))
rows = db.execute(stmt).all() rows = db.execute(stmt).all()
logger.debug("radius=%dm -> %d place(s) found", radius, len(rows))
if len(rows) >= min_results or radius >= max_radius_m: if len(rows) >= min_results or radius >= max_radius_m:
break break
radius += step_m radius += step_m
if len(rows) < min_results:
logger.warning(
"hit max_radius_m=%dm with only %d/%d place(s) found near lat=%.5f lon=%.5f",
max_radius_m, len(rows), min_results, lat, lon,
)
logger.info("nearby search done: radius=%dm count=%d", radius, len(rows))
return NearbyResult(search_radius_m=radius, rows=rows) return NearbyResult(search_radius_m=radius, rows=rows)

View file

@ -0,0 +1,181 @@
city: moscow
places:
- slug: solntsevo-metro-station
category: building
location: { lat: 55.6497, lon: 37.3911 }
address: "Солнцевский проспект, вл1"
district: "Солнцево"
built_year: "2018"
architect_builder: "Метрогипротранс"
architectural_style: "Станция мелкого заложения"
notable_people: null
title:
ru: "Станция метро «Солнцево»"
en: "Solntsevo Metro Station"
content:
ru:
short: |
Станция метро «Солнцево» открылась в 2018 году в составе западного участка Калининско-Солнцевской линии и стала главными транспортными воротами района.
medium: |
Станцию открыли 26 мая 2018 года — вместе с ней в тот же день заработали ещё три станции нового западного радиуса Калининско-Солнцевской линии, соединившего спальный район Солнцево с центром Москвы напрямую, без пересадок через кольцо. До этого жителям приходилось добираться на метро с пересадками или долгой поездкой на наземном транспорте. Станция мелкого заложения расположена прямо под Солнцевским проспектом и с открытия стала главным ориентиром и точкой притяжения района.
long: |
Появление метро в Солнцево обсуждалось десятилетиями: район, застроенный в основном в 19601980-е годы как город-спутник Москвы, долгое время оставался без своей ветки метро, несмотря на присоединение к городу ещё в 1984 году. Ситуация изменилась только с решением о продлении Калининско-Солнцевской линии на запад.
Станция «Солнцево» открылась 26 мая 2018 года одновременно со станциями «Боровское шоссе», «Новопеределкино» и «Рассказовка» — весь новый участок сразу связал западные районы Москвы с центром без пересадок. Станция мелкого заложения построена открытым способом прямо под Солнцевским проспектом.
Для жителей района открытие метро стало заметным событием: время в пути до центра сократилось в разы, а сама станция и привокзальная площадь быстро превратились в главный общественный узел Солнцево — здесь проходят автобусные маршруты, расположены магазины и кафе.
en:
short: |
Solntsevo metro station opened in 2018 as part of the western extension of the Kalininsko-Solntsevskaya line, becoming the district's main transport gateway.
medium: |
The station opened on May 26, 2018, together with three other new stations on the western extension of the Kalininsko-Solntsevskaya line, connecting the residential district of Solntsevo directly to central Moscow without transfers through the Ring line. Before that, residents relied on transfers or long surface-transport commutes. Built as a shallow station directly beneath Solntsevsky Prospekt, it has become the district's main landmark and gathering point since opening.
long: |
Bringing the metro to Solntsevo was debated for decades: the district, mostly built up in the 1960s1980s as a satellite town of Moscow, remained without its own metro line for years even after being formally annexed into the city in 1984. That changed with the decision to extend the Kalininsko-Solntsevskaya line westward.
Solntsevo station opened on May 26, 2018, simultaneously with the stations Borovskoye Shosse, Novoperedelkino, and Rasskazovka — the entire new stretch instantly linked Moscow's western districts to the center without any transfers. The shallow station was built by open-cut method directly beneath Solntsevsky Prospekt.
For local residents, the metro's arrival was a major event: travel time to the center dropped dramatically, and the station and its forecourt quickly became Solntsevo's main public hub, with bus routes, shops, and cafés clustered around it.
- slug: solntsevsky-park-bogdanova
category: park
location: { lat: 55.6536, lon: 37.3958 }
address: "улица Богданова"
district: "Солнцево"
built_year: null
architect_builder: null
architectural_style: "Парк с прудом"
notable_people: null
title:
ru: "Солнцевский парк культуры и отдыха и Большой Солнцевский пруд"
en: "Solntsevo Park of Culture and Leisure and the Bolshoy Solntsevsky Pond"
content:
ru:
short: |
Центральный парк района Солнцево на улице Богданова разбит вокруг Большого Солнцевского пруда — главного места отдыха у воды для жителей района.
medium: |
Парк сложился вокруг Большого Солнцевского пруда — одного из немногих крупных водоёмов района, оставшихся ещё с довоенных времён, когда на этом месте были деревенские пруды подмосковного села Солнцево. Сегодня вдоль воды проложены пешеходные дорожки, установлены скамейки и детские площадки, а сам парк остаётся главным местом для прогулок и отдыха у воды в спальном районе, застроенном в основном многоэтажными домами.
long: |
Село Солнцево, давшее имя нынешнему московскому району, существовало ещё до массовой жилой застройки 19601970-х годов, и пруды на его территории — в том числе нынешний Большой Солнцевский пруд — сохранились именно с той, дореволюционной и раннесоветской эпохи, когда здесь были обычные сельские водоёмы.
При превращении Солнцево в плотный жилой массив многоэтажек в советское время пруд и прилегающую территорию сохранили и благоустроили как парковую зону — редкий для района участок с открытой водой и деревьями среди панельной застройки.
Сегодня Солнцевский парк культуры и отдыха на улице Богданова — это пешеходные дорожки вдоль воды, зоны отдыха и спортивные площадки. Для района, где исторической застройки почти не сохранилось, а большая часть кварталов возведена в последние полвека, парк с прудом остаётся одной из немногих зелёных и «нетиповых» точек притяжения.
en:
short: |
Solntsevo's central park on Bogdanova Street is built around the Bolshoy Solntsevsky Pond — the district's main waterside recreation spot.
medium: |
The park grew up around the Bolshoy Solntsevsky Pond, one of the district's few large bodies of water, dating back to when this was the site of village ponds belonging to the pre-war settlement of Solntsevo. Today, walking paths, benches, and playgrounds line the water, and the park remains the main spot for a stroll or waterside rest in a district otherwise dominated by high-rise apartment blocks.
long: |
The village of Solntsevo, which gave its name to today's Moscow district, existed long before the mass residential construction of the 1960s1970s, and the ponds on its territory — including today's Bolshoy Solntsevsky Pond — survive from that earlier, pre-revolutionary and early-Soviet era, when these were simply village water bodies.
As Solntsevo was transformed into a dense high-rise residential area during the Soviet period, the pond and its surrounding land were preserved and landscaped as a park zone — a rare stretch of open water and trees amid the panel-built apartment blocks.
Today, the Solntsevo Park of Culture and Leisure on Bogdanova Street offers waterside walking paths, rest areas, and sports grounds. In a district with almost no surviving historic architecture, where most neighborhoods were built within the last half-century, the pond-side park remains one of the few green, distinctive gathering spots.
- slug: meshchersky-forest-park
category: park
location: { lat: 55.6611, lon: 37.3788 }
address: "Новомещерский проезд"
district: "Солнцево / Очаково-Матвеевское"
built_year: null
architect_builder: null
architectural_style: "Лесопарк с прудом"
notable_people: null
title:
ru: "Мещерский лесопарк"
en: "Meshchersky Forest Park"
content:
ru:
short: |
Мещерский лесопарк — крупный зелёный массив с прудом и оборудованным пляжем на западе Москвы, популярное место прогулок у жителей окрестных районов.
medium: |
Парк занимает лесной массив вокруг Мещерского пруда, где официально разрешено купание — на берегу оборудован песчаный пляж с кабинками для переодевания. Помимо пляжной зоны, в парке проложены пешеходные и беговые дорожки среди леса, что делает его заметным исключением на карте плотно застроенных спальных районов запада Москвы, где крупных лесных массивов сохранилось немного.
long: |
Мещерский лесопарк — один из немногих по-настоящему крупных лесных массивов, сохранившихся на западе Москвы среди плотной жилой застройки последних десятилетий. Ядром парка служит Мещерский пруд, вокруг которого и сформировалась рекреационная зона.
В отличие от многих благоустроенных городских парков, Мещерский сохраняет облик именно лесопарка: большая часть территории — это лес с грунтовыми и оборудованными дорожками, а не подстриженные газоны и клумбы. Летом главная точка притяжения — песчаный пляж на берегу пруда с официально разрешённым купанием и кабинками для переодевания.
Для жителей окрестных районов, где основная часть территории занята многоэтажными жилыми кварталами, парк остаётся одним из немногих мест, где можно оказаться в настоящем лесу, не выезжая далеко за пределы города.
en:
short: |
Meshchersky Forest Park is a large green tract with a pond and an equipped beach in western Moscow, a popular walking destination for residents of the surrounding districts.
medium: |
The park occupies a forested area around Meshchersky Pond, where swimming is officially permitted — a sandy beach with changing cabins has been set up on the shore. Beyond the beach zone, the park has walking and running trails through the forest, making it a notable exception on the map of western Moscow's densely built residential districts, where large forest tracts are otherwise scarce.
long: |
Meshchersky Forest Park is one of the few genuinely large forest tracts remaining in western Moscow amid the dense residential construction of recent decades. The park's core is Meshchersky Pond, around which the recreational zone developed.
Unlike many landscaped urban parks, Meshchersky retains the character of an actual forest park: most of its area is woodland with dirt and improved paths, rather than manicured lawns and flowerbeds. In summer, the main draw is the sandy beach on the pond's shore, with officially permitted swimming and changing cabins.
For residents of the surrounding districts, where most of the land is taken up by high-rise residential blocks, the park remains one of the few places to find real forest without traveling far outside the city.
- slug: levenson-dacha
category: building
location: { lat: 55.652966, lon: 37.353345 }
address: "Чоботовский проезд, 4"
district: "Ново-Переделкино"
built_year: "1900"
architect_builder: "Фёдор Шехтель"
architectural_style: "Модерн (национально-романтическое направление)"
notable_people: "Заказчик — С. А. Левенсон, владелец московской типографии"
title:
ru: "Дача Левенсона (Теремок Шехтеля)"
en: "Levenson Dacha (Shekhtel's Little Tower)"
content:
ru:
short: |
Деревянная дача, построенная в 1900 году архитектором Фёдором Шехтелем для типографа Левенсона, — редкий сохранившийся памятник раннего русского модерна с башенкой и резным петушком.
medium: |
Дачу в 1900 году построил знаменитый архитектор Фёдор Шехтель по заказу Сергея Левенсона, владельца одной из крупнейших московских типографий. Деревянный дом с угловой башенкой, увенчанной сказочным петушком-флюгером, стилизован под русский терем и считается одной из первых работ, определивших национально-романтическую ветвь русского модерна — направление, соединившее фольклорные мотивы с новым для того времени архитектурным языком. С 2012 года дача имеет статус объекта культурного наследия федерального значения.
long: |
Дача Левенсона в Ново-Переделкино — один из немногих сохранившихся деревянных памятников раннего творчества архитектора Фёдора Шехтеля, впоследствии прославившегося как один из главных мастеров московского модерна. Дом построили в 1900 году по заказу Сергея Александровича Левенсона, владельца крупной московской типографии, для летнего отдыха его семьи.
Архитектурное решение дачи стилизовано под русский сказочный терем: сруб украшен резьбой, а угловую башенку венчает флюгер в виде петушка. Именно за этот облик здание получило народное прозвище «Теремок Шехтеля». Специалисты считают дачу одной из первых построек, определивших так называемое национально-романтическое направление русского модерна, — стиль, обращавшийся к мотивам народного деревянного зодчества, но использовавший новые для начала XX века архитектурные приёмы.
Здание пережило XX век в относительной сохранности, что редкость для деревянной дачной архитектуры Подмосковья. В 2012 году дача Левенсона получила статус объекта культурного наследия федерального значения, что должно защищать её от сноса и реконструкции, искажающей исторический облик.
en:
short: |
A wooden dacha built in 1900 by architect Fyodor Shekhtel for printing magnate Levenson — a rare surviving monument of early Russian Art Nouveau, with a tower and a carved rooster weathervane.
medium: |
The dacha was built in 1900 by the celebrated architect Fyodor Shekhtel, commissioned by Sergei Levenson, owner of one of Moscow's largest printing houses. The wooden house, with a corner tower topped by a whimsical rooster weathervane, is styled after a Russian fairy-tale tower and is considered one of the works that defined the national-romantic branch of Russian Art Nouveau — a style blending folk motifs with the new architectural language of its era. Since 2012 the dacha has held status as a federally protected cultural heritage site.
long: |
The Levenson Dacha in Novo-Peredelkino is one of the few surviving wooden monuments from the early career of architect Fyodor Shekhtel, who would go on to become one of the leading masters of Moscow Art Nouveau. The house was built in 1900 for Sergei Levenson, owner of a major Moscow printing house, as a summer retreat for his family.
Its architecture is styled after a Russian fairy-tale tower: the log structure is decorated with carved ornamentation, and its corner tower is crowned by a rooster-shaped weathervane — the source of its popular nickname, "Shekhtel's Little Tower." Specialists consider the dacha one of the buildings that defined the so-called national-romantic branch of Russian Art Nouveau, a style drawing on traditional wooden folk architecture while employing architectural techniques new to the early 20th century.
The building survived the 20th century in relatively good condition, a rarity for wooden dacha architecture around Moscow. In 2012 the Levenson Dacha was granted status as a federally protected cultural heritage site, intended to shield it from demolition or renovation that would distort its historic appearance.
- slug: peredelkino-writers-village
category: district
location: { lat: 55.654173, lon: 37.347168 }
address: "посёлок Переделкино, platform Переделкино / Мичуринец"
district: "Ново-Переделкино"
built_year: "19331935 (первые дома), статус заповедника — 1988"
architect_builder: "Эрнст Май"
architectural_style: "Дачный посёлок"
notable_people: "Борис Пастернак, Корней Чуковский и другие советские писатели"
title:
ru: "Городок писателей «Переделкино»"
en: "Peredelkino Writers' Village"
content:
ru:
short: |
Дачный посёлок Переделкино создан в 1933 году как резиденция для советских писателей — здесь жили Борис Пастернак, Корней Чуковский и десятки других литераторов.
medium: |
Идею создания писательского посёлка приписывают Максиму Горькому, предложившему Сталину устроить для советских литераторов дачное поселение по образцу европейских творческих колоний. Проект поручили немецкому архитектору Эрнсту Маю, первые дома были готовы к 1935 году, а после войны построили ещё около двадцати. Среди жителей посёлка в разное время были Борис Пастернак (здесь он написал «Доктора Живаго» и здесь же похоронен), Корней Чуковский и многие другие крупные советские писатели. В 1988 году посёлок получил статус историко-культурного заповедника.
long: |
Городок писателей в Переделкино возник в 1933 году по инициативе, которую приписывают Максиму Горькому: он предложил Иосифу Сталину создать для советских литераторов дачный посёлок по образцу европейских творческих колоний — место, где писатели могли бы жить и работать за городом, вдали от коммунальной тесноты Москвы. Проектированием посёлка занимался немецкий архитектор Эрнст Май, приглашённый в СССР в числе других западных специалистов. Первые дома были построены к 1935 году, после Великой Отечественной войны добавили ещё около двадцати.
Список живших здесь литераторов составляет значительную часть советского литературного канона: в разные годы в Переделкино жили и работали Борис Пастернак, Корней Чуковский, Константин Паустовский, Евгений Евтушенко и многие другие. Дом Пастернака, где он написал роман «Доктор Живаго» и где похоронен, стал музеем и остаётся, пожалуй, самым посещаемым объектом посёлка.
В 1988 году, ещё в советское время, посёлок получил официальный статус историко-культурного заповедника — редкое признание значимости места, целиком связанного с историей литературы, а не с одним отдельным памятником архитектуры.
en:
short: |
The Peredelkino dacha village was created in 1933 as a residence for Soviet writers — home to Boris Pasternak, Korney Chukovsky, and dozens of other literary figures.
medium: |
The idea for a writers' village is credited to Maxim Gorky, who proposed to Stalin that Soviet writers be given a dacha settlement modeled on European artist colonies. The project was assigned to German architect Ernst May; the first houses were completed by 1935, with roughly twenty more added after the war. Residents over the years included Boris Pasternak (who wrote "Doctor Zhivago" here and is buried here), Korney Chukovsky, and many other major Soviet writers. In 1988 the village was granted status as a historical-cultural heritage site.
long: |
The writers' village in Peredelkino was established in 1933 on an initiative credited to Maxim Gorky, who proposed to Joseph Stalin that Soviet writers be given a dacha settlement modeled on European artist colonies — a place where writers could live and work outside the city, away from Moscow's cramped communal apartments. The settlement was designed by the German architect Ernst May, one of several Western specialists invited to work in the USSR at the time. The first houses were completed by 1935, with about twenty more added after the Great Patriotic War.
The roster of writers who lived here reads like a substantial slice of the Soviet literary canon: over the years, Peredelkino was home to Boris Pasternak, Korney Chukovsky, Konstantin Paustovsky, Yevgeny Yevtushenko, and many others. Pasternak's house, where he wrote the novel "Doctor Zhivago" and where he is buried, has become a museum and remains perhaps the village's most-visited site.
In 1988, still in the Soviet era, the village was granted official status as a historical-cultural heritage reserve — a rare form of recognition for a place defined by its entire literary history rather than by a single architectural monument.

View file

@ -6,26 +6,33 @@ Usage:
""" """
import argparse import argparse
import logging
from pathlib import Path from pathlib import Path
import yaml import yaml
from app.core.logging import configure_logging
from app.db.session import SessionLocal from app.db.session import SessionLocal
from app.models.city import City from app.models.city import City
from app.models.place import Place from app.models.place import Place
from app.models.place_content import PlaceContent from app.models.place_content import PlaceContent
logger = logging.getLogger("guidecity.seed_loader")
def load_cities(path: Path) -> None: def load_cities(path: Path) -> None:
logger.info("loading cities from %s", path)
data = yaml.safe_load(path.read_text(encoding="utf-8")) data = yaml.safe_load(path.read_text(encoding="utf-8"))
db = SessionLocal() db = SessionLocal()
try: try:
for c in data["cities"]: for c in data["cities"]:
city = db.query(City).filter(City.slug == c["slug"]).one_or_none() city = db.query(City).filter(City.slug == c["slug"]).one_or_none()
if city is None: if city is None:
logger.debug("creating new city slug=%r", c["slug"])
city = City(slug=c["slug"], name_ru=c["name_ru"], name_en=c["name_en"]) city = City(slug=c["slug"], name_ru=c["name_ru"], name_en=c["name_en"])
db.add(city) db.add(city)
else: else:
logger.debug("updating existing city slug=%r", c["slug"])
city.name_ru = c["name_ru"] city.name_ru = c["name_ru"]
city.name_en = c["name_en"] city.name_en = c["name_en"]
center = c.get("center") center = c.get("center")
@ -33,7 +40,10 @@ def load_cities(path: Path) -> None:
city.center_point = f"SRID=4326;POINT({center['lon']} {center['lat']})" city.center_point = f"SRID=4326;POINT({center['lon']} {center['lat']})"
db.flush() db.flush()
db.commit() db.commit()
print(f"Loaded {len(data['cities'])} cities from {path.name}") logger.info("loaded %d cities from %s", len(data["cities"]), path.name)
except Exception:
logger.exception("failed loading cities from %s", path)
raise
finally: finally:
db.close() db.close()
@ -79,6 +89,7 @@ def _upsert_content(db, place_id: int, language: str, length: str, title: str, b
def load_places(path: Path) -> None: def load_places(path: Path) -> None:
logger.info("loading places from %s", path)
data = yaml.safe_load(path.read_text(encoding="utf-8")) data = yaml.safe_load(path.read_text(encoding="utf-8"))
city_slug = data["city"] city_slug = data["city"]
@ -86,11 +97,13 @@ def load_places(path: Path) -> None:
try: try:
city = db.query(City).filter(City.slug == city_slug).one_or_none() city = db.query(City).filter(City.slug == city_slug).one_or_none()
if city is None: if city is None:
logger.error("city %r not found; run --cities seed/cities.yaml first", city_slug)
raise SystemExit( raise SystemExit(
f"City '{city_slug}' not found. Run `--cities seed/cities.yaml` first." f"City '{city_slug}' not found. Run `--cities seed/cities.yaml` first."
) )
for place_data in data["places"]: for place_data in data["places"]:
logger.debug("upserting place slug=%r", place_data["slug"])
place = _upsert_place(db, city.id, place_data) place = _upsert_place(db, city.id, place_data)
titles = place_data.get("title", {}) titles = place_data.get("title", {})
for language, lengths in place_data["content"].items(): for language, lengths in place_data["content"].items():
@ -99,12 +112,16 @@ def load_places(path: Path) -> None:
_upsert_content(db, place.id, language, length, title, body.strip()) _upsert_content(db, place.id, language, length, title, body.strip())
db.commit() db.commit()
print(f"Loaded {len(data['places'])} places from {path.name}") logger.info("loaded %d places from %s", len(data["places"]), path.name)
except Exception:
logger.exception("failed loading places from %s", path)
raise
finally: finally:
db.close() db.close()
def main() -> None: def main() -> None:
configure_logging()
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
group = parser.add_mutually_exclusive_group(required=True) group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--cities", help="Path to a cities YAML file") group.add_argument("--cities", help="Path to a cities YAML file")

View file

@ -1,6 +1,7 @@
services: services:
db: db:
image: postgis/postgis:16-3.4 image: postgis/postgis:16-3.4
restart: unless-stopped
environment: environment:
POSTGRES_USER: ${POSTGRES_USER} POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
@ -17,8 +18,10 @@ services:
api: api:
build: ./backend build: ./backend
restart: unless-stopped
environment: environment:
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
ports: ports:
- "8000:8000" - "8000:8000"
depends_on: depends_on:
@ -30,6 +33,7 @@ services:
adminer: adminer:
image: adminer image: adminer
restart: unless-stopped
ports: ports:
- "8080:8080" - "8080:8080"
depends_on: depends_on:

37
scripts/notify_telegram.sh Executable file
View file

@ -0,0 +1,37 @@
#!/bin/sh
# Sends a file (e.g. a freshly built APK) to a Telegram chat via a bot.
# Usage: scripts/notify_telegram.sh <path-to-file> ["optional caption"]
#
# Reads TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID from .telegram.env
# (gitignored) at the repo root, or from the environment if already set.
set -eu
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
ENV_FILE="$REPO_ROOT/.telegram.env"
if [ -f "$ENV_FILE" ]; then
# shellcheck disable=SC1090
. "$ENV_FILE"
fi
if [ -z "${TELEGRAM_BOT_TOKEN:-}" ] || [ -z "${TELEGRAM_CHAT_ID:-}" ]; then
echo "TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID not set (checked $ENV_FILE and environment)." >&2
exit 1
fi
FILE_PATH="${1:?Usage: notify_telegram.sh <path-to-file> [caption]}"
CAPTION="${2:-$(basename "$FILE_PATH")}"
if [ ! -f "$FILE_PATH" ]; then
echo "File not found: $FILE_PATH" >&2
exit 1
fi
curl -sS -F "chat_id=${TELEGRAM_CHAT_ID}" \
-F "document=@${FILE_PATH}" \
-F "caption=${CAPTION}" \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
| tee /dev/stderr \
| grep -q '"ok":true'