Skip to content

Kotlin Android App Project — Build a Full-Featured Application

DodaTech Updated 2026-06-28 8 min read

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")
            }
        }
    }
}
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

  1. Not handling configuration changes in state: ViewModel state must survive rotation. Use StateFlow or LiveData inside ViewModel, not in the Composable.

  2. Calling suspend functions from Compose without coroutine scope: Use viewModelScope in ViewModel, not the composable's scope, for operations that must survive configuration changes.

  3. Blocking the main thread: Network calls and database operations must run on background threads. Room and Retrofit handle this when using coroutines.

  4. Not handling empty and error states: Every screen should handle loading, success, error, and empty states with appropriate UI.

  5. Hardcoding API keys: Use BuildConfig or a local.properties file. Do not commit API keys to version control.

  6. Forgetting ProGuard rules: Release builds need ProGuard/R8 rules to keep Serialization, Hilt, and Retrofit classes.

Practice Questions

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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

Should I use Compose or XML for new projects?

Compose is recommended for all new Android projects. XML is in maintenance mode.

How do I handle dark mode in Compose?

Use MaterialTheme with isSystemInDarkTheme() or a custom theme state. Compose adapts automatically.

What is the best way to manage API keys?

For development, use local.properties. For production, use a backend service or encrypted storage.

How do I publish to Google Play?

Generate a signed APK/AAB, create a Google Play Console account, set up store listing, and upload the binary.

What testing strategy should I use?

Unit test ViewModels and repositories. UI test critical user flows with Compose testing. Use test dispatchers for coroutine testing.

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