Add Android app skeleton (Kotlin/Compose, Catppuccin Mocha theme)

Full app scaffold wired to the backend: onboarding, content-length
picker, map screen (permission flow + text-search fallback), guide
screen with a swipeable place-card stack driven by on-device TTS
narration and a 60s re-scan loop, place detail screen with
mute/favorite controls, favorites (Room-backed), and settings.
Hilt for DI, Retrofit+kotlinx.serialization for the API client,
DataStore for onboarding/content-length prefs, FusedLocationProviderClient
for location. The 2GIS MapKit integration is isolated behind
map/DgisMapView.kt (currently a placeholder) since its exact Maven
coordinates need confirming from the 2GIS developer portal.

Verified by actually building it: installed a minimal Android SDK
(platform 34 + build-tools, no emulator) and JDK 17 locally, then ran
:app:compileDebugKotlin, :app:assembleDebug (produced a real debug
APK), and :app:lintDebug (0 errors, 44 non-blocking warnings, mostly
"newer dependency version available"). Caught and fixed two real bugs
this way: a wrong Maven artifact for the Retrofit kotlinx.serialization
converter, and missing ExperimentalMaterial3Api opt-in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
vrubelroman 2026-07-09 19:05:49 +00:00
parent ca28c93daf
commit c5596870fb
59 changed files with 2695 additions and 0 deletions

View file

@ -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)
}

2
android/app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,2 @@
# Add project specific ProGuard rules here.
# See https://developer.android.com/studio/build/shrink-code for details.

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application
android:name=".GuideCityApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.GuideCity">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.GuideCity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,7 @@
package com.guidecity.app
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class GuideCityApp : Application()

View file

@ -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()
}
}
}
}

View file

@ -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
}

View file

@ -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<List<FavoritePlaceEntity>>
@Query("SELECT EXISTS(SELECT 1 FROM favorite_places WHERE placeId = :placeId)")
fun isFavorite(placeId: Int): Flow<Boolean>
@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)
}

View file

@ -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,
)

View file

@ -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()
}

View file

@ -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<Boolean> =
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<ContentLength?> =
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 }
}
}

View file

@ -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<CityDto>
@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<PlaceListItemDto>
@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<PlaceListItemDto>
@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
}

View file

@ -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)
}
}

View file

@ -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,
)

View file

@ -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<NearbyPlaceDto>,
)

View file

@ -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,
)

View file

@ -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<List<FavoritePlaceEntity>> = dao.observeAll()
fun isFavorite(placeId: Int): Flow<Boolean> = dao.isFavorite(placeId)
suspend fun addFavorite(place: FavoritePlaceEntity) = dao.insert(place)
suspend fun removeFavorite(placeId: Int) = dao.deleteById(placeId)
}

View file

@ -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<CityDto> = 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<PlaceListItemDto> =
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,
)
}

View file

@ -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()
}

View file

@ -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) }
}
}

View file

@ -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<LatLon?>(null)
val location: StateFlow<LatLon?> = _location.asStateFlow()
fun set(latLon: LatLon) {
_location.value = latLon
}
}

View file

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

View file

@ -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() })
}
}
}
}

View file

@ -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
}

View file

@ -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,
)
}

View file

@ -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,
),
)

View file

@ -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<String, (() -> 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()
}
}

View file

@ -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)
}
}
}
}
}
}

View file

@ -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<String>("placeId")).toInt()
private val _uiState = MutableStateFlow(PlaceDetailUiState())
val uiState: StateFlow<PlaceDetailUiState> = _uiState.asStateFlow()
val isFavorite: StateFlow<Boolean> = 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()
}
}

View file

@ -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) },
)
}
}
}
}
}
}

View file

@ -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<List<FavoritePlaceEntity>> = favoritesRepository.observeFavorites()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
}

View file

@ -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(),
)
}
}
}
}
}

View file

@ -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<NearbyPlaceDto> = emptyList(),
val activeIndex: Int = 0,
val doneIndices: Set<Int> = 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<GuideUiState> = _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()
}
}

View file

@ -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,
)
}
}
}

View file

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

View file

@ -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()
}
}
}
}
}
}

View file

@ -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>(MapUiState.CheckingPermission)
val uiState: StateFlow<MapUiState> = _uiState.asStateFlow()
private val _searchResults = MutableStateFlow<List<PlaceListItemDto>>(emptyList())
val searchResults: StateFlow<List<PlaceListItemDto>> = _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()
}
}

View file

@ -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))
}
}
}

View file

@ -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()
}
}
}

View file

@ -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)
}
}

View file

@ -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<ContentLength> = _selected.asStateFlow()
fun select(length: ContentLength) {
_selected.value = length
}
fun confirmSelection(onDone: () -> Unit) {
viewModelScope.launch {
userPrefsDataStore.setContentLength(_selected.value)
onDone()
}
}
}

View file

@ -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)
}
}

View file

@ -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<ContentLength?> = userPrefsDataStore.contentLength
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
fun setContentLength(length: ContentLength) {
viewModelScope.launch { userPrefsDataStore.setContentLength(length) }
}
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M0,0h108v108h-108z"
android:fillColor="#1E1E2E" />
</vector>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Simple location-pin placeholder mark, sized within the adaptive icon safe zone. -->
<path
android:pathData="M54,26 C42,26 33,35 33,47 C33,63 54,86 54,86 C54,86 75,63 75,47 C75,35 66,26 54,26 Z"
android:fillColor="#CBA6F7" />
<path
android:pathData="M54,38 m-9,0 a9,9 0 1,0 18,0 a9,9 0 1,0 -18,0"
android:fillColor="#1E1E2E" />
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">guideCity</string>
<string name="onboarding_title">Добро пожаловать в guideCity</string>
<string name="onboarding_body">guideCity — это голосовой гид для прогулок по городу. Разрешите доступ к геолокации, и мы найдём интересные места поблизости и расскажем их историю прямо во время прогулки по Москве.</string>
<string name="onboarding_ok">ОК</string>
<string name="content_length_title">Насколько подробный рассказ вы хотите слышать?</string>
<string name="content_length_short">Кратко</string>
<string name="content_length_medium">Средне</string>
<string name="content_length_long">Подробно</string>
<string name="content_length_continue">Продолжить</string>
<string name="map_location_permission_rationale">guideCity нужен доступ к геолокации, чтобы найти места поблизости. Вы также можете ввести место вручную.</string>
<string name="map_grant_permission">Разрешить доступ к геолокации</string>
<string name="map_search_placeholder">Введите место для поиска…</string>
<string name="guide_no_places_nearby">Поблизости пока не найдено интересных мест.</string>
<string name="guide_favorite_add">Добавить в избранное</string>
<string name="guide_favorite_remove">Убрать из избранного</string>
<string name="guide_mute">Выключить озвучку</string>
<string name="guide_unmute">Включить озвучку</string>
<string name="favorites_title">Избранное</string>
<string name="favorites_empty">Пока нет избранных мест. Нажмите на значок сердца рядом с местом, чтобы сохранить его здесь.</string>
<string name="settings_title">Настройки</string>
<string name="settings_content_length_label">Подробность рассказа</string>
<string name="place_not_found">Место не найдено.</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Catppuccin Mocha base/mauve, referenced only by the pre-Compose window
theme in themes.xml. The Compose theme (theme/Color.kt) is the source
of truth for in-app colors. -->
<color name="catppuccin_base">#1E1E2E</color>
<color name="catppuccin_mauve">#CBA6F7</color>
</resources>

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">guideCity</string>
<string name="onboarding_title">Welcome to guideCity</string>
<string name="onboarding_body">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.</string>
<string name="onboarding_ok">OK</string>
<string name="content_length_title">How much detail would you like?</string>
<string name="content_length_short">Short</string>
<string name="content_length_medium">Medium</string>
<string name="content_length_long">Long</string>
<string name="content_length_continue">Continue</string>
<string name="map_location_permission_rationale">guideCity needs your location to find nearby places. You can also search for a place by name instead.</string>
<string name="map_grant_permission">Grant location access</string>
<string name="map_search_placeholder">Search for a place instead…</string>
<string name="guide_no_places_nearby">No places found nearby yet.</string>
<string name="guide_favorite_add">Add to favorites</string>
<string name="guide_favorite_remove">Remove from favorites</string>
<string name="guide_mute">Mute narration</string>
<string name="guide_unmute">Resume narration</string>
<string name="favorites_title">Favorites</string>
<string name="favorites_empty">No favorites yet. Tap the heart icon on a place to save it here.</string>
<string name="settings_title">Settings</string>
<string name="settings_content_length_label">Content length</string>
<string name="place_not_found">Place not found.</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Compose drives all real theming (see theme/Theme.kt); this is only the
pre-Compose window background shown while the activity inflates. -->
<style name="Theme.GuideCity" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/catppuccin_base</item>
<item name="android:statusBarColor">@color/catppuccin_base</item>
</style>
</resources>

8
android/build.gradle.kts Normal file
View file

@ -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
}

View file

@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

View file

@ -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" }

Binary file not shown.

View file

@ -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

196
android/gradlew vendored Executable file
View file

@ -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" "$@"

89
android/gradlew.bat vendored Normal file
View file

@ -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

View file

@ -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/

View file

@ -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")