From be67902b453fa708777b163d0d85ae9084e20221 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Thu, 9 Jul 2026 20:10:10 +0000 Subject: [PATCH 01/10] Add structured logging throughout the backend Request-level logging middleware (method/path/status/duration), per-module loggers in routes/services (nearby search radius expansion, 404s, name search), LOG_LEVEL setting wired through docker-compose, and seed_loader converted from print() to logging. Verified: pytest passes, and manually confirmed log lines appear for both successful and 404 requests via `docker compose logs api`. Co-Authored-By: Claude Sonnet 5 --- .env.example | 1 + backend/app/api/routes/cities.py | 5 +++++ backend/app/api/routes/nearby.py | 7 +++++++ backend/app/api/routes/places.py | 7 +++++++ backend/app/config.py | 1 + backend/app/core/logging.py | 29 +++++++++++++++++++++++++++ backend/app/main.py | 19 +++++++++++++++++- backend/app/services/geocoding.py | 8 +++++++- backend/app/services/nearby_search.py | 19 ++++++++++++++++++ backend/seed/seed_loader.py | 21 +++++++++++++++++-- docker-compose.yml | 1 + 11 files changed, 114 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index fb6fe50..ed3000c 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ POSTGRES_USER=guidecity POSTGRES_PASSWORD=changeme POSTGRES_DB=guidecity +LOG_LEVEL=INFO diff --git a/backend/app/api/routes/cities.py b/backend/app/api/routes/cities.py index a573996..9f920e4 100644 --- a/backend/app/api/routes/cities.py +++ b/backend/app/api/routes/cities.py @@ -1,3 +1,5 @@ +import logging + from fastapi import APIRouter, Depends from geoalchemy2 import Geometry from sqlalchemy import cast, func, select @@ -8,6 +10,8 @@ from app.models.city import City from app.schemas.city import CityOut from app.schemas.common import LatLon, LocalizedName +logger = logging.getLogger("guidecity.cities") + router = APIRouter(tags=["cities"]) @@ -31,4 +35,5 @@ def list_cities(db: Session = Depends(get_db)) -> list[CityOut]: center=center, ) ) + logger.debug("listed %d cities", len(out)) return out diff --git a/backend/app/api/routes/nearby.py b/backend/app/api/routes/nearby.py index 09b9306..68f6e39 100644 --- a/backend/app/api/routes/nearby.py +++ b/backend/app/api/routes/nearby.py @@ -1,3 +1,5 @@ +import logging + from fastapi import APIRouter, Depends, Query from sqlalchemy.orm import Session @@ -8,6 +10,8 @@ from app.schemas.nearby import NearbyPlace, NearbyResponse from app.schemas.place import ContentOut from app.services.nearby_search import find_nearby +logger = logging.getLogger("guidecity.nearby") + router = APIRouter(tags=["nearby"]) @@ -32,6 +36,9 @@ def nearby( ), db: Session = Depends(get_db), ) -> 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( db, lat=lat, diff --git a/backend/app/api/routes/places.py b/backend/app/api/routes/places.py index 26e545a..7393c2a 100644 --- a/backend/app/api/routes/places.py +++ b/backend/app/api/routes/places.py @@ -1,3 +1,5 @@ +import logging + from fastapi import APIRouter, Depends, HTTPException, Query from geoalchemy2 import Geometry from sqlalchemy import and_, cast, func, select @@ -13,6 +15,8 @@ from app.schemas.common import LatLon from app.schemas.place import ContentOut, PlaceDetail, PlaceListItem from app.services.geocoding import search_places +logger = logging.getLogger("guidecity.places") + router = APIRouter(tags=["places"]) @@ -56,6 +60,7 @@ def get_place( ) row = db.execute(stmt).first() 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") place, content, lon, lat = row @@ -84,6 +89,7 @@ def list_city_places( ) -> list[PlaceListItem]: city = db.scalar(select(City).where(City.slug == city_slug)) if city is None: + logger.warning("city_slug=%r not found", city_slug) raise HTTPException(status_code=404, detail="City not found") stmt = ( @@ -111,6 +117,7 @@ def list_city_places( stmt = stmt.where(PlaceContent.title.ilike(f"%{q}%")) rows = db.execute(stmt).all() + logger.debug("city_slug=%r listing -> %d place(s)", city_slug, len(rows)) return [ 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 diff --git a/backend/app/config.py b/backend/app/config.py index 09195f7..e073dca 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -6,6 +6,7 @@ class Settings(BaseSettings): database_url: str = "postgresql+psycopg://guidecity:guidecity@localhost:5432/guidecity" api_v1_prefix: str = "/api/v1" + log_level: str = "INFO" settings = Settings() diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py index a9dd3e3..66190dc 100644 --- a/backend/app/core/logging.py +++ b/backend/app/core/logging.py @@ -1,4 +1,11 @@ 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: @@ -6,3 +13,25 @@ def configure_logging(level: int = logging.INFO) -> None: level=level, 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 diff --git a/backend/app/main.py b/backend/app/main.py index 2a6460f..076ac94 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,9 +1,26 @@ +import logging +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + from fastapi import FastAPI from app.api.routes import cities, health, nearby, places from app.config import settings +from app.core.logging import configure_logging, log_requests -app = FastAPI(title="guideCity API", version="0.1.0") +configure_logging(level=getattr(logging, settings.log_level.upper(), logging.INFO)) +logger = logging.getLogger("guidecity") + + +@asynccontextmanager +async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + logger.info("guideCity API startup complete (log_level=%s)", settings.log_level) + yield + logger.info("guideCity API shutting down") + + +app = FastAPI(title="guideCity API", version="0.1.0", lifespan=lifespan) +app.middleware("http")(log_requests) app.include_router(health.router, prefix=settings.api_v1_prefix) app.include_router(cities.router, prefix=settings.api_v1_prefix) diff --git a/backend/app/services/geocoding.py b/backend/app/services/geocoding.py index 0637f36..400b6c5 100644 --- a/backend/app/services/geocoding.py +++ b/backend/app/services/geocoding.py @@ -4,6 +4,8 @@ Used when the user denies location permission and types a place of interest, or when 2GIS's own geocoding API isn't wired into a given client flow yet. """ +import logging + from geoalchemy2 import Geometry from sqlalchemy import and_, cast, func, select from sqlalchemy.orm import Session @@ -13,6 +15,8 @@ from app.models.enums import ContentLanguage, ContentLength from app.models.place import Place from app.models.place_content import PlaceContent +logger = logging.getLogger("guidecity.geocoding") + def search_places( db: Session, @@ -44,4 +48,6 @@ def search_places( if city_slug is not None: stmt = stmt.join(City, City.id == Place.city_id).where(City.slug == city_slug) - return db.execute(stmt).all() + results = db.execute(stmt).all() + logger.info("name search q=%r city_slug=%s lang=%s -> %d result(s)", q, city_slug, language, len(results)) + return results diff --git a/backend/app/services/nearby_search.py b/backend/app/services/nearby_search.py index 94c763f..c4f40d7 100644 --- a/backend/app/services/nearby_search.py +++ b/backend/app/services/nearby_search.py @@ -10,6 +10,7 @@ results toward the user's direction of travel and to pick a shorter content Not implemented in this iteration — callers should not pass them yet. """ +import logging from dataclasses import dataclass from geoalchemy2 import Geometry @@ -21,6 +22,8 @@ from app.models.enums import ContentLanguage, ContentLength from app.models.place import Place from app.models.place_content import PlaceContent +logger = logging.getLogger("guidecity.nearby_search") + @dataclass class NearbyResult: @@ -44,6 +47,14 @@ def find_nearby( city_id: int | None = None if city_slug is not None: 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})") distance_expr = func.ST_Distance(Place.location, origin) @@ -75,8 +86,16 @@ def find_nearby( while True: stmt = base_stmt.where(func.ST_DWithin(Place.location, origin, radius)) 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: break 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) diff --git a/backend/seed/seed_loader.py b/backend/seed/seed_loader.py index 99daeb5..b34a19b 100644 --- a/backend/seed/seed_loader.py +++ b/backend/seed/seed_loader.py @@ -6,26 +6,33 @@ Usage: """ import argparse +import logging from pathlib import Path import yaml +from app.core.logging import configure_logging from app.db.session import SessionLocal from app.models.city import City from app.models.place import Place from app.models.place_content import PlaceContent +logger = logging.getLogger("guidecity.seed_loader") + def load_cities(path: Path) -> None: + logger.info("loading cities from %s", path) data = yaml.safe_load(path.read_text(encoding="utf-8")) db = SessionLocal() try: for c in data["cities"]: city = db.query(City).filter(City.slug == c["slug"]).one_or_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"]) db.add(city) else: + logger.debug("updating existing city slug=%r", c["slug"]) city.name_ru = c["name_ru"] city.name_en = c["name_en"] center = c.get("center") @@ -33,7 +40,10 @@ def load_cities(path: Path) -> None: city.center_point = f"SRID=4326;POINT({center['lon']} {center['lat']})" db.flush() db.commit() - print(f"Loaded {len(data['cities'])} cities from {path.name}") + logger.info("loaded %d cities from %s", len(data["cities"]), path.name) + except Exception: + logger.exception("failed loading cities from %s", path) + raise finally: db.close() @@ -79,6 +89,7 @@ def _upsert_content(db, place_id: int, language: str, length: str, title: str, b def load_places(path: Path) -> None: + logger.info("loading places from %s", path) data = yaml.safe_load(path.read_text(encoding="utf-8")) city_slug = data["city"] @@ -86,11 +97,13 @@ def load_places(path: Path) -> None: try: city = db.query(City).filter(City.slug == city_slug).one_or_none() if city is None: + logger.error("city %r not found; run --cities seed/cities.yaml first", city_slug) raise SystemExit( f"City '{city_slug}' not found. Run `--cities seed/cities.yaml` first." ) for place_data in data["places"]: + logger.debug("upserting place slug=%r", place_data["slug"]) place = _upsert_place(db, city.id, place_data) titles = place_data.get("title", {}) for language, lengths in place_data["content"].items(): @@ -99,12 +112,16 @@ def load_places(path: Path) -> None: _upsert_content(db, place.id, language, length, title, body.strip()) db.commit() - print(f"Loaded {len(data['places'])} places from {path.name}") + logger.info("loaded %d places from %s", len(data["places"]), path.name) + except Exception: + logger.exception("failed loading places from %s", path) + raise finally: db.close() def main() -> None: + configure_logging() parser = argparse.ArgumentParser(description=__doc__) group = parser.add_mutually_exclusive_group(required=True) group.add_argument("--cities", help="Path to a cities YAML file") diff --git a/docker-compose.yml b/docker-compose.yml index 697da63..542ea37 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,7 @@ services: build: ./backend environment: DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + LOG_LEVEL: ${LOG_LEVEL:-INFO} ports: - "8000:8000" depends_on: From 6e90956b3ea3148abc3b3c289c5265541c3a01f6 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Thu, 9 Jul 2026 20:10:31 +0000 Subject: [PATCH 02/10] Add logging throughout the Android app and unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Log.d/i/w/e calls covering location resolution, TTS lifecycle and errors, nearby/search network calls and their failure paths, and screen-level state transitions. OkHttp's logging interceptor now routes through Log (tag "OkHttp") instead of println for consistent filtering. Enabled testOptions.unitTests.isReturnDefaultValues so android.util.Log calls don't crash plain JVM unit tests. Added mockk + kotlinx-coroutines-test and a GuideViewModelTest suite covering the load-success, load-failure, and retry paths — including a regression test for the infinite-spinner bug (isLoading must clear and errorMessage must be set on a failed nearby-search call, not left hanging). Verified: ./gradlew testDebugUnitTest passes (4/4). Co-Authored-By: Claude Sonnet 5 --- android/app/build.gradle.kts | 10 ++ .../java/com/guidecity/app/GuideCityApp.kt | 10 +- .../java/com/guidecity/app/MainActivity.kt | 4 + .../app/data/remote/RetrofitClient.kt | 6 +- .../app/location/LocationProvider.kt | 33 ++++- .../java/com/guidecity/app/tts/TtsManager.kt | 35 ++++- .../app/ui/detail/PlaceDetailViewModel.kt | 11 +- .../app/ui/favorites/FavoritesViewModel.kt | 5 + .../guidecity/app/ui/guide/GuideViewModel.kt | 18 +++ .../com/guidecity/app/ui/map/MapViewModel.kt | 22 ++- .../app/ui/onboarding/OnboardingViewModel.kt | 4 + .../ui/preference/ContentLengthViewModel.kt | 5 + .../app/ui/settings/SettingsViewModel.kt | 4 + .../com/guidecity/app/MainDispatcherRule.kt | 24 ++++ .../app/data/local/prefs/ContentLengthTest.kt | 14 ++ .../app/ui/guide/GuideViewModelTest.kt | 132 ++++++++++++++++++ android/gradle/libs.versions.toml | 4 + 17 files changed, 323 insertions(+), 18 deletions(-) create mode 100644 android/app/src/test/java/com/guidecity/app/MainDispatcherRule.kt create mode 100644 android/app/src/test/java/com/guidecity/app/data/local/prefs/ContentLengthTest.kt create mode 100644 android/app/src/test/java/com/guidecity/app/ui/guide/GuideViewModelTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 85db5b1..92914c7 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -67,6 +67,14 @@ android { compose = 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 { @@ -107,6 +115,8 @@ dependencies { // isolated integration point in the meantime. testImplementation(libs.junit) + testImplementation(libs.mockk) + testImplementation(libs.kotlinx.coroutines.test) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/android/app/src/main/java/com/guidecity/app/GuideCityApp.kt b/android/app/src/main/java/com/guidecity/app/GuideCityApp.kt index deece02..1c3c302 100644 --- a/android/app/src/main/java/com/guidecity/app/GuideCityApp.kt +++ b/android/app/src/main/java/com/guidecity/app/GuideCityApp.kt @@ -1,7 +1,15 @@ package com.guidecity.app import android.app.Application +import android.util.Log import dagger.hilt.android.HiltAndroidApp +private const val TAG = "GuideCityApp" + @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})") + } +} diff --git a/android/app/src/main/java/com/guidecity/app/MainActivity.kt b/android/app/src/main/java/com/guidecity/app/MainActivity.kt index 8920f00..cbd919a 100644 --- a/android/app/src/main/java/com/guidecity/app/MainActivity.kt +++ b/android/app/src/main/java/com/guidecity/app/MainActivity.kt @@ -1,6 +1,7 @@ package com.guidecity.app import android.os.Bundle +import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge @@ -8,10 +9,13 @@ import com.guidecity.app.navigation.GuideCityNavHost import com.guidecity.app.theme.GuideCityTheme import dagger.hilt.android.AndroidEntryPoint +private const val TAG = "MainActivity" + @AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + Log.d(TAG, "onCreate") enableEdgeToEdge() setContent { GuideCityTheme { diff --git a/android/app/src/main/java/com/guidecity/app/data/remote/RetrofitClient.kt b/android/app/src/main/java/com/guidecity/app/data/remote/RetrofitClient.kt index 198ff83..e276466 100644 --- a/android/app/src/main/java/com/guidecity/app/data/remote/RetrofitClient.kt +++ b/android/app/src/main/java/com/guidecity/app/data/remote/RetrofitClient.kt @@ -1,5 +1,6 @@ package com.guidecity.app.data.remote +import android.util.Log import com.guidecity.app.BuildConfig import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType @@ -8,6 +9,8 @@ import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.kotlinx.serialization.asConverterFactory +private const val TAG = "OkHttp" + object RetrofitClient { private val json = Json { @@ -18,7 +21,7 @@ object RetrofitClient { private val okHttpClient: OkHttpClient by lazy { OkHttpClient.Builder() .addInterceptor( - HttpLoggingInterceptor().apply { + HttpLoggingInterceptor { message -> Log.d(TAG, message) }.apply { level = if (BuildConfig.DEBUG) { HttpLoggingInterceptor.Level.BODY } else { @@ -30,6 +33,7 @@ object RetrofitClient { } val apiService: ApiService by lazy { + Log.i(TAG, "creating Retrofit client, baseUrl=${BuildConfig.API_BASE_URL}") Retrofit.Builder() .baseUrl(BuildConfig.API_BASE_URL) .client(okHttpClient) diff --git a/android/app/src/main/java/com/guidecity/app/location/LocationProvider.kt b/android/app/src/main/java/com/guidecity/app/location/LocationProvider.kt index c62bac0..5eeb5f9 100644 --- a/android/app/src/main/java/com/guidecity/app/location/LocationProvider.kt +++ b/android/app/src/main/java/com/guidecity/app/location/LocationProvider.kt @@ -4,6 +4,7 @@ import android.Manifest import android.annotation.SuppressLint import android.content.Context import android.content.pm.PackageManager +import android.util.Log import androidx.core.content.ContextCompat import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.location.LocationServices @@ -13,6 +14,8 @@ import kotlinx.coroutines.tasks.await import javax.inject.Inject import javax.inject.Singleton +private const val TAG = "LocationProvider" + data class LatLon(val lat: Double, val lon: Double) @Singleton @@ -26,18 +29,34 @@ class LocationProvider @Inject constructor( fun hasLocationPermission(): Boolean { val fine = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) val coarse = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) - return fine == PackageManager.PERMISSION_GRANTED || coarse == PackageManager.PERMISSION_GRANTED + val granted = fine == PackageManager.PERMISSION_GRANTED || coarse == PackageManager.PERMISSION_GRANTED + Log.d(TAG, "hasLocationPermission: fine=$fine coarse=$coarse -> $granted") + return granted } /** Returns null if permission isn't granted or no location could be resolved. */ @SuppressLint("MissingPermission") suspend fun getCurrentLocation(): LatLon? { - if (!hasLocationPermission()) return null + if (!hasLocationPermission()) { + Log.w(TAG, "getCurrentLocation: permission not granted, returning null") + return null + } - val current = fusedClient - .getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, null) - .await() - val location = current ?: fusedClient.lastLocation.await() - return location?.let { LatLon(it.latitude, it.longitude) } + return try { + val current = fusedClient + .getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, null) + .await() + val location = current ?: fusedClient.lastLocation.await() + 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 + } } } diff --git a/android/app/src/main/java/com/guidecity/app/tts/TtsManager.kt b/android/app/src/main/java/com/guidecity/app/tts/TtsManager.kt index 7a1c145..6cb6bac 100644 --- a/android/app/src/main/java/com/guidecity/app/tts/TtsManager.kt +++ b/android/app/src/main/java/com/guidecity/app/tts/TtsManager.kt @@ -3,12 +3,15 @@ package com.guidecity.app.tts import android.content.Context import android.speech.tts.TextToSpeech import android.speech.tts.UtteranceProgressListener +import android.util.Log import dagger.hilt.android.qualifiers.ApplicationContext import java.util.Locale import java.util.UUID import javax.inject.Inject import javax.inject.Singleton +private const val TAG = "TtsManager" + /** * Wraps Android's built-in, on-device [TextToSpeech] engine — this satisfies * the app's "local voice narration" requirement without a custom ML model. @@ -32,29 +35,44 @@ class TtsManager @Inject constructor( private val tts: TextToSpeech = TextToSpeech(context) { status -> isReady = status == TextToSpeech.SUCCESS - pendingUtterance?.let { (text, onDone) -> speakInternal(text, onDone) } + if (!isReady) { + Log.e(TAG, "TextToSpeech engine init failed, status=$status") + } else { + Log.i(TAG, "TextToSpeech engine ready") + } + pendingUtterance?.let { (text, onDone) -> + Log.d(TAG, "flushing pending utterance queued before engine was ready") + speakInternal(text, onDone) + } pendingUtterance = null }.apply { setOnUtteranceProgressListener( object : UtteranceProgressListener() { - override fun onStart(utteranceId: String?) = Unit + override fun onStart(utteranceId: String?) { + Log.d(TAG, "utterance started: $utteranceId") + } override fun onDone(utteranceId: String?) { + Log.d(TAG, "utterance done: $utteranceId") onDoneCallback?.invoke() } @Deprecated("Deprecated in Java, but still the callback the platform invokes") - override fun onError(utteranceId: String?) = Unit + override fun onError(utteranceId: String?) { + Log.e(TAG, "utterance error: $utteranceId") + } }, ) } fun setLanguage(locale: Locale) { - tts.language = locale + val result = tts.setLanguage(locale) + Log.d(TAG, "setLanguage($locale) -> $result") } /** Speaks [text], calling [onDone] on the main thread once narration finishes. */ fun speak(text: String, onDone: (() -> Unit)? = null) { + Log.d(TAG, "speak() called, muted=$isMuted ready=$isReady length=${text.length}") lastSpokenText = text onDoneCallback = onDone if (isMuted) return @@ -67,26 +85,33 @@ class TtsManager @Inject constructor( private fun speakInternal(text: String, onDone: (() -> Unit)?) { onDoneCallback = onDone - tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, UUID.randomUUID().toString()) + val result = tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, UUID.randomUUID().toString()) + if (result != TextToSpeech.SUCCESS) { + Log.e(TAG, "tts.speak() returned error code $result") + } } /** Stops narration and suppresses further [speak] calls until [unmute]. */ fun mute() { + Log.d(TAG, "mute()") isMuted = true tts.stop() } /** Resumes narration from the start of the last spoken text. */ fun unmute() { + Log.d(TAG, "unmute()") isMuted = false lastSpokenText?.let { speakInternal(it, onDoneCallback) } } fun stop() { + Log.d(TAG, "stop()") tts.stop() } fun shutdown() { + Log.d(TAG, "shutdown()") tts.shutdown() } } diff --git a/android/app/src/main/java/com/guidecity/app/ui/detail/PlaceDetailViewModel.kt b/android/app/src/main/java/com/guidecity/app/ui/detail/PlaceDetailViewModel.kt index 5253e79..3c62735 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/detail/PlaceDetailViewModel.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/detail/PlaceDetailViewModel.kt @@ -1,5 +1,6 @@ package com.guidecity.app.ui.detail +import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -20,6 +21,8 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject +private const val TAG = "PlaceDetailViewModel" + data class PlaceDetailUiState( val isLoading: Boolean = true, val place: PlaceDetailDto? = null, @@ -52,9 +55,13 @@ class PlaceDetailViewModel @Inject constructor( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) init { + Log.d(TAG, "init: placeId=$placeId") viewModelScope.launch { val length = (userPrefsDataStore.contentLength.first() ?: ContentLength.MEDIUM).toApiValue() - val place = runCatching { placesRepository.getPlace(placeId, lang = "ru", length = length) }.getOrNull() + val place = runCatching { placesRepository.getPlace(placeId, lang = "ru", length = length) } + .onFailure { Log.e(TAG, "getPlace($placeId) failed", it) } + .getOrNull() + Log.d(TAG, "loaded place=${place?.slug}") _uiState.value = _uiState.value.copy(isLoading = false, place = place) place?.let { ttsManager.speak(it.content.body) } } @@ -62,11 +69,13 @@ class PlaceDetailViewModel @Inject constructor( fun toggleMute() { if (ttsManager.isMuted) ttsManager.unmute() else ttsManager.mute() + Log.d(TAG, "toggleMute: isMuted=${ttsManager.isMuted}") _uiState.value = _uiState.value.copy(isMuted = ttsManager.isMuted) } fun toggleFavorite() { val place = _uiState.value.place ?: return + Log.d(TAG, "toggleFavorite: placeId=$placeId currentlyFavorite=${isFavorite.value}") viewModelScope.launch { if (isFavorite.value) { favoritesRepository.removeFavorite(placeId) diff --git a/android/app/src/main/java/com/guidecity/app/ui/favorites/FavoritesViewModel.kt b/android/app/src/main/java/com/guidecity/app/ui/favorites/FavoritesViewModel.kt index aa6e104..32af551 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/favorites/FavoritesViewModel.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/favorites/FavoritesViewModel.kt @@ -1,5 +1,6 @@ package com.guidecity.app.ui.favorites +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.guidecity.app.data.local.db.FavoritePlaceEntity @@ -7,13 +8,17 @@ import com.guidecity.app.data.repository.FavoritesRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import javax.inject.Inject +private const val TAG = "FavoritesViewModel" + @HiltViewModel class FavoritesViewModel @Inject constructor( favoritesRepository: FavoritesRepository, ) : ViewModel() { val favorites: StateFlow> = favoritesRepository.observeFavorites() + .onEach { Log.d(TAG, "favorites updated: ${it.size} item(s)") } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) } diff --git a/android/app/src/main/java/com/guidecity/app/ui/guide/GuideViewModel.kt b/android/app/src/main/java/com/guidecity/app/ui/guide/GuideViewModel.kt index f43ecbf..8f5d7e9 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/guide/GuideViewModel.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/guide/GuideViewModel.kt @@ -1,5 +1,6 @@ package com.guidecity.app.ui.guide +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.guidecity.app.data.local.prefs.ContentLength @@ -20,6 +21,8 @@ import kotlinx.coroutines.launch import java.util.Locale import javax.inject.Inject +private const val TAG = "GuideViewModel" + data class GuideUiState( val isLoading: Boolean = true, val places: List = emptyList(), @@ -54,6 +57,7 @@ class GuideViewModel @Inject constructor( init { viewModelScope.launch { contentLength = userPrefsDataStore.contentLength.first() ?: ContentLength.MEDIUM + Log.i(TAG, "init: contentLength=$contentLength") ttsManager.setLanguage(Locale("ru")) loadNearby(startNarration = true) startRescanLoop() @@ -63,9 +67,11 @@ class GuideViewModel @Inject constructor( private fun startRescanLoop() { if (rescanStarted) return rescanStarted = true + Log.d(TAG, "starting 60s re-scan loop") viewModelScope.launch { while (isActive) { delay(60_000) + Log.d(TAG, "re-scan tick") loadNearby(startNarration = false) } } @@ -74,10 +80,13 @@ class GuideViewModel @Inject constructor( private suspend fun loadNearby(startNarration: Boolean) { val location = selectedLocationHolder.location.value ?: locationProvider.getCurrentLocation() if (location == null) { + Log.w(TAG, "loadNearby: no location available") _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = "Location unavailable") return } + Log.d(TAG, "loadNearby: lat=${location.lat} lon=${location.lon} length=${contentLength.toApiValue()}") + runCatching { placesRepository.getNearby( lat = location.lat, @@ -87,6 +96,7 @@ class GuideViewModel @Inject constructor( length = contentLength.toApiValue(), ) }.onSuccess { response -> + Log.i(TAG, "loadNearby success: ${response.places.size} place(s), radius=${response.searchRadiusM}m") _uiState.value = _uiState.value.copy( isLoading = false, places = response.places, @@ -98,6 +108,7 @@ class GuideViewModel @Inject constructor( narrateActive() } }.onFailure { error -> + Log.e(TAG, "loadNearby failed", error) _uiState.value = _uiState.value.copy( isLoading = false, errorMessage = error.message ?: "Network error", @@ -107,6 +118,7 @@ class GuideViewModel @Inject constructor( /** Retries after a failed load (e.g. tap a "retry" button shown on error). */ fun retry() { + Log.d(TAG, "retry() called") viewModelScope.launch { _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) loadNearby(startNarration = true) @@ -116,13 +128,16 @@ class GuideViewModel @Inject constructor( private fun narrateActive() { val state = _uiState.value 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() } } private fun onNarrationDone() { val state = _uiState.value val nextIndex = state.activeIndex + 1 + Log.d(TAG, "onNarrationDone: finished index=${state.activeIndex}, nextIndex=$nextIndex") if (nextIndex >= state.places.size) { + Log.i(TAG, "onNarrationDone: reached end of nearby list") _uiState.value = state.copy(doneIndices = state.doneIndices + state.activeIndex) return } @@ -137,17 +152,20 @@ class GuideViewModel @Inject constructor( 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 (manual swipe)") _uiState.value = state.copy(activeIndex = index) narrateActive() } fun toggleMute() { if (ttsManager.isMuted) ttsManager.unmute() else ttsManager.mute() + Log.d(TAG, "toggleMute: isMuted=${ttsManager.isMuted}") _uiState.value = _uiState.value.copy(isMuted = ttsManager.isMuted) } override fun onCleared() { super.onCleared() + Log.d(TAG, "onCleared") ttsManager.stop() } } diff --git a/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt b/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt index 92df0f7..83e4287 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt @@ -1,5 +1,6 @@ package com.guidecity.app.ui.map +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.guidecity.app.data.remote.dto.PlaceListItemDto @@ -14,6 +15,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject +private const val TAG = "MapViewModel" + sealed interface MapUiState { data object CheckingPermission : MapUiState data object NeedsPermission : MapUiState @@ -35,7 +38,9 @@ class MapViewModel @Inject constructor( val searchResults: StateFlow> = _searchResults.asStateFlow() fun checkInitialPermission(onLocationResolved: () -> Unit) { - if (locationProvider.hasLocationPermission()) { + val hasPermission = locationProvider.hasLocationPermission() + Log.d(TAG, "checkInitialPermission: hasPermission=$hasPermission") + if (hasPermission) { resolveLocation(onLocationResolved) } else { _uiState.value = MapUiState.NeedsPermission @@ -43,6 +48,7 @@ class MapViewModel @Inject constructor( } fun onPermissionResult(granted: Boolean, onLocationResolved: () -> Unit) { + Log.i(TAG, "onPermissionResult: granted=$granted") if (granted) { resolveLocation(onLocationResolved) } else { @@ -55,9 +61,11 @@ class MapViewModel @Inject constructor( viewModelScope.launch { val location = locationProvider.getCurrentLocation() if (location != null) { + Log.i(TAG, "resolveLocation: got lat=${location.lat} lon=${location.lon}") selectedLocationHolder.set(location) onLocationResolved() } else { + Log.w(TAG, "resolveLocation: location unavailable, falling back to search") _uiState.value = MapUiState.SearchFallback } } @@ -68,14 +76,22 @@ class MapViewModel @Inject constructor( _searchResults.value = emptyList() return } + Log.d(TAG, "searchPlaces: query=$query") viewModelScope.launch { - _searchResults.value = runCatching { + runCatching { placesRepository.searchPlaces(query, citySlug = "moscow", lang = "ru") - }.getOrDefault(emptyList()) + }.onSuccess { results -> + Log.d(TAG, "searchPlaces: ${results.size} result(s) for query=$query") + _searchResults.value = results + }.onFailure { error -> + Log.e(TAG, "searchPlaces failed for query=$query", error) + _searchResults.value = emptyList() + } } } fun selectSearchResult(place: PlaceListItemDto, onLocationResolved: () -> Unit) { + Log.i(TAG, "selectSearchResult: placeId=${place.id} slug=${place.slug}") selectedLocationHolder.set(LatLon(place.location.lat, place.location.lon)) onLocationResolved() } diff --git a/android/app/src/main/java/com/guidecity/app/ui/onboarding/OnboardingViewModel.kt b/android/app/src/main/java/com/guidecity/app/ui/onboarding/OnboardingViewModel.kt index 219c432..f550a49 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/onboarding/OnboardingViewModel.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/onboarding/OnboardingViewModel.kt @@ -1,5 +1,6 @@ package com.guidecity.app.ui.onboarding +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.guidecity.app.data.local.prefs.UserPrefsDataStore @@ -7,12 +8,15 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject +private const val TAG = "OnboardingViewModel" + @HiltViewModel class OnboardingViewModel @Inject constructor( private val userPrefsDataStore: UserPrefsDataStore, ) : ViewModel() { fun markOnboardingSeen(onDone: () -> Unit) { viewModelScope.launch { + Log.i(TAG, "markOnboardingSeen") userPrefsDataStore.setOnboardingSeen(true) onDone() } diff --git a/android/app/src/main/java/com/guidecity/app/ui/preference/ContentLengthViewModel.kt b/android/app/src/main/java/com/guidecity/app/ui/preference/ContentLengthViewModel.kt index 7310fe2..6cca989 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/preference/ContentLengthViewModel.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/preference/ContentLengthViewModel.kt @@ -1,5 +1,6 @@ package com.guidecity.app.ui.preference +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.guidecity.app.data.local.prefs.ContentLength @@ -11,6 +12,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject +private const val TAG = "ContentLengthViewModel" + @HiltViewModel class ContentLengthViewModel @Inject constructor( private val userPrefsDataStore: UserPrefsDataStore, @@ -20,11 +23,13 @@ class ContentLengthViewModel @Inject constructor( val selected: StateFlow = _selected.asStateFlow() fun select(length: ContentLength) { + Log.d(TAG, "select: $length") _selected.value = length } fun confirmSelection(onDone: () -> Unit) { viewModelScope.launch { + Log.i(TAG, "confirmSelection: ${_selected.value}") userPrefsDataStore.setContentLength(_selected.value) onDone() } diff --git a/android/app/src/main/java/com/guidecity/app/ui/settings/SettingsViewModel.kt b/android/app/src/main/java/com/guidecity/app/ui/settings/SettingsViewModel.kt index bf98ceb..d0d7969 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/settings/SettingsViewModel.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/settings/SettingsViewModel.kt @@ -1,5 +1,6 @@ package com.guidecity.app.ui.settings +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.guidecity.app.data.local.prefs.ContentLength @@ -11,6 +12,8 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject +private const val TAG = "SettingsViewModel" + @HiltViewModel class SettingsViewModel @Inject constructor( private val userPrefsDataStore: UserPrefsDataStore, @@ -19,6 +22,7 @@ class SettingsViewModel @Inject constructor( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) fun setContentLength(length: ContentLength) { + Log.i(TAG, "setContentLength: $length") viewModelScope.launch { userPrefsDataStore.setContentLength(length) } } } diff --git a/android/app/src/test/java/com/guidecity/app/MainDispatcherRule.kt b/android/app/src/test/java/com/guidecity/app/MainDispatcherRule.kt new file mode 100644 index 0000000..9d39d77 --- /dev/null +++ b/android/app/src/test/java/com/guidecity/app/MainDispatcherRule.kt @@ -0,0 +1,24 @@ +package com.guidecity.app + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +/** Swaps `Dispatchers.Main` for a test dispatcher so `viewModelScope` coroutines run synchronously. */ +@ExperimentalCoroutinesApi +class MainDispatcherRule( + private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(), +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} diff --git a/android/app/src/test/java/com/guidecity/app/data/local/prefs/ContentLengthTest.kt b/android/app/src/test/java/com/guidecity/app/data/local/prefs/ContentLengthTest.kt new file mode 100644 index 0000000..a6cafa3 --- /dev/null +++ b/android/app/src/test/java/com/guidecity/app/data/local/prefs/ContentLengthTest.kt @@ -0,0 +1,14 @@ +package com.guidecity.app.data.local.prefs + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ContentLengthTest { + + @Test + fun `toApiValue lowercases the enum name`() { + assertEquals("short", ContentLength.SHORT.toApiValue()) + assertEquals("medium", ContentLength.MEDIUM.toApiValue()) + assertEquals("long", ContentLength.LONG.toApiValue()) + } +} diff --git a/android/app/src/test/java/com/guidecity/app/ui/guide/GuideViewModelTest.kt b/android/app/src/test/java/com/guidecity/app/ui/guide/GuideViewModelTest.kt new file mode 100644 index 0000000..aca0ecc --- /dev/null +++ b/android/app/src/test/java/com/guidecity/app/ui/guide/GuideViewModelTest.kt @@ -0,0 +1,132 @@ +package com.guidecity.app.ui.guide + +import androidx.lifecycle.viewModelScope +import com.guidecity.app.MainDispatcherRule +import com.guidecity.app.data.local.prefs.ContentLength +import com.guidecity.app.data.local.prefs.UserPrefsDataStore +import com.guidecity.app.data.remote.dto.ContentDto +import com.guidecity.app.data.remote.dto.LatLonDto +import com.guidecity.app.data.remote.dto.NearbyPlaceDto +import com.guidecity.app.data.remote.dto.NearbyResponseDto +import com.guidecity.app.data.repository.PlacesRepository +import com.guidecity.app.location.LatLon +import com.guidecity.app.location.LocationProvider +import com.guidecity.app.location.SelectedLocationHolder +import com.guidecity.app.tts.TtsManager +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import java.io.IOException + +/** + * Covers the loadNearby() success/failure/retry paths — in particular a + * regression test for a real bug we hit: on a failed nearby-search call, the + * screen used to stay stuck on the loading spinner forever because the + * early-return path never cleared `isLoading`. See GuideViewModel.loadNearby. + */ +class GuideViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val testLocation = LatLon(lat = 55.75, lon = 37.62) + + private fun fakePlace(id: Int): NearbyPlaceDto = NearbyPlaceDto( + id = id, + slug = "place-$id", + category = "monument", + location = LatLonDto(lat = 55.75, lon = 37.62), + content = ContentDto(language = "ru", length = "medium", title = "Title $id", body = "Body $id"), + distanceM = 10.0 * id, + ) + + private fun buildViewModel( + placesRepository: PlacesRepository, + ttsManager: TtsManager = mockk(relaxed = true), + ): GuideViewModel { + val locationProvider = mockk(relaxed = true) + + val selectedLocationHolder = mockk() + every { selectedLocationHolder.location } returns MutableStateFlow(testLocation) + + val userPrefsDataStore = mockk() + 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() + 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(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() + 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() + 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() + } +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 8b783f6..1c04b3a 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -19,6 +19,8 @@ ksp = "2.0.20-1.0.25" junit = "4.13.2" androidxTestExtJunit = "1.2.1" espressoCore = "3.6.1" +mockk = "1.13.12" +kotlinxCoroutinesTest = "1.8.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -57,6 +59,8 @@ 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-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } 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" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From 0cc8d9b7675c005c6c3d2b1af4c7e07f59c1c1e1 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Thu, 9 Jul 2026 20:10:40 +0000 Subject: [PATCH 03/10] Point mobile app at nginx-fronted https://guidetest.vrubel.xyz/ Verified reachable end-to-end (health/nearby endpoints respond correctly through the reverse proxy) and set as the preferred API_BASE_URL for testing from a physical device on any network, ahead of the LAN-IP and emulator-loopback fallbacks. Co-Authored-By: Claude Sonnet 5 --- README.md | 11 +++++++++-- android/local.properties.example | 12 +++++++----- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 632549c..4dc055d 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,10 @@ also reachable from other devices on the same LAN at `http://:8000/` — no extra config needed, just make sure nothing (firewall, VPN) blocks port 8000 on that interface. +It's also reverse-proxied behind nginx at **https://guidetest.vrubel.xyz/**, +which works from anywhere (not just the local network) and is the preferred +`API_BASE_URL` for the Android app during this test phase. + ## Android quickstart ```bash @@ -57,10 +61,13 @@ cd android && ./gradlew :app:assembleDebug ``` Point the app's API base URL (`API_BASE_URL` in `local.properties`) at -`http://10.0.2.2:8000/` for the emulator, or your machine's LAN IP -(e.g. `http://192.168.8.173:8000/`) for a physical device on the same +`https://guidetest.vrubel.xyz/` (works from anywhere), `http://10.0.2.2:8000/` +for the emulator, or your machine's LAN IP for a physical device on the same Wi-Fi/LAN. +Run unit tests before building — `./gradlew testDebugUnitTest`, then +`./gradlew :app:assembleDebug`. + ## Notes / current scope This is iteration 1: Moscow only (Gorky Park, Red Square area, Moscow-City), diff --git a/android/local.properties.example b/android/local.properties.example index dbd5163..6432a12 100644 --- a/android/local.properties.example +++ b/android/local.properties.example @@ -5,8 +5,10 @@ sdk.dir=/path/to/your/Android/Sdk # Test key provided for development; do not ship this as-is in a public repo. DGIS_API_KEY=b4df01a8-61db-4cb9-8286-7e069495987d -# Base URL of the guideCity backend API. -# Emulator -> host loopback: http://10.0.2.2:8000/ -# Physical device on the same Wi-Fi/LAN -> your machine's LAN IP, e.g. http://192.168.1.50:8000/ -# (backend already binds 0.0.0.0:8000 via docker-compose, so it's reachable on the LAN IP with no extra config) -API_BASE_URL=http://192.168.8.173:8000/ +# Base URL of the guideCity backend API. Options, in order of preference: +# 1. Nginx-fronted HTTPS domain (works from anywhere, not just the LAN): +API_BASE_URL=https://guidetest.vrubel.xyz/ +# 2. Emulator -> host loopback: http://10.0.2.2:8000/ +# 3. Physical device on the same Wi-Fi/LAN -> your machine's LAN IP, e.g. http://192.168.1.50:8000/ +# (backend already binds 0.0.0.0:8000 via docker-compose, so it's reachable on the LAN IP with +# no extra config; cleartext HTTP is allowed in debug builds only, see app/src/debug/AndroidManifest.xml) From bb80307479d84ae8c090fc55efd849998de4137c Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Thu, 9 Jul 2026 20:17:07 +0000 Subject: [PATCH 04/10] Add Telegram notification script for sending build artifacts scripts/notify_telegram.sh sends a file (e.g. the debug APK) to a Telegram chat via Bot API sendDocument. Reads TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID from .telegram.env (gitignored, real credentials never committed) or the environment. Verified end-to-end: sent the current debug APK and confirmed delivery. Co-Authored-By: Claude Sonnet 5 --- .telegram.env.example | 9 +++++++++ scripts/notify_telegram.sh | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 .telegram.env.example create mode 100755 scripts/notify_telegram.sh diff --git a/.telegram.env.example b/.telegram.env.example new file mode 100644 index 0000000..03b18f5 --- /dev/null +++ b/.telegram.env.example @@ -0,0 +1,9 @@ +# Copy to .telegram.env (gitignored) and fill in real values. +# Used by scripts/notify_telegram.sh to send build artifacts (e.g. the APK) +# to a Telegram chat after a successful build. + +TELEGRAM_BOT_TOKEN=123456:ABC-DEF... +# Numeric chat id of the person/group the bot should message. A bot can't +# message you first — send it any message (e.g. /start) once, then run +# `curl "https://api.telegram.org/bot/getUpdates"` to find your chat id. +TELEGRAM_CHAT_ID=123456789 diff --git a/scripts/notify_telegram.sh b/scripts/notify_telegram.sh new file mode 100755 index 0000000..157797c --- /dev/null +++ b/scripts/notify_telegram.sh @@ -0,0 +1,37 @@ +#!/bin/sh +# Sends a file (e.g. a freshly built APK) to a Telegram chat via a bot. +# Usage: scripts/notify_telegram.sh ["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 [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' From 2901950520980d88fc172e12272087e3b40610d3 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Thu, 9 Jul 2026 20:26:00 +0000 Subject: [PATCH 05/10] Add 5 real places near Solntsevo (Domostroitelnaya 14) for live testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Metro Solntsevo, the district's central park/pond (Bogdanova St), Meshchersky forest park, the Levenson dacha (a Shekhtel-designed 1900 Art Nouveau landmark), and the Peredelkino writers' village — all researched with real coordinates/history, in RU/EN x short/medium/long. Verified against the live nearby endpoint: all 5 are found from the user's actual coordinates (55.662303, 37.425486), radius expanding to 5100m to cover the farthest (Peredelkino village, ~5012m out). Co-Authored-By: Claude Sonnet 5 --- backend/seed/moscow_solntsevo.yaml | 181 +++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 backend/seed/moscow_solntsevo.yaml diff --git a/backend/seed/moscow_solntsevo.yaml b/backend/seed/moscow_solntsevo.yaml new file mode 100644 index 0000000..d6d8771 --- /dev/null +++ b/backend/seed/moscow_solntsevo.yaml @@ -0,0 +1,181 @@ +city: moscow +places: + - slug: solntsevo-metro-station + category: building + location: { lat: 55.6497, lon: 37.3911 } + address: "Солнцевский проспект, вл1" + district: "Солнцево" + built_year: "2018" + architect_builder: "Метрогипротранс" + architectural_style: "Станция мелкого заложения" + notable_people: null + title: + ru: "Станция метро «Солнцево»" + en: "Solntsevo Metro Station" + content: + ru: + short: | + Станция метро «Солнцево» открылась в 2018 году в составе западного участка Калининско-Солнцевской линии и стала главными транспортными воротами района. + medium: | + Станцию открыли 26 мая 2018 года — вместе с ней в тот же день заработали ещё три станции нового западного радиуса Калининско-Солнцевской линии, соединившего спальный район Солнцево с центром Москвы напрямую, без пересадок через кольцо. До этого жителям приходилось добираться на метро с пересадками или долгой поездкой на наземном транспорте. Станция мелкого заложения расположена прямо под Солнцевским проспектом и с открытия стала главным ориентиром и точкой притяжения района. + long: | + Появление метро в Солнцево обсуждалось десятилетиями: район, застроенный в основном в 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. From 531fab801db06550c47b1d093334f457236dc01b Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Thu, 9 Jul 2026 20:44:36 +0000 Subject: [PATCH 06/10] Add restart: unless-stopped to all docker-compose services Root cause of the 502: the host's disk filled up (down to ~511MB free), which appears to have restarted the Docker daemon. Every other service on this shared host came back up automatically (they all have restart policies); guideCity's three containers didn't and stayed down. Freed ~3.2GB via `docker builder prune` + `docker image prune` (safe: build cache and dangling images only, no running containers touched) and brought the stack back up. Co-Authored-By: Claude Sonnet 5 --- docker-compose.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 542ea37..d633235 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,7 @@ services: db: image: postgis/postgis:16-3.4 + restart: unless-stopped environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} @@ -17,6 +18,7 @@ services: api: build: ./backend + restart: unless-stopped environment: DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} LOG_LEVEL: ${LOG_LEVEL:-INFO} @@ -31,6 +33,7 @@ services: adminer: image: adminer + restart: unless-stopped ports: - "8080:8080" depends_on: From e22086345162fcab07267bbd4ac4609d9c0b4272 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Thu, 9 Jul 2026 20:58:17 +0000 Subject: [PATCH 07/10] Add map summary screen (place count + Start button) before the guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After resolving location, the map screen now checks how many places are nearby (same server-side iterative radius expansion the guide screen uses) and shows "Found N places within Rm" with a Start button, instead of jumping straight into narration. The search cap is raised to 10km (was 5km) — if nothing turns up within that, a "nothing found" message is shown instead. This also addresses a UX report: the guide screen's Tinder-style card stack only shows one card at a time by design, which read as "it only found one place" even when 5 were actually there. Added a "1 / 5" position indicator to the card stack so it's clear there's more to swipe through, and the new summary screen surfaces the real count upfront regardless. Verified: ./gradlew testDebugUnitTest passes, :app:assembleDebug produces a working APK, sent to Telegram. Co-Authored-By: Claude Sonnet 5 --- .../app/data/repository/PlacesRepository.kt | 2 + .../guidecity/app/ui/guide/PlaceCardStack.kt | 52 +++++++++++------ .../com/guidecity/app/ui/map/MapScreen.kt | 50 +++++++++++++++-- .../com/guidecity/app/ui/map/MapViewModel.kt | 56 ++++++++++++++++--- .../app/src/main/res/values-ru/strings.xml | 4 ++ android/app/src/main/res/values/strings.xml | 4 ++ 6 files changed, 136 insertions(+), 32 deletions(-) diff --git a/android/app/src/main/java/com/guidecity/app/data/repository/PlacesRepository.kt b/android/app/src/main/java/com/guidecity/app/data/repository/PlacesRepository.kt index 1890d6e..1aa7934 100644 --- a/android/app/src/main/java/com/guidecity/app/data/repository/PlacesRepository.kt +++ b/android/app/src/main/java/com/guidecity/app/data/repository/PlacesRepository.kt @@ -34,6 +34,7 @@ class PlacesRepository @Inject constructor( lang: String, length: String, minResults: Int = 5, + maxRadiusM: Int = 10_000, ): NearbyResponseDto = apiService.getNearby( lat = lat, lon = lon, @@ -41,5 +42,6 @@ class PlacesRepository @Inject constructor( lang = lang, length = length, minResults = minResults, + maxRadiusM = maxRadiusM, ) } diff --git a/android/app/src/main/java/com/guidecity/app/ui/guide/PlaceCardStack.kt b/android/app/src/main/java/com/guidecity/app/ui/guide/PlaceCardStack.kt index 381ba67..f682604 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/guide/PlaceCardStack.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/guide/PlaceCardStack.kt @@ -1,17 +1,22 @@ package com.guidecity.app.ui.guide import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column 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.material3.MaterialTheme +import androidx.compose.material3.Text 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 com.guidecity.app.theme.Mocha import kotlinx.coroutines.flow.distinctUntilChanged /** @@ -46,24 +51,37 @@ fun PlaceCardStack( .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, + Column(modifier = modifier) { + // Makes it obvious there's more than one place to swipe through, + // even when only the active card is visible on screen. + Text( + text = "${activeIndex + 1} / ${places.size}", + style = MaterialTheme.typography.labelLarge, + color = Mocha.Subtext1, modifier = Modifier - .padding(12.dp) - .clickable { onCardClick(place.id) }, + .fillMaxWidth() + .padding(top = 8.dp), ) + + 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) }, + ) + } } } diff --git a/android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt b/android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt index 7888355..55b3bc4 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt @@ -55,11 +55,11 @@ fun MapScreen( val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestMultiplePermissions(), ) { results -> - viewModel.onPermissionResult(results.values.any { it }, onLocationResolved) + viewModel.onPermissionResult(results.values.any { it }) } LaunchedEffect(Unit) { - viewModel.checkInitialPermission(onLocationResolved) + viewModel.checkInitialPermission() } Scaffold( @@ -89,7 +89,7 @@ fun MapScreen( modifier = Modifier.fillMaxSize(), ) - when (uiState) { + when (val state = uiState) { MapUiState.NeedsPermission -> { Column( modifier = Modifier @@ -132,7 +132,7 @@ fun MapScreen( ListItem( headlineContent = { Text(place.name) }, modifier = Modifier.clickable { - viewModel.selectSearchResult(place, onLocationResolved) + viewModel.selectSearchResult(place) }, ) } @@ -140,9 +140,47 @@ fun MapScreen( } } - MapUiState.ResolvingLocation, MapUiState.CheckingPermission -> { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + MapUiState.ResolvingLocation, MapUiState.CheckingPermission, MapUiState.CheckingNearby -> { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { 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)) + } } } } diff --git a/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt b/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt index 83e4287..3cc1a05 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt @@ -16,12 +16,16 @@ import kotlinx.coroutines.launch import javax.inject.Inject private const val TAG = "MapViewModel" +private const val MAX_SEARCH_RADIUS_M = 10_000 sealed interface MapUiState { data object CheckingPermission : MapUiState data object NeedsPermission : MapUiState data object ResolvingLocation : MapUiState data object SearchFallback : MapUiState + data object CheckingNearby : MapUiState + data class ReadyToStart(val placesFound: Int, val radiusM: Int) : MapUiState + data object NoPlacesNearby : MapUiState } @HiltViewModel @@ -37,33 +41,33 @@ class MapViewModel @Inject constructor( private val _searchResults = MutableStateFlow>(emptyList()) val searchResults: StateFlow> = _searchResults.asStateFlow() - fun checkInitialPermission(onLocationResolved: () -> Unit) { + fun checkInitialPermission() { val hasPermission = locationProvider.hasLocationPermission() Log.d(TAG, "checkInitialPermission: hasPermission=$hasPermission") if (hasPermission) { - resolveLocation(onLocationResolved) + resolveLocation() } else { _uiState.value = MapUiState.NeedsPermission } } - fun onPermissionResult(granted: Boolean, onLocationResolved: () -> Unit) { + fun onPermissionResult(granted: Boolean) { Log.i(TAG, "onPermissionResult: granted=$granted") if (granted) { - resolveLocation(onLocationResolved) + resolveLocation() } else { _uiState.value = MapUiState.SearchFallback } } - private fun resolveLocation(onLocationResolved: () -> Unit) { + private fun resolveLocation() { _uiState.value = MapUiState.ResolvingLocation viewModelScope.launch { val location = locationProvider.getCurrentLocation() if (location != null) { Log.i(TAG, "resolveLocation: got lat=${location.lat} lon=${location.lon}") selectedLocationHolder.set(location) - onLocationResolved() + checkNearby(location) } else { Log.w(TAG, "resolveLocation: location unavailable, falling back to search") _uiState.value = MapUiState.SearchFallback @@ -71,6 +75,39 @@ class MapViewModel @Inject constructor( } } + /** + * 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") + _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) { if (query.isBlank()) { _searchResults.value = emptyList() @@ -90,9 +127,10 @@ class MapViewModel @Inject constructor( } } - fun selectSearchResult(place: PlaceListItemDto, onLocationResolved: () -> Unit) { + fun selectSearchResult(place: PlaceListItemDto) { Log.i(TAG, "selectSearchResult: placeId=${place.id} slug=${place.slug}") - selectedLocationHolder.set(LatLon(place.location.lat, place.location.lon)) - onLocationResolved() + val location = LatLon(place.location.lat, place.location.lon) + selectedLocationHolder.set(location) + checkNearby(location) } } diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index d3d07e8..19c9a19 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -15,6 +15,10 @@ guideCity нужен доступ к геолокации, чтобы найти места поблизости. Вы также можете ввести место вручную. Разрешить доступ к геолокации Введите место для поиска… + Ищем места поблизости… + Найдено мест поблизости: %1$d (в радиусе %2$d м) + Начать + В радиусе 10 км ничего не нашлось. Поблизости пока не найдено интересных мест. Повторить diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index bdae1cf..2dfb5aa 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -15,6 +15,10 @@ guideCity needs your location to find nearby places. You can also search for a place by name instead. Grant location access Search for a place instead… + Looking for places nearby… + Found %1$d place(s) nearby (within %2$d m) + Start + Nothing found within 10 km of here. No places found nearby yet. Retry From 7f1a6d51fc5f184b68c86ad004af5ceb3055d95a Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Thu, 9 Jul 2026 21:08:19 +0000 Subject: [PATCH 08/10] Restructure guide flow: list overview, swipe in detail, real map markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three UX fixes: 1. The placeholder map was always rendered with userLocation=null and places=emptyList() — MapScreen never passed the actually-resolved location/nearby list to DgisMapView, so it only ever showed the "waiting for location" placeholder text. MapViewModel now exposes resolvedLocation and nearbyPlaces, wired through to the map. (Note: this is still the Canvas-based placeholder, not the real 2GIS SDK — that integration is still pending, as flagged since the skeleton was first built.) 2. GuideScreen's Tinder-style pager only showed one card at a time, which read as "it only found one place" even with 5 nearby. Swapped it for a plain scrollable list (auto-scrolls to the active/narrating item); tapping an item opens PlaceDetailScreen, which now owns the swipe-between-places interaction instead. PlaceDetailViewModel fetches the same nearby-ordered list GuideViewModel uses (falling back to a single non-swipeable place when opened from Favorites/search for something outside that list). 3. Narration now leads with the place's name and distance from the user before the body text (util/Narration.kt), in both the guide list and the detail swipe view. Verified: testDebugUnitTest passes, assembleDebug produces a working APK, sent to Telegram. Co-Authored-By: Claude Sonnet 5 --- .../app/ui/detail/PlaceDetailScreen.kt | 48 ++++-- .../app/ui/detail/PlaceDetailViewModel.kt | 143 +++++++++++++++--- .../com/guidecity/app/ui/guide/GuideScreen.kt | 47 ++++-- .../guidecity/app/ui/guide/GuideViewModel.kt | 13 +- .../guidecity/app/ui/guide/PlaceCardStack.kt | 87 ----------- .../com/guidecity/app/ui/map/MapScreen.kt | 6 +- .../com/guidecity/app/ui/map/MapViewModel.kt | 10 ++ .../java/com/guidecity/app/util/Narration.kt | 23 +++ .../app/src/main/res/values-ru/strings.xml | 1 + android/app/src/main/res/values/strings.xml | 1 + 10 files changed, 235 insertions(+), 144 deletions(-) delete mode 100644 android/app/src/main/java/com/guidecity/app/ui/guide/PlaceCardStack.kt create mode 100644 android/app/src/main/java/com/guidecity/app/util/Narration.kt diff --git a/android/app/src/main/java/com/guidecity/app/ui/detail/PlaceDetailScreen.kt b/android/app/src/main/java/com/guidecity/app/ui/detail/PlaceDetailScreen.kt index 0c77b9c..1666322 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/detail/PlaceDetailScreen.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/detail/PlaceDetailScreen.kt @@ -4,6 +4,8 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize 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.verticalScroll import androidx.compose.material.icons.Icons @@ -20,8 +22,10 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -29,6 +33,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.guidecity.app.R import com.guidecity.app.theme.GuideStackColors +import kotlinx.coroutines.flow.distinctUntilChanged @Composable fun PlaceDetailScreen( @@ -38,11 +43,12 @@ fun PlaceDetailScreen( ) { val state by viewModel.uiState.collectAsState() val isFavorite by viewModel.isFavorite.collectAsState() + val activePlace = state.places.getOrNull(state.activeIndex) Scaffold( topBar = { TopAppBar( - title = { Text(state.place?.content?.title.orEmpty()) }, + title = { Text(activePlace?.content?.title.orEmpty()) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, contentDescription = null) @@ -79,7 +85,6 @@ fun PlaceDetailScreen( .fillMaxSize() .padding(padding), ) { - val place = state.place when { state.isLoading -> { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -87,20 +92,43 @@ fun PlaceDetailScreen( } } - place == null -> { + state.errorMessage != null || state.places.isEmpty() -> { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text(text = stringResource(R.string.place_not_found)) } } else -> { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(16.dp), - ) { - Text(text = place.content.body, style = MaterialTheme.typography.bodyLarge) + // Swiping here moves between nearby places, one full-content + // page at a time; the overview list (GuideScreen) only + // scrolls, tapping into an item is what brings you here. + val pagerState = rememberPagerState(initialPage = state.activeIndex) { state.places.size } + + LaunchedEffect(state.activeIndex) { + if (pagerState.currentPage != state.activeIndex) { + pagerState.animateScrollToPage(state.activeIndex) + } + } + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage } + .distinctUntilChanged() + .collect { page -> viewModel.setActiveIndex(page) } + } + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + ) { page -> + val place = state.places[page] + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + Text(text = place.content.body, style = MaterialTheme.typography.bodyLarge) + } } } } diff --git a/android/app/src/main/java/com/guidecity/app/ui/detail/PlaceDetailViewModel.kt b/android/app/src/main/java/com/guidecity/app/ui/detail/PlaceDetailViewModel.kt index 3c62735..a925802 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/detail/PlaceDetailViewModel.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/detail/PlaceDetailViewModel.kt @@ -7,35 +7,42 @@ import androidx.lifecycle.viewModelScope import com.guidecity.app.data.local.db.FavoritePlaceEntity import com.guidecity.app.data.local.prefs.ContentLength import com.guidecity.app.data.local.prefs.UserPrefsDataStore -import com.guidecity.app.data.remote.dto.PlaceDetailDto +import com.guidecity.app.data.remote.dto.NearbyPlaceDto import com.guidecity.app.data.repository.FavoritesRepository import com.guidecity.app.data.repository.PlacesRepository +import com.guidecity.app.location.LocationProvider +import com.guidecity.app.location.SelectedLocationHolder import com.guidecity.app.tts.TtsManager +import com.guidecity.app.util.buildNarrationText import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch 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( val isLoading: Boolean = true, - val place: PlaceDetailDto? = null, + val places: List = emptyList(), + val activeIndex: Int = 0, val isMuted: Boolean = false, + val errorMessage: String? = null, ) /** - * Self-contained per-place narrator: works whether reached from the guide - * card stack, favorites, or search. On narration completion the user goes - * back manually — auto-advancing straight to the *next* nearby place's - * detail (as in the original spec) would need this screen to share - * GuideViewModel's ordered list, which only exists when arriving from the - * guide screen; left as a follow-up once that shared-state wiring is added. + * Shows one place's full content with narration, and — when the requested + * place is part of the user's current nearby list — lets them swipe to the + * other nearby places too, reusing the same ordering, active/narration + * progression, and 10km search cap as the guide list screen. When reached + * from Favorites/search for a place that isn't in that list, it falls back + * to a single, non-swipeable place fetched directly by id. */ @HiltViewModel class PlaceDetailViewModel @Inject constructor( @@ -43,30 +50,117 @@ class PlaceDetailViewModel @Inject constructor( private val placesRepository: PlacesRepository, private val favoritesRepository: FavoritesRepository, private val userPrefsDataStore: UserPrefsDataStore, + private val selectedLocationHolder: SelectedLocationHolder, + private val locationProvider: LocationProvider, private val ttsManager: TtsManager, ) : ViewModel() { - private val placeId: Int = checkNotNull(savedStateHandle.get("placeId")).toInt() + private val initialPlaceId: Int = checkNotNull(savedStateHandle.get("placeId")).toInt() private val _uiState = MutableStateFlow(PlaceDetailUiState()) val uiState: StateFlow = _uiState.asStateFlow() - val isFavorite: StateFlow = favoritesRepository.isFavorite(placeId) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + private val _isFavorite = MutableStateFlow(false) + val isFavorite: StateFlow = _isFavorite.asStateFlow() + + private var favoriteObserveJob: Job? = null init { - Log.d(TAG, "init: placeId=$placeId") + Log.d(TAG, "init: placeId=$initialPlaceId") viewModelScope.launch { val length = (userPrefsDataStore.contentLength.first() ?: ContentLength.MEDIUM).toApiValue() - val place = runCatching { placesRepository.getPlace(placeId, lang = "ru", length = length) } - .onFailure { Log.e(TAG, "getPlace($placeId) failed", it) } - .getOrNull() - Log.d(TAG, "loaded place=${place?.slug}") - _uiState.value = _uiState.value.copy(isLoading = false, place = place) - place?.let { ttsManager.speak(it.content.body) } + val location = selectedLocationHolder.location.value ?: locationProvider.getCurrentLocation() + + val nearby = location?.let { loc -> + runCatching { + placesRepository.getNearby( + lat = loc.lat, + lon = loc.lon, + citySlug = "moscow", + lang = "ru", + length = length, + minResults = 5, + maxRadiusM = 10_000, + ) + }.onFailure { Log.e(TAG, "getNearby failed", it) }.getOrNull() + } + + val indexInNearby = nearby?.places?.indexOfFirst { it.id == initialPlaceId } ?: -1 + if (nearby != null && indexInNearby >= 0) { + Log.i(TAG, "opened as part of nearby list, index=$indexInNearby of ${nearby.places.size}") + _uiState.value = _uiState.value.copy( + isLoading = false, + places = nearby.places, + activeIndex = indexInNearby, + ) + observeFavorite(initialPlaceId) + narrateActive() + } else { + loadSinglePlaceFallback(length) + } } } + private fun loadSinglePlaceFallback(length: String) { + viewModelScope.launch { + val place = runCatching { placesRepository.getPlace(initialPlaceId, lang = "ru", length = length) } + .onFailure { Log.e(TAG, "getPlace($initialPlaceId) failed", it) } + .getOrNull() + if (place == null) { + _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = "not_found") + return@launch + } + val asSingleEntry = NearbyPlaceDto( + id = place.id, + slug = place.slug, + category = place.category, + location = place.location, + address = place.address, + district = place.district, + builtYear = place.builtYear, + architectBuilder = place.architectBuilder, + architecturalStyle = place.architecturalStyle, + content = place.content, + distanceM = UNKNOWN_DISTANCE_M, + ) + Log.i(TAG, "loaded as standalone fallback (not in current nearby list): ${place.slug}") + _uiState.value = _uiState.value.copy(isLoading = false, places = listOf(asSingleEntry), activeIndex = 0) + observeFavorite(place.id) + narrateActive() + } + } + + private fun observeFavorite(placeId: Int) { + favoriteObserveJob?.cancel() + favoriteObserveJob = viewModelScope.launch { + favoritesRepository.isFavorite(placeId).collect { _isFavorite.value = it } + } + } + + private fun narrateActive() { + val state = _uiState.value + val place = state.places.getOrNull(state.activeIndex) ?: return + val text = buildNarrationText(place.content.title, place.distanceM, place.content.body) + ttsManager.speak(text) { onNarrationDone() } + } + + private fun onNarrationDone() { + val state = _uiState.value + val nextIndex = state.activeIndex + 1 + if (nextIndex >= state.places.size) return + setActiveIndex(nextIndex) + } + + /** Called both on TTS auto-advance and on manual swipe. */ + fun setActiveIndex(index: Int) { + val state = _uiState.value + if (index !in state.places.indices || index == state.activeIndex) return + Log.d(TAG, "setActiveIndex: ${state.activeIndex} -> $index") + _uiState.value = state.copy(activeIndex = index) + observeFavorite(state.places[index].id) + narrateActive() + } + fun toggleMute() { if (ttsManager.isMuted) ttsManager.unmute() else ttsManager.mute() Log.d(TAG, "toggleMute: isMuted=${ttsManager.isMuted}") @@ -74,15 +168,16 @@ class PlaceDetailViewModel @Inject constructor( } fun toggleFavorite() { - val place = _uiState.value.place ?: return - Log.d(TAG, "toggleFavorite: placeId=$placeId currentlyFavorite=${isFavorite.value}") + val state = _uiState.value + val place = state.places.getOrNull(state.activeIndex) ?: return + Log.d(TAG, "toggleFavorite: placeId=${place.id} currentlyFavorite=${isFavorite.value}") viewModelScope.launch { if (isFavorite.value) { - favoritesRepository.removeFavorite(placeId) + favoritesRepository.removeFavorite(place.id) } else { favoritesRepository.addFavorite( FavoritePlaceEntity( - placeId = placeId, + placeId = place.id, slug = place.slug, name = place.content.title, category = place.category, diff --git a/android/app/src/main/java/com/guidecity/app/ui/guide/GuideScreen.kt b/android/app/src/main/java/com/guidecity/app/ui/guide/GuideScreen.kt index 830fe10..e294b68 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/guide/GuideScreen.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/guide/GuideScreen.kt @@ -1,12 +1,14 @@ package com.guidecity.app.ui.guide +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height 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.filled.Favorite import androidx.compose.material.icons.filled.Settings @@ -16,10 +18,12 @@ import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -28,6 +32,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.guidecity.app.R +import com.guidecity.app.theme.Mocha @Composable fun GuideScreen( @@ -37,6 +42,11 @@ fun GuideScreen( viewModel: GuideViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsState() + val listState = rememberLazyListState() + + LaunchedEffect(state.activeIndex) { + listState.animateScrollToItem(state.activeIndex) + } Scaffold( topBar = { @@ -76,7 +86,6 @@ fun GuideScreen( horizontalAlignment = Alignment.CenterHorizontally, ) { Text(text = state.errorMessage.orEmpty()) - Spacer(modifier = Modifier.height(16.dp)) Button(onClick = { viewModel.retry() }) { Text(text = stringResource(R.string.guide_retry)) } @@ -96,14 +105,30 @@ fun GuideScreen( } else -> { - PlaceCardStack( - places = state.places, - activeIndex = state.activeIndex, - doneIndices = state.doneIndices, - onCardClick = onOpenDetail, - onPageChanged = { viewModel.setActiveIndex(it) }, - modifier = Modifier.fillMaxSize(), - ) + Column(modifier = Modifier.fillMaxSize()) { + Text( + text = stringResource(R.string.guide_places_count, state.places.size), + style = MaterialTheme.typography.labelLarge, + color = Mocha.Subtext1, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + 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) }, + ) + } + } + } } } } diff --git a/android/app/src/main/java/com/guidecity/app/ui/guide/GuideViewModel.kt b/android/app/src/main/java/com/guidecity/app/ui/guide/GuideViewModel.kt index 8f5d7e9..015686b 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/guide/GuideViewModel.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/guide/GuideViewModel.kt @@ -10,6 +10,7 @@ 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.util.buildNarrationText import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -129,7 +130,8 @@ class GuideViewModel @Inject constructor( val state = _uiState.value 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() { @@ -148,15 +150,6 @@ class GuideViewModel @Inject constructor( 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 - Log.d(TAG, "setActiveIndex: ${state.activeIndex} -> $index (manual swipe)") - _uiState.value = state.copy(activeIndex = index) - narrateActive() - } - fun toggleMute() { if (ttsManager.isMuted) ttsManager.unmute() else ttsManager.mute() Log.d(TAG, "toggleMute: isMuted=${ttsManager.isMuted}") diff --git a/android/app/src/main/java/com/guidecity/app/ui/guide/PlaceCardStack.kt b/android/app/src/main/java/com/guidecity/app/ui/guide/PlaceCardStack.kt deleted file mode 100644 index f682604..0000000 --- a/android/app/src/main/java/com/guidecity/app/ui/guide/PlaceCardStack.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.guidecity.app.ui.guide - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -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.material3.MaterialTheme -import androidx.compose.material3.Text -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 com.guidecity.app.theme.Mocha -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, - activeIndex: Int, - doneIndices: Set, - 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) } - } - - Column(modifier = modifier) { - // Makes it obvious there's more than one place to swipe through, - // even when only the active card is visible on screen. - Text( - text = "${activeIndex + 1} / ${places.size}", - style = MaterialTheme.typography.labelLarge, - color = Mocha.Subtext1, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - ) - - 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) }, - ) - } - } -} diff --git a/android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt b/android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt index 55b3bc4..fa3ba59 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt @@ -50,6 +50,8 @@ fun MapScreen( ) { val uiState by viewModel.uiState.collectAsState() val searchResults by viewModel.searchResults.collectAsState() + val resolvedLocation by viewModel.resolvedLocation.collectAsState() + val nearbyPlaces by viewModel.nearbyPlaces.collectAsState() var query by remember { mutableStateOf("") } val permissionLauncher = rememberLauncherForActivityResult( @@ -83,8 +85,8 @@ fun MapScreen( .padding(padding), ) { DgisMapView( - userLocation = null, - places = emptyList(), + userLocation = resolvedLocation, + places = nearbyPlaces, onPlaceClick = {}, modifier = Modifier.fillMaxSize(), ) diff --git a/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt b/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt index 3cc1a05..f478c58 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt @@ -3,6 +3,7 @@ package com.guidecity.app.ui.map import android.util.Log import androidx.lifecycle.ViewModel 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.repository.PlacesRepository import com.guidecity.app.location.LatLon @@ -41,6 +42,12 @@ class MapViewModel @Inject constructor( private val _searchResults = MutableStateFlow>(emptyList()) val searchResults: StateFlow> = _searchResults.asStateFlow() + private val _resolvedLocation = MutableStateFlow(null) + val resolvedLocation: StateFlow = _resolvedLocation.asStateFlow() + + private val _nearbyPlaces = MutableStateFlow>(emptyList()) + val nearbyPlaces: StateFlow> = _nearbyPlaces.asStateFlow() + fun checkInitialPermission() { val hasPermission = locationProvider.hasLocationPermission() Log.d(TAG, "checkInitialPermission: hasPermission=$hasPermission") @@ -66,6 +73,7 @@ class MapViewModel @Inject constructor( val location = locationProvider.getCurrentLocation() if (location != null) { Log.i(TAG, "resolveLocation: got lat=${location.lat} lon=${location.lon}") + _resolvedLocation.value = location selectedLocationHolder.set(location) checkNearby(location) } else { @@ -96,6 +104,7 @@ class MapViewModel @Inject constructor( ) }.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 { @@ -130,6 +139,7 @@ class MapViewModel @Inject constructor( fun selectSearchResult(place: PlaceListItemDto) { Log.i(TAG, "selectSearchResult: placeId=${place.id} slug=${place.slug}") val location = LatLon(place.location.lat, place.location.lon) + _resolvedLocation.value = location selectedLocationHolder.set(location) checkNearby(location) } diff --git a/android/app/src/main/java/com/guidecity/app/util/Narration.kt b/android/app/src/main/java/com/guidecity/app/util/Narration.kt new file mode 100644 index 0000000..3c3ff46 --- /dev/null +++ b/android/app/src/main/java/com/guidecity/app/util/Narration.kt @@ -0,0 +1,23 @@ +package com.guidecity.app.util + +import kotlin.math.roundToInt + +/** Formats a distance in meters for spoken narration (Russian only, matching the app's current hardcoded lang="ru"). */ +fun formatDistanceForNarration(distanceM: Double): String = + if (distanceM >= 1000) { + "%.1f км".format(distanceM / 1000) + } else { + "${distanceM.roundToInt()} метров" + } + +/** + * Prefixes the narration body with the place's name and its distance from + * the user. Pass a negative [distanceM] (no real distance known — e.g. a + * place opened standalone from Favorites/search) to omit the distance clause. + */ +fun buildNarrationText(title: String, distanceM: Double, body: String): String = + if (distanceM < 0) { + "$title. $body" + } else { + "$title. Расстояние: ${formatDistanceForNarration(distanceM)}. $body" + } diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 19c9a19..a5cbcb3 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -21,6 +21,7 @@ В радиусе 10 км ничего не нашлось. Поблизости пока не найдено интересных мест. + Мест поблизости: %1$d Повторить Добавить в избранное Убрать из избранного diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 2dfb5aa..9fce130 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -21,6 +21,7 @@ Nothing found within 10 km of here. No places found nearby yet. + %1$d place(s) nearby Retry Add to favorites Remove from favorites From 7c4d5727e45868804037b642e9da9adc43ca89dc Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Thu, 9 Jul 2026 21:28:52 +0000 Subject: [PATCH 09/10] Wire up the real 2GIS MapKit SDK (was a placeholder) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added the actual ru.dgis.sdk:sdk-map + compose-map dependencies (from https://artifactory.2gis.dev/sdk-maven-release) instead of the Canvas placeholder guess. Verified the exact API surface by downloading the AARs directly and inspecting classes with javap (DGis.initialize, MapOptions, MapComposableState/MapComposable, CameraPosition/GeoPoint) rather than relying on possibly-stale docs. DgisSdkProvider lazily calls DGis.initialize() once per process, catching failure so a missing key doesn't crash the app; DgisMapView renders the real MapComposable when that succeeds, falling back to the old placeholder canvas otherwise. Important finding: the existing DGIS_API_KEY (2GIS's public REST/JS API key format) does NOT work with this native SDK. It requires a separate dgissdk.key file issued per-app-package from dev.2gis.com, placed in app/src/main/assets/ (gitignored). Documented in README. Without it the app still runs fine on the placeholder map. Also restricted ndk.abiFilters to arm64-v8a — the SDK's native libs otherwise balloon the APK from ~19MB to ~190MB. Verified: testDebugUnitTest passes, assembleDebug produces a working (arm64-only, ~69MB) APK. Not runtime-verified against a real map key or device — no emulator/device available in this environment. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 1 + README.md | 32 +++++++++++- android/app/build.gradle.kts | 17 +++++-- .../java/com/guidecity/app/map/DgisMapView.kt | 51 ++++++++++++++----- .../com/guidecity/app/map/DgisSdkProvider.kt | 39 ++++++++++++++ .../com/guidecity/app/ui/map/MapScreen.kt | 1 + .../com/guidecity/app/ui/map/MapViewModel.kt | 5 ++ android/gradle/libs.versions.toml | 4 ++ android/settings.gradle.kts | 4 +- 9 files changed, 134 insertions(+), 20 deletions(-) create mode 100644 android/app/src/main/java/com/guidecity/app/map/DgisSdkProvider.kt diff --git a/.gitignore b/.gitignore index 8a6bf9c..ec83ee6 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ android/captures/ android/.externalNativeBuild/ *.keystore *.jks +android/app/src/main/assets/dgissdk.key # IDE .vscode/ diff --git a/README.md b/README.md index 4dc055d..4c18852 100644 --- a/README.md +++ b/README.md @@ -68,11 +68,39 @@ Wi-Fi/LAN. Run unit tests before building — `./gradlew testDebugUnitTest`, then `./gradlew :app:assembleDebug`. +## 2GIS map SDK + +The app depends on the real 2GIS MapKit SDK (`ru.dgis.sdk:sdk-map` + +`compose-map`, from `https://artifactory.2gis.dev/sdk-maven-release`, wired +up in `settings.gradle.kts`/`app/build.gradle.kts`). It needs a **separate** +key from the `DGIS_API_KEY` above: + +- `DGIS_API_KEY` (the `b4df01a...` value) is a 2GIS **public REST/JS API** + key — not used by the native SDK at all currently. +- The native MapKit SDK instead needs a **`dgissdk.key` file**, issued + per-app (tied to the package name `com.guidecity.app`) from + https://dev.2gis.com/. Place it at `android/app/src/main/assets/dgissdk.key` + (gitignored — don't commit it). + +Without that key file, `DgisSdkProvider` fails to initialize (logged, not +crashed) and `DgisMapView` falls back to a placeholder canvas rendering +(user location + nearby place dots, no real map tiles). Once a real key is +in place, the real map should render automatically — no code changes needed. + +Also note: the SDK bundles native libraries per CPU architecture, which +balloons APK size a lot (~19MB → ~190MB unfiltered). `app/build.gradle.kts` +restricts `ndk.abiFilters` to `arm64-v8a` only (~69MB) since that covers +virtually all real devices today — remove that filter if you need to test +on an x86 emulator or 32-bit device. + ## Notes / current scope This is iteration 1: Moscow only (Gorky Park, Red Square area, Moscow-City), no user accounts (favorites are local/Room-only), no speed/heading-aware auto content-length selection yet (reserved API params exist, unused). +Nearby-search radius caps at 10km. Place markers aren't yet plotted on the +real 2GIS map (only the placeholder does that) — follow-up work. -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. +The 2GIS REST/JS API key above is used directly via +`local.properties`/`BuildConfig` for development convenience — don't ship it +as-is in a public repo. Same goes for `dgissdk.key` once you have one. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 92914c7..12d819b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -42,6 +42,14 @@ android { "API_BASE_URL", "\"${localProp("API_BASE_URL", "http://10.0.2.2:8000/")}\"", ) + + // The 2GIS SDK ships native libs for every ABI, which balloons the APK + // (~19MB -> ~190MB unfiltered). Almost all real devices are arm64 + // today, and this keeps debug builds small enough to send anywhere + // (e.g. under Telegram bot's 50MB upload limit). + ndk { + abiFilters += "arm64-v8a" + } } buildTypes { @@ -110,9 +118,12 @@ dependencies { implementation(libs.play.services.location) implementation(libs.kotlinx.coroutines.play.services) - // 2GIS MapKit SDK: add once the exact Maven coordinates/repository are - // confirmed from https://docs.2gis.com/ — see map/DgisMapView.kt for the - // isolated integration point in the meantime. + // 2GIS MapKit SDK. Requires a real dgissdk.key in app/src/main/assets/, + // obtained per-app from dev.2gis.com — see README for details. Without + // it, DgisSdkProvider's init fails gracefully and DgisMapView falls back + // to its placeholder rendering. + implementation(libs.dgis.sdk.map) + implementation(libs.dgis.compose.map) testImplementation(libs.junit) testImplementation(libs.mockk) diff --git a/android/app/src/main/java/com/guidecity/app/map/DgisMapView.kt b/android/app/src/main/java/com/guidecity/app/map/DgisMapView.kt index 6c3af65..12040a6 100644 --- a/android/app/src/main/java/com/guidecity/app/map/DgisMapView.kt +++ b/android/app/src/main/java/com/guidecity/app/map/DgisMapView.kt @@ -7,37 +7,64 @@ 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.remember 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 ru.dgis.sdk.Context as DgisContext +import ru.dgis.sdk.coordinates.Bearing +import ru.dgis.sdk.coordinates.GeoPoint +import ru.dgis.sdk.coordinates.Latitude +import ru.dgis.sdk.coordinates.Longitude +import ru.dgis.sdk.compose.map.MapComposable +import ru.dgis.sdk.compose.map.MapComposableState +import ru.dgis.sdk.map.CameraPosition +import ru.dgis.sdk.map.MapOptions +import ru.dgis.sdk.map.Tilt +import ru.dgis.sdk.map.Zoom import kotlin.math.cos /** - * Isolated integration point for the 2GIS MapKit SDK. + * Renders the real 2GIS MapKit map when the SDK initialized successfully + * (i.e. a valid `dgissdk.key` is present — see [DgisSdkProvider]); otherwise + * falls back to 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 — stays fully + * functional and demoable without a real key. * - * 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. + * Follow-up not yet implemented: plotting [places] as markers on the real + * map (needs the SDK's MapObjectManager/Marker API) — for now the real map + * only centers on [userLocation]; the placeholder still shows place dots. */ @Composable fun DgisMapView( + sdkContext: DgisContext?, userLocation: LatLon?, places: List, onPlaceClick: (placeId: Int) -> Unit, modifier: Modifier = Modifier, ) { + if (sdkContext != null && userLocation != null) { + val mapState = remember(userLocation) { + MapComposableState( + MapOptions().apply { + position = CameraPosition( + point = GeoPoint(Latitude(userLocation.lat), Longitude(userLocation.lon)), + zoom = Zoom(15f), + tilt = Tilt(), + bearing = Bearing(), + ) + }, + ) + } + MapComposable(modifier = modifier.fillMaxSize(), state = mapState) + return + } + Box( modifier = modifier .fillMaxSize() diff --git a/android/app/src/main/java/com/guidecity/app/map/DgisSdkProvider.kt b/android/app/src/main/java/com/guidecity/app/map/DgisSdkProvider.kt new file mode 100644 index 0000000..e2f67b4 --- /dev/null +++ b/android/app/src/main/java/com/guidecity/app/map/DgisSdkProvider.kt @@ -0,0 +1,39 @@ +package com.guidecity.app.map + +import android.util.Log +import dagger.hilt.android.qualifiers.ApplicationContext +import ru.dgis.sdk.Context as DgisContext +import ru.dgis.sdk.DGis +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG = "DgisSdkProvider" + +/** + * Lazily initializes the 2GIS MapKit SDK once per process. + * + * Requires a real `dgissdk.key` file in `app/src/main/assets/`, issued + * per-app (tied to the package name) from https://dev.2gis.com/ — the + * `DGIS_API_KEY` used elsewhere in this app (2GIS's public REST/JS API key + * format) is a *different* product and will not work here. Without a valid + * key file, [sdkContext] is null and [DgisMapView] falls back to its + * placeholder rendering instead of crashing. + */ +@Singleton +class DgisSdkProvider @Inject constructor( + @ApplicationContext private val androidContext: android.content.Context, +) { + val sdkContext: DgisContext? by lazy { + runCatching { DGis.initialize(androidContext) } + .onSuccess { Log.i(TAG, "2GIS SDK initialized") } + .onFailure { + Log.e( + TAG, + "2GIS SDK initialization failed — is a valid dgissdk.key present in " + + "app/src/main/assets/? (see README)", + it, + ) + } + .getOrNull() + } +} diff --git a/android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt b/android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt index fa3ba59..8457c3a 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt @@ -85,6 +85,7 @@ fun MapScreen( .padding(padding), ) { DgisMapView( + sdkContext = viewModel.dgisSdkContext, userLocation = resolvedLocation, places = nearbyPlaces, onPlaceClick = {}, diff --git a/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt b/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt index f478c58..52cdb4b 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt @@ -9,6 +9,7 @@ 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.map.DgisSdkProvider import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -34,8 +35,12 @@ class MapViewModel @Inject constructor( private val locationProvider: LocationProvider, private val selectedLocationHolder: SelectedLocationHolder, private val placesRepository: PlacesRepository, + dgisSdkProvider: DgisSdkProvider, ) : ViewModel() { + /** Null until a valid `dgissdk.key` is present — see [DgisSdkProvider]. */ + val dgisSdkContext = dgisSdkProvider.sdkContext + private val _uiState = MutableStateFlow(MapUiState.CheckingPermission) val uiState: StateFlow = _uiState.asStateFlow() diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 1c04b3a..3fafd63 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -21,6 +21,7 @@ androidxTestExtJunit = "1.2.1" espressoCore = "3.6.1" mockk = "1.13.12" kotlinxCoroutinesTest = "1.8.1" +dgisSdk = "13.5.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -62,6 +63,9 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } +dgis-sdk-map = { group = "ru.dgis.sdk", name = "sdk-map", version.ref = "dgisSdk" } +dgis-compose-map = { group = "ru.dgis.sdk", name = "compose-map", version.ref = "dgisSdk" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 66bc726..7558f1e 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -11,9 +11,7 @@ dependencyResolutionManagement { repositories { google() 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). + maven { url = uri("https://artifactory.2gis.dev/sdk-maven-release") } } } From 37edc96d50f22786af006d282f1de70cdf1e13fe Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Thu, 9 Jul 2026 21:46:47 +0000 Subject: [PATCH 10/10] =?UTF-8?q?Replace=202GIS=20MapKit=20with=20OpenStre?= =?UTF-8?q?etMap=20(osmdroid)=20=E2=80=94=20no=20key=20needed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Getting a working mobile-SDK key from 2GIS turned out to require a sales-mediated B2B process (Platform Manager subscription, demo keys explicitly excluded from mobile SDK use per their own docs), not a quick self-serve signup. Swapped to osmdroid instead: free, no API key or account, works immediately. CityMapView (replacing DgisMapView/DgisSdkProvider) wraps osmdroid's View-based MapView via AndroidView, forwarding lifecycle events, with markers for the user's location and each nearby place. Configured required OSM tile-usage-policy user agent + app-private tile cache in GuideCityApp. Removed the now-unused DGIS_API_KEY plumbing and the arm64-only ABI filter (osmdroid has no heavy native libs, so it's not needed) — APK is back down to ~19.6MB from ~69MB. Verified: testDebugUnitTest passes, assembleDebug produces a working APK, sent via the Telegram bot (previously blocked by its 50MB limit with the 2GIS build). Co-Authored-By: Claude Sonnet 5 --- .gitignore | 1 - README.md | 43 ++----- android/app/build.gradle.kts | 21 +--- .../java/com/guidecity/app/GuideCityApp.kt | 17 +++ .../java/com/guidecity/app/map/CityMapView.kt | 113 +++++++++++++++++ .../java/com/guidecity/app/map/DgisMapView.kt | 114 ------------------ .../com/guidecity/app/map/DgisSdkProvider.kt | 39 ------ .../com/guidecity/app/ui/map/MapScreen.kt | 5 +- .../com/guidecity/app/ui/map/MapViewModel.kt | 5 - android/gradle/libs.versions.toml | 5 +- android/local.properties.example | 3 - android/settings.gradle.kts | 1 - 12 files changed, 147 insertions(+), 220 deletions(-) create mode 100644 android/app/src/main/java/com/guidecity/app/map/CityMapView.kt delete mode 100644 android/app/src/main/java/com/guidecity/app/map/DgisMapView.kt delete mode 100644 android/app/src/main/java/com/guidecity/app/map/DgisSdkProvider.kt diff --git a/.gitignore b/.gitignore index ec83ee6..8a6bf9c 100644 --- a/.gitignore +++ b/.gitignore @@ -27,7 +27,6 @@ android/captures/ android/.externalNativeBuild/ *.keystore *.jks -android/app/src/main/assets/dgissdk.key # IDE .vscode/ diff --git a/README.md b/README.md index 4c18852..c4a564b 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ voice while showing a swipeable card stack. Starting city: Moscow. ``` ┌────────────────────┐ │ Android app │ Kotlin + Jetpack Compose - │ (android/) │ 2GIS map, on-device TTS, + │ (android/) │ OSM map, on-device TTS, └─────────┬──────────┘ location, Room favorites │ 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, backed by PostgreSQL+PostGIS. See `backend/` for details. - `android/` — Kotlin/Compose app skeleton (MVVM, Hilt, Retrofit, Room, - DataStore, 2GIS MapKit, on-device TextToSpeech). + DataStore, OpenStreetMap via osmdroid, on-device TextToSpeech). ## Backend quickstart @@ -56,7 +56,7 @@ which works from anywhere (not just the local network) and is the preferred ```bash cp android/local.properties.example android/local.properties -# fill in sdk.dir and DGIS_API_KEY (test key: b4df01a8-61db-4cb9-8286-7e069495987d) +# fill in sdk.dir (and API_BASE_URL if not using the default) cd android && ./gradlew :app:assembleDebug ``` @@ -68,39 +68,18 @@ Wi-Fi/LAN. Run unit tests before building — `./gradlew testDebugUnitTest`, then `./gradlew :app:assembleDebug`. -## 2GIS map SDK +## Map -The app depends on the real 2GIS MapKit SDK (`ru.dgis.sdk:sdk-map` + -`compose-map`, from `https://artifactory.2gis.dev/sdk-maven-release`, wired -up in `settings.gradle.kts`/`app/build.gradle.kts`). It needs a **separate** -key from the `DGIS_API_KEY` above: - -- `DGIS_API_KEY` (the `b4df01a...` value) is a 2GIS **public REST/JS API** - key — not used by the native SDK at all currently. -- The native MapKit SDK instead needs a **`dgissdk.key` file**, issued - per-app (tied to the package name `com.guidecity.app`) from - https://dev.2gis.com/. Place it at `android/app/src/main/assets/dgissdk.key` - (gitignored — don't commit it). - -Without that key file, `DgisSdkProvider` fails to initialize (logged, not -crashed) and `DgisMapView` falls back to a placeholder canvas rendering -(user location + nearby place dots, no real map tiles). Once a real key is -in place, the real map should render automatically — no code changes needed. - -Also note: the SDK bundles native libraries per CPU architecture, which -balloons APK size a lot (~19MB → ~190MB unfiltered). `app/build.gradle.kts` -restricts `ndk.abiFilters` to `arm64-v8a` only (~69MB) since that covers -virtually all real devices today — remove that filter if you need to test -on an x86 emulator or 32-bit device. +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 This is iteration 1: Moscow only (Gorky Park, Red Square area, Moscow-City), no user accounts (favorites are local/Room-only), no speed/heading-aware auto content-length selection yet (reserved API params exist, unused). -Nearby-search radius caps at 10km. Place markers aren't yet plotted on the -real 2GIS map (only the placeholder does that) — follow-up work. - -The 2GIS REST/JS API key above is used directly via -`local.properties`/`BuildConfig` for development convenience — don't ship it -as-is in a public repo. Same goes for `dgissdk.key` once you have one. +Nearby-search radius caps at 10km. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 12d819b..4d60840 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -32,24 +32,11 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - buildConfigField( - "String", - "DGIS_API_KEY", - "\"${localProp("DGIS_API_KEY", "")}\"", - ) buildConfigField( "String", "API_BASE_URL", "\"${localProp("API_BASE_URL", "http://10.0.2.2:8000/")}\"", ) - - // The 2GIS SDK ships native libs for every ABI, which balloons the APK - // (~19MB -> ~190MB unfiltered). Almost all real devices are arm64 - // today, and this keeps debug builds small enough to send anywhere - // (e.g. under Telegram bot's 50MB upload limit). - ndk { - abiFilters += "arm64-v8a" - } } buildTypes { @@ -118,12 +105,8 @@ dependencies { implementation(libs.play.services.location) implementation(libs.kotlinx.coroutines.play.services) - // 2GIS MapKit SDK. Requires a real dgissdk.key in app/src/main/assets/, - // obtained per-app from dev.2gis.com — see README for details. Without - // it, DgisSdkProvider's init fails gracefully and DgisMapView falls back - // to its placeholder rendering. - implementation(libs.dgis.sdk.map) - implementation(libs.dgis.compose.map) + // OpenStreetMap tiles via osmdroid — free, no API key/registration needed. + implementation(libs.osmdroid.android) testImplementation(libs.junit) testImplementation(libs.mockk) diff --git a/android/app/src/main/java/com/guidecity/app/GuideCityApp.kt b/android/app/src/main/java/com/guidecity/app/GuideCityApp.kt index 1c3c302..42adf8c 100644 --- a/android/app/src/main/java/com/guidecity/app/GuideCityApp.kt +++ b/android/app/src/main/java/com/guidecity/app/GuideCityApp.kt @@ -3,6 +3,8 @@ package com.guidecity.app import android.app.Application import android.util.Log import dagger.hilt.android.HiltAndroidApp +import org.osmdroid.config.Configuration +import java.io.File private const val TAG = "GuideCityApp" @@ -11,5 +13,20 @@ 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") + } } } diff --git a/android/app/src/main/java/com/guidecity/app/map/CityMapView.kt b/android/app/src/main/java/com/guidecity/app/map/CityMapView.kt new file mode 100644 index 0000000..cbb8e04 --- /dev/null +++ b/android/app/src/main/java/com/guidecity/app/map/CityMapView.kt @@ -0,0 +1,113 @@ +package com.guidecity.app.map + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import com.guidecity.app.data.remote.dto.NearbyPlaceDto +import com.guidecity.app.location.LatLon +import com.guidecity.app.theme.Mocha +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.MapView +import org.osmdroid.views.overlay.Marker + +/** + * OpenStreetMap tiles via osmdroid — free, no API key or registration + * needed (unlike the 2GIS native MapKit SDK, which requires a paid/sales + * process for a mobile SDK key; see git history if that's ever revisited). + * + * Wraps osmdroid's View-based [MapView] via [AndroidView] since it has no + * native Compose API, forwarding lifecycle events so tile loading/caching + * behaves correctly. + */ +@Composable +fun CityMapView( + userLocation: LatLon?, + places: List, + 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(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() + }, + ) +} diff --git a/android/app/src/main/java/com/guidecity/app/map/DgisMapView.kt b/android/app/src/main/java/com/guidecity/app/map/DgisMapView.kt deleted file mode 100644 index 12040a6..0000000 --- a/android/app/src/main/java/com/guidecity/app/map/DgisMapView.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.guidecity.app.map - -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.drawscope.Stroke -import com.guidecity.app.data.remote.dto.NearbyPlaceDto -import com.guidecity.app.location.LatLon -import com.guidecity.app.theme.Mocha -import ru.dgis.sdk.Context as DgisContext -import ru.dgis.sdk.coordinates.Bearing -import ru.dgis.sdk.coordinates.GeoPoint -import ru.dgis.sdk.coordinates.Latitude -import ru.dgis.sdk.coordinates.Longitude -import ru.dgis.sdk.compose.map.MapComposable -import ru.dgis.sdk.compose.map.MapComposableState -import ru.dgis.sdk.map.CameraPosition -import ru.dgis.sdk.map.MapOptions -import ru.dgis.sdk.map.Tilt -import ru.dgis.sdk.map.Zoom -import kotlin.math.cos - -/** - * Renders the real 2GIS MapKit map when the SDK initialized successfully - * (i.e. a valid `dgissdk.key` is present — see [DgisSdkProvider]); otherwise - * falls back to 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 — stays fully - * functional and demoable without a real key. - * - * Follow-up not yet implemented: plotting [places] as markers on the real - * map (needs the SDK's MapObjectManager/Marker API) — for now the real map - * only centers on [userLocation]; the placeholder still shows place dots. - */ -@Composable -fun DgisMapView( - sdkContext: DgisContext?, - userLocation: LatLon?, - places: List, - onPlaceClick: (placeId: Int) -> Unit, - modifier: Modifier = Modifier, -) { - if (sdkContext != null && userLocation != null) { - val mapState = remember(userLocation) { - MapComposableState( - MapOptions().apply { - position = CameraPosition( - point = GeoPoint(Latitude(userLocation.lat), Longitude(userLocation.lon)), - zoom = Zoom(15f), - tilt = Tilt(), - bearing = Bearing(), - ) - }, - ) - } - MapComposable(modifier = modifier.fillMaxSize(), state = mapState) - return - } - - 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) - } - } - } -} diff --git a/android/app/src/main/java/com/guidecity/app/map/DgisSdkProvider.kt b/android/app/src/main/java/com/guidecity/app/map/DgisSdkProvider.kt deleted file mode 100644 index e2f67b4..0000000 --- a/android/app/src/main/java/com/guidecity/app/map/DgisSdkProvider.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.guidecity.app.map - -import android.util.Log -import dagger.hilt.android.qualifiers.ApplicationContext -import ru.dgis.sdk.Context as DgisContext -import ru.dgis.sdk.DGis -import javax.inject.Inject -import javax.inject.Singleton - -private const val TAG = "DgisSdkProvider" - -/** - * Lazily initializes the 2GIS MapKit SDK once per process. - * - * Requires a real `dgissdk.key` file in `app/src/main/assets/`, issued - * per-app (tied to the package name) from https://dev.2gis.com/ — the - * `DGIS_API_KEY` used elsewhere in this app (2GIS's public REST/JS API key - * format) is a *different* product and will not work here. Without a valid - * key file, [sdkContext] is null and [DgisMapView] falls back to its - * placeholder rendering instead of crashing. - */ -@Singleton -class DgisSdkProvider @Inject constructor( - @ApplicationContext private val androidContext: android.content.Context, -) { - val sdkContext: DgisContext? by lazy { - runCatching { DGis.initialize(androidContext) } - .onSuccess { Log.i(TAG, "2GIS SDK initialized") } - .onFailure { - Log.e( - TAG, - "2GIS SDK initialization failed — is a valid dgissdk.key present in " + - "app/src/main/assets/? (see README)", - it, - ) - } - .getOrNull() - } -} diff --git a/android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt b/android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt index 8457c3a..27e1f88 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt @@ -39,7 +39,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.guidecity.app.R -import com.guidecity.app.map.DgisMapView +import com.guidecity.app.map.CityMapView @Composable fun MapScreen( @@ -84,8 +84,7 @@ fun MapScreen( .fillMaxSize() .padding(padding), ) { - DgisMapView( - sdkContext = viewModel.dgisSdkContext, + CityMapView( userLocation = resolvedLocation, places = nearbyPlaces, onPlaceClick = {}, diff --git a/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt b/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt index 52cdb4b..f478c58 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt @@ -9,7 +9,6 @@ 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.map.DgisSdkProvider import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -35,12 +34,8 @@ class MapViewModel @Inject constructor( private val locationProvider: LocationProvider, private val selectedLocationHolder: SelectedLocationHolder, private val placesRepository: PlacesRepository, - dgisSdkProvider: DgisSdkProvider, ) : ViewModel() { - /** Null until a valid `dgissdk.key` is present — see [DgisSdkProvider]. */ - val dgisSdkContext = dgisSdkProvider.sdkContext - private val _uiState = MutableStateFlow(MapUiState.CheckingPermission) val uiState: StateFlow = _uiState.asStateFlow() diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 3fafd63..8a3a9a6 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -21,7 +21,7 @@ androidxTestExtJunit = "1.2.1" espressoCore = "3.6.1" mockk = "1.13.12" kotlinxCoroutinesTest = "1.8.1" -dgisSdk = "13.5.0" +osmdroid = "6.1.20" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -63,8 +63,7 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } -dgis-sdk-map = { group = "ru.dgis.sdk", name = "sdk-map", version.ref = "dgisSdk" } -dgis-compose-map = { group = "ru.dgis.sdk", name = "compose-map", version.ref = "dgisSdk" } +osmdroid-android = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/android/local.properties.example b/android/local.properties.example index 6432a12..c81b7a7 100644 --- a/android/local.properties.example +++ b/android/local.properties.example @@ -2,9 +2,6 @@ sdk.dir=/path/to/your/Android/Sdk -# Test key provided for development; do not ship this as-is in a public repo. -DGIS_API_KEY=b4df01a8-61db-4cb9-8286-7e069495987d - # Base URL of the guideCity backend API. Options, in order of preference: # 1. Nginx-fronted HTTPS domain (works from anywhere, not just the LAN): API_BASE_URL=https://guidetest.vrubel.xyz/ diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 7558f1e..493fcee 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -11,7 +11,6 @@ dependencyResolutionManagement { repositories { google() mavenCentral() - maven { url = uri("https://artifactory.2gis.dev/sdk-maven-release") } } }