diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 164f4fd..b8677ba 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -76,6 +76,7 @@ dependencies { implementation(libs.bundles.retrofit) implementation(libs.yandex.mapkit) + implementation(libs.play.services.location) testImplementation(libs.junit) androidTestImplementation(libs.androidx.test.ext.junit) diff --git a/app/src/main/java/com/obrekht/coffeeshops/app/utils/DistanceFormatter.kt b/app/src/main/java/com/obrekht/coffeeshops/app/utils/DistanceFormatter.kt new file mode 100644 index 0000000..23ccff1 --- /dev/null +++ b/app/src/main/java/com/obrekht/coffeeshops/app/utils/DistanceFormatter.kt @@ -0,0 +1,19 @@ +package com.obrekht.coffeeshops.app.utils + +import android.content.Context +import com.obrekht.coffeeshops.R +import java.text.NumberFormat + +fun Long.formatDistance(context: Context): String { + val value = when { + this > 1000 -> this / 1000.0 + else -> this + } + val unit = when { + this > 1000 -> context.getString(R.string.unit_short_kilometers) + else -> context.getString(R.string.unit_short_meters) + } + return NumberFormat.getNumberInstance().apply { + maximumFractionDigits = 1 + }.format(value) + " $unit" +} diff --git a/app/src/main/java/com/obrekht/coffeeshops/app/utils/PermissionExt.kt b/app/src/main/java/com/obrekht/coffeeshops/app/utils/PermissionExt.kt new file mode 100644 index 0000000..d63e09f --- /dev/null +++ b/app/src/main/java/com/obrekht/coffeeshops/app/utils/PermissionExt.kt @@ -0,0 +1,16 @@ +package com.obrekht.coffeeshops.app.utils + +import android.Manifest.permission.ACCESS_COARSE_LOCATION +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat + +fun Context.hasLocationPermission(): Boolean { + return ContextCompat.checkSelfPermission( + this, + ACCESS_COARSE_LOCATION + ) == PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission( + this, + ACCESS_COARSE_LOCATION + ) == PackageManager.PERMISSION_GRANTED +} diff --git a/app/src/main/java/com/obrekht/coffeeshops/coffeeshops/data/repository/DefaultCoffeeShopsRepository.kt b/app/src/main/java/com/obrekht/coffeeshops/coffeeshops/data/repository/DefaultCoffeeShopsRepository.kt index 884816c..a86860e 100644 --- a/app/src/main/java/com/obrekht/coffeeshops/coffeeshops/data/repository/DefaultCoffeeShopsRepository.kt +++ b/app/src/main/java/com/obrekht/coffeeshops/coffeeshops/data/repository/DefaultCoffeeShopsRepository.kt @@ -5,17 +5,21 @@ import com.obrekht.coffeeshops.coffeeshops.data.model.toEntity import com.obrekht.coffeeshops.coffeeshops.data.remote.CoffeeShopsApiService import com.obrekht.coffeeshops.coffeeshops.ui.model.CoffeeShop import com.obrekht.coffeeshops.core.data.model.EmptyBodyException +import com.obrekht.coffeeshops.geolocation.data.repository.GeoLocationRepository import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.combine import retrofit2.HttpException import javax.inject.Inject class DefaultCoffeeShopsRepository @Inject constructor( private val coffeeShopsDao: CoffeeShopsDao, - private val coffeeShopsApiService: CoffeeShopsApiService + private val coffeeShopsApiService: CoffeeShopsApiService, + private val geoLocationRepository: GeoLocationRepository ) : CoffeeShopsRepository { override suspend fun refreshAll() { + geoLocationRepository.refreshCurrentLocation() + val response = coffeeShopsApiService.getAll() if (!response.isSuccessful) { throw HttpException(response) @@ -26,16 +30,23 @@ class DefaultCoffeeShopsRepository @Inject constructor( } override fun getCoffeeShopsStream(): Flow> { - return coffeeShopsDao.observeAll().map { coffeeShopList -> - coffeeShopList.map { - CoffeeShop(it.id, it.name, it.point, null) + return coffeeShopsDao.observeAll() + .combine(geoLocationRepository.currentLocation) { coffeeShopList, _ -> + coffeeShopList.map { + val distance = geoLocationRepository.getDistanceToLocation( + it.point.latitude, it.point.longitude + ) + CoffeeShop(it.id, it.name, it.point, distance?.toLong()) + } } - } } override suspend fun getCoffeeShopById(id: Long): CoffeeShop? { return coffeeShopsDao.getById(id)?.run { - CoffeeShop(id, name, point, point.latitude.toLong()) + val distance = geoLocationRepository.getDistanceToLocation( + point.latitude, point.longitude + ) + CoffeeShop(id, name, point, distance?.toLong()) } } } diff --git a/app/src/main/java/com/obrekht/coffeeshops/coffeeshops/ui/nearby/NearbyCoffeeShopsAdapter.kt b/app/src/main/java/com/obrekht/coffeeshops/coffeeshops/ui/nearby/NearbyCoffeeShopsAdapter.kt index 1719038..d8f29f4 100644 --- a/app/src/main/java/com/obrekht/coffeeshops/coffeeshops/ui/nearby/NearbyCoffeeShopsAdapter.kt +++ b/app/src/main/java/com/obrekht/coffeeshops/coffeeshops/ui/nearby/NearbyCoffeeShopsAdapter.kt @@ -6,6 +6,7 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import com.obrekht.coffeeshops.R +import com.obrekht.coffeeshops.app.utils.formatDistance import com.obrekht.coffeeshops.coffeeshops.ui.model.CoffeeShop import com.obrekht.coffeeshops.databinding.ItemCoffeeShopBinding @@ -48,7 +49,8 @@ class CoffeeShopViewHolder( with(binding) { name.text = coffeeShop.name distance.text = coffeeShop.distance?.let { - itemView.context.getString(R.string.distance_to_me_meters, it) + val distanceString = it.formatDistance(itemView.context) + itemView.context.getString(R.string.distance_to_me, distanceString) } ?: itemView.context.getString(R.string.unknown_distance) } } diff --git a/app/src/main/java/com/obrekht/coffeeshops/coffeeshops/ui/nearby/NearbyCoffeeShopsFragment.kt b/app/src/main/java/com/obrekht/coffeeshops/coffeeshops/ui/nearby/NearbyCoffeeShopsFragment.kt index ef6fdb9..058b458 100644 --- a/app/src/main/java/com/obrekht/coffeeshops/coffeeshops/ui/nearby/NearbyCoffeeShopsFragment.kt +++ b/app/src/main/java/com/obrekht/coffeeshops/coffeeshops/ui/nearby/NearbyCoffeeShopsFragment.kt @@ -1,8 +1,10 @@ package com.obrekht.coffeeshops.coffeeshops.ui.nearby +import android.Manifest import android.os.Bundle import android.view.View import android.view.ViewGroup.MarginLayoutParams +import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.StringRes import androidx.core.view.WindowInsetsCompat import androidx.core.view.updateLayoutParams @@ -14,6 +16,7 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import com.google.android.material.snackbar.Snackbar import com.obrekht.coffeeshops.R +import com.obrekht.coffeeshops.app.utils.hasLocationPermission import com.obrekht.coffeeshops.app.utils.setOnApplyWindowInsetsListener import com.obrekht.coffeeshops.core.ui.model.SnackbarAction import com.obrekht.coffeeshops.databinding.FragmentNearbyCoffeeShopsBinding @@ -30,6 +33,16 @@ class NearbyCoffeeShopsFragment : Fragment(R.layout.fragment_nearby_coffee_shops private var adapter: NearbyCoffeeShopsAdapter? = null + private val locationPermissionRequest = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + if (permissions.any { it.value }) { + viewModel.refreshCurrentLocation() + } else { + showErrorSnackbar(R.string.error_location_permission_denied) + } + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { _binding = FragmentNearbyCoffeeShopsBinding.bind(view) @@ -43,6 +56,25 @@ class NearbyCoffeeShopsFragment : Fragment(R.layout.fragment_nearby_coffee_shops windowInsets } + when { + requireContext().hasLocationPermission() -> { + viewModel.refreshCurrentLocation() + } + + requireActivity().shouldShowRequestPermissionRationale( + Manifest.permission.ACCESS_COARSE_LOCATION + ) -> { + showErrorSnackbar( + R.string.error_location_permission_rationale, + SnackbarAction(getString(R.string.request)) { + requestLocationPermission() + } + ) + } + + else -> requestLocationPermission() + } + adapter = NearbyCoffeeShopsAdapter { coffeeShop, _ -> navigateToMap(coffeeShop.id) } @@ -104,6 +136,15 @@ class NearbyCoffeeShopsFragment : Fragment(R.layout.fragment_nearby_coffee_shops findNavController().navigate(action) } + private fun requestLocationPermission() { + locationPermissionRequest.launch( + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + ) + } + private fun showErrorSnackbar( @StringRes messageResId: Int, snackbarAction: SnackbarAction? = null diff --git a/app/src/main/java/com/obrekht/coffeeshops/coffeeshops/ui/nearby/NearbyCoffeeShopsViewModel.kt b/app/src/main/java/com/obrekht/coffeeshops/coffeeshops/ui/nearby/NearbyCoffeeShopsViewModel.kt index d9220a1..9039847 100644 --- a/app/src/main/java/com/obrekht/coffeeshops/coffeeshops/ui/nearby/NearbyCoffeeShopsViewModel.kt +++ b/app/src/main/java/com/obrekht/coffeeshops/coffeeshops/ui/nearby/NearbyCoffeeShopsViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.obrekht.coffeeshops.auth.data.repository.AuthRepository import com.obrekht.coffeeshops.coffeeshops.data.repository.CoffeeShopsRepository import com.obrekht.coffeeshops.coffeeshops.ui.model.CoffeeShop +import com.obrekht.coffeeshops.geolocation.data.repository.GeoLocationRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -18,7 +19,8 @@ import javax.inject.Inject @HiltViewModel class NearbyCoffeeShopsViewModel @Inject constructor( private val authRepository: AuthRepository, - private val coffeeShopsRepository: CoffeeShopsRepository + private val geoLocationRepository: GeoLocationRepository, + private val coffeeShopsRepository: CoffeeShopsRepository, ) : ViewModel() { private val _uiState = MutableStateFlow(UiState()) @@ -56,6 +58,10 @@ class NearbyCoffeeShopsViewModel @Inject constructor( } } + fun refreshCurrentLocation() { + geoLocationRepository.refreshCurrentLocation() + } + private fun setLoadingState(isLoading: Boolean) { _uiState.update { it.copy(isLoading = isLoading) diff --git a/app/src/main/java/com/obrekht/coffeeshops/geolocation/data/di/GeoLocationRepositoryModule.kt b/app/src/main/java/com/obrekht/coffeeshops/geolocation/data/di/GeoLocationRepositoryModule.kt new file mode 100644 index 0000000..be1a662 --- /dev/null +++ b/app/src/main/java/com/obrekht/coffeeshops/geolocation/data/di/GeoLocationRepositoryModule.kt @@ -0,0 +1,21 @@ +package com.obrekht.coffeeshops.geolocation.data.di + +import com.obrekht.coffeeshops.geolocation.data.repository.GeoLocationRepository +import com.obrekht.coffeeshops.geolocation.data.repository.PlayServicesGeoLocationRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + + +@Module +@InstallIn(SingletonComponent::class) +abstract class GeoLocationRepositoryModule { + + @Singleton + @Binds + abstract fun bindGeoLocationRepository( + repository: PlayServicesGeoLocationRepository + ): GeoLocationRepository +} diff --git a/app/src/main/java/com/obrekht/coffeeshops/geolocation/data/repository/GeoLocationRepository.kt b/app/src/main/java/com/obrekht/coffeeshops/geolocation/data/repository/GeoLocationRepository.kt new file mode 100644 index 0000000..5d2e5ba --- /dev/null +++ b/app/src/main/java/com/obrekht/coffeeshops/geolocation/data/repository/GeoLocationRepository.kt @@ -0,0 +1,12 @@ +package com.obrekht.coffeeshops.geolocation.data.repository + +import android.location.Location +import kotlinx.coroutines.flow.StateFlow + +interface GeoLocationRepository { + + val currentLocation: StateFlow + + fun refreshCurrentLocation(withLastLocation: Boolean = false) + fun getDistanceToLocation(latitude: Double, longitude: Double): Float? +} diff --git a/app/src/main/java/com/obrekht/coffeeshops/geolocation/data/repository/PlayServicesGeoLocationRepository.kt b/app/src/main/java/com/obrekht/coffeeshops/geolocation/data/repository/PlayServicesGeoLocationRepository.kt new file mode 100644 index 0000000..2ad51cc --- /dev/null +++ b/app/src/main/java/com/obrekht/coffeeshops/geolocation/data/repository/PlayServicesGeoLocationRepository.kt @@ -0,0 +1,64 @@ +package com.obrekht.coffeeshops.geolocation.data.repository + +import android.annotation.SuppressLint +import android.content.Context +import android.location.Location +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority +import com.google.android.gms.tasks.CancellationTokenSource +import com.obrekht.coffeeshops.app.utils.hasLocationPermission +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject + +class PlayServicesGeoLocationRepository @Inject constructor( + @ApplicationContext private val context: Context +): GeoLocationRepository { + + private val _currentLocation = MutableStateFlow(Location(null)) + override val currentLocation = _currentLocation.asStateFlow() + + private val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context) + + init { + refreshCurrentLocation(true) + } + + @SuppressLint("MissingPermission") + override fun refreshCurrentLocation(withLastLocation: Boolean) { + if (!context.hasLocationPermission()) return + + if (withLastLocation) { + fusedLocationClient.lastLocation.addOnSuccessListener { location: Location? -> + location?.let { + _currentLocation.value = it + } + } + } + + fusedLocationClient.getCurrentLocation( + Priority.PRIORITY_HIGH_ACCURACY, + CancellationTokenSource().token + ).addOnSuccessListener { location: Location? -> + location?.let { + _currentLocation.value = it + } + } + } + + override fun getDistanceToLocation(latitude: Double, longitude: Double): Float? { + val location = _currentLocation.value + if (location.provider == null) { + return null + } + + val results = FloatArray(3) + Location.distanceBetween( + location.latitude, location.longitude, + latitude, longitude, + results + ) + return results[0] + } +} diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 3f41a40..6658840 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -19,7 +19,7 @@ Неизвестная ошибка. Не удалось загрузить кофейни. Не удалось загрузить меню. - %1$d м от вас + %1$s от вас Повторить Перейти к оплате Меню @@ -27,4 +27,9 @@ Ваш заказ Ваша корзина пуста. Расстояние неизвестно + Location permission denied. + Запросить + м + км + Пожалуйста, разрешите доступ к вашему местоположению. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c35c85a..1176408 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -19,7 +19,7 @@ Unknown error. Failed to load coffee shops. Failed to load menu. - %1$d m from you + %1$s from you Retry Proceed to payment %1$d ₽ @@ -28,4 +28,9 @@ Your order Your cart is empty. Distance is unknown + Location permission denied. + Please, allow access to your location. + Request + m + km diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0df129f..3f2d027 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,7 @@ room = "2.6.1" okhttp-bom = "4.12.0" retrofit = "2.10.0-SNAPSHOT" yandex-mapkit = "4.5.0-lite" +play-services = "21.0.1" # Tests junit = "4.13.2" androidx-test-ext-junit = "1.1.5" @@ -44,6 +45,7 @@ okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-intercepto retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit-kotlinx-serialization = { module = "com.squareup.retrofit2:converter-kotlinx-serialization", version.ref = "retrofit" } yandex-mapkit = { module = "com.yandex.android:maps.mobile", version.ref = "yandex-mapkit" } +play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "play-services" } # Tests junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" }