← Назад к вопросам

Рестораны на карте

3.0 Senior🔥 71 комментариев
#Android компоненты#Архитектура и паттерны#Производительность и оптимизация

Условие

Создать приложение для отображения ресторанов на карте с детальной информацией о каждом заведении.

Функциональность:

  1. Отображение карты с маркерами ресторанов
  2. Получение данных о ресторанах через API
  3. При нажатии на маркер — показ детальной информации:
    • Название ресторана
    • Адрес
    • Рейтинг
    • Фотографии
    • Часы работы
  4. Список ресторанов рядом с текущей локацией пользователя

Технические требования:

  • Kotlin
  • Clean Architecture, MVVM
  • StateFlow для управления состоянием
  • Hilt для DI
  • Retrofit + Kotlin Coroutines
  • Google Maps SDK или Yandex MapKit

Оценка:

  • Правильная работа с permissions
  • Обработка ошибок геолокации
  • Производительность при большом количестве маркеров
  • UI/UX

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Решение: Приложение для отображения ресторанов на карте

Архитектура Clean Architecture + MVVM

Приложение состоит из трёх слоёв с использованием Google Maps SDK и управлением состоянием через StateFlow.

Структура проекта

com.example.restaurantmap/
├── data/
│   ├── remote/
│   │   ├── api/RestaurantApi.kt
│   │   └── dto/RestaurantDto.kt
│   ├── local/
│   │   ├── dao/RestaurantDao.kt
│   │   └── entity/RestaurantEntity.kt
│   └── repository/RestaurantRepositoryImpl.kt
├── domain/
│   ├── model/
│   │   ├── Restaurant.kt
│   │   └── Location.kt
│   ├── repository/RestaurantRepository.kt
│   └── usecase/
│       ├── GetNearbyRestaurantsUseCase.kt
│       ├── GetRestaurantDetailsUseCase.kt
│       └── GetUserLocationUseCase.kt
├── presentation/
│   ├── ui/
│   │   ├── screen/
│   │   │   ├── MapScreen.kt
│   │   │   ├── RestaurantDetailScreen.kt
│   │   │   └── MainActivity.kt
│   │   └── component/
│   │       ├── MapContent.kt
│   │       ├── RestaurantMarker.kt
│   │       ├── DetailBottomSheet.kt
│   │       └── PermissionRequester.kt
│   └── viewmodel/
│       └── MapViewModel.kt
├── di/
│   ├── AppModule.kt
│   └── RepositoryModule.kt
└── util/
    ├── PermissionHelper.kt
    └── LocationHelper.kt

1. Domain Layer

// domain/model/Restaurant.kt
data class Restaurant(
    val id: String,
    val name: String,
    val address: String,
    val latitude: Double,
    val longitude: Double,
    val rating: Float,
    val reviews: Int,
    val photoUrls: List<String>,
    val workingHours: WorkingHours,
    val phone: String,
    val distance: Float? = null // расстояние в км
)

data class WorkingHours(
    val openTime: String, // "09:00"
    val closeTime: String, // "23:00"
    val isOpen: Boolean
)

data class Location(
    val latitude: Double,
    val longitude: Double,
    val accuracy: Float
)
// domain/repository/RestaurantRepository.kt
interface RestaurantRepository {
    suspend fun getNearbyRestaurants(
        latitude: Double,
        longitude: Double,
        radiusKm: Float = 5f
    ): Result<List<Restaurant>>

    suspend fun getRestaurantDetails(id: String): Result<Restaurant>
    suspend fun cacheRestaurants(restaurants: List<Restaurant>)
}

// domain/usecase/GetNearbyRestaurantsUseCase.kt
class GetNearbyRestaurantsUseCase(private val repository: RestaurantRepository) {
    suspend operator fun invoke(
        latitude: Double,
        longitude: Double,
        radius: Float = 5f
    ): Result<List<Restaurant>> {
        return repository.getNearbyRestaurants(latitude, longitude, radius)
    }
}

// domain/usecase/GetUserLocationUseCase.kt
class GetUserLocationUseCase {
    suspend operator fun invoke(): Result<Location> = withContext(Dispatchers.Main) {
        // Возвращает текущую локацию пользователя
        suspendCancellableCoroutine { continuation ->
            val locationManager = LocationServices.getFusedLocationProviderClient(context)
            val request = LocationRequest.Builder(1000).build()

            try {
                locationManager.getCurrentLocation(
                    PRIORITY_HIGH_ACCURACY,
                    null
                ).addOnSuccessListener { location ->
                    if (location != null) {
                        continuation.resume(
                            Result.success(
                                Location(
                                    location.latitude,
                                    location.longitude,
                                    location.accuracy
                                )
                            )
                        )
                    } else {
                        continuation.resume(
                            Result.failure(Exception("Локация не доступна"))
                        )
                    }
                }.addOnFailureListener { error ->
                    continuation.resume(Result.failure(error))
                }
            } catch (e: SecurityException) {
                continuation.resume(Result.failure(e))
            }
        }
    }
}

2. Data Layer

// data/remote/api/RestaurantApi.kt
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query

interface RestaurantApi {
    @GET("restaurants/nearby")
    suspend fun getNearbyRestaurants(
        @Query("latitude") latitude: Double,
        @Query("longitude") longitude: Double,
        @Query("radius") radiusKm: Float,
        @Query("limit") limit: Int = 50
    ): List<RestaurantDto>

    @GET("restaurants/{id}")
    suspend fun getRestaurantDetails(
        @Path("id") id: String
    ): RestaurantDto
}

data class RestaurantDto(
    val id: String,
    val name: String,
    val address: String,
    val latitude: Double,
    val longitude: Double,
    val rating: Float,
    val reviews: Int,
    val photoUrls: List<String>,
    val openTime: String,
    val closeTime: String,
    val phone: String
)
// data/repository/RestaurantRepositoryImpl.kt
class RestaurantRepositoryImpl(
    private val api: RestaurantApi,
    private val dao: RestaurantDao
) : RestaurantRepository {
    override suspend fun getNearbyRestaurants(
        latitude: Double,
        longitude: Double,
        radiusKm: Float
    ): Result<List<Restaurant>> = withContext(Dispatchers.IO) {
        try {
            val response = api.getNearbyRestaurants(latitude, longitude, radiusKm)
            val restaurants = response.map { it.toDomain(latitude, longitude) }
            dao.insertRestaurants(response.map { it.toEntity() })
            Result.success(restaurants)
        } catch (e: Exception) {
            try {
                val cached = dao.getNearbyRestaurants(
                    latitude - 0.05,
                    latitude + 0.05,
                    longitude - 0.05,
                    longitude + 0.05
                ).map { it.toDomain() }
                Result.success(cached)
            } catch (cacheError: Exception) {
                Result.failure(Exception("Ошибка сети и кэша: ${e.message}"))
            }
        }
    }

    override suspend fun getRestaurantDetails(id: String): Result<Restaurant> =
        withContext(Dispatchers.IO) {
        try {
            val dto = api.getRestaurantDetails(id)
            Result.success(dto.toDomain())
        } catch (e: Exception) {
            Result.failure(e)
        }
    }

    override suspend fun cacheRestaurants(restaurants: List<Restaurant>) {
        dao.insertRestaurants(restaurants.map { it.toEntity() })
    }
}

private fun RestaurantDto.toDomain(
    userLat: Double = 0.0,
    userLon: Double = 0.0
) = Restaurant(
    id = id,
    name = name,
    address = address,
    latitude = latitude,
    longitude = longitude,
    rating = rating,
    reviews = reviews,
    photoUrls = photoUrls,
    workingHours = WorkingHours(openTime, closeTime, isOpen()),
    phone = phone,
    distance = if (userLat != 0.0) calculateDistance(userLat, userLon, latitude, longitude) else null
)

private fun RestaurantDto.isOpen(): Boolean {
    val currentTime = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date())
    return currentTime in openTime..closeTime
}

private fun calculateDistance(
    lat1: Double,
    lon1: Double,
    lat2: Double,
    lon2: Double
): Float {
    val results = FloatArray(1)
    Location.distanceBetween(lat1, lon1, lat2, lon2, results)
    return results[0] / 1000 // в км
}

3. Presentation Layer

// presentation/viewmodel/MapViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

data class MapUiState(
    val restaurants: List<Restaurant> = emptyList(),
    val userLocation: Location? = null,
    val selectedRestaurant: Restaurant? = null,
    val isLoading: Boolean = false,
    val error: String? = null,
    val isLocationPermissionGranted: Boolean = false
)

