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
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue