Sudoku Logic: A Guide for Android Engineers

Sudoku is a classic logic-based puzzle that challenges both casual enthusiasts and seasoned programmers. Beyond entertainment, solving Sudoku programmatically can improve an engineer's problem-solving and algorithmic thinking. In this article, we explore two popular LeetCode challenges — Valid Sudoku and Sudoku Solver — along with efficient solutions in Kotlin, tailored for Android engineers.




1. Understanding Sudoku Rules

A Sudoku puzzle is a 9x9 grid divided into 3x3 sub-grids. The objective is to fill the grid so that:

  1. Each row contains numbers 1-9 without repetition.
  2. Each column contains numbers 1-9 without repetition.
  3. Each 3x3 sub-grid contains numbers 1-9 without repetition.

Challenge 1: Valid Sudoku

Problem: Check whether a partially filled Sudoku board is valid, without requiring it to be solvable.

Kotlin Solution

fun isValidSudoku(board: Array<CharArray>): Boolean {
    val rows = Array(9) { mutableSetOf<Char>() }
    val cols = Array(9) { mutableSetOf<Char>() }
    val grids = Array(9) { mutableSetOf<Char>() }

    for (i in 0 until 9) {
        for (j in 0 until 9) {
            val num = board[i][j]
            if (num == '.') continue
            
            val gridIndex = (i / 3) * 3 + j / 3
            if (num in rows[i] || num in cols[j] || num in grids[gridIndex]) {
                return false
            }

            rows[i].add(num)
            cols[j].add(num)
            grids[gridIndex].add(num)
        }
    }
    return true
}

Explanation

  1. Tracking Numbers:

    • Use three arrays of sets to store numbers seen in rows, columns, and sub-grids.
    • Each array has 9 sets (one for each row, column, or grid).
  2. Grid Mapping:

    • Map each cell to its corresponding sub-grid using the formula:
      gridIndex = (i / 3) * 3 + j / 3.
  3. Validation:

    • For every non-empty cell, check if the number already exists in the corresponding row, column, or grid. If so, return false.
  4. Time Complexity:

    • O(81)O(81): Iterating over the fixed 9x9 board.
  5. Space Complexity:

    • O(27)O(27): Using three arrays of 9 sets.

Challenge 2: Sudoku Solver

Problem: Write a program to solve a given Sudoku puzzle by filling empty cells.

Kotlin Solution

fun solveSudoku(board: Array<CharArray>) {
    solve(board)
}

private fun solve(board: Array<CharArray>): Boolean {
    for (row in 0 until 9) {
        for (col in 0 until 9) {
            if (board[row][col] == '.') {
                for (num in '1'..'9') {
                    if (isValidPlacement(board, row, col, num)) {
                        board[row][col] = num
                        if (solve(board)) return true
                        board[row][col] = '.' // Backtrack
                    }
                }
                return false // Trigger backtracking
            }
        }
    }
    return true // Puzzle is solved
}

private fun isValidPlacement(board: Array<CharArray>, row: Int, col: Int, num: Char): Boolean {
    for (i in 0 until 9) {
        if (board[row][i] == num || board[i][col] == num || 
            board[(row / 3) * 3 + i / 3][(col / 3) * 3 + i % 3] == num) {
            return false
        }
    }
    return true
}

Explanation

  1. Backtracking Approach:

    • Identify the first empty cell ('.').
    • Try placing numbers from '1' to '9'.
    • Check if the placement is valid. If yes:
      • Place the number and proceed to the next empty cell recursively.
      • If the board cannot be solved with the current placement, backtrack by resetting the cell.
  2. Validation:

    • Ensure the number does not conflict with the row, column, or 3x3 grid.
  3. Time Complexity:

    • In the worst case, O(9m)O(9^m), where mm is the number of empty cells.
  4. Space Complexity:

    • O(m)O(m) for the recursion stack.

How Does This Relate to Android?

  1. Logic Implementation:

    • The same backtracking and validation techniques can be applied to game development in Android (e.g., building a Sudoku app).
  2. Data Structures:

    • Kotlin's Array and Set are essential in Android development for handling collections efficiently.
  3. Performance Optimization:

    • Reducing space and time complexity is vital for smooth Android app performance.
  4. UI Updates:

    • Integrate this logic with Jetpack Compose or RecyclerView to dynamically update the Sudoku board UI based on user interaction.

Conclusion

Mastering Sudoku logic teaches key programming concepts:

  • Data Validation: Ensuring correctness in user input or data processing.
  • Backtracking: A versatile approach for solving constraint-based problems.
  • Efficiency: Balancing complexity with performance.

For Android engineers, these problems also highlight Kotlin's expressiveness and its suitability for crafting efficient algorithms.


Whether you are preparing for a coding interview or building a Sudoku app, these solutions provide a foundation to tackle complex challenges with confidence.

How to Implement the SOLID Principles in an Android App Using Kotlin

The SOLID principles are fundamental design principles that help developers create maintainable, scalable, and robust software. These principles, initially introduced by Robert C. Martin (Uncle Bob), provide a framework for writing clean and reusable code. In this article, we'll explore how to implement the SOLID principles in an Android app using Kotlin, with practical examples and use cases for each principle.



1. Single Responsibility Principle (SRP)

Definition: A class should have only one reason to change.

Use Case: Managing responsibilities for a User Profile feature.
A common violation of SRP in Android development is creating a class that handles UI logic, network calls, and database operations. By adhering to SRP, we can separate these responsibilities.

Implementation Example:

class UserProfileRepository {
    fun getUserProfile(userId: String): UserProfile {
        // Fetch user profile from API or database
    }
}

class UserProfileViewModel(private val repository: UserProfileRepository) : ViewModel() {
    val userProfile = MutableLiveData<UserProfile>()

    fun loadUserProfile(userId: String) {
        viewModelScope.launch {
            val profile = repository.getUserProfile(userId)
            userProfile.value = profile
        }
    }
}

Here, the UserProfileRepository handles data fetching, while the UserProfileViewModel handles UI logic.


2. Open/Closed Principle (OCP)

Definition: A class should be open for extension but closed for modification.

Use Case: Adding new payment methods without modifying existing code.
When adding new functionality, we should avoid changing existing classes. Instead, we can extend them.

Implementation Example:

interface PaymentProcessor {
    fun processPayment(amount: Double)
}

class CreditCardPayment : PaymentProcessor {
    override fun processPayment(amount: Double) {
        println("Processing credit card payment of $$amount")
    }
}

class PayPalPayment : PaymentProcessor {
    override fun processPayment(amount: Double) {
        println("Processing PayPal payment of $$amount")
    }
}

fun processPayment(paymentProcessor: PaymentProcessor, amount: Double) {
    paymentProcessor.processPayment(amount)
}

Here, adding a new payment method requires creating a new class that implements PaymentProcessor, without modifying existing code.


3. Liskov Substitution Principle (LSP)

Definition: Subtypes must be substitutable for their base types.

Use Case: Implementing different types of notifications (e.g., Email and SMS).
The derived class should work seamlessly in place of its base class without altering the program's behavior.

Implementation Example:

open class Notification {
    open fun send(message: String) {
        println("Sending notification: $message")
    }
}

class EmailNotification : Notification() {
    override fun send(message: String) {
        println("Sending email: $message")
    }
}

class SMSNotification : Notification() {
    override fun send(message: String) {
        println("Sending SMS: $message")
    }
}

fun notifyUser(notification: Notification, message: String) {
    notification.send(message)
}

4. Interface Segregation Principle (ISP)

Definition: A class should not be forced to implement methods it does not use.

Use Case: Managing different authentication methods like Google and Facebook login.
Instead of creating a single interface with all authentication methods, split it into smaller interfaces.

Implementation Example:

interface GoogleAuth {
    fun authenticateWithGoogle()
}

interface FacebookAuth {
    fun authenticateWithFacebook()
}

class GoogleLogin : GoogleAuth {
    override fun authenticateWithGoogle() {
        println("Authenticated with Google")
    }
}

class FacebookLogin : FacebookAuth {
    override fun authenticateWithFacebook() {
        println("Authenticated with Facebook")
    }
}

5. Dependency Inversion Principle (DIP)

Definition: High-level modules should not depend on low-level modules. Both should depend on abstractions.

Use Case: Injecting dependencies in a weather app.
We can use interfaces and dependency injection to reduce coupling between classes.

Implementation Example:

interface WeatherService {
    fun fetchWeatherData(city: String): WeatherData
}

class OpenWeatherService : WeatherService {
    override fun fetchWeatherData(city: String): WeatherData {
        // Fetch weather data from OpenWeather API
    }
}

class WeatherViewModel(private val service: WeatherService) : ViewModel() {
    val weatherData = MutableLiveData&lt;WeatherData&gt;()

    fun getWeather(city: String) {
        viewModelScope.launch {
            val data = service.fetchWeatherData(city)
            weatherData.value = data
        }
    }
}

// Using Hilt for Dependency Injection
@Module
@InstallIn(SingletonComponent::class)
object WeatherModule {
    @Provides
    fun provideWeatherService(): WeatherService = OpenWeatherService()
}

Additional Detailed Example: A Ride-Sharing App

Scenario: A ride-sharing app implementing SOLID principles to manage different ride types (e.g., economy, luxury) and location services.

Single Responsibility Principle

Separate ride management from location tracking:

class RideManager {
    fun calculateFare(distance: Double, rate: Double): Double {
        return distance * rate
    }
}

class LocationTracker {
    fun getCurrentLocation(): Location {
        // Logic to fetch current location
    }
}

Open/Closed Principle

Add new ride types without modifying the existing system:

abstract class Ride {
    abstract fun calculateFare(distance: Double): Double
}

class EconomyRide : Ride() {
    override fun calculateFare(distance: Double): Double {
        return distance * 0.5
    }
}

class LuxuryRide : Ride() {
    override fun calculateFare(distance: Double): Double {
        return distance * 1.5
    }
}

Liskov Substitution Principle

Substitute ride types without affecting functionality:

fun showRideFare(ride: Ride, distance: Double) {
    println("Fare: ${ride.calculateFare(distance)}")
}

Interface Segregation Principle

Segregate ride payment and ride tracking:

interface RidePayment {
    fun processPayment(amount: Double)
}

interface RideTracking {
    fun trackRide(rideId: String)
}

Dependency Inversion Principle

Decouple the ViewModel from the ride repository:

interface RidePayment {
    fun processPayment(amount: Double)
}

interface RideTracking {
    fun trackRide(rideId: String)
}

Summary: Why SOLID Principles Matter in Android Development

  • Maintainability: Classes with a single responsibility and clear boundaries are easier to debug and extend.
  • Scalability: By adhering to OCP and DIP, the app can grow without disrupting existing functionality.
  • Reusability: Following ISP and LSP ensures that components are modular and can be reused across different parts of the application.
  • Testability: SOLID principles promote decoupled and well-structured code, making it easier to write and maintain unit tests.

By applying the SOLID principles, you can design Android apps with improved modularity, testability, and extensibility. Whether building an e-commerce app, a music player, or a ride-sharing app, these principles help organize code effectively. They ensure that your app remains adaptable to future requirements while reducing the risk of bugs and technical debt. Use these examples as a starting point for implementing SOLID in your projects!

Code Challenge: Implement a Task Manager in Kotlin Android

An overview of common LeetCode-style Android engineer coding challenges in Kotlin, incorporating the latest UI and technologies in Android development, such as Jetpack Compose, Kotlin Coroutines, and Flow.

These challenges simulate real-world scenarios and test problem-solving skills, algorithmic thinking, and practical Android development knowledge.




1. Challenge: Implement a Task Manager

Problem Statement
Create an Android app using Jetpack Compose that allows users to manage tasks. Users can:

Features Implemented:

    1. Add tasks with title and description.
    2. Toggle task completion status.
    3. Delete tasks.
    4. Filter tasks by All, Completed, or Pending states.

Technologies Used:

    1. Jetpack Compose for modern UI development.
    2. Room Database for persistent local storage.
    3. StateFlow for reactive state updates.
    4. ViewModel for managing UI state and logic.

Additional Enhancements:

    1. Unit tests for ViewModel logic using JUnit.
    2. Snackbar notifications for task actions.

Solution Outline

We'll use the following:

  • Jetpack Compose for UI.
  • ViewModel to manage state.
  • StateFlow for reactive state updates.
  • Room Database for local storage.

Code Implementation

// File: TaskManager.kt

package com.example.taskmanager

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.room.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch

// Data Model
@Entity(tableName = "tasks")
data class Task(
    @PrimaryKey(autoGenerate = true) val id: Int = 0,
    val title: String,
    val description: String,
    val isCompleted: Boolean = false
)

// DAO
@Dao
interface TaskDao {
    @Insert
    suspend fun insertTask(task: Task)

    @Update
    suspend fun updateTask(task: Task)

    @Delete
    suspend fun deleteTask(task: Task)

    @Query("SELECT * FROM tasks")
    fun getAllTasks(): List<Task>
}

// Database
@Database(entities = [Task::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun taskDao(): TaskDao
}

// ViewModel
class TaskViewModel(private val taskDao: TaskDao) : ViewModel() {
    private val _tasks = MutableStateFlow<List<Task>>(emptyList())
    val tasks: StateFlow<List<Task>> get() = _tasks

    init {
        fetchTasks()
    }

    private fun fetchTasks() {
        viewModelScope.launch {
            _tasks.value = taskDao.getAllTasks()
        }
    }

    fun addTask(title: String, description: String) {
        viewModelScope.launch {
            taskDao.insertTask(Task(title = title, description = description))
            fetchTasks()
        }
    }

    fun toggleCompletion(task: Task) {
        viewModelScope.launch {
            taskDao.updateTask(task.copy(isCompleted = !task.isCompleted))
            fetchTasks()
        }
    }

    fun deleteTask(task: Task) {
        viewModelScope.launch {
            taskDao.deleteTask(task)
            fetchTasks()
        }
    }
}

// ViewModel Factory
class TaskViewModelFactory(private val taskDao: TaskDao) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(TaskViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return TaskViewModel(taskDao) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

// UI with Jetpack Compose
@Composable
fun TaskManagerUI(viewModel: TaskViewModel) {
    val tasks by viewModel.tasks.collectAsState()
    var title by remember { mutableStateOf("") }
    var description by remember { mutableStateOf("") }

    Column(Modifier.padding(16.dp)) {
        Row(Modifier.fillMaxWidth()) {
            BasicTextField(value = title, onValueChange = { title = it }, modifier = Modifier.weight(1f))
            Spacer(modifier = Modifier.width(8.dp))
            BasicTextField(value = description, onValueChange = { description = it }, modifier = Modifier.weight(2f))
            Spacer(modifier = Modifier.width(8.dp))
            Button(onClick = {
                viewModel.addTask(title, description)
                title = ""
                description = ""
            }) {
                Text("Add Task")
            }
        }
        Spacer(modifier = Modifier.height(16.dp))
        LazyColumn {
            items(tasks) { task ->
                Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
                    Text(task.title)
                    Row {
                        Checkbox(checked = task.isCompleted, onCheckedChange = {
                            viewModel.toggleCompletion(task)
                        })
                        Button(onClick = { viewModel.deleteTask(task) }) {
                            Text("Delete")
                        }
                    }
                }
            }
        }
    }
}

// Entry Point
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val db = Room.databaseBuilder(
            applicationContext,
            AppDatabase::class.java, "task-db"
        ).build()
        val taskDao = db.taskDao()
        val viewModel: TaskViewModel by viewModels { TaskViewModelFactory(taskDao) }

        setContent {
            TaskManagerUI(viewModel)
        }
    }
}

@Preview(showBackground = true)
@Composable
fun TaskManagerPreview() {
    // Empty preview
    TaskManagerUI(viewModel())
}

Features Highlighted

  1. Jetpack Compose: Declarative and modern UI toolkit for Kotlin.
  2. Room: Manages persistent data.
  3. StateFlow: Reactive state management with Kotlin Coroutines.
  4. ViewModel: Decoupled business logic.

a. Unit Tests for TaskViewModel

Unit tests for TaskViewModel can be implemented using JUnit and a mock of the TaskDao. Here's the code:

// File: TaskViewModelTest.kt

package com.example.taskmanager

import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.*
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mockito.Mockito.*

@OptIn(ExperimentalCoroutinesApi::class)
class TaskViewModelTest {
    @get:Rule
    val instantTaskExecutorRule = InstantTaskExecutorRule()

    private val testDispatcher = StandardTestDispatcher()

    private lateinit var mockTaskDao: TaskDao
    private lateinit var taskViewModel: TaskViewModel

    @Before
    fun setUp() {
        Dispatchers.setMain(testDispatcher)
        mockTaskDao = mock(TaskDao::class.java)
        taskViewModel = TaskViewModel(mockTaskDao)
    }

