Compare commits
No commits in common. "37edc96d50f22786af006d282f1de70cdf1e13fe" and "188bf01d6eb6ee249989069967cc577a6ffe0292" have entirely different histories.
37edc96d50
...
188bf01d6e
44 changed files with 272 additions and 1162 deletions
|
|
@ -1,4 +1,3 @@
|
||||||
POSTGRES_USER=guidecity
|
POSTGRES_USER=guidecity
|
||||||
POSTGRES_PASSWORD=changeme
|
POSTGRES_PASSWORD=changeme
|
||||||
POSTGRES_DB=guidecity
|
POSTGRES_DB=guidecity
|
||||||
LOG_LEVEL=INFO
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
# 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
|
|
||||||
30
README.md
30
README.md
|
|
@ -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/) │ OSM map, on-device TTS,
|
│ (android/) │ 2GIS 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, OpenStreetMap via osmdroid, on-device TextToSpeech).
|
DataStore, 2GIS MapKit, on-device TextToSpeech).
|
||||||
|
|
||||||
## Backend quickstart
|
## Backend quickstart
|
||||||
|
|
||||||
|
|
@ -48,38 +48,24 @@ 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 API_BASE_URL if not using the default)
|
# fill in sdk.dir and DGIS_API_KEY (test key: b4df01a8-61db-4cb9-8286-7e069495987d)
|
||||||
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
|
||||||
`https://guidetest.vrubel.xyz/` (works from anywhere), `http://10.0.2.2:8000/`
|
`http://10.0.2.2:8000/` for the emulator, or your machine's LAN IP
|
||||||
for the emulator, or your machine's LAN IP for a physical device on the same
|
(e.g. `http://192.168.8.173:8000/`) 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.
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,11 @@ 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",
|
||||||
|
|
@ -62,14 +67,6 @@ 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 {
|
||||||
|
|
@ -105,12 +102,11 @@ dependencies {
|
||||||
implementation(libs.play.services.location)
|
implementation(libs.play.services.location)
|
||||||
implementation(libs.kotlinx.coroutines.play.services)
|
implementation(libs.kotlinx.coroutines.play.services)
|
||||||
|
|
||||||
// OpenStreetMap tiles via osmdroid — free, no API key/registration needed.
|
// 2GIS MapKit SDK: add once the exact Maven coordinates/repository are
|
||||||
implementation(libs.osmdroid.android)
|
// confirmed from https://docs.2gis.com/ — see map/DgisMapView.kt for the
|
||||||
|
// 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))
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,7 @@
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
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
|
||||||
|
|
@ -9,13 +8,10 @@ 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 {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
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
|
||||||
|
|
@ -9,8 +8,6 @@ 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 {
|
||||||
|
|
@ -21,7 +18,7 @@ object RetrofitClient {
|
||||||
private val okHttpClient: OkHttpClient by lazy {
|
private val okHttpClient: OkHttpClient by lazy {
|
||||||
OkHttpClient.Builder()
|
OkHttpClient.Builder()
|
||||||
.addInterceptor(
|
.addInterceptor(
|
||||||
HttpLoggingInterceptor { message -> Log.d(TAG, message) }.apply {
|
HttpLoggingInterceptor().apply {
|
||||||
level = if (BuildConfig.DEBUG) {
|
level = if (BuildConfig.DEBUG) {
|
||||||
HttpLoggingInterceptor.Level.BODY
|
HttpLoggingInterceptor.Level.BODY
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -33,7 +30,6 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ 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,
|
||||||
|
|
@ -42,6 +41,5 @@ class PlacesRepository @Inject constructor(
|
||||||
lang = lang,
|
lang = lang,
|
||||||
length = length,
|
length = length,
|
||||||
minResults = minResults,
|
minResults = minResults,
|
||||||
maxRadiusM = maxRadiusM,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ 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
|
||||||
|
|
@ -14,8 +13,6 @@ 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
|
||||||
|
|
@ -29,34 +26,18 @@ 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)
|
||||||
val granted = fine == PackageManager.PERMISSION_GRANTED || coarse == PackageManager.PERMISSION_GRANTED
|
return 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()) {
|
if (!hasLocationPermission()) return null
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
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()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,15 +3,12 @@ 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.
|
||||||
|
|
@ -35,44 +32,29 @@ 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
|
||||||
if (!isReady) {
|
pendingUtterance?.let { (text, onDone) -> speakInternal(text, onDone) }
|
||||||
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?) {
|
override fun onStart(utteranceId: String?) = Unit
|
||||||
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?) {
|
override fun onError(utteranceId: String?) = Unit
|
||||||
Log.e(TAG, "utterance error: $utteranceId")
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setLanguage(locale: Locale) {
|
fun setLanguage(locale: Locale) {
|
||||||
val result = tts.setLanguage(locale)
|
tts.language = 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
|
||||||
|
|
@ -85,33 +67,26 @@ class TtsManager @Inject constructor(
|
||||||
|
|
||||||
private fun speakInternal(text: String, onDone: (() -> Unit)?) {
|
private fun speakInternal(text: String, onDone: (() -> Unit)?) {
|
||||||
onDoneCallback = onDone
|
onDoneCallback = onDone
|
||||||
val result = tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, UUID.randomUUID().toString())
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ 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
|
||||||
|
|
@ -22,10 +20,8 @@ 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
|
||||||
|
|
@ -33,7 +29,6 @@ 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(
|
||||||
|
|
@ -43,12 +38,11 @@ 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(activePlace?.content?.title.orEmpty()) },
|
title = { Text(state.place?.content?.title.orEmpty()) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(Icons.Filled.ArrowBack, contentDescription = null)
|
Icon(Icons.Filled.ArrowBack, contentDescription = null)
|
||||||
|
|
@ -85,6 +79,7 @@ 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) {
|
||||||
|
|
@ -92,43 +87,20 @@ fun PlaceDetailScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
state.errorMessage != null || state.places.isEmpty() -> {
|
place == null -> {
|
||||||
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
|
Column(
|
||||||
// page at a time; the overview list (GuideScreen) only
|
modifier = Modifier
|
||||||
// scrolls, tapping into an item is what brings you here.
|
.fillMaxSize()
|
||||||
val pagerState = rememberPagerState(initialPage = state.activeIndex) { state.places.size }
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp),
|
||||||
LaunchedEffect(state.activeIndex) {
|
) {
|
||||||
if (pagerState.currentPage != state.activeIndex) {
|
Text(text = place.content.body, style = MaterialTheme.typography.bodyLarge)
|
||||||
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(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(16.dp),
|
|
||||||
) {
|
|
||||||
Text(text = place.content.body, style = MaterialTheme.typography.bodyLarge)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,38 @@
|
||||||
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.NearbyPlaceDto
|
import com.guidecity.app.data.remote.dto.PlaceDetailDto
|
||||||
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 places: List<NearbyPlaceDto> = emptyList(),
|
val place: PlaceDetailDto? = null,
|
||||||
val activeIndex: Int = 0,
|
|
||||||
val isMuted: Boolean = false,
|
val isMuted: Boolean = false,
|
||||||
val errorMessage: String? = null,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows one place's full content with narration, and — when the requested
|
* Self-contained per-place narrator: works whether reached from the guide
|
||||||
* place is part of the user's current nearby list — lets them swipe to the
|
* card stack, favorites, or search. On narration completion the user goes
|
||||||
* other nearby places too, reusing the same ordering, active/narration
|
* back manually — auto-advancing straight to the *next* nearby place's
|
||||||
* progression, and 10km search cap as the guide list screen. When reached
|
* detail (as in the original spec) would need this screen to share
|
||||||
* from Favorites/search for a place that isn't in that list, it falls back
|
* GuideViewModel's ordered list, which only exists when arriving from the
|
||||||
* to a single, non-swipeable place fetched directly by id.
|
* guide screen; left as a follow-up once that shared-state wiring is added.
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class PlaceDetailViewModel @Inject constructor(
|
class PlaceDetailViewModel @Inject constructor(
|
||||||
|
|
@ -50,134 +40,40 @@ 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 initialPlaceId: Int = checkNotNull(savedStateHandle.get<String>("placeId")).toInt()
|
private val placeId: 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()
|
||||||
|
|
||||||
private val _isFavorite = MutableStateFlow(false)
|
val isFavorite: StateFlow<Boolean> = favoritesRepository.isFavorite(placeId)
|
||||||
val isFavorite: StateFlow<Boolean> = _isFavorite.asStateFlow()
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||||
|
|
||||||
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 location = selectedLocationHolder.location.value ?: locationProvider.getCurrentLocation()
|
val place = runCatching { placesRepository.getPlace(placeId, lang = "ru", length = length) }.getOrNull()
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = false, place = place)
|
||||||
val nearby = location?.let { loc ->
|
place?.let { ttsManager.speak(it.content.body) }
|
||||||
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 state = _uiState.value
|
val place = _uiState.value.place ?: return
|
||||||
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(place.id)
|
favoritesRepository.removeFavorite(placeId)
|
||||||
} else {
|
} else {
|
||||||
favoritesRepository.addFavorite(
|
favoritesRepository.addFavorite(
|
||||||
FavoritePlaceEntity(
|
FavoritePlaceEntity(
|
||||||
placeId = place.id,
|
placeId = placeId,
|
||||||
slug = place.slug,
|
slug = place.slug,
|
||||||
name = place.content.title,
|
name = place.content.title,
|
||||||
category = place.category,
|
category = place.category,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
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
|
||||||
|
|
@ -8,17 +7,13 @@ 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())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
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
|
||||||
|
|
@ -18,12 +16,10 @@ 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
|
||||||
|
|
@ -32,7 +28,6 @@ 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(
|
||||||
|
|
@ -42,11 +37,6 @@ 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 = {
|
||||||
|
|
@ -86,6 +76,7 @@ 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))
|
||||||
}
|
}
|
||||||
|
|
@ -105,30 +96,14 @@ fun GuideScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
PlaceCardStack(
|
||||||
Text(
|
places = state.places,
|
||||||
text = stringResource(R.string.guide_places_count, state.places.size),
|
activeIndex = state.activeIndex,
|
||||||
style = MaterialTheme.typography.labelLarge,
|
doneIndices = state.doneIndices,
|
||||||
color = Mocha.Subtext1,
|
onCardClick = onOpenDetail,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
onPageChanged = { viewModel.setActiveIndex(it) },
|
||||||
)
|
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) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
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
|
||||||
|
|
@ -10,7 +9,6 @@ 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
|
||||||
|
|
@ -22,8 +20,6 @@ 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(),
|
||||||
|
|
@ -58,7 +54,6 @@ 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()
|
||||||
|
|
@ -68,11 +63,9 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -81,13 +74,10 @@ 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,
|
||||||
|
|
@ -97,7 +87,6 @@ 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,
|
||||||
|
|
@ -109,7 +98,6 @@ 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",
|
||||||
|
|
@ -119,7 +107,6 @@ 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)
|
||||||
|
|
@ -129,17 +116,13 @@ 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
|
||||||
Log.d(TAG, "narrateActive: index=${state.activeIndex} placeId=${place.id} slug=${place.slug}")
|
ttsManager.speak(place.content.body) { onNarrationDone() }
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
@ -150,15 +133,21 @@ 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
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) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.CityMapView
|
import com.guidecity.app.map.DgisMapView
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MapScreen(
|
fun MapScreen(
|
||||||
|
|
@ -50,18 +50,16 @@ 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 })
|
viewModel.onPermissionResult(results.values.any { it }, onLocationResolved)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.checkInitialPermission()
|
viewModel.checkInitialPermission(onLocationResolved)
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
|
@ -84,14 +82,14 @@ fun MapScreen(
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding),
|
.padding(padding),
|
||||||
) {
|
) {
|
||||||
CityMapView(
|
DgisMapView(
|
||||||
userLocation = resolvedLocation,
|
userLocation = null,
|
||||||
places = nearbyPlaces,
|
places = emptyList(),
|
||||||
onPlaceClick = {},
|
onPlaceClick = {},
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
|
|
||||||
when (val state = uiState) {
|
when (uiState) {
|
||||||
MapUiState.NeedsPermission -> {
|
MapUiState.NeedsPermission -> {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -134,7 +132,7 @@ fun MapScreen(
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(place.name) },
|
headlineContent = { Text(place.name) },
|
||||||
modifier = Modifier.clickable {
|
modifier = Modifier.clickable {
|
||||||
viewModel.selectSearchResult(place)
|
viewModel.selectSearchResult(place, onLocationResolved)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -142,47 +140,9 @@ fun MapScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MapUiState.ResolvingLocation, MapUiState.CheckingPermission, MapUiState.CheckingNearby -> {
|
MapUiState.ResolvingLocation, MapUiState.CheckingPermission -> {
|
||||||
Column(
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
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
|
||||||
|
|
@ -16,17 +14,11 @@ 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
|
||||||
|
|
@ -42,105 +34,49 @@ 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()
|
||||||
|
|
||||||
private val _resolvedLocation = MutableStateFlow<LatLon?>(null)
|
fun checkInitialPermission(onLocationResolved: () -> Unit) {
|
||||||
val resolvedLocation: StateFlow<LatLon?> = _resolvedLocation.asStateFlow()
|
if (locationProvider.hasLocationPermission()) {
|
||||||
|
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) {
|
fun onPermissionResult(granted: Boolean, onLocationResolved: () -> Unit) {
|
||||||
Log.i(TAG, "onPermissionResult: granted=$granted")
|
|
||||||
if (granted) {
|
if (granted) {
|
||||||
resolveLocation()
|
resolveLocation(onLocationResolved)
|
||||||
} else {
|
} else {
|
||||||
_uiState.value = MapUiState.SearchFallback
|
_uiState.value = MapUiState.SearchFallback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveLocation() {
|
private fun resolveLocation(onLocationResolved: () -> Unit) {
|
||||||
_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)
|
||||||
checkNearby(location)
|
onLocationResolved()
|
||||||
} 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 {
|
||||||
runCatching {
|
_searchResults.value = runCatching {
|
||||||
placesRepository.searchPlaces(query, citySlug = "moscow", lang = "ru")
|
placesRepository.searchPlaces(query, citySlug = "moscow", lang = "ru")
|
||||||
}.onSuccess { results ->
|
}.getOrDefault(emptyList())
|
||||||
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) {
|
fun selectSearchResult(place: PlaceListItemDto, onLocationResolved: () -> Unit) {
|
||||||
Log.i(TAG, "selectSearchResult: placeId=${place.id} slug=${place.slug}")
|
selectedLocationHolder.set(LatLon(place.location.lat, place.location.lon))
|
||||||
val location = LatLon(place.location.lat, place.location.lon)
|
onLocationResolved()
|
||||||
_resolvedLocation.value = location
|
|
||||||
selectedLocationHolder.set(location)
|
|
||||||
checkNearby(location)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
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
|
||||||
|
|
@ -8,15 +7,12 @@ 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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
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
|
||||||
|
|
@ -12,8 +11,6 @@ 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,
|
||||||
|
|
@ -23,13 +20,11 @@ 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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
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
|
||||||
|
|
@ -12,8 +11,6 @@ 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,
|
||||||
|
|
@ -22,7 +19,6 @@ 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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
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"
|
|
||||||
}
|
|
||||||
|
|
@ -15,13 +15,8 @@
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,8 @@
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -19,9 +19,6 @@ 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" }
|
||||||
|
|
@ -60,10 +57,6 @@ 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" }
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,11 @@
|
||||||
|
|
||||||
sdk.dir=/path/to/your/Android/Sdk
|
sdk.dir=/path/to/your/Android/Sdk
|
||||||
|
|
||||||
# Base URL of the guideCity backend API. Options, in order of preference:
|
# Test key provided for development; do not ship this as-is in a public repo.
|
||||||
# 1. Nginx-fronted HTTPS domain (works from anywhere, not just the LAN):
|
DGIS_API_KEY=b4df01a8-61db-4cb9-8286-7e069495987d
|
||||||
API_BASE_URL=https://guidetest.vrubel.xyz/
|
|
||||||
# 2. Emulator -> host loopback: http://10.0.2.2:8000/
|
# Base URL of the guideCity backend API.
|
||||||
# 3. Physical device on the same Wi-Fi/LAN -> your machine's LAN IP, e.g. http://192.168.1.50:8000/
|
# Emulator -> host loopback: http://10.0.2.2:8000/
|
||||||
# (backend already binds 0.0.0.0:8000 via docker-compose, so it's reachable on the LAN IP with
|
# Physical device on the same Wi-Fi/LAN -> your machine's LAN IP, e.g. http://192.168.1.50:8000/
|
||||||
# no extra config; cleartext HTTP is allowed in debug builds only, see app/src/debug/AndroidManifest.xml)
|
# (backend already binds 0.0.0.0:8000 via docker-compose, so it's reachable on the LAN IP with no extra config)
|
||||||
|
API_BASE_URL=http://192.168.8.173:8000/
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ 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).
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
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
|
||||||
|
|
@ -10,8 +8,6 @@ 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"])
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -35,5 +31,4 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import logging
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -10,8 +8,6 @@ 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"])
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -36,9 +32,6 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
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
|
||||||
|
|
@ -15,8 +13,6 @@ 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"])
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -60,7 +56,6 @@ 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
|
||||||
|
|
@ -89,7 +84,6 @@ 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 = (
|
||||||
|
|
@ -117,7 +111,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ 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()
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,4 @@
|
||||||
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:
|
||||||
|
|
@ -13,25 +6,3 @@ 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
|
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,9 @@
|
||||||
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
|
|
||||||
|
|
||||||
configure_logging(level=getattr(logging, settings.log_level.upper(), logging.INFO))
|
app = FastAPI(title="guideCity API", version="0.1.0")
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ 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
|
||||||
|
|
@ -15,8 +13,6 @@ 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,
|
||||||
|
|
@ -48,6 +44,4 @@ 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)
|
||||||
|
|
||||||
results = db.execute(stmt).all()
|
return 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
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ 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
|
||||||
|
|
@ -22,8 +21,6 @@ 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:
|
||||||
|
|
@ -47,14 +44,6 @@ 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)
|
||||||
|
|
@ -86,16 +75,8 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -1,181 +0,0 @@
|
||||||
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: |
|
|
||||||
Появление метро в Солнцево обсуждалось десятилетиями: район, застроенный в основном в 1960–1980-е годы как город-спутник Москвы, долгое время оставался без своей ветки метро, несмотря на присоединение к городу ещё в 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 1960s–1980s 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: |
|
|
||||||
Село Солнцево, давшее имя нынешнему московскому району, существовало ещё до массовой жилой застройки 1960–1970-х годов, и пруды на его территории — в том числе нынешний Большой Солнцевский пруд — сохранились именно с той, дореволюционной и раннесоветской эпохи, когда здесь были обычные сельские водоёмы.
|
|
||||||
|
|
||||||
При превращении Солнцево в плотный жилой массив многоэтажек в советское время пруд и прилегающую территорию сохранили и благоустроили как парковую зону — редкий для района участок с открытой водой и деревьями среди панельной застройки.
|
|
||||||
|
|
||||||
Сегодня Солнцевский парк культуры и отдыха на улице Богданова — это пешеходные дорожки вдоль воды, зоны отдыха и спортивные площадки. Для района, где исторической застройки почти не сохранилось, а большая часть кварталов возведена в последние полвека, парк с прудом остаётся одной из немногих зелёных и «нетиповых» точек притяжения.
|
|
||||||
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 1960s–1970s, 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: "1933–1935 (первые дома), статус заповедника — 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.
|
|
||||||
|
|
@ -6,33 +6,26 @@ 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")
|
||||||
|
|
@ -40,10 +33,7 @@ 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()
|
||||||
logger.info("loaded %d cities from %s", len(data["cities"]), path.name)
|
print(f"Loaded {len(data['cities'])} cities from {path.name}")
|
||||||
except Exception:
|
|
||||||
logger.exception("failed loading cities from %s", path)
|
|
||||||
raise
|
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
@ -89,7 +79,6 @@ 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"]
|
||||||
|
|
||||||
|
|
@ -97,13 +86,11 @@ 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():
|
||||||
|
|
@ -112,16 +99,12 @@ 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()
|
||||||
logger.info("loaded %d places from %s", len(data["places"]), path.name)
|
print(f"Loaded {len(data['places'])} places from {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")
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
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}
|
||||||
|
|
@ -18,10 +17,8 @@ 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:
|
||||||
|
|
@ -33,7 +30,6 @@ services:
|
||||||
|
|
||||||
adminer:
|
adminer:
|
||||||
image: adminer
|
image: adminer
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
#!/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'
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue