Lunchtime: A Restaurant Discovery App Showcasing Android Best Practices (Playground)
Lunchtime is a modern Android application built with best practices and latest technologies. This project was created using the default Android Studio New App template (Empty Activity) and implemented as a Kotlin Jetpack Compose project.
- The app will use the Google Places API as its data source.
- The app will prompt the user for permission to access their current location.
- Upon launch, the app will execute a search and display nearby restaurants.
- A search feature will allow the user to search for specific restaurants.
- Users can choose to display search results as a list or as pins on a map.
- Selecting a search result will display basic information about the restaurant.
- Third-party libraries may be used at the developer's discretion, but the Google Places API client must not be used.
- Users can flag restaurants as favorites, with favorite status reflected in current and future search results.
For security best practices, sensitive information such as the Google Places API key is stored in
local.properties (referenced via BuildConfig) and excluded from GitHub.
- Go to the Google Cloud Console.
- Create or select a project.
- Enable the Places API (New).
- Go to APIs & Services > Credentials and create an API key.
- Add
google.places.api.key=YOUR_API_KEYto yourlocal.propertiesfile.
- Android Studio Ladybug | 2024.2.1 Patch 2
- Gradle Version: 8.9
- Android Gradle Plugin: 8.13.2
- Kotlin: 2.0.0
- Compile SDK: 36
- Min SDK: 24
- Target SDK: 36
The project includes comprehensive test coverage focusing on meaningful tests rather than just coverage metrics:
Unit Tests (Fast, no device needed):
./gradlew testGetRestaurantsUseCaseTest- Business logic (distance sorting)SearchViewModelTest- Debouncing behavior and search flowBaseViewModelTest- Shared loading/error handling utilitiesFavoritesViewModelTest- Toggle favorites logicDestinationsTest- Navigation serializationLocationViewModelTest- Location update flow and refresh behaviorLocationPermissionViewModelTest- Permission state machine and callbacks
Android Instrumentation Tests (Requires device/emulator):
./gradlew connectedAndroidTestFavoritesDataSourceTest- Real DataStore persistence and concurrent operations
The project is organized into three Gradle modules following Clean Architecture:
:app — Presentation layer + DI wiring (depends on :domain and :data)
:domain — Pure business logic, no Android framework imports (no dependencies)
:data — Data sources and repository implementations (depends on :domain)
domain/src/main/java/com/nes/lunchtime/domain/
├── Restaurant.kt # Domain models (Restaurant, PlaceDetails)
├── RestaurantsRepository.kt # Repository interface (contract)
└── GetRestaurantsUseCase.kt # Business logic: fetch & sort by distance
data/src/main/java/com/nes/lunchtime/
├── data/
│ ├── remote/
│ │ ├── RestaurantsRepository.kt # RestaurantsRepositoryImpl
│ │ ├── GooglePlacesClient.kt # Ktor HTTP client for Places API
│ │ └── model/
│ │ ├── Request.kt # API request payloads
│ │ └── Response.kt # API response models
│ ├── local/
│ │ ├── FavoritesDataSource.kt # DataStore read/write
│ │ └── FavoritesRepository.kt # Favorites data access
│ └── location/
│ ├── LocationRepository.kt # GPS state via FusedLocationProvider
│ ├── LocationPermissionManager.kt # Runtime permission helpers
│ └── LocationUtils.kt # Distance calculation utilities
└── di/
├── DataStoreModule.kt # DataStore preferences
└── LocationModule.kt # FusedLocationProviderClient
app/src/main/java/com/nes/lunchtime/
├── app/
│ └── LunchTimeApp.kt # Hilt Application class
├── data/
│ └── remote/
│ └── RestaurantsRepository.kt # Binds RestaurantsRepository interface → impl
├── di/
│ ├── AppModule.kt # App-level Hilt module
│ ├── NetworkModule.kt # HttpClient (Ktor)
│ └── ApiKeyModule.kt # API key from BuildConfig
└── ui/ # Presentation layer (Compose + MVVM)
├── MainActivity.kt
├── base/
│ └── BaseViewModel.kt # Shared executeWithLoading helper
├── components/ # Reusable composables
│ ├── BrandedAppHeader.kt
│ ├── RestaurantCard.kt
│ ├── RestaurantImage.kt
│ ├── CircularIndicator.kt
│ └── ViewSwitcherButton.kt
├── home/
│ ├── HomeScreen.kt
│ ├── nearby/NearByViewModel.kt # SharedFlow + transformLatest pattern
│ ├── search/SearchViewModel.kt # 500ms debounce
│ ├── favorites/FavoritesViewModel.kt
│ ├── list/RestaurantListView.kt
│ └── map/RestaurantMapView.kt
├── details/
│ ├── DetailsScreen.kt
│ └── DetailsViewModel.kt
├── location/
│ ├── LocationPermissionDialog.kt
│ ├── LocationPermissionViewModel.kt # Activity-scoped, permission state machine
│ └── LocationViewModel.kt # HomeScreen-scoped, continuous GPS updates
├── navigation/
│ └── Destinations.kt # Type-safe nav routes (Kotlin Serialization)
└── theme/
├── Color.kt
├── Type.kt
├── Dimens.kt
└── Theme.kt
Module dependency rules:
:domainhas zero dependencies on:dataor:app:datadepends on:domain(implements its repository interfaces):appdepends on:domain(use cases, models) and:data(for Hilt wiring)- UI code in
:appnever imports:dataclasses directly — only domain types cross the boundary
-
Clean Architecture with clear separation of concerns:
- Data Layer (Repository Pattern)
- Domain Layer (Use Cases/Business Logic)
- Presentation Layer (MVVM with ViewModels)
-
Type-Safe Navigation: Uses the latest Jetpack Navigation (2.8.0+) with Kotlin Serialization for compile-time safe routing
-
Single Activity architecture using Jetpack Compose
-
Unidirectional Data Flow using
StateFlow, and actions are passed up via lambdas -
State Management using
sealedclasses for UI states -
State Encapsulation in ViewModels (
MutableStateFlowfield is always private) -
Error Handling with
Resultand centralized error state management -
Dependency Injection: Powered by Hilt for modular and testable code
-
BaseViewModel Pattern: Eliminates code duplication with shared
executeWithLoadinghelper for consistent loading/error handling -
Centralized Design System:
Dimens.ktfor consistent spacing and eliminating magic numbers -
Search Debouncing: 500ms debounce to reduce API calls while user types
-
Scoped ViewModel Separation: Permission and location concerns are split into two focused ViewModels with different lifetimes:
LocationPermissionViewModel(Activity-scoped) — owns the permission state machine;MainActivityonly recomposes when permission state changes (grant/deny), which is rareLocationViewModel(HomeScreen-scoped) — owns continuous GPS updates; starts immediately on composition since permission is already guaranteed by the timeHomeScreenis shown
-
Compose Recomposition Scoping: Location ticks only recompose the composables that actually consume location:
HomeScreenLayoutis a pure layout composable with arestaurantContentslot — it has no location parameter and is unaffected by GPS updatesRestaurantContentcollectslocationStatedirectly fromLocationViewModel; only the map pin position (RestaurantMapView) redraws on each tick — the list, search bar, and scaffold are skipped by ComposecurrentViewType(list vs. map) is hoisted toHomeScreenContentwithrememberSaveable, preserving the user's view choice across NearBy loading cycles and location refreshes
-
Fresh GPS on Every Session:
LocationRepositoryusesgetCurrentLocation()(notlastLocation) as the initial emission, avoiding the stale OS-level cache that persists across app restarts
1. Navigation & Permission Flow
graph TD
MA[MainActivity] --> PVM[LocationPermissionViewModel]
PVM -- Granted --> NavHost[NavHost / Type-Safe Routes]
PVM -- Loading/Denied/Error --> PermUI[Permission & Error UI]
NavHost --> HS[HomeScreen]
NavHost --> DS[DetailsScreen]
2. Architecture Overview
graph TB
subgraph Presentation["Presentation Layer (UI)"]
HS[HomeScreen<br/>Jetpack Compose]
DS[DetailsScreen<br/>Jetpack Compose]
VM1[NearByViewModel]
VM2[SearchViewModel]
VM3[FavoritesViewModel]
VM4[DetailsViewModel]
VM5[LocationViewModel]
VM6[LocationPermissionViewModel]
HS --> VM1
HS --> VM2
HS --> VM3
HS --> VM5
DS --> VM4
end
subgraph Domain["Domain Layer (Business Logic)"]
UC[GetRestaurantsUseCase]
M1[Restaurant Model]
M2[PlaceDetails Model]
RI[RestaurantsRepository<br/>Interface]
VM1 --> UC
VM2 --> UC
VM4 --> RI
UC --> RI
end
subgraph Data["Data Layer (Repositories)"]
RP[RestaurantsRepositoryImpl]
FR[FavoritesRepository]
LR[LocationRepository]
VM3 --> FR
VM5 --> LR
RI -. impl .-> RP
end
subgraph DataSources["Data Sources"]
GPC[GooglePlacesClient<br/>Ktor HTTP]
FDS[FavoritesDataSource<br/>DataStore]
FLP[FusedLocationProviderClient<br/>Location API]
RP --> GPC
FR --> FDS
LR --> FLP
end
subgraph External["External Services"]
API[Google Places API<br/>REST]
GPC --> API
end
style Presentation fill:#e3f2fd
style Domain fill:#fff9c4
style Data fill:#f3e5f5
style DataSources fill:#e8f5e9
style External fill:#ffebee
- UI:
- Jetpack Compose for declarative UI
- Material Design 3 components
- Google Maps Compose
- Networking:
- Ktor Client for HTTP requests
- Native coroutines support
- Lightweight and flexible compared to Retrofit
- Easy configuration and interceptors
- Coil Compose for image loading and caching
- Built specifically for Compose with native integration
- Memory and disk caching out of the box
- Coroutines-based image loading
- Smaller footprint compared to Glide/Picasso
- Kotlin Serialization for JSON parsing
- Compile-time type safety
- Better performance than Gson/Moshi
- Native Kotlin support with less boilerplate
- Direct integration with Ktor
- Ktor Client for HTTP requests
- Dependency Injection:
- Hilt
- Asynchronous Operations:
- Kotlin Coroutines & Flow
- Data Persistence:
- DataStore for lightweight favorites storage
- Location-based services:
- FusedLocationProvider
- Simplified API compared to LocationManager
- Battery-efficient
- FusedLocationProvider
- Testing:
- Unit tests with JUnit4
- MockK for mocking
- Coroutines test utilities
- Custom test rules for coroutines testing
| List View | Map View | Details View |
![]() |
![]() |
![]() |
- Google Places API
- Android Map Compose
- Android DI (Dagger and Hilt)
- Android Best Practices
At its core, this app uses the Google Places API to fetch and display nearby restaurants based on the user's location.
When you launch the app, it starts by asking for location permission in a user-friendly way, explaining why it's needed. Once permission is granted, the app fetches nearby restaurants and displays them. There's also a real-time search feature, so users can find specific places, and they can switch between a list view and a map view, with pins placed on the map.
Selecting a restaurant brings up basic details like name, address, and ratings. A cool bonus: users can favorite restaurants, and that status is saved.
The app follows Clean Architecture principles, splitting the codebase into three layers:
- Data Layer using the Repository Pattern for clean data handling.
- Domain Layer, where all the business logic lives in reusable use cases.
- Presentation Layer, built with Jetpack Compose and MVVM, which keeps the UI reactive and maintainable.
- For state management, StateFlow is used with a unidirectional data flow, which simplifies updates and keeps things predictable. Errors are managed using a Result wrapper, and sealed classes help define clear UI states.
Location handling is split into two focused ViewModels. LocationPermissionViewModel lives at the Activity scope and manages the permission lifecycle — the Activity only recomposes when permission state actually changes, which is rare. Once permission is granted, LocationViewModel takes over at the HomeScreen scope and drives continuous GPS updates via a callbackFlow-wrapped FusedLocationProviderClient. Each GPS tick only recomposes RestaurantContent (where the map pin lives), leaving the scaffold, search bar, and restaurant list untouched.
The tech stack includes:
- Ktor Client for networking—lightweight, flexible, and coroutine-friendly.
- Coil for image loading—optimized for Compose with caching baked in.
- DataStore for simple favorites persistence.
- Hilt for dependency injection.
- FusedLocationProvider for battery-efficient location tracking.
The implementation intentionally keeps things simple—no Room database for caching, no offline support—but these would be straightforward to add if needed. The architecture is designed to support these extensions. Testing was also a focus. Unit tests are written with JUnit4 and dependencies are mocked with MockK, using coroutine testing tools for asynchronous workflows. In summary, this app demonstrates best practices in production-quality Android apps using modern tools and patterns. It's clean, maintainable, performant, and thoroughly tested.
Connect and follow me on LinkedIn: Sergey N