    @Test
    fun `addTask adds a new task and updates task list`() = runTest {
        // Arrange
        val task = Task(title = "Test Task", description = "Test Description")
        `when`(mockTaskDao.getAllTasks()).thenReturn(listOf(task))

        // Act
        taskViewModel.addTask(task.title, task.description)
        advanceUntilIdle()

        // Assert
        val tasks = taskViewModel.tasks.first()
        assertEquals(1, tasks.size)
        assertEquals("Test Task", tasks[0].title)
        verify(mockTaskDao).insertTask(any(Task::class.java))
    }

    @Test
    fun `toggleCompletion toggles the task's completed state`() = runTest {
        // Arrange
        val task = Task(id = 1, title = "Task", description = "Description", isCompleted = false)
        `when`(mockTaskDao.getAllTasks()).thenReturn(listOf(task))

        // Act
        taskViewModel.toggleCompletion(task)
        advanceUntilIdle()

        // Assert
        verify(mockTaskDao).updateTask(task.copy(isCompleted = true))
    }

    @Test
    fun `deleteTask removes the task from the list`() = runTest {
        // Arrange
        val task = Task(id = 1, title = "Task", description = "Description")
        `when`(mockTaskDao.getAllTasks()).thenReturn(listOf())

        // Act
        taskViewModel.deleteTask(task)
        advanceUntilIdle()

        // Assert
        val tasks = taskViewModel.tasks.first()
        assertEquals(0, tasks.size)
        verify(mockTaskDao).deleteTask(task)
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
    }
}

b. Filtering Tasks by State (Completed/Pending)

Add filtering functionality to the UI, using a state variable and the TaskViewModel. Here's the updated code:

Update ViewModel

// File: TaskViewModel.kt

enum class FilterType { ALL, COMPLETED, PENDING }

class TaskViewModel(private val taskDao: TaskDao) : ViewModel() {
    private val _tasks = MutableStateFlow<List<Task>>(emptyList())
    val tasks: StateFlow<List<Task>> get() = _tasks

    private val _filter = MutableStateFlow(FilterType.ALL)

    val filteredTasks = _tasks.combine(_filter) { tasks, filter ->
        when (filter) {
            FilterType.ALL -> tasks
            FilterType.COMPLETED -> tasks.filter { it.isCompleted }
            FilterType.PENDING -> tasks.filter { !it.isCompleted }
        }
    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())

    // Filter setter
    fun setFilter(filter: FilterType) {
        _filter.value = filter
    }
}

Update UI

// File: TaskManagerUI.kt

@Composable
fun TaskManagerUI(viewModel: TaskViewModel) {
    val tasks by viewModel.filteredTasks.collectAsState()
    var title by remember { mutableStateOf("") }
    var description by remember { mutableStateOf("") }

    Column(Modifier.padding(16.dp)) {
        Row(Modifier.fillMaxWidth()) {
            BasicTextField(value = title, onValueChange = { title = it }, modifier = Modifier.weight(1f))
            Spacer(modifier = Modifier.width(8.dp))
            BasicTextField(value = description, onValueChange = { description = it }, modifier = Modifier.weight(2f))
            Spacer(modifier = Modifier.width(8.dp))
            Button(onClick = {
                viewModel.addTask(title, description)
                title = ""
                description = ""
            }) {
                Text("Add Task")
            }
        }
        Spacer(modifier = Modifier.height(16.dp))
        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround) {
            Button(onClick = { viewModel.setFilter(FilterType.ALL) }) { Text("All") }
            Button(onClick = { viewModel.setFilter(FilterType.COMPLETED) }) { Text("Completed") }
            Button(onClick = { viewModel.setFilter(FilterType.PENDING) }) { Text("Pending") }
        }
        Spacer(modifier = Modifier.height(16.dp))
        LazyColumn {
            items(tasks) { task -&gt;
                Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
                    Text(task.title)
                    Row {
                        Checkbox(checked = task.isCompleted, onCheckedChange = {
                            viewModel.toggleCompletion(task)
                        })
                        Button(onClick = { viewModel.deleteTask(task) }) {
                            Text("Delete")
                        }
                    }
                }
            }
        }
    }
}

Summary of Updates

  1. Unit Tests ensure the ViewModel logic functions correctly.
  2. Filtering adds an interactive feature for better usability.

Suggestions:

a. Refactor filtering logic into a separate function for better modularity.
b. Add a Snackbar to notify users when tasks are added, updated, or deleted.

Happy coding!


What’s your favorite part of this article? Share in the comments below!

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!