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:
parent
ca28c93daf
commit
c5596870fb
59 changed files with 2695 additions and 0 deletions
115
android/app/build.gradle.kts
Normal file
115
android/app/build.gradle.kts
Normal 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
2
android/app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# See https://developer.android.com/studio/build/shrink-code for details.
|
||||||
28
android/app/src/main/AndroidManifest.xml
Normal file
28
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package com.guidecity.app
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
|
||||||
|
@HiltAndroidApp
|
||||||
|
class GuideCityApp : Application()
|
||||||
22
android/app/src/main/java/com/guidecity/app/MainActivity.kt
Normal file
22
android/app/src/main/java/com/guidecity/app/MainActivity.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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>,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
32
android/app/src/main/java/com/guidecity/app/di/AppModule.kt
Normal file
32
android/app/src/main/java/com/guidecity/app/di/AppModule.kt
Normal 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()
|
||||||
|
}
|
||||||
|
|
@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
android/app/src/main/java/com/guidecity/app/theme/Color.kt
Normal file
44
android/app/src/main/java/com/guidecity/app/theme/Color.kt
Normal 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
|
||||||
|
}
|
||||||
44
android/app/src/main/java/com/guidecity/app/theme/Theme.kt
Normal file
44
android/app/src/main/java/com/guidecity/app/theme/Theme.kt
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
31
android/app/src/main/java/com/guidecity/app/theme/Type.kt
Normal file
31
android/app/src/main/java/com/guidecity/app/theme/Type.kt
Normal 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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
151
android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt
Normal file
151
android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
10
android/app/src/main/res/drawable/ic_launcher_background.xml
Normal file
10
android/app/src/main/res/drawable/ic_launcher_background.xml
Normal 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>
|
||||||
14
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
14
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
32
android/app/src/main/res/values-ru/strings.xml
Normal file
32
android/app/src/main/res/values-ru/strings.xml
Normal 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>
|
||||||
8
android/app/src/main/res/values/colors.xml
Normal file
8
android/app/src/main/res/values/colors.xml
Normal 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>
|
||||||
32
android/app/src/main/res/values/strings.xml
Normal file
32
android/app/src/main/res/values/strings.xml
Normal 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>
|
||||||
9
android/app/src/main/res/values/themes.xml
Normal file
9
android/app/src/main/res/values/themes.xml
Normal 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
8
android/build.gradle.kts
Normal 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
|
||||||
|
}
|
||||||
4
android/gradle.properties
Normal file
4
android/gradle.properties
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
67
android/gradle/libs.versions.toml
Normal file
67
android/gradle/libs.versions.toml
Normal 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" }
|
||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
196
android/gradlew
vendored
Executable 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
89
android/gradlew.bat
vendored
Normal 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
|
||||||
11
android/local.properties.example
Normal file
11
android/local.properties.example
Normal 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/
|
||||||
21
android/settings.gradle.kts
Normal file
21
android/settings.gradle.kts
Normal 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")
|
||||||
Loading…
Add table
Add a link
Reference in a new issue