diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 0000000..85db5b1
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -0,0 +1,115 @@
+import java.util.Properties
+
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.kotlin.serialization)
+ alias(libs.plugins.hilt.android)
+ alias(libs.plugins.ksp)
+}
+
+val localProperties = Properties().apply {
+ val file = rootProject.file("local.properties")
+ if (file.exists()) {
+ file.inputStream().use { load(it) }
+ }
+}
+
+fun localProp(key: String, default: String): String =
+ (localProperties.getProperty(key) ?: System.getenv(key) ?: default)
+
+android {
+ namespace = "com.guidecity.app"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.guidecity.app"
+ minSdk = 26
+ targetSdk = 34
+ versionCode = 1
+ versionName = "0.1.0"
+
+ 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/")}\"",
+ )
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ freeCompilerArgs += listOf(
+ "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
+ )
+ }
+
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+ implementation(libs.androidx.activity.compose)
+
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.androidx.material3)
+ implementation(libs.androidx.material.icons.extended)
+ implementation(libs.androidx.navigation.compose)
+ debugImplementation(libs.androidx.ui.tooling)
+
+ implementation(libs.hilt.android)
+ ksp(libs.hilt.compiler)
+ implementation(libs.hilt.navigation.compose)
+
+ implementation(libs.retrofit.core)
+ implementation(libs.retrofit.kotlinx.serialization.converter)
+ implementation(libs.okhttp.logging.interceptor)
+ implementation(libs.kotlinx.serialization.json)
+
+ implementation(libs.androidx.room.runtime)
+ implementation(libs.androidx.room.ktx)
+ ksp(libs.androidx.room.compiler)
+
+ implementation(libs.androidx.datastore.preferences)
+
+ 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.
+
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.test.ext.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+ androidTestImplementation(libs.androidx.ui.test.junit4)
+ debugImplementation(libs.androidx.ui.test.manifest)
+}
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
new file mode 100644
index 0000000..70bba3b
--- /dev/null
+++ b/android/app/proguard-rules.pro
@@ -0,0 +1,2 @@
+# Add project specific ProGuard rules here.
+# See https://developer.android.com/studio/build/shrink-code for details.
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..f2af286
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/com/guidecity/app/GuideCityApp.kt b/android/app/src/main/java/com/guidecity/app/GuideCityApp.kt
new file mode 100644
index 0000000..deece02
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/GuideCityApp.kt
@@ -0,0 +1,7 @@
+package com.guidecity.app
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class GuideCityApp : Application()
diff --git a/android/app/src/main/java/com/guidecity/app/MainActivity.kt b/android/app/src/main/java/com/guidecity/app/MainActivity.kt
new file mode 100644
index 0000000..8920f00
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/MainActivity.kt
@@ -0,0 +1,22 @@
+package com.guidecity.app
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import com.guidecity.app.navigation.GuideCityNavHost
+import com.guidecity.app.theme.GuideCityTheme
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ GuideCityTheme {
+ GuideCityNavHost()
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/guidecity/app/data/local/db/AppDatabase.kt b/android/app/src/main/java/com/guidecity/app/data/local/db/AppDatabase.kt
new file mode 100644
index 0000000..fb2b1f5
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/data/local/db/AppDatabase.kt
@@ -0,0 +1,9 @@
+package com.guidecity.app.data.local.db
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+
+@Database(entities = [FavoritePlaceEntity::class], version = 1, exportSchema = false)
+abstract class AppDatabase : RoomDatabase() {
+ abstract fun favoritePlaceDao(): FavoritePlaceDao
+}
diff --git a/android/app/src/main/java/com/guidecity/app/data/local/db/FavoritePlaceDao.kt b/android/app/src/main/java/com/guidecity/app/data/local/db/FavoritePlaceDao.kt
new file mode 100644
index 0000000..a1093d0
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/data/local/db/FavoritePlaceDao.kt
@@ -0,0 +1,27 @@
+package com.guidecity.app.data.local.db
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface FavoritePlaceDao {
+
+ @Query("SELECT * FROM favorite_places ORDER BY savedAt DESC")
+ fun observeAll(): Flow>
+
+ @Query("SELECT EXISTS(SELECT 1 FROM favorite_places WHERE placeId = :placeId)")
+ fun isFavorite(placeId: Int): Flow
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(place: FavoritePlaceEntity)
+
+ @Delete
+ suspend fun delete(place: FavoritePlaceEntity)
+
+ @Query("DELETE FROM favorite_places WHERE placeId = :placeId")
+ suspend fun deleteById(placeId: Int)
+}
diff --git a/android/app/src/main/java/com/guidecity/app/data/local/db/FavoritePlaceEntity.kt b/android/app/src/main/java/com/guidecity/app/data/local/db/FavoritePlaceEntity.kt
new file mode 100644
index 0000000..b10ecf4
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/data/local/db/FavoritePlaceEntity.kt
@@ -0,0 +1,15 @@
+package com.guidecity.app.data.local.db
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "favorite_places")
+data class FavoritePlaceEntity(
+ @PrimaryKey val placeId: Int,
+ val slug: String,
+ val name: String,
+ val category: String,
+ val lat: Double,
+ val lon: Double,
+ val savedAt: Long,
+)
diff --git a/android/app/src/main/java/com/guidecity/app/data/local/prefs/ContentLength.kt b/android/app/src/main/java/com/guidecity/app/data/local/prefs/ContentLength.kt
new file mode 100644
index 0000000..b223b65
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/data/local/prefs/ContentLength.kt
@@ -0,0 +1,11 @@
+package com.guidecity.app.data.local.prefs
+
+enum class ContentLength {
+ SHORT,
+ MEDIUM,
+ LONG,
+ ;
+
+ /** The lowercase value the backend API expects for the `length` query param. */
+ fun toApiValue(): String = name.lowercase()
+}
diff --git a/android/app/src/main/java/com/guidecity/app/data/local/prefs/UserPrefsDataStore.kt b/android/app/src/main/java/com/guidecity/app/data/local/prefs/UserPrefsDataStore.kt
new file mode 100644
index 0000000..4a1c6cc
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/data/local/prefs/UserPrefsDataStore.kt
@@ -0,0 +1,41 @@
+package com.guidecity.app.data.local.prefs
+
+import android.content.Context
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private val Context.dataStore by preferencesDataStore(name = "guide_city_prefs")
+
+@Singleton
+class UserPrefsDataStore @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+ private object Keys {
+ val ONBOARDING_SEEN = booleanPreferencesKey("onboarding_seen")
+ val CONTENT_LENGTH = stringPreferencesKey("content_length_pref")
+ }
+
+ val onboardingSeen: Flow =
+ context.dataStore.data.map { it[Keys.ONBOARDING_SEEN] ?: false }
+
+ suspend fun setOnboardingSeen(seen: Boolean) {
+ context.dataStore.edit { it[Keys.ONBOARDING_SEEN] = seen }
+ }
+
+ /** Null until the user has made a choice on the content-length picker screen. */
+ val contentLength: Flow =
+ context.dataStore.data.map { prefs ->
+ prefs[Keys.CONTENT_LENGTH]?.let { runCatching { ContentLength.valueOf(it) }.getOrNull() }
+ }
+
+ suspend fun setContentLength(length: ContentLength) {
+ context.dataStore.edit { it[Keys.CONTENT_LENGTH] = length.name }
+ }
+}
diff --git a/android/app/src/main/java/com/guidecity/app/data/remote/ApiService.kt b/android/app/src/main/java/com/guidecity/app/data/remote/ApiService.kt
new file mode 100644
index 0000000..a7646f2
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/data/remote/ApiService.kt
@@ -0,0 +1,51 @@
+package com.guidecity.app.data.remote
+
+import com.guidecity.app.data.remote.dto.CityDto
+import com.guidecity.app.data.remote.dto.NearbyResponseDto
+import com.guidecity.app.data.remote.dto.PlaceDetailDto
+import com.guidecity.app.data.remote.dto.PlaceListItemDto
+import retrofit2.http.GET
+import retrofit2.http.Path
+import retrofit2.http.Query
+
+interface ApiService {
+
+ @GET("api/v1/cities")
+ suspend fun getCities(): List
+
+ @GET("api/v1/cities/{citySlug}/places")
+ suspend fun getCityPlaces(
+ @Path("citySlug") citySlug: String,
+ @Query("lang") lang: String = "ru",
+ @Query("category") category: String? = null,
+ @Query("district") district: String? = null,
+ @Query("q") q: String? = null,
+ ): List
+
+ @GET("api/v1/places/{placeId}")
+ suspend fun getPlace(
+ @Path("placeId") placeId: Int,
+ @Query("lang") lang: String = "ru",
+ @Query("length") length: String = "medium",
+ ): PlaceDetailDto
+
+ @GET("api/v1/places/search")
+ suspend fun searchPlaces(
+ @Query("q") q: String,
+ @Query("city_slug") citySlug: String? = null,
+ @Query("lang") lang: String = "ru",
+ ): List
+
+ @GET("api/v1/nearby")
+ suspend fun getNearby(
+ @Query("lat") lat: Double,
+ @Query("lon") lon: Double,
+ @Query("city_slug") citySlug: String? = null,
+ @Query("lang") lang: String = "ru",
+ @Query("length") length: String = "medium",
+ @Query("min_results") minResults: Int = 5,
+ @Query("initial_radius_m") initialRadiusM: Int = 300,
+ @Query("step_m") stepM: Int = 200,
+ @Query("max_radius_m") maxRadiusM: Int = 5000,
+ ): NearbyResponseDto
+}
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
new file mode 100644
index 0000000..198ff83
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/data/remote/RetrofitClient.kt
@@ -0,0 +1,40 @@
+package com.guidecity.app.data.remote
+
+import com.guidecity.app.BuildConfig
+import kotlinx.serialization.json.Json
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.Retrofit
+import retrofit2.converter.kotlinx.serialization.asConverterFactory
+
+object RetrofitClient {
+
+ private val json = Json {
+ ignoreUnknownKeys = true
+ isLenient = true
+ }
+
+ private val okHttpClient: OkHttpClient by lazy {
+ OkHttpClient.Builder()
+ .addInterceptor(
+ HttpLoggingInterceptor().apply {
+ level = if (BuildConfig.DEBUG) {
+ HttpLoggingInterceptor.Level.BODY
+ } else {
+ HttpLoggingInterceptor.Level.NONE
+ }
+ },
+ )
+ .build()
+ }
+
+ val apiService: ApiService by lazy {
+ Retrofit.Builder()
+ .baseUrl(BuildConfig.API_BASE_URL)
+ .client(okHttpClient)
+ .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
+ .build()
+ .create(ApiService::class.java)
+ }
+}
diff --git a/android/app/src/main/java/com/guidecity/app/data/remote/dto/CityDto.kt b/android/app/src/main/java/com/guidecity/app/data/remote/dto/CityDto.kt
new file mode 100644
index 0000000..1f68595
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/data/remote/dto/CityDto.kt
@@ -0,0 +1,23 @@
+package com.guidecity.app.data.remote.dto
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class LatLonDto(
+ val lat: Double,
+ val lon: Double,
+)
+
+@Serializable
+data class LocalizedNameDto(
+ val ru: String,
+ val en: String,
+)
+
+@Serializable
+data class CityDto(
+ val id: Int,
+ val slug: String,
+ val name: LocalizedNameDto,
+ val center: LatLonDto? = null,
+)
diff --git a/android/app/src/main/java/com/guidecity/app/data/remote/dto/NearbyDto.kt b/android/app/src/main/java/com/guidecity/app/data/remote/dto/NearbyDto.kt
new file mode 100644
index 0000000..131070f
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/data/remote/dto/NearbyDto.kt
@@ -0,0 +1,26 @@
+package com.guidecity.app.data.remote.dto
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class NearbyPlaceDto(
+ val id: Int,
+ val slug: String,
+ val category: String,
+ val location: LatLonDto,
+ val address: String? = null,
+ val district: String? = null,
+ @SerialName("built_year") val builtYear: String? = null,
+ @SerialName("architect_builder") val architectBuilder: String? = null,
+ @SerialName("architectural_style") val architecturalStyle: String? = null,
+ val content: ContentDto,
+ @SerialName("distance_m") val distanceM: Double,
+)
+
+@Serializable
+data class NearbyResponseDto(
+ @SerialName("search_radius_m") val searchRadiusM: Int,
+ val count: Int,
+ val places: List,
+)
diff --git a/android/app/src/main/java/com/guidecity/app/data/remote/dto/PlaceDto.kt b/android/app/src/main/java/com/guidecity/app/data/remote/dto/PlaceDto.kt
new file mode 100644
index 0000000..58ff89b
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/data/remote/dto/PlaceDto.kt
@@ -0,0 +1,35 @@
+package com.guidecity.app.data.remote.dto
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class ContentDto(
+ val language: String,
+ val length: String,
+ val title: String,
+ val body: String,
+)
+
+@Serializable
+data class PlaceListItemDto(
+ val id: Int,
+ val slug: String,
+ val category: String,
+ val name: String,
+ val location: LatLonDto,
+)
+
+@Serializable
+data class PlaceDetailDto(
+ val id: Int,
+ val slug: String,
+ val category: String,
+ val location: LatLonDto,
+ val address: String? = null,
+ val district: String? = null,
+ @SerialName("built_year") val builtYear: String? = null,
+ @SerialName("architect_builder") val architectBuilder: String? = null,
+ @SerialName("architectural_style") val architecturalStyle: String? = null,
+ val content: ContentDto,
+)
diff --git a/android/app/src/main/java/com/guidecity/app/data/repository/FavoritesRepository.kt b/android/app/src/main/java/com/guidecity/app/data/repository/FavoritesRepository.kt
new file mode 100644
index 0000000..503000d
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/data/repository/FavoritesRepository.kt
@@ -0,0 +1,20 @@
+package com.guidecity.app.data.repository
+
+import com.guidecity.app.data.local.db.FavoritePlaceDao
+import com.guidecity.app.data.local.db.FavoritePlaceEntity
+import kotlinx.coroutines.flow.Flow
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class FavoritesRepository @Inject constructor(
+ private val dao: FavoritePlaceDao,
+) {
+ fun observeFavorites(): Flow> = dao.observeAll()
+
+ fun isFavorite(placeId: Int): Flow = dao.isFavorite(placeId)
+
+ suspend fun addFavorite(place: FavoritePlaceEntity) = dao.insert(place)
+
+ suspend fun removeFavorite(placeId: Int) = dao.deleteById(placeId)
+}
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
new file mode 100644
index 0000000..1890d6e
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/data/repository/PlacesRepository.kt
@@ -0,0 +1,45 @@
+package com.guidecity.app.data.repository
+
+import com.guidecity.app.data.remote.ApiService
+import com.guidecity.app.data.remote.dto.CityDto
+import com.guidecity.app.data.remote.dto.NearbyResponseDto
+import com.guidecity.app.data.remote.dto.PlaceDetailDto
+import com.guidecity.app.data.remote.dto.PlaceListItemDto
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class PlacesRepository @Inject constructor(
+ private val apiService: ApiService,
+) {
+ suspend fun getCities(): List = apiService.getCities()
+
+ suspend fun getPlace(placeId: Int, lang: String, length: String): PlaceDetailDto =
+ apiService.getPlace(placeId, lang, length)
+
+ suspend fun searchPlaces(query: String, citySlug: String?, lang: String): List =
+ apiService.searchPlaces(query, citySlug, lang)
+
+ /**
+ * Fetches nearby places sorted by distance, iteratively expanding the
+ * search radius server-side until at least [minResults] are found.
+ *
+ * `speed`/`heading`-aware filtering is a reserved v2 extension on the
+ * backend (see nearby_search.py); this client doesn't send them yet.
+ */
+ suspend fun getNearby(
+ lat: Double,
+ lon: Double,
+ citySlug: String?,
+ lang: String,
+ length: String,
+ minResults: Int = 5,
+ ): NearbyResponseDto = apiService.getNearby(
+ lat = lat,
+ lon = lon,
+ citySlug = citySlug,
+ lang = lang,
+ length = length,
+ minResults = minResults,
+ )
+}
diff --git a/android/app/src/main/java/com/guidecity/app/di/AppModule.kt b/android/app/src/main/java/com/guidecity/app/di/AppModule.kt
new file mode 100644
index 0000000..4f22d86
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/di/AppModule.kt
@@ -0,0 +1,32 @@
+package com.guidecity.app.di
+
+import android.content.Context
+import androidx.room.Room
+import com.guidecity.app.data.local.db.AppDatabase
+import com.guidecity.app.data.local.db.FavoritePlaceDao
+import com.guidecity.app.data.remote.ApiService
+import com.guidecity.app.data.remote.RetrofitClient
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object AppModule {
+
+ @Provides
+ @Singleton
+ fun provideApiService(): ApiService = RetrofitClient.apiService
+
+ @Provides
+ @Singleton
+ fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase =
+ Room.databaseBuilder(context, AppDatabase::class.java, "guide_city.db").build()
+
+ @Provides
+ @Singleton
+ fun provideFavoritePlaceDao(database: AppDatabase): FavoritePlaceDao = database.favoritePlaceDao()
+}
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
new file mode 100644
index 0000000..c62bac0
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/location/LocationProvider.kt
@@ -0,0 +1,43 @@
+package com.guidecity.app.location
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.pm.PackageManager
+import androidx.core.content.ContextCompat
+import com.google.android.gms.location.FusedLocationProviderClient
+import com.google.android.gms.location.LocationServices
+import com.google.android.gms.location.Priority
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.tasks.await
+import javax.inject.Inject
+import javax.inject.Singleton
+
+data class LatLon(val lat: Double, val lon: Double)
+
+@Singleton
+class LocationProvider @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+ private val fusedClient: FusedLocationProviderClient by lazy {
+ LocationServices.getFusedLocationProviderClient(context)
+ }
+
+ 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
+ }
+
+ /** Returns null if permission isn't granted or no location could be resolved. */
+ @SuppressLint("MissingPermission")
+ suspend fun getCurrentLocation(): LatLon? {
+ if (!hasLocationPermission()) 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) }
+ }
+}
diff --git a/android/app/src/main/java/com/guidecity/app/location/SelectedLocationHolder.kt b/android/app/src/main/java/com/guidecity/app/location/SelectedLocationHolder.kt
new file mode 100644
index 0000000..231310b
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/location/SelectedLocationHolder.kt
@@ -0,0 +1,22 @@
+package com.guidecity.app.location
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Carries the location resolved on the map screen (via GPS or a typed-place
+ * search fallback) over to the guide screen, without threading it through
+ * Compose Navigation arguments.
+ */
+@Singleton
+class SelectedLocationHolder @Inject constructor() {
+ private val _location = MutableStateFlow(null)
+ val location: StateFlow = _location.asStateFlow()
+
+ fun set(latLon: LatLon) {
+ _location.value = latLon
+ }
+}
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
new file mode 100644
index 0000000..6c3af65
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/map/DgisMapView.kt
@@ -0,0 +1,87 @@
+package com.guidecity.app.map
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.unit.dp
+import com.guidecity.app.data.remote.dto.NearbyPlaceDto
+import com.guidecity.app.location.LatLon
+import com.guidecity.app.theme.Mocha
+import kotlin.math.cos
+
+/**
+ * Isolated integration point for the 2GIS MapKit SDK.
+ *
+ * This currently renders a lightweight placeholder (user location centered,
+ * nearby places plotted by relative lat/lon offset) so the rest of the app
+ * — permission flow, nearby fetch, navigation to place detail — is fully
+ * functional and demoable before the real SDK is wired in.
+ *
+ * To integrate the real map: replace the Canvas placeholder below with the
+ * 2GIS MapKit view (see android/build.gradle.kts and settings.gradle.kts for
+ * the pending dependency/repository TODOs — the exact Maven coordinates need
+ * confirming from https://docs.2gis.com/), keeping this function's signature
+ * so callers (MapScreen, GuideScreen) don't need to change.
+ */
+@Composable
+fun DgisMapView(
+ userLocation: LatLon?,
+ places: List,
+ onPlaceClick: (placeId: Int) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .background(Mocha.Mantle),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (userLocation == null) {
+ Text(
+ text = "Map placeholder — waiting for location",
+ color = Mocha.Subtext1,
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ return@Box
+ }
+
+ Canvas(modifier = Modifier.fillMaxSize()) {
+ val centerX = size.width / 2f
+ val centerY = size.height / 2f
+ val metersPerDegreeLat = 111_320.0
+ val metersPerDegreeLon = 111_320.0 * cos(Math.toRadians(userLocation.lat))
+ val pixelsPerMeter = 0.6f
+
+ fun offsetFor(lat: Double, lon: Double): Offset {
+ val dyMeters = (lat - userLocation.lat) * metersPerDegreeLat
+ val dxMeters = (lon - userLocation.lon) * metersPerDegreeLon
+ return Offset(
+ x = centerX + (dxMeters * pixelsPerMeter).toFloat(),
+ y = centerY - (dyMeters * pixelsPerMeter).toFloat(),
+ )
+ }
+
+ // User location marker.
+ drawCircle(color = Mocha.Blue, radius = 14f, center = Offset(centerX, centerY))
+ drawCircle(
+ color = Mocha.Blue,
+ radius = 22f,
+ center = Offset(centerX, centerY),
+ style = Stroke(width = 3f),
+ )
+
+ places.forEach { place ->
+ val point = offsetFor(place.location.lat, place.location.lon)
+ drawCircle(color = Mocha.Peach, radius = 10f, center = point)
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/guidecity/app/navigation/NavGraph.kt b/android/app/src/main/java/com/guidecity/app/navigation/NavGraph.kt
new file mode 100644
index 0000000..8e4d03d
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/navigation/NavGraph.kt
@@ -0,0 +1,117 @@
+package com.guidecity.app.navigation
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import com.guidecity.app.data.local.prefs.UserPrefsDataStore
+import com.guidecity.app.ui.detail.PlaceDetailScreen
+import com.guidecity.app.ui.favorites.FavoritesScreen
+import com.guidecity.app.ui.guide.GuideScreen
+import com.guidecity.app.ui.map.MapScreen
+import com.guidecity.app.ui.onboarding.OnboardingScreen
+import com.guidecity.app.ui.preference.ContentLengthScreen
+import com.guidecity.app.ui.settings.SettingsScreen
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import javax.inject.Inject
+
+object Routes {
+ const val ONBOARDING = "onboarding"
+ const val CONTENT_LENGTH_PICKER = "content_length_picker"
+ const val MAP = "map"
+ const val GUIDE = "guide"
+ const val DETAIL = "detail/{placeId}"
+ const val FAVORITES = "favorites"
+ const val SETTINGS = "settings"
+
+ fun detail(placeId: Int) = "detail/$placeId"
+}
+
+/** Decides the start destination from persisted onboarding/content-length prefs. */
+@HiltViewModel
+class AppEntryViewModel @Inject constructor(
+ userPrefsDataStore: UserPrefsDataStore,
+) : ViewModel() {
+ val startDestination = combine(
+ userPrefsDataStore.onboardingSeen,
+ userPrefsDataStore.contentLength,
+ ) { seen, length ->
+ when {
+ !seen -> Routes.ONBOARDING
+ length == null -> Routes.CONTENT_LENGTH_PICKER
+ else -> Routes.MAP
+ }
+ }.stateIn(viewModelScope, SharingStarted.Eagerly, null)
+}
+
+@Composable
+fun GuideCityNavHost() {
+ val navController = rememberNavController()
+ val entryViewModel: AppEntryViewModel = hiltViewModel()
+ val startDestination by entryViewModel.startDestination.collectAsState()
+
+ Surface(modifier = Modifier.fillMaxSize()) {
+ val resolvedStart = startDestination ?: return@Surface // waiting on first DataStore emission
+
+ NavHost(navController = navController, startDestination = resolvedStart) {
+ composable(Routes.ONBOARDING) {
+ OnboardingScreen(
+ onContinue = {
+ navController.navigate(Routes.CONTENT_LENGTH_PICKER) {
+ popUpTo(Routes.ONBOARDING) { inclusive = true }
+ }
+ },
+ )
+ }
+ composable(Routes.CONTENT_LENGTH_PICKER) {
+ ContentLengthScreen(
+ onContinue = {
+ navController.navigate(Routes.MAP) {
+ popUpTo(Routes.CONTENT_LENGTH_PICKER) { inclusive = true }
+ }
+ },
+ )
+ }
+ composable(Routes.MAP) {
+ MapScreen(
+ onLocationResolved = { navController.navigate(Routes.GUIDE) },
+ onOpenFavorites = { navController.navigate(Routes.FAVORITES) },
+ onOpenSettings = { navController.navigate(Routes.SETTINGS) },
+ )
+ }
+ composable(Routes.GUIDE) {
+ GuideScreen(
+ onOpenDetail = { placeId -> navController.navigate(Routes.detail(placeId)) },
+ onOpenFavorites = { navController.navigate(Routes.FAVORITES) },
+ onOpenSettings = { navController.navigate(Routes.SETTINGS) },
+ )
+ }
+ composable(Routes.DETAIL) { backStackEntry ->
+ val placeId = backStackEntry.arguments?.getString("placeId")?.toIntOrNull()
+ if (placeId != null) {
+ PlaceDetailScreen(placeId = placeId, onBack = { navController.popBackStack() })
+ }
+ }
+ composable(Routes.FAVORITES) {
+ FavoritesScreen(
+ onBack = { navController.popBackStack() },
+ onOpenDetail = { placeId -> navController.navigate(Routes.detail(placeId)) },
+ )
+ }
+ composable(Routes.SETTINGS) {
+ SettingsScreen(onBack = { navController.popBackStack() })
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/guidecity/app/theme/Color.kt b/android/app/src/main/java/com/guidecity/app/theme/Color.kt
new file mode 100644
index 0000000..3aed29b
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/theme/Color.kt
@@ -0,0 +1,44 @@
+package com.guidecity.app.theme
+
+import androidx.compose.ui.graphics.Color
+
+// Catppuccin Mocha palette — https://github.com/catppuccin/catppuccin
+object Mocha {
+ val Rosewater = Color(0xFFF5E0DC)
+ val Flamingo = Color(0xFFF2CDCD)
+ val Pink = Color(0xFFF5C2E7)
+ val Mauve = Color(0xFFCBA6F7)
+ val Red = Color(0xFFF38BA8)
+ val Maroon = Color(0xFFEBA0AC)
+ val Peach = Color(0xFFFAB387)
+ val Yellow = Color(0xFFF9E2AF)
+ val Green = Color(0xFFA6E3A1)
+ val Teal = Color(0xFF94E2D5)
+ val Sky = Color(0xFF89DCEB)
+ val Sapphire = Color(0xFF74C7EC)
+ val Blue = Color(0xFF89B4FA)
+ val Lavender = Color(0xFFB4BEFE)
+ val Text = Color(0xFFCDD6F4)
+ val Subtext1 = Color(0xFFBAC2DE)
+ val Subtext0 = Color(0xFFA6ADC8)
+ val Overlay2 = Color(0xFF9399B2)
+ val Overlay1 = Color(0xFF7F849C)
+ val Overlay0 = Color(0xFF6C7086)
+ val Surface2 = Color(0xFF585B70)
+ val Surface1 = Color(0xFF45475A)
+ val Surface0 = Color(0xFF313244)
+ val Base = Color(0xFF1E1E2E)
+ val Mantle = Color(0xFF181825)
+ val Crust = Color(0xFF11111B)
+}
+
+/**
+ * Semantic colors for the guide card stack, distinct from Material3 roles:
+ * which card is currently narrating, which is done, which hasn't played yet.
+ */
+object GuideStackColors {
+ val ActiveCard = Mocha.Green
+ val DoneCard = Mocha.Overlay0
+ val UpcomingCard = Mocha.Surface2
+ val FavoriteMark = Mocha.Pink
+}
diff --git a/android/app/src/main/java/com/guidecity/app/theme/Theme.kt b/android/app/src/main/java/com/guidecity/app/theme/Theme.kt
new file mode 100644
index 0000000..5cdaf19
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/theme/Theme.kt
@@ -0,0 +1,44 @@
+package com.guidecity.app.theme
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.runtime.Composable
+
+/**
+ * guideCity ships a single Catppuccin Mocha (dark) palette. It's mapped onto
+ * both the dark and light color schemes so the app looks the same regardless
+ * of the system theme, rather than inverting to a light palette.
+ */
+private val MochaColorScheme = darkColorScheme(
+ primary = Mocha.Mauve,
+ onPrimary = Mocha.Crust,
+ primaryContainer = Mocha.Surface0,
+ onPrimaryContainer = Mocha.Text,
+ secondary = Mocha.Sky,
+ onSecondary = Mocha.Crust,
+ secondaryContainer = Mocha.Surface1,
+ onSecondaryContainer = Mocha.Text,
+ tertiary = Mocha.Peach,
+ onTertiary = Mocha.Crust,
+ background = Mocha.Base,
+ onBackground = Mocha.Text,
+ surface = Mocha.Mantle,
+ onSurface = Mocha.Text,
+ surfaceVariant = Mocha.Surface0,
+ onSurfaceVariant = Mocha.Subtext1,
+ outline = Mocha.Overlay1,
+ error = Mocha.Red,
+ onError = Mocha.Crust,
+ errorContainer = Mocha.Maroon,
+)
+
+@Composable
+fun GuideCityTheme(content: @Composable () -> Unit) {
+ // The Mocha palette is used for both system themes rather than swapping
+ // to a light variant, so isSystemInDarkTheme() is deliberately unused.
+ MaterialTheme(
+ colorScheme = MochaColorScheme,
+ typography = GuideCityTypography,
+ content = content,
+ )
+}
diff --git a/android/app/src/main/java/com/guidecity/app/theme/Type.kt b/android/app/src/main/java/com/guidecity/app/theme/Type.kt
new file mode 100644
index 0000000..4181450
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/theme/Type.kt
@@ -0,0 +1,31 @@
+package com.guidecity.app.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+val GuideCityTypography = Typography(
+ bodyLarge = TextStyle(
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp,
+ ),
+ titleLarge = TextStyle(
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ ),
+ titleMedium = TextStyle(
+ fontWeight = FontWeight.Medium,
+ fontSize = 18.sp,
+ lineHeight = 24.sp,
+ ),
+ labelLarge = TextStyle(
+ fontWeight = FontWeight.Medium,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.1.sp,
+ ),
+)
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
new file mode 100644
index 0000000..7a1c145
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/tts/TtsManager.kt
@@ -0,0 +1,92 @@
+package com.guidecity.app.tts
+
+import android.content.Context
+import android.speech.tts.TextToSpeech
+import android.speech.tts.UtteranceProgressListener
+import dagger.hilt.android.qualifiers.ApplicationContext
+import java.util.Locale
+import java.util.UUID
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Wraps Android's built-in, on-device [TextToSpeech] engine — this satisfies
+ * the app's "local voice narration" requirement without a custom ML model.
+ *
+ * A single app-wide instance is shared by the guide screen and the place
+ * detail screen (both injected via Hilt) so only one narration plays at a
+ * time; calling [speak] naturally interrupts whatever was playing before via
+ * [TextToSpeech.QUEUE_FLUSH].
+ */
+@Singleton
+class TtsManager @Inject constructor(
+ @ApplicationContext context: Context,
+) {
+ private var isReady = false
+ private var pendingUtterance: Pair Unit)?>? = null
+ private var onDoneCallback: (() -> Unit)? = null
+ private var lastSpokenText: String? = null
+
+ var isMuted: Boolean = false
+ private set
+
+ private val tts: TextToSpeech = TextToSpeech(context) { status ->
+ isReady = status == TextToSpeech.SUCCESS
+ pendingUtterance?.let { (text, onDone) -> speakInternal(text, onDone) }
+ pendingUtterance = null
+ }.apply {
+ setOnUtteranceProgressListener(
+ object : UtteranceProgressListener() {
+ override fun onStart(utteranceId: String?) = Unit
+
+ override fun onDone(utteranceId: String?) {
+ onDoneCallback?.invoke()
+ }
+
+ @Deprecated("Deprecated in Java, but still the callback the platform invokes")
+ override fun onError(utteranceId: String?) = Unit
+ },
+ )
+ }
+
+ fun setLanguage(locale: Locale) {
+ tts.language = locale
+ }
+
+ /** Speaks [text], calling [onDone] on the main thread once narration finishes. */
+ fun speak(text: String, onDone: (() -> Unit)? = null) {
+ lastSpokenText = text
+ onDoneCallback = onDone
+ if (isMuted) return
+ if (!isReady) {
+ pendingUtterance = text to onDone
+ return
+ }
+ speakInternal(text, onDone)
+ }
+
+ private fun speakInternal(text: String, onDone: (() -> Unit)?) {
+ onDoneCallback = onDone
+ tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, UUID.randomUUID().toString())
+ }
+
+ /** Stops narration and suppresses further [speak] calls until [unmute]. */
+ fun mute() {
+ isMuted = true
+ tts.stop()
+ }
+
+ /** Resumes narration from the start of the last spoken text. */
+ fun unmute() {
+ isMuted = false
+ lastSpokenText?.let { speakInternal(it, onDoneCallback) }
+ }
+
+ fun stop() {
+ tts.stop()
+ }
+
+ fun shutdown() {
+ tts.shutdown()
+ }
+}
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
new file mode 100644
index 0000000..0c77b9c
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/ui/detail/PlaceDetailScreen.kt
@@ -0,0 +1,109 @@
+package com.guidecity.app.ui.detail
+
+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.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.filled.Favorite
+import androidx.compose.material.icons.filled.FavoriteBorder
+import androidx.compose.material.icons.filled.VolumeOff
+import androidx.compose.material.icons.filled.VolumeUp
+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.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+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.GuideStackColors
+
+@Composable
+fun PlaceDetailScreen(
+ placeId: Int,
+ onBack: () -> Unit,
+ viewModel: PlaceDetailViewModel = hiltViewModel(),
+) {
+ val state by viewModel.uiState.collectAsState()
+ val isFavorite by viewModel.isFavorite.collectAsState()
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(state.place?.content?.title.orEmpty()) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.Filled.ArrowBack, contentDescription = null)
+ }
+ },
+ actions = {
+ IconButton(onClick = { viewModel.toggleMute() }) {
+ Icon(
+ imageVector = if (state.isMuted) Icons.Filled.VolumeOff else Icons.Filled.VolumeUp,
+ contentDescription = stringResource(
+ if (state.isMuted) R.string.guide_unmute else R.string.guide_mute,
+ ),
+ )
+ }
+ IconButton(onClick = { viewModel.toggleFavorite() }) {
+ Icon(
+ imageVector = if (isFavorite) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
+ contentDescription = stringResource(
+ if (isFavorite) R.string.guide_favorite_remove else R.string.guide_favorite_add,
+ ),
+ tint = if (isFavorite) {
+ GuideStackColors.FavoriteMark
+ } else {
+ MaterialTheme.colorScheme.onSurface
+ },
+ )
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding),
+ ) {
+ val place = state.place
+ when {
+ state.isLoading -> {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+ }
+
+ place == null -> {
+ 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)
+ }
+ }
+ }
+ }
+ }
+}
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
new file mode 100644
index 0000000..5253e79
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/ui/detail/PlaceDetailViewModel.kt
@@ -0,0 +1,93 @@
+package com.guidecity.app.ui.detail
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+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.repository.FavoritesRepository
+import com.guidecity.app.data.repository.PlacesRepository
+import com.guidecity.app.tts.TtsManager
+import dagger.hilt.android.lifecycle.HiltViewModel
+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
+
+data class PlaceDetailUiState(
+ val isLoading: Boolean = true,
+ val place: PlaceDetailDto? = null,
+ val isMuted: Boolean = false,
+)
+
+/**
+ * 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.
+ */
+@HiltViewModel
+class PlaceDetailViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
+ private val placesRepository: PlacesRepository,
+ private val favoritesRepository: FavoritesRepository,
+ private val userPrefsDataStore: UserPrefsDataStore,
+ private val ttsManager: TtsManager,
+) : ViewModel() {
+
+ private val placeId: 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)
+
+ init {
+ viewModelScope.launch {
+ val length = (userPrefsDataStore.contentLength.first() ?: ContentLength.MEDIUM).toApiValue()
+ val place = runCatching { placesRepository.getPlace(placeId, lang = "ru", length = length) }.getOrNull()
+ _uiState.value = _uiState.value.copy(isLoading = false, place = place)
+ place?.let { ttsManager.speak(it.content.body) }
+ }
+ }
+
+ fun toggleMute() {
+ if (ttsManager.isMuted) ttsManager.unmute() else ttsManager.mute()
+ _uiState.value = _uiState.value.copy(isMuted = ttsManager.isMuted)
+ }
+
+ fun toggleFavorite() {
+ val place = _uiState.value.place ?: return
+ viewModelScope.launch {
+ if (isFavorite.value) {
+ favoritesRepository.removeFavorite(placeId)
+ } else {
+ favoritesRepository.addFavorite(
+ FavoritePlaceEntity(
+ placeId = placeId,
+ slug = place.slug,
+ name = place.content.title,
+ category = place.category,
+ lat = place.location.lat,
+ lon = place.location.lon,
+ savedAt = System.currentTimeMillis(),
+ ),
+ )
+ }
+ }
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ ttsManager.stop()
+ }
+}
diff --git a/android/app/src/main/java/com/guidecity/app/ui/favorites/FavoritesScreen.kt b/android/app/src/main/java/com/guidecity/app/ui/favorites/FavoritesScreen.kt
new file mode 100644
index 0000000..edf3cca
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/ui/favorites/FavoritesScreen.kt
@@ -0,0 +1,67 @@
+package com.guidecity.app.ui.favorites
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.guidecity.app.R
+
+@Composable
+fun FavoritesScreen(
+ onBack: () -> Unit,
+ onOpenDetail: (placeId: Int) -> Unit,
+ viewModel: FavoritesViewModel = hiltViewModel(),
+) {
+ val favorites by viewModel.favorites.collectAsState()
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.favorites_title)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.Filled.ArrowBack, contentDescription = null)
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding),
+ ) {
+ if (favorites.isEmpty()) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text(text = stringResource(R.string.favorites_empty))
+ }
+ } else {
+ LazyColumn {
+ items(favorites, key = { it.placeId }) { favorite ->
+ ListItem(
+ headlineContent = { Text(favorite.name) },
+ modifier = Modifier.clickable { onOpenDetail(favorite.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
new file mode 100644
index 0000000..aa6e104
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/ui/favorites/FavoritesViewModel.kt
@@ -0,0 +1,19 @@
+package com.guidecity.app.ui.favorites
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.guidecity.app.data.local.db.FavoritePlaceEntity
+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.stateIn
+import javax.inject.Inject
+
+@HiltViewModel
+class FavoritesViewModel @Inject constructor(
+ favoritesRepository: FavoritesRepository,
+) : ViewModel() {
+ val favorites: StateFlow> = favoritesRepository.observeFavorites()
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
+}
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
new file mode 100644
index 0000000..b0c40e3
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/ui/guide/GuideScreen.kt
@@ -0,0 +1,89 @@
+package com.guidecity.app.ui.guide
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Favorite
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.VolumeOff
+import androidx.compose.material.icons.filled.VolumeUp
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.guidecity.app.R
+
+@Composable
+fun GuideScreen(
+ onOpenDetail: (placeId: Int) -> Unit,
+ onOpenFavorites: () -> Unit,
+ onOpenSettings: () -> Unit,
+ viewModel: GuideViewModel = hiltViewModel(),
+) {
+ val state by viewModel.uiState.collectAsState()
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.app_name)) },
+ actions = {
+ IconButton(onClick = { viewModel.toggleMute() }) {
+ Icon(
+ imageVector = if (state.isMuted) Icons.Filled.VolumeOff else Icons.Filled.VolumeUp,
+ contentDescription = stringResource(
+ if (state.isMuted) R.string.guide_unmute else R.string.guide_mute,
+ ),
+ )
+ }
+ IconButton(onClick = onOpenFavorites) {
+ Icon(Icons.Filled.Favorite, contentDescription = stringResource(R.string.favorites_title))
+ }
+ IconButton(onClick = onOpenSettings) {
+ Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.settings_title))
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding),
+ ) {
+ when {
+ state.isLoading -> {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+ }
+
+ state.places.isEmpty() -> {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text(text = stringResource(R.string.guide_no_places_nearby))
+ }
+ }
+
+ else -> {
+ PlaceCardStack(
+ places = state.places,
+ activeIndex = state.activeIndex,
+ doneIndices = state.doneIndices,
+ onCardClick = onOpenDetail,
+ onPageChanged = { viewModel.setActiveIndex(it) },
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ }
+ }
+ }
+}
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
new file mode 100644
index 0000000..425037b
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/ui/guide/GuideViewModel.kt
@@ -0,0 +1,139 @@
+package com.guidecity.app.ui.guide
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.guidecity.app.data.local.prefs.ContentLength
+import com.guidecity.app.data.local.prefs.UserPrefsDataStore
+import com.guidecity.app.data.remote.dto.NearbyPlaceDto
+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 dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import java.util.Locale
+import javax.inject.Inject
+
+data class GuideUiState(
+ val isLoading: Boolean = true,
+ val places: List = emptyList(),
+ val activeIndex: Int = 0,
+ val doneIndices: Set = emptySet(),
+ val isMuted: Boolean = false,
+)
+
+/**
+ * Owns the nearby-places list, which card is actively narrating, which ones
+ * have finished, and the 60-second re-scan loop. Re-scanning currently only
+ * re-sorts by fresh distance (TODO(v2): factor in the user's speed/heading
+ * once the backend's reserved `speed`/`heading` nearby params are used, to
+ * auto-pick a shorter content length when moving fast through a dense area).
+ */
+@HiltViewModel
+class GuideViewModel @Inject constructor(
+ private val placesRepository: PlacesRepository,
+ private val locationProvider: LocationProvider,
+ private val selectedLocationHolder: SelectedLocationHolder,
+ private val userPrefsDataStore: UserPrefsDataStore,
+ private val ttsManager: TtsManager,
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(GuideUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private var contentLength: ContentLength = ContentLength.MEDIUM
+ private var rescanStarted = false
+
+ init {
+ viewModelScope.launch {
+ contentLength = userPrefsDataStore.contentLength.first() ?: ContentLength.MEDIUM
+ ttsManager.setLanguage(Locale("ru"))
+ loadNearby(startNarration = true)
+ startRescanLoop()
+ }
+ }
+
+ private fun startRescanLoop() {
+ if (rescanStarted) return
+ rescanStarted = true
+ viewModelScope.launch {
+ while (isActive) {
+ delay(60_000)
+ loadNearby(startNarration = false)
+ }
+ }
+ }
+
+ private suspend fun loadNearby(startNarration: Boolean) {
+ val location = selectedLocationHolder.location.value ?: locationProvider.getCurrentLocation()
+ if (location == null) {
+ _uiState.value = _uiState.value.copy(isLoading = false)
+ return
+ }
+
+ val response = runCatching {
+ placesRepository.getNearby(
+ lat = location.lat,
+ lon = location.lon,
+ citySlug = "moscow",
+ lang = "ru",
+ length = contentLength.toApiValue(),
+ )
+ }.getOrNull() ?: return
+
+ _uiState.value = _uiState.value.copy(
+ isLoading = false,
+ places = response.places,
+ activeIndex = 0,
+ doneIndices = emptySet(),
+ )
+
+ if (startNarration) {
+ narrateActive()
+ }
+ }
+
+ private fun narrateActive() {
+ val state = _uiState.value
+ val place = state.places.getOrNull(state.activeIndex) ?: return
+ ttsManager.speak(place.content.body) { onNarrationDone() }
+ }
+
+ private fun onNarrationDone() {
+ val state = _uiState.value
+ val nextIndex = state.activeIndex + 1
+ if (nextIndex >= state.places.size) {
+ _uiState.value = state.copy(doneIndices = state.doneIndices + state.activeIndex)
+ return
+ }
+ _uiState.value = state.copy(
+ doneIndices = state.doneIndices + state.activeIndex,
+ activeIndex = nextIndex,
+ )
+ narrateActive()
+ }
+
+ /** User manually swiped to a different card; doesn't force-complete skipped ones. */
+ fun setActiveIndex(index: Int) {
+ val state = _uiState.value
+ if (index !in state.places.indices || index == state.activeIndex) return
+ _uiState.value = state.copy(activeIndex = index)
+ narrateActive()
+ }
+
+ fun toggleMute() {
+ if (ttsManager.isMuted) ttsManager.unmute() else ttsManager.mute()
+ _uiState.value = _uiState.value.copy(isMuted = ttsManager.isMuted)
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ ttsManager.stop()
+ }
+}
diff --git a/android/app/src/main/java/com/guidecity/app/ui/guide/PlaceCard.kt b/android/app/src/main/java/com/guidecity/app/ui/guide/PlaceCard.kt
new file mode 100644
index 0000000..0f4fd0c
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/ui/guide/PlaceCard.kt
@@ -0,0 +1,48 @@
+package com.guidecity.app.ui.guide
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.guidecity.app.data.remote.dto.NearbyPlaceDto
+import com.guidecity.app.theme.GuideStackColors
+import com.guidecity.app.theme.Mocha
+
+enum class CardState { ACTIVE, DONE, UPCOMING }
+
+@Composable
+fun PlaceCard(
+ place: NearbyPlaceDto,
+ state: CardState,
+ modifier: Modifier = Modifier,
+) {
+ val borderColor = when (state) {
+ CardState.ACTIVE -> GuideStackColors.ActiveCard
+ CardState.DONE -> GuideStackColors.DoneCard
+ CardState.UPCOMING -> GuideStackColors.UpcomingCard
+ }
+ Card(
+ modifier = modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = Mocha.Surface0),
+ border = BorderStroke(2.dp, borderColor),
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(text = place.content.title, style = MaterialTheme.typography.titleMedium, color = Mocha.Text)
+ Text(
+ text = place.content.body,
+ style = MaterialTheme.typography.bodyLarge,
+ color = Mocha.Subtext1,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+}
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
new file mode 100644
index 0000000..381ba67
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/ui/guide/PlaceCardStack.kt
@@ -0,0 +1,69 @@
+package com.guidecity.app.ui.guide
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.guidecity.app.data.remote.dto.NearbyPlaceDto
+import kotlinx.coroutines.flow.distinctUntilChanged
+
+/**
+ * Tinder-style swipeable stack of place preview cards, ordered by distance.
+ * Swiping changes which card is "active" ([onPageChanged]); tapping a card
+ * opens its full detail screen ([onCardClick]). Done cards stay in the
+ * pager (dimmed via [CardState.DONE]) rather than being removed, so the
+ * user can still swipe back to review one that already finished narrating.
+ */
+@Composable
+fun PlaceCardStack(
+ places: List,
+ 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) }
+ }
+
+ 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
new file mode 100644
index 0000000..7888355
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt
@@ -0,0 +1,151 @@
+package com.guidecity.app.ui.map
+
+import android.Manifest
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+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.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Favorite
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.OutlinedTextField
+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.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.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
+
+@Composable
+fun MapScreen(
+ onLocationResolved: () -> Unit,
+ onOpenFavorites: () -> Unit,
+ onOpenSettings: () -> Unit,
+ viewModel: MapViewModel = hiltViewModel(),
+) {
+ val uiState by viewModel.uiState.collectAsState()
+ val searchResults by viewModel.searchResults.collectAsState()
+ var query by remember { mutableStateOf("") }
+
+ val permissionLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestMultiplePermissions(),
+ ) { results ->
+ viewModel.onPermissionResult(results.values.any { it }, onLocationResolved)
+ }
+
+ LaunchedEffect(Unit) {
+ viewModel.checkInitialPermission(onLocationResolved)
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.app_name)) },
+ actions = {
+ IconButton(onClick = onOpenFavorites) {
+ Icon(Icons.Filled.Favorite, contentDescription = stringResource(R.string.favorites_title))
+ }
+ IconButton(onClick = onOpenSettings) {
+ Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.settings_title))
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding),
+ ) {
+ DgisMapView(
+ userLocation = null,
+ places = emptyList(),
+ onPlaceClick = {},
+ modifier = Modifier.fillMaxSize(),
+ )
+
+ when (uiState) {
+ MapUiState.NeedsPermission -> {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Text(stringResource(R.string.map_location_permission_rationale))
+ Spacer(modifier = Modifier.height(16.dp))
+ Button(
+ onClick = {
+ permissionLauncher.launch(
+ arrayOf(
+ Manifest.permission.ACCESS_FINE_LOCATION,
+ Manifest.permission.ACCESS_COARSE_LOCATION,
+ ),
+ )
+ },
+ ) {
+ Text(stringResource(R.string.map_grant_permission))
+ }
+ }
+ }
+
+ MapUiState.SearchFallback -> {
+ Column(modifier = Modifier.fillMaxSize()) {
+ OutlinedTextField(
+ value = query,
+ onValueChange = {
+ query = it
+ viewModel.searchPlaces(it)
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ placeholder = { Text(stringResource(R.string.map_search_placeholder)) },
+ )
+ LazyColumn {
+ items(searchResults) { place ->
+ ListItem(
+ headlineContent = { Text(place.name) },
+ modifier = Modifier.clickable {
+ viewModel.selectSearchResult(place, onLocationResolved)
+ },
+ )
+ }
+ }
+ }
+ }
+
+ MapUiState.ResolvingLocation, MapUiState.CheckingPermission -> {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+ }
+ }
+ }
+ }
+}
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
new file mode 100644
index 0000000..92df0f7
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt
@@ -0,0 +1,82 @@
+package com.guidecity.app.ui.map
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.guidecity.app.data.remote.dto.PlaceListItemDto
+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 dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+sealed interface MapUiState {
+ data object CheckingPermission : MapUiState
+ data object NeedsPermission : MapUiState
+ data object ResolvingLocation : MapUiState
+ data object SearchFallback : MapUiState
+}
+
+@HiltViewModel
+class MapViewModel @Inject constructor(
+ private val locationProvider: LocationProvider,
+ private val selectedLocationHolder: SelectedLocationHolder,
+ private val placesRepository: PlacesRepository,
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(MapUiState.CheckingPermission)
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val _searchResults = MutableStateFlow>(emptyList())
+ val searchResults: StateFlow> = _searchResults.asStateFlow()
+
+ fun checkInitialPermission(onLocationResolved: () -> Unit) {
+ if (locationProvider.hasLocationPermission()) {
+ resolveLocation(onLocationResolved)
+ } else {
+ _uiState.value = MapUiState.NeedsPermission
+ }
+ }
+
+ fun onPermissionResult(granted: Boolean, onLocationResolved: () -> Unit) {
+ if (granted) {
+ resolveLocation(onLocationResolved)
+ } else {
+ _uiState.value = MapUiState.SearchFallback
+ }
+ }
+
+ private fun resolveLocation(onLocationResolved: () -> Unit) {
+ _uiState.value = MapUiState.ResolvingLocation
+ viewModelScope.launch {
+ val location = locationProvider.getCurrentLocation()
+ if (location != null) {
+ selectedLocationHolder.set(location)
+ onLocationResolved()
+ } else {
+ _uiState.value = MapUiState.SearchFallback
+ }
+ }
+ }
+
+ fun searchPlaces(query: String) {
+ if (query.isBlank()) {
+ _searchResults.value = emptyList()
+ return
+ }
+ viewModelScope.launch {
+ _searchResults.value = runCatching {
+ placesRepository.searchPlaces(query, citySlug = "moscow", lang = "ru")
+ }.getOrDefault(emptyList())
+ }
+ }
+
+ fun selectSearchResult(place: PlaceListItemDto, onLocationResolved: () -> Unit) {
+ selectedLocationHolder.set(LatLon(place.location.lat, place.location.lon))
+ onLocationResolved()
+ }
+}
diff --git a/android/app/src/main/java/com/guidecity/app/ui/onboarding/OnboardingScreen.kt b/android/app/src/main/java/com/guidecity/app/ui/onboarding/OnboardingScreen.kt
new file mode 100644
index 0000000..4e08240
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/ui/onboarding/OnboardingScreen.kt
@@ -0,0 +1,40 @@
+package com.guidecity.app.ui.onboarding
+
+import androidx.compose.foundation.layout.Arrangement
+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.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.guidecity.app.R
+
+@Composable
+fun OnboardingScreen(
+ onContinue: () -> Unit,
+ viewModel: OnboardingViewModel = hiltViewModel(),
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(32.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(text = stringResource(R.string.onboarding_title), style = MaterialTheme.typography.titleLarge)
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(text = stringResource(R.string.onboarding_body), style = MaterialTheme.typography.bodyLarge)
+ Spacer(modifier = Modifier.height(32.dp))
+ Button(onClick = { viewModel.markOnboardingSeen(onContinue) }) {
+ Text(text = stringResource(R.string.onboarding_ok))
+ }
+ }
+}
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
new file mode 100644
index 0000000..219c432
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/ui/onboarding/OnboardingViewModel.kt
@@ -0,0 +1,20 @@
+package com.guidecity.app.ui.onboarding
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.guidecity.app.data.local.prefs.UserPrefsDataStore
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class OnboardingViewModel @Inject constructor(
+ private val userPrefsDataStore: UserPrefsDataStore,
+) : ViewModel() {
+ fun markOnboardingSeen(onDone: () -> Unit) {
+ viewModelScope.launch {
+ userPrefsDataStore.setOnboardingSeen(true)
+ onDone()
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/guidecity/app/ui/preference/ContentLengthScreen.kt b/android/app/src/main/java/com/guidecity/app/ui/preference/ContentLengthScreen.kt
new file mode 100644
index 0000000..b2fab5f
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/ui/preference/ContentLengthScreen.kt
@@ -0,0 +1,77 @@
+package com.guidecity.app.ui.preference
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+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.data.local.prefs.ContentLength
+
+@Composable
+fun ContentLengthScreen(
+ onContinue: () -> Unit,
+ viewModel: ContentLengthViewModel = hiltViewModel(),
+) {
+ val selected by viewModel.selected.collectAsState()
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(32.dp),
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Text(text = stringResource(R.string.content_length_title), style = MaterialTheme.typography.titleLarge)
+ Spacer(modifier = Modifier.height(24.dp))
+
+ ContentLengthOption(
+ label = stringResource(R.string.content_length_short),
+ selected = selected == ContentLength.SHORT,
+ onSelect = { viewModel.select(ContentLength.SHORT) },
+ )
+ ContentLengthOption(
+ label = stringResource(R.string.content_length_medium),
+ selected = selected == ContentLength.MEDIUM,
+ onSelect = { viewModel.select(ContentLength.MEDIUM) },
+ )
+ ContentLengthOption(
+ label = stringResource(R.string.content_length_long),
+ selected = selected == ContentLength.LONG,
+ onSelect = { viewModel.select(ContentLength.LONG) },
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+ Button(onClick = { viewModel.confirmSelection(onContinue) }) {
+ Text(text = stringResource(R.string.content_length_continue))
+ }
+ }
+}
+
+@Composable
+private fun ContentLengthOption(label: String, selected: Boolean, onSelect: () -> Unit) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .selectable(selected = selected, onClick = onSelect),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RadioButton(selected = selected, onClick = onSelect)
+ Text(text = label, style = MaterialTheme.typography.bodyLarge)
+ }
+}
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
new file mode 100644
index 0000000..7310fe2
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/ui/preference/ContentLengthViewModel.kt
@@ -0,0 +1,32 @@
+package com.guidecity.app.ui.preference
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.guidecity.app.data.local.prefs.ContentLength
+import com.guidecity.app.data.local.prefs.UserPrefsDataStore
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class ContentLengthViewModel @Inject constructor(
+ private val userPrefsDataStore: UserPrefsDataStore,
+) : ViewModel() {
+
+ private val _selected = MutableStateFlow(ContentLength.MEDIUM)
+ val selected: StateFlow = _selected.asStateFlow()
+
+ fun select(length: ContentLength) {
+ _selected.value = length
+ }
+
+ fun confirmSelection(onDone: () -> Unit) {
+ viewModelScope.launch {
+ userPrefsDataStore.setContentLength(_selected.value)
+ onDone()
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/guidecity/app/ui/settings/SettingsScreen.kt b/android/app/src/main/java/com/guidecity/app/ui/settings/SettingsScreen.kt
new file mode 100644
index 0000000..b8aa6c5
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/ui/settings/SettingsScreen.kt
@@ -0,0 +1,89 @@
+package com.guidecity.app.ui.settings
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+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.data.local.prefs.ContentLength
+
+@Composable
+fun SettingsScreen(
+ onBack: () -> Unit,
+ viewModel: SettingsViewModel = hiltViewModel(),
+) {
+ val selected by viewModel.contentLength.collectAsState()
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.settings_title)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.Filled.ArrowBack, contentDescription = null)
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(16.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.settings_content_length_label),
+ style = MaterialTheme.typography.titleMedium,
+ )
+
+ SettingsOption(
+ label = stringResource(R.string.content_length_short),
+ isSelected = selected == ContentLength.SHORT,
+ onSelect = { viewModel.setContentLength(ContentLength.SHORT) },
+ )
+ SettingsOption(
+ label = stringResource(R.string.content_length_medium),
+ isSelected = selected == ContentLength.MEDIUM,
+ onSelect = { viewModel.setContentLength(ContentLength.MEDIUM) },
+ )
+ SettingsOption(
+ label = stringResource(R.string.content_length_long),
+ isSelected = selected == ContentLength.LONG,
+ onSelect = { viewModel.setContentLength(ContentLength.LONG) },
+ )
+ }
+ }
+}
+
+@Composable
+private fun SettingsOption(label: String, isSelected: Boolean, onSelect: () -> Unit) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .selectable(selected = isSelected, onClick = onSelect),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RadioButton(selected = isSelected, onClick = onSelect)
+ Text(text = label)
+ }
+}
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
new file mode 100644
index 0000000..bf98ceb
--- /dev/null
+++ b/android/app/src/main/java/com/guidecity/app/ui/settings/SettingsViewModel.kt
@@ -0,0 +1,24 @@
+package com.guidecity.app.ui.settings
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.guidecity.app.data.local.prefs.ContentLength
+import com.guidecity.app.data.local.prefs.UserPrefsDataStore
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class SettingsViewModel @Inject constructor(
+ private val userPrefsDataStore: UserPrefsDataStore,
+) : ViewModel() {
+ val contentLength: StateFlow = userPrefsDataStore.contentLength
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
+
+ fun setContentLength(length: ContentLength) {
+ viewModelScope.launch { userPrefsDataStore.setContentLength(length) }
+ }
+}
diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..f6e17d7
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..a160e25
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..6b78462
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..6b78462
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml
new file mode 100644
index 0000000..e2f6c8b
--- /dev/null
+++ b/android/app/src/main/res/values-ru/strings.xml
@@ -0,0 +1,32 @@
+
+
+ guideCity
+
+ Добро пожаловать в guideCity
+ guideCity — это голосовой гид для прогулок по городу. Разрешите доступ к геолокации, и мы найдём интересные места поблизости и расскажем их историю прямо во время прогулки по Москве.
+ ОК
+
+ Насколько подробный рассказ вы хотите слышать?
+ Кратко
+ Средне
+ Подробно
+ Продолжить
+
+ guideCity нужен доступ к геолокации, чтобы найти места поблизости. Вы также можете ввести место вручную.
+ Разрешить доступ к геолокации
+ Введите место для поиска…
+
+ Поблизости пока не найдено интересных мест.
+ Добавить в избранное
+ Убрать из избранного
+ Выключить озвучку
+ Включить озвучку
+
+ Избранное
+ Пока нет избранных мест. Нажмите на значок сердца рядом с местом, чтобы сохранить его здесь.
+
+ Настройки
+ Подробность рассказа
+
+ Место не найдено.
+
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..c5b2e2c
--- /dev/null
+++ b/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,8 @@
+
+
+
+ #1E1E2E
+ #CBA6F7
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..f52e75d
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,32 @@
+
+
+ guideCity
+
+ Welcome to guideCity
+ guideCity is a walking tour guide. Grant location access and we\'ll find interesting places nearby and narrate their history as you explore Moscow on foot.
+ OK
+
+ How much detail would you like?
+ Short
+ Medium
+ Long
+ Continue
+
+ 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…
+
+ No places found nearby yet.
+ Add to favorites
+ Remove from favorites
+ Mute narration
+ Resume narration
+
+ Favorites
+ No favorites yet. Tap the heart icon on a place to save it here.
+
+ Settings
+ Content length
+
+ Place not found.
+
diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..5beec49
--- /dev/null
+++ b/android/app/src/main/res/values/themes.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 0000000..970835b
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,8 @@
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.kotlin.compose) apply false
+ alias(libs.plugins.kotlin.serialization) apply false
+ alias(libs.plugins.hilt.android) apply false
+ alias(libs.plugins.ksp) apply false
+}
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..f0a2e55
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
new file mode 100644
index 0000000..8b783f6
--- /dev/null
+++ b/android/gradle/libs.versions.toml
@@ -0,0 +1,67 @@
+[versions]
+agp = "8.5.2"
+kotlin = "2.0.20"
+coreKtx = "1.13.1"
+lifecycle = "2.8.6"
+activityCompose = "1.9.2"
+composeBom = "2024.09.02"
+navigationCompose = "2.8.0"
+hilt = "2.52"
+hiltNavigationCompose = "1.2.0"
+retrofit = "2.11.0"
+okhttp = "4.12.0"
+kotlinxSerializationJson = "1.7.3"
+room = "2.6.1"
+datastore = "1.1.1"
+playServicesLocation = "21.3.0"
+kotlinxCoroutinesPlayServices = "1.8.1"
+ksp = "2.0.20-1.0.25"
+junit = "4.13.2"
+androidxTestExtJunit = "1.2.1"
+espressoCore = "3.6.1"
+
+[libraries]
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
+androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-ui = { group = "androidx.compose.ui", name = "ui" }
+androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
+androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
+
+hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
+hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
+hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
+
+retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
+retrofit-kotlinx-serialization-converter = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
+okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
+kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
+
+androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
+androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
+androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
+
+androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
+
+play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" }
+kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "kotlinxCoroutinesPlayServices" }
+
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExtJunit" }
+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" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
+hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
+ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..2c35211
Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..09523c0
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/android/gradlew b/android/gradlew
new file mode 100755
index 0000000..ef7d318
--- /dev/null
+++ b/android/gradlew
@@ -0,0 +1,196 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 1
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+fi
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+exec "$JAVACMD" "$@"
diff --git a/android/gradlew.bat b/android/gradlew.bat
new file mode 100644
index 0000000..7303b0f
--- /dev/null
+++ b/android/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" -Dorg.gradle.appname=%APP_BASE_NAME% -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/android/local.properties.example b/android/local.properties.example
new file mode 100644
index 0000000..67c4b36
--- /dev/null
+++ b/android/local.properties.example
@@ -0,0 +1,11 @@
+# Copy this file to local.properties (gitignored) and fill in real values.
+
+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 -> your machine's LAN IP, e.g. http://192.168.1.50:8000/
+API_BASE_URL=http://10.0.2.2:8000/
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 0000000..66bc726
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -0,0 +1,21 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ 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).
+ }
+}
+
+rootProject.name = "guideCity"
+include(":app")