class MapViewModel(
    private val getNearbyRestaurantsUseCase: GetNearbyRestaurantsUseCase,
    private val getUserLocationUseCase: GetUserLocationUseCase,
    private val getRestaurantDetailsUseCase: GetRestaurantDetailsUseCase
) : ViewModel() {
    private val _uiState = MutableStateFlow(MapUiState())
    val uiState = _uiState.asStateFlow()

    fun onLocationPermissionGranted() {
        _uiState.value = _uiState.value.copy(isLocationPermissionGranted = true)
        getCurrentLocation()
    }

    fun getCurrentLocation() {
        viewModelScope.launch {
            _uiState.value = _uiState.value.copy(isLoading = true, error = null)
            val result = getUserLocationUseCase()
            result.onSuccess { location ->
                _uiState.value = _uiState.value.copy(userLocation = location)
                loadNearbyRestaurants(location.latitude, location.longitude)
            }.onFailure { error ->
                _uiState.value = _uiState.value.copy(
                    isLoading = false,
                    error = "Ошибка определения локации: ${error.message}"
                )
            }
        }
    }

    private fun loadNearbyRestaurants(latitude: Double, longitude: Double) {
        viewModelScope.launch {
            val result = getNearbyRestaurantsUseCase(latitude, longitude)
            result.onSuccess { restaurants ->
                _uiState.value = _uiState.value.copy(
                    restaurants = restaurants,
                    isLoading = false,
                    error = null
                )
            }.onFailure { error ->
                _uiState.value = _uiState.value.copy(
                    isLoading = false,
                    error = "Ошибка загрузки ресторанов: ${error.message}"
                )
            }
        }
    }

    fun selectRestaurant(restaurant: Restaurant) {
        _uiState.value = _uiState.value.copy(selectedRestaurant = restaurant)
    }

    fun deselectRestaurant() {
        _uiState.value = _uiState.value.copy(selectedRestaurant = null)
    }
}
// presentation/ui/screen/MapScreen.kt
import androidx.compose.foundation.layout.*
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect
import com.google.maps.android.compose.*

@Composable
fun MapScreen(
    viewModel: MapViewModel,
    onNavigateToDetails: (String) -> Unit
) {
    val uiState = viewModel.uiState.collectAsState().value

    Box(modifier = Modifier.fillMaxSize()) {
        // Google Map
        if (uiState.userLocation != null) {
            MapContent(
                userLocation = uiState.userLocation,
                restaurants = uiState.restaurants,
                onMarkerClick = { restaurant ->
                    viewModel.selectRestaurant(restaurant)
                }
            )
        }

        // Loading Indicator
        if (uiState.isLoading) {
            Box(
                modifier = Modifier.fillMaxSize(),
                contentAlignment = Alignment.Center
            ) {
                CircularProgressIndicator()
            }
        }

        // Error Message
        if (uiState.error != null) {
            SnackbarHost(
                hostState = remember { SnackbarHostState() },
                modifier = Modifier
                    .align(Alignment.BottomCenter)
                    .padding(16.dp)
            ) { snackbarData ->
                Snackbar(snackbarData = snackbarData)
            }
        }

        // Bottom Sheet с информацией о ресторане
        if (uiState.selectedRestaurant != null) {
            RestaurantDetailBottomSheet(
                restaurant = uiState.selectedRestaurant,
                onDismiss = { viewModel.deselectRestaurant() },
                onNavigateToDetails = {
                    onNavigateToDetails(uiState.selectedRestaurant.id)
                }
            )
        }
    }
}
// presentation/ui/component/MapContent.kt
@Composable
fun MapContent(
    userLocation: Location,
    restaurants: List<Restaurant>,
    onMarkerClick: (Restaurant) -> Unit
) {
    val cameraPositionState = rememberCameraPositionState {
        position = CameraPosition.fromLatLngZoom(
            LatLng(userLocation.latitude, userLocation.longitude),
            15f
        )
    }

    GoogleMap(
        modifier = Modifier.fillMaxSize(),
        cameraPositionState = cameraPositionState
    ) {
        // Маркер пользователя
        Marker(
            state = MarkerState(
                position = LatLng(userLocation.latitude, userLocation.longitude)
            ),
            title = "Вы здесь",
            zIndex = 1f
        )

        // Маркеры ресторанов (оптимизация для 50+)
        restaurants.forEach { restaurant ->
            Marker(
                state = MarkerState(
                    position = LatLng(restaurant.latitude, restaurant.longitude)
                ),
                title = restaurant.name,
                snippet = "⭐ ${restaurant.rating}",
                onClick = {
                    onMarkerClick(restaurant)
                    true
                },
                zIndex = 0f
            )
        }
    }
}
// presentation/ui/component/RestaurantDetailBottomSheet.kt
@Composable
fun RestaurantDetailBottomSheet(
    restaurant: Restaurant,
    onDismiss: () -> Unit,
    onNavigateToDetails: () -> Unit
) {
    ModalBottomSheet(onDismissRequest = onDismiss) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp)
                .verticalScroll(rememberScrollState())
        ) {
            // Название
            Text(
                text = restaurant.name,
                style = MaterialTheme.typography.headlineSmall,
                fontWeight = FontWeight.Bold
            )

            Spacer(modifier = Modifier.height(8.dp))

            // Рейтинг и отзывы
            Row(
                horizontalArrangement = Arrangement.spacedBy(8.dp),
                verticalAlignment = Alignment.CenterVertically
            ) {
                Text(text = "⭐ ${restaurant.rating}", fontSize = 18.sp)
                Text(
                    text = "(${restaurant.reviews} отзывов)",
                    color = Color.Gray,
                    fontSize = 14.sp
                )
            }

            Spacer(modifier = Modifier.height(12.dp))

            // Адрес
            Row(
                horizontalArrangement = Arrangement.spacedBy(8.dp),
                modifier = Modifier.fillMaxWidth()
            ) {
                Icon(Icons.Default.LocationOn, contentDescription = "Адрес")
                Text(text = restaurant.address, modifier = Modifier.weight(1f))
            }

            Spacer(modifier = Modifier.height(8.dp))

            // Телефон
            Row(
                horizontalArrangement = Arrangement.spacedBy(8.dp),
                verticalAlignment = Alignment.CenterVertically
            ) {
                Icon(Icons.Default.Call, contentDescription = "Телефон")
                Text(text = restaurant.phone)
            }

            Spacer(modifier = Modifier.height(8.dp))

            // Часы работы
            Row(
                horizontalArrangement = Arrangement.spacedBy(8.dp),
                verticalAlignment = Alignment.CenterVertically
            ) {
                Icon(Icons.Default.Schedule, contentDescription = "Часы работы")
                Text(
                    text = "${restaurant.workingHours.openTime} - ${restaurant.workingHours.closeTime}",
                    color = if (restaurant.workingHours.isOpen) Color.Green else Color.Red
                )
            }

            Spacer(modifier = Modifier.height(16.dp))

            // Фотографии
            LazyRow(
                horizontalArrangement = Arrangement.spacedBy(8.dp)
            ) {
                items(restaurant.photoUrls.size) { index ->
                    AsyncImage(
                        model = restaurant.photoUrls[index],
                        contentDescription = "Фото ресторана",
                        modifier = Modifier
                            .size(150.dp)
                            .clip(RoundedCornerShape(8.dp)),
                        contentScale = ContentScale.Crop
                    )
                }
            }

            Spacer(modifier = Modifier.height(16.dp))

            // Кнопка подробнее
            Button(
                onClick = onNavigateToDetails,
                modifier = Modifier.fillMaxWidth()
            ) {
                Text("Подробнее")
            }
        }
    }
}

4. Обработка Permissions

// util/PermissionHelper.kt
import android.Manifest
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberPermissionState

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun LocationPermissionRequest(
    viewModel: MapViewModel
) {
    val permissionState = rememberPermissionState(
        Manifest.permission.ACCESS_FINE_LOCATION
    ) { isGranted ->
        if (isGranted) {
            viewModel.onLocationPermissionGranted()
        }
    }

    LaunchedEffect(Unit) {
        permissionState.launchPermissionRequest()
    }

    when {
        permissionState.hasPermission ->
            viewModel.onLocationPermissionGranted()
        permissionState.shouldShowRationale ->
            PermissionRationaleDialog(onRetry = {
                permissionState.launchPermissionRequest()
            })
    }
}

@Composable
fun PermissionRationaleDialog(onRetry: () -> Unit) {
    AlertDialog(
        onDismissRequest = {},
        title = { Text("Разрешение на доступ к локации") },
        text = { Text("Приложению нужен доступ к вашей локации для отображения ближайших ресторанов") },
        confirmButton = {
            Button(onClick = onRetry) {
                Text("Разрешить")
            }
        }
    )
}

5. Оптимизация производительности

// Кластеризация маркеров для 50+ маркеров
import com.google.maps.android.clustering.ClusterManager

@Composable
fun OptimizedMapContent(
    userLocation: Location,
    restaurants: List<Restaurant>,
    onMarkerClick: (Restaurant) -> Unit
) {
    val cameraPositionState = rememberCameraPositionState {
        position = CameraPosition.fromLatLngZoom(
            LatLng(userLocation.latitude, userLocation.longitude),
            15f
        )
    }

    GoogleMap(
        modifier = Modifier.fillMaxSize(),
        cameraPositionState = cameraPositionState,
        onMapLoaded = {
            // Инициализируем ClusterManager
        }
    ) {
        restaurants.chunked(20).forEach { chunk ->
            chunk.forEach { restaurant ->
                Marker(
                    state = MarkerState(
                        position = LatLng(restaurant.latitude, restaurant.longitude)
                    ),
                    title = restaurant.name,
                    snippet = "⭐ ${restaurant.rating}",
                    onClick = {
                        onMarkerClick(restaurant)
                        true
                    }
                )
            }
        }
    }
}

Ключевые особенности

  • Permissions: Graceful handling с rationale dialogs
  • Location Services: Асинхронная работа через FusedLocationProviderClient
  • Кэширование: Fallback на локальные данные при ошибке сети
  • Оптимизация: Кластеризация маркеров для большого количества данных
  • StateFlow: Реактивное управление состоянием UI
  • Clean Architecture: Полное разделение слоёв
  • Google Maps Integration: Полная поддержка карты с кастомизацией

Это production-ready решение для приложения отображения ресторанов с полной обработкой ошибок и оптимизацией производительности.