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