• Latest Code...

    Featured Post

    Implementing Hilt in a Kotlin Android Jetpack Compose Project with MVVM Architecture

     In modern Android development, maintaining a scalable codebase can be challenging, especially when it comes to dependency management. Hilt,...

    Weather App with MVVM Clean Architecture in Kotlin

     Building a weather app is a great way to demonstrate your proficiency in Android development, particularly with modern tools and architecture patterns. In this guide, we’ll walk through building a weather app using MVVM Clean Architecture while leveraging tools like Jetpack Compose, Retrofit, Coroutines, Flow, and Hilt. We'll also include comprehensive testing practices with JUnit, Espresso, and Mockito.  


    Project Overview

    Goal

    Create a weather app where users can:

    • Search for the weather in any U.S. city.
    • Auto-load the last searched city on launch.
    • Display weather icons alongside weather details.
    • Access weather data based on location permissions.

    Key Technologies

    • Kotlin
    • MVVM Clean Architecture
    • Retrofit for API calls
    • Jetpack Compose for UI
    • Hilt for dependency injection
    • JUnit, Espresso, Mockito for testing
    • Coroutines and Flow for asynchronous programming
    • Arrow Library for functional programming

    Here's an outline and project structure for the requested weather-based Android application using MVVM Clean Architecture. The app will integrate the OpenWeatherMap API, Geocoder API, and fulfill all other requirements.


    Project Setup

    1. Gradle dependencies: Add the following to your build.gradle files:
      // Module-level (app)
      plugins {
          id 'com.android.application'
          id 'kotlin-kapt'
          id 'dagger.hilt.android.plugin'
      }
      
      android {
          compileSdk 34
          defaultConfig {
              applicationId "com.example.weatherapp"
              minSdk 21
              targetSdk 34
              versionCode 1
              versionName "1.0"
      
              buildConfigField "String", "BASE_URL", '"https://api.openweathermap.org/data/2.5/"'
              buildConfigField "String", "API_KEY", '"YOUR_API_KEY_HERE"'
          }
      }
      
      dependencies {
          // Core
          implementation "androidx.core:core-ktx:1.12.0"
          implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.1"
          implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1"
          implementation "androidx.activity:activity-compose:1.8.0"
          implementation "androidx.compose.ui:ui:1.6.0"
          implementation "androidx.compose.material3:material3:1.2.0"
      
          // Hilt
          implementation "com.google.dagger:hilt-android:2.48"
          kapt "com.google.dagger:hilt-android-compiler:2.48"
      
          // Retrofit
          implementation "com.squareup.retrofit2:retrofit:2.9.0"
          implementation "com.squareup.retrofit2:converter-gson:2.9.0"
      
          // Coroutines and Flow
          implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
      
          // Testing
          testImplementation "junit:junit:4.13.2"
          androidTestImplementation "androidx.test.ext:junit:1.1.5"
          androidTestImplementation "androidx.test.espresso:espresso-core:3.5.1"
      
          // Arrow for functional programming
          implementation "io.arrow-kt:arrow-core:1.2.0" 
         
          // Unit Testing
          testImplementation "org.mockito:mockito-core:4.11.0"
          testImplementation "org.mockito.kotlin:mockito-kotlin:4.1.0"
      
          // Coroutines Testing
          testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3"
      
         // Arrow Testing
         testImplementation "io.arrow-kt:arrow-core:1.2.0"
      
         // Jetpack ViewModel Testing
         testImplementation "androidx.arch.core:core-testing:2.2.0"
      }

    Project Structure

    com.example.weatherapp
    │
    ├── di                 # Hilt-related classes
    │   └── AppModule.kt
    │
    ├── data               # Data layer
    │   ├── api
    │   │   ├── WeatherApiService.kt
    │   │   └── WeatherResponse.kt
    │   ├── repository
    │       ├── WeatherRepository.kt
    │       └── WeatherRepositoryImpl.kt
    │
    ├── domain             # Domain layer
    │   ├── model
    │   │   └── Weather.kt
    │   ├── repository
    │   │   └── WeatherRepository.kt
    │   ├── usecase
    │       └── GetWeatherByCityNameUseCase.kt
    │
    ├── presentation       # UI layer
    │   ├── viewmodel
    │   │   └── WeatherViewModel.kt
    │   └── ui
    │       ├── screens
    │           └── WeatherScreen.kt
    │       └── components
    │           └── LoadingState.kt
    │
    └── utils              # Utility classes
        └── Resource.kt    # For handling state (Loading, Success, Error)

    Implementation

    1. AppModule.kt (Dependency Injection)

    @Module
    @InstallIn(SingletonComponent::class)
    object AppModule {
    
        @Provides
        @Singleton
        fun provideRetrofit(): Retrofit {
            return Retrofit.Builder()
                .baseUrl(BuildConfig.BASE_URL)
                .addConverterFactory(GsonConverterFactory.create())
                .build()
        }
    
        @Provides
        @Singleton
        fun provideWeatherApiService(retrofit: Retrofit): WeatherApiService {
            return retrofit.create(WeatherApiService::class.java)
        }
    
        @Provides
        @Singleton
        fun provideWeatherRepository(api: WeatherApiService): WeatherRepository {
            return WeatherRepositoryImpl(api)
        }
    }
    

    2. WeatherApiService.kt

    interface WeatherApiService {
        @GET("weather")
        suspend fun getWeatherByCityName(
            @Query("q") cityName: String,
            @Query("appid") apiKey: String = BuildConfig.API_KEY,
            @Query("units") units: String = "metric"
        ): Response<WeatherResponse>
    }

    3. WeatherRepositoryImpl.kt

    class WeatherRepositoryImpl(private val api: WeatherApiService) : WeatherRepository {
        override suspend fun getWeatherByCity(cityName: String): Resource<Weather> {
            return try {
                val response = api.getWeatherByCityName(cityName)
                if (response.isSuccessful) {
                    val weatherData = response.body()?.toDomainModel()
                    Resource.Success(weatherData)
                } else {
                    Resource.Error(response.message())
                }
            } catch (e: Exception) {
                Resource.Error(e.localizedMessage ?: "An unexpected error occurred")
            }
        }
    }

    4. WeatherViewModel.kt

    @HiltViewModel
    class WeatherViewModel @Inject constructor(
        private val getWeatherByCityNameUseCase: GetWeatherByCityNameUseCase
    ) : ViewModel() {
    
        private val _weatherState = MutableStateFlow<Resource<Weather>>(Resource.Loading())
        val weatherState: StateFlow<Resource<Weather>> get() = _weatherState
    
        fun fetchWeather(cityName: String) {
            viewModelScope.launch {
                _weatherState.value = Resource.Loading()
                _weatherState.value = getWeatherByCityNameUseCase(cityName)
            }
        }
    }

    5. WeatherScreen.kt

    @Composable
    fun WeatherScreen(viewModel: WeatherViewModel = hiltViewModel()) {
        val weatherState by viewModel.weatherState.collectAsState()
    
        Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
            TextField(
                value = cityInput,
                onValueChange = { cityInput = it },
                modifier = Modifier.fillMaxWidth(),
                label = { Text("Enter City") }
            )
            Spacer(modifier = Modifier.height(8.dp))
            Button(onClick = { viewModel.fetchWeather(cityInput) }) {
                Text("Get Weather")
            }
            Spacer(modifier = Modifier.height(16.dp))
    
            when (weatherState) {
                is Resource.Loading -> LoadingState()
                is Resource.Success -> WeatherDetails(weather = weatherState.data)
                is Resource.Error -> ErrorState(message = weatherState.message)
            }
        }
    }

    Features Included

    1. Auto-search with debounce (handled using StateFlow and TextField events in Compose).
    2. Location permission handling (using ActivityCompat.requestPermissions).
    3. Weather icon rendering (from OpenWeatherMap's image URLs).
    4. Proper error and loading states using the Resource class.

    Testing

    1. Unit Test Example: WeatherRepositoryImpl

    Test how the repository handles API responses using Mockito.

    @ExperimentalCoroutinesApi
    class WeatherRepositoryImplTest {
    
        @Mock
        private lateinit var mockApiService: WeatherApiService
    
        private lateinit var repository: WeatherRepository
    
        @get:Rule
        val coroutineRule = MainCoroutineRule() // Custom rule to manage coroutine scope
    
        @Before
        fun setup() {
            MockitoAnnotations.openMocks(this)
            repository = WeatherRepositoryImpl(mockApiService)
        }
    
        @Test
        fun `getWeatherByCity returns success`() = runTest {
            // Given
            val cityName = "Boston"
            val mockResponse = WeatherResponse(/* Populate with mock data */)
            Mockito.`when`(mockApiService.getWeatherByCityName(cityName)).thenReturn(Response.success(mockResponse))
    
            // When
            val result = repository.getWeatherByCity(cityName)
    
            // Then
            assert(result is Resource.Success)
            assertEquals(mockResponse.toDomainModel(), (result as Resource.Success).data)
        }
    
        @Test
        fun `getWeatherByCity returns error on exception`() = runTest {
            // Given
            val cityName = "Unknown"
            Mockito.`when`(mockApiService.getWeatherByCityName(cityName)).thenThrow(RuntimeException("Network error"))
    
            // When
            val result = repository.getWeatherByCity(cityName)
    
            // Then
            assert(result is Resource.Error)
            assertEquals("Network error", (result as Resource.Error).message)
        }
    }

    2. Unit Test Example: WeatherViewModel

    Test how the ViewModel handles states.

    @ExperimentalCoroutinesApi
    class WeatherViewModelTest {
    
        @Mock
        private lateinit var mockUseCase: GetWeatherByCityNameUseCase
    
        private lateinit var viewModel: WeatherViewModel
    
        @get:Rule
        val coroutineRule = MainCoroutineRule()
    
        @Before
        fun setup() {
            MockitoAnnotations.openMocks(this)
            viewModel = WeatherViewModel(mockUseCase)
        }
    
        @Test
        fun `fetchWeather sets loading and success states`() = runTest {
            // Given
            val cityName = "Los Angeles"
            val mockWeather = Weather(/* Populate with mock data */)
            Mockito.`when`(mockUseCase(cityName)).thenReturn(Resource.Success(mockWeather))
    
            // When
            viewModel.fetchWeather(cityName)
    
            // Then
            val states = viewModel.weatherState.take(2).toList()
            assert(states[0] is Resource.Loading)
            assert(states[1] is Resource.Success &amp;&amp; states[1].data == mockWeather)
        }
    
        @Test
        fun `fetchWeather sets error state`() = runTest {
            // Given
            val cityName = "InvalidCity"
            val errorMessage = "City not found"
            Mockito.`when`(mockUseCase(cityName)).thenReturn(Resource.Error(errorMessage))
    
            // When
            viewModel.fetchWeather(cityName)
    
            // Then
            val states = viewModel.weatherState.take(2).toList()
            assert(states[0] is Resource.Loading)
            assert(states[1] is Resource.Error &amp;&amp; states[1].message == errorMessage)
        }
    }

    UI Testing

    1. Setup Dependencies

    In build.gradle:

    // UI Testing
    androidTestImplementation "androidx.test.espresso:espresso-core:3.5.1"
    androidTestImplementation "androidx.test.ext:junit:1.1.5"
    
    // Jetpack Compose Testing
    androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.6.0"
    debugImplementation "androidx.compose.ui:ui-test-manifest:1.6.0"

    2. UI Test Example: WeatherScreen

    Use Compose's testing APIs to check interactions and states.

    @HiltAndroidTest
    @UninstallModules(AppModule::class) // Mock dependencies if needed
    class WeatherScreenTest {
    
        @get:Rule(order = 0)
        val hiltRule = HiltAndroidRule(this)
    
        @get:Rule(order = 1)
        val composeRule = createAndroidComposeRule&lt;MainActivity&gt;() // Replace with the app's main activity
    
        @Before
        fun setup() {
            hiltRule.inject()
        }
    
        @Test
        fun enterCityAndFetchWeather_displaysWeatherInfo() {
            // Given
            val cityName = "San Francisco"
    
            // When
            composeRule.onNodeWithTag("CityInputField").performTextInput(cityName)
            composeRule.onNodeWithTag("FetchWeatherButton").performClick()
    
            // Then
            composeRule.onNodeWithText("Weather Info").assertExists()
        }
    
        @Test
        fun fetchWeather_displaysLoadingAndErrorState() {
            // Simulate API response delay and error
            composeRule.onNodeWithTag("CityInputField").performTextInput("InvalidCity")
            composeRule.onNodeWithTag("FetchWeatherButton").performClick()
    
            // Check loading state
            composeRule.onNodeWithTag("LoadingIndicator").assertExists()
    
            // After API call fails
            composeRule.waitUntil { composeRule.onNodeWithTag("ErrorText").fetchSemanticsNode() != null }
            composeRule.onNodeWithTag("ErrorText").assertTextContains("City not found")
        }
    }

    3. Testing Tips

    • Use mock responses for network requests during UI tests (e.g., using MockWebServer).
    • Add tags (Modifier.testTag()) to Composable elements for easier identification in tests.
    • Use coroutines test dispatchers for consistent timing in tests.

    Conclusion

    This weather app demonstrates clean architecture principles and incorporates modern Android tools and practices. By focusing on separation of concerns, defensive coding, and robust testing, you can create a scalable and maintainable app.

    Sample Output

    When a user enters "New York" and clicks the search button:

    • A loading spinner appears.
    • The app fetches weather details via the API.
    • The UI updates with temperature, humidity, and a weather icon.

    Happy coding!


    If you faced any kind of error or problems? Share in the comments below!

    Contact Form

    Name

    Email *

    Message *