Kotlin Android App Project — Build a Full-Featured Application
In this tutorial, you will learn about Kotlin Android App Project. We cover key concepts, practical examples, and best practices to help you master this topic.
A Kotlin Android app project combines Jetpack Compose UI, Room persistence, Retrofit networking, ViewModel state management, Navigation Component, Hilt dependency injection, and Material 3 design into a production-ready application.
What You'll Learn
- Structure a professional Android project
- Implement MVVM architecture with Compose
- Build offline-first with Room and Retrofit
- Handle loading, error, and empty states
- Implement feature-based modularization
- Configure CI/CD for Android
- Publish to the Google Play Store
Why It Matters
Building a complete Android app ties together all the Kotlin and Android skills you have learned. A weather app is the classic example: it fetches data from a network API, caches it locally, displays it in a beautiful UI, and handles all edge cases. This project serves as a template for any real-world Android application.
Real-World Use
DodaTech's Android configuration utility follows the same architecture: Room caches device configuration, Retrofit fetches updates from the server, ViewModel manages UI state, and Compose renders the interface with Material 3.
Learning Path
flowchart LR A[CLI Tool Project] --> B[Android App Project\nYou are here] B --> C[Compose UI Project] style B fill:#f90,color:#fff
Project Architecture
com.example.weatherapp/
├── data/
│ ├── local/ # Room database
│ ├── remote/ # Retrofit API
│ └── repository/ # Repository implementations
├── di/ # Hilt modules
├── domain/
│ └── model/ # Domain models
├── ui/
│ ├── theme/ # Material 3 theming
│ ├── components/ # Reusable composables
│ ├── screen/ # Screen-level composables
│ └── navigation/ # NavHost and routes
└── util/ # Extensions and utilities
Data Layer: Room Entities
@Entity(tableName = "weather_cache")
data class WeatherEntity(
@PrimaryKey val cityId: String,
val cityName: String,
val temperature: Double,
val feelsLike: Double,
val humidity: Int,
val description: String,
val iconCode: String,
val windSpeed: Double,
val lastUpdated: Long = System.currentTimeMillis()
)
@Dao
interface WeatherDao {
@Query("SELECT * FROM weather_cache WHERE cityId = :cityId")
fun getWeather(cityId: String): Flow<WeatherEntity?>
@Query("SELECT * FROM weather_cache ORDER BY lastUpdated DESC")
fun getAllCachedWeather(): Flow<List<WeatherEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertWeather(weather: WeatherEntity)
@Query("DELETE FROM weather_cache WHERE lastUpdated < :threshold")
suspend fun deleteOldCache(threshold: Long)
}
Data Layer: Retrofit API
interface WeatherApi {
@GET("weather")
suspend fun getWeather(
@Query("q") city: String,
@Query("appid") apiKey: String,
@Query("units") units: String = "metric"
): WeatherResponse
@GET("forecast")
suspend fun getForecast(
@Query("lat") lat: Double,
@Query("lon") lon: Double,
@Query("appid") apiKey: String
): ForecastResponse
}
@Serializable
data class WeatherResponse(
val main: MainData,
val weather: List<WeatherDescription>,
val wind: WindData,
val name: String,
val dt: Long
)
@Serializable
data class MainData(
val temp: Double,
val feels_like: Double,
val humidity: Int
)
@Serializable
data class WeatherDescription(
val description: String,
val icon: String
)
@Serializable
data class WindData(val speed: Double)
Repository Pattern
class WeatherRepository @Inject constructor(
private val api: WeatherApi,
private val dao: WeatherDao,
private val apiKeyProvider: ApiKeyProvider
) {
fun getWeather(cityId: String): Flow<ApiResult<WeatherEntity>> = flow {
emit(ApiResult.Loading)
// Try cache first
val cached = dao.getWeather(cityId).first()
if (cached != null) {
emit(ApiResult.Success(cached))
}
// Fetch fresh data
try {
val response = api.getWeather(cityId, apiKeyProvider.getApiKey())
val entity = response.toEntity()
dao.insertWeather(entity)
emit(ApiResult.Success(entity))
} catch (e: Exception) {
if (cached == null) {
emit(ApiResult.Error(e.message ?: "Network error"))
}
}
}.flowOn(Dispatchers.IO)
fun getAllCities(): Flow<List<WeatherEntity>> {
return dao.getAllCachedWeather()
}
}
ViewModel
@HiltViewModel
class WeatherViewModel @Inject constructor(
private val repository: WeatherRepository,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
private val _searchQuery = savedStateHandle.getStateFlow("searchQuery", "")
val searchQuery: StateFlow<String> = _searchQuery
private val _weatherState = MutableStateFlow<WeatherUiState>(WeatherUiState.Empty)
val weatherState: StateFlow<WeatherUiState> = _weatherState.asStateFlow()
private val _favoriteCities = MutableStateFlow<List<WeatherEntity>>(emptyList())
val favoriteCities: StateFlow<List<WeatherEntity>> = _favoriteCities.asStateFlow()
init {
loadFavorites()
}
fun searchCity(cityName: String) {
viewModelScope.launch {
repository.getWeather(cityName).collect { result ->
_weatherState.value = when (result) {
is ApiResult.Loading -> WeatherUiState.Loading
is ApiResult.Success -> WeatherUiState.Success(result.data)
is ApiResult.Error -> WeatherUiState.Error(result.message)
}
}
}
}
fun refreshWeather(cityId: String) {
viewModelScope.launch {
repository.getWeather(cityId).collect { _weatherState.value = it.toUiState() }
}
}
private fun loadFavorites() {
viewModelScope.launch {
repository.getAllCities().collect { cities ->
_favoriteCities.value = cities
}
}
}
}
sealed class WeatherUiState {
data object Empty : WeatherUiState()
data object Loading : WeatherUiState()
data class Success(val weather: WeatherEntity) : WeatherUiState()
data class Error(val message: String) : WeatherUiState()
}
Compose UI
@Composable
fun WeatherScreen(viewModel: WeatherViewModel = hiltViewModel()) {
val weatherState by viewModel.weatherState.collectAsState()
val favorites by viewModel.favoriteCities.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Weather") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp)
) {
SearchBar(
query = viewModel.searchQuery,
onQueryChange = { viewModel.searchCity(it) }
)
Spacer(modifier = Modifier.height(16.dp))
when (val state = weatherState) {
is WeatherUiState.Empty -> EmptyState()
is WeatherUiState.Loading -> LoadingIndicator()
is WeatherUiState.Success -> WeatherCard(state.weather)
is WeatherUiState.Error -> ErrorState(state.message, onRetry = { viewModel.searchCity("London") })
}
Spacer(modifier = Modifier.height(24.dp))
if (favorites.isNotEmpty()) {
Text(
"Saved Cities",
style = MaterialTheme.typography.titleMedium
)
LazyColumn {
items(favorites) { city ->
CityCard(city, onClick = { viewModel.searchCity(city.cityName) })
}
}
}
}
}
}
@Composable
fun WeatherCard(weather: WeatherEntity) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = weather.cityName,
style = MaterialTheme.typography.headlineMedium
)
Text(
text = "${String.format("%.1f", weather.temperature)}°C",
fontSize = 48.sp,
fontWeight = FontWeight.Light,
color = MaterialTheme.colorScheme.primary
)
Text(
text = weather.description,
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
WeatherDetail("Humidity", "${weather.humidity}%")
WeatherDetail("Wind", "${String.format("%.1f", weather.windSpeed)} m/s")
WeatherDetail("Feels Like", "${String.format("%.1f", weather.feelsLike)}°C")
}
}
}
}
Navigation
sealed class WeatherRoutes(val route: String) {
data object Home : WeatherRoutes("home")
data object Search : WeatherRoutes("search")
data class Detail(val cityId: String) : WeatherRoutes("detail/{cityId}") {
companion object {
const val ROUTE = "detail/{cityId}"
fun createRoute(cityId: String) = "detail/$cityId"
}
}
}
@Composable
fun WeatherNavGraph(navController: NavHostController) {
NavHost(
navController = navController,
startDestination = WeatherRoutes.Home.route
) {
composable(WeatherRoutes.Home.route) {
WeatherHomeScreen(
onCitySelected = { cityId ->
navController.navigate(WeatherRoutes.Detail.createRoute(cityId))
}
)
}
composable(
route = WeatherRoutes.Detail.ROUTE,
arguments = listOf(navArgument("cityId") { type = NavType.StringType })
) { backStackEntry ->
val cityId = backStackEntry.arguments?.getString("cityId") ?: return@composable
WeatherDetailScreen(
cityId = cityId,
onBack = { navController.popBackStack() }
)
}
}
}
Hilt Modules
@Module
@InstallIn(SingletonComponent::class)
object WeatherModule {
@Provides
@Singleton
fun provideWeatherApi(@ApiKey apiKey: String): WeatherApi {
return Retrofit.Builder()
.baseUrl("https://api.openweathermap.org/data/2.5/")
.addConverterFactory(JsonConverterFactory.create())
.build()
.create(WeatherApi::class.java)
}
@Provides
@Singleton
fun provideWeatherDatabase(@ApplicationContext context: Context): WeatherDatabase {
return Room.databaseBuilder(
context, WeatherDatabase::class.java, "weather_db"
).build()
}
@Provides
fun provideWeatherDao(database: WeatherDatabase): WeatherDao {
return database.weatherDao()
}
}
CI/CD Configuration
# .github/workflows/android.yml
name: Android CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Run tests
run: ./gradlew test
- name: Build debug APK
run: ./gradlew assembleDebug
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: app-debug
path: app/build/outputs/apk/debug/
Common Mistakes
Not handling configuration changes in state: ViewModel state must survive rotation. Use StateFlow or LiveData inside ViewModel, not in the Composable.
Calling suspend functions from Compose without coroutine scope: Use viewModelScope in ViewModel, not the composable's scope, for operations that must survive configuration changes.
Blocking the main thread: Network calls and database operations must run on background threads. Room and Retrofit handle this when using coroutines.
Not handling empty and error states: Every screen should handle loading, success, error, and empty states with appropriate UI.
Hardcoding API keys: Use BuildConfig or a local.properties file. Do not commit API keys to version control.
Forgetting ProGuard rules: Release builds need ProGuard/R8 rules to keep Serialization, Hilt, and Retrofit classes.
Practice Questions
- What is the MVVM architecture and why is it recommended?
Answer: MVVM separates Model (data), View (UI), and ViewModel (state/logic). ViewModel survives configuration changes, making the architecture testable and maintainable.
- How does the repository pattern work with Room and Retrofit?
Answer: The repository abstracts data sources. It returns Flow from Room (cached data) and attempts to refresh from Retrofit (network). The UI observes the Flow and updates automatically.
- What is the purpose of sealed classes in UI state management?
Answer: Sealed classes represent all possible UI states (Loading, Success, Error, Empty). The when expression on the state ensures exhaustive handling of every state.
- How do you handle API key security in Android?
Answer: Use BuildConfig fields with values in local.properties (not committed to git). For production, use a backend proxy that adds the API key server-side.
- Challenge: Add offline support to the weather app. When the device is offline, show cached data with a "last updated" timestamp. Queue failed refresh requests for retry when connectivity returns.
Answer: Use a ConnectivityManager Observer in the repository. Return cached Flow from Room when offline. Use WorkManager to schedule a periodic background sync.
Mini Project
Build the complete weather app with all features:
- Search cities and display current weather
- Save favorite cities (Room persistence)
- 5-day forecast screen
- Offline support with cached data
- Automatic location detection
- Settings screen (units, dark mode)
- Widget showing current conditions
- CI/CD with GitHub Actions
- Unit and UI tests
This project consolidates every Android skill into a production-ready application.
FAQ
What's Next
After building the Android app, create a Compose UI project with advanced animations. You can also explore KMP app for cross-platform development.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro