Showing posts with label MVVM. Show all posts
Showing posts with label MVVM. Show all posts

UI Design Principle or Paradigm or Pattern with Best and latest approach

When designing UI for Android app development, it's crucial to follow modern design principles, paradigms, and patterns to ensure the app is user-friendly, maintainable, and aligned with best practices. Below is a structured overview of each category with the best and latest approach as of 2025.


1. UI Design Principles (Timeless + Google-backed)

These are foundational ideas that guide good UI/UX:

Principle Description
Material Design 3 (Material You) Google’s latest design system with dynamic theming, accessibility, and better adaptability across devices.
Clarity Interface should communicate clearly; avoid ambiguity. Use labels, icons, and empty states well.
Consistency Components and interactions should behave the same throughout the app.
Feedback Every user action should get an immediate visual or haptic response.
Affordance Users should instinctively understand what an element does (e.g., tappable cards, clickable icons).
Minimalism Remove unnecessary elements and focus on core user tasks.
Accessibility-first Ensure all UI elements are usable by screen readers, have proper contrast, and support font scaling.

2. UI Paradigms (Approach to thinking about UI)

Paradigm Description Example in Android
Declarative UI (Latest) UI is a function of state. No need to imperatively update the UI. Jetpack Compose
Reactive Programming  UI reacts to data/state changes. Kotlin Flows + Compose
Unidirectional Data Flow (UDF)  Data flows from a single source to UI; UI sends events up. MVI / MVVM
Responsive Design UI adapts to screen sizes and device capabilities. WindowSizeClass, Foldables, Tablets
Theme-Aware / Adaptive UI  UI adapts to user's theme (dark/light) and preferences. Material You + Dynamic Colors

3. UI Design Patterns (Architectural + UX)

A. Architecture Patterns

Pattern Status Tool
MVVM (Model-View-ViewModel)  - Standard Jetpack ViewModel, Compose, StateFlow
MVI (Model-View-Intent) - Trending Orbit MVI, Decompose
Clean Architecture - Recommended Layers: UI → UseCases → Repository → Data Source
Hexagonal / Ports & Adapters - Advanced Enterprise-level separation

B. UI Interaction Patterns

Pattern Usage
Scaffold Layout Provides topBar, bottomBar, FAB, drawer in Compose.
Navigation Component (Jetpack) For in-app navigation and deep linking.
LazyColumn / LazyGrid Efficient lists/grids in Compose.
BottomSheet / ModalBottomSheet For additional layered content.
Pull to Refresh Common in feed-style UIs.
Gesture Detection Compose has pointerInput, Modifier.clickable, etc.
Animation & Motion Use Compose’s animate*AsState, MotionLayout, Transitions.

4. Best Practices & Modern Tools (2025)

Area Tool/Approach
UI Toolkit Jetpack Compose (replacing XML gradually)
State Management Kotlin Flow + StateFlow or MVI with Orbit/Dagger
Design System Material 3 (Material You)
Navigation Navigation-Compose or Decompose for MVI
Themes Dynamic Theming with MaterialTheme and color schemes
Multiplatform KMP with Compose Multiplatform
Accessibility semantics { }, TalkBack testing, font scaling, haptics
Testing Compose UI Test, Robolectric, Espresso (for hybrid apps)

UI Example in Compose (Material 3)

@Composable
fun ProductCard(product: Product) {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(8.dp),
        shape = RoundedCornerShape(16.dp),
        elevation = CardDefaults.cardElevation()
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Text(product.name, style = MaterialTheme.typography.titleMedium)
            Text("$${product.price}", style = MaterialTheme.typography.bodyMedium)
        }
    }
}

Summary: Latest & Best Approach (2025)

Use Jetpack Compose
Follow Material 3 (Material You)
Use StateFlow/Flow for state
Apply MVVM or MVI with Clean Architecture
Design for Accessibility + Responsive UI
Leverage Compose Preview, Theming, and Composable testing
Add animations via animate*, LaunchedEffect, or MotionLayout


📢 Feedback: Did you find this article helpful? Let me know your thoughts or suggestions for improvements! Please leave a comment below. I’d love to hear from you! 👇

Happy coding! 💻

Shipping High-Quality Android Code with Testing in Iterative Cycles (Kotlin + Jetpack Compose)

In modern Android development, building robust apps goes far beyond just writing code. It’s about delivering high-quality features in iterative cycles with the confidence that everything works as expected. This article walks you through a full feature development cycle in Android using Kotlin, Jetpack Compose, and modern testing practices.

We’ll build a simple feature: a button that fetches and displays user data. You'll learn how to:

  • Structure code using ViewModel and Repository

  • Write unit and UI tests

  • Integrate automated testing in CI/CD pipelines

Let’s dive in 

Full Cycle with Testing - Detailed Steps


Step 1: Set up your environment and dependencies

Before starting, ensure your project has the following dependencies in build.gradle (Module-level):

dependencies {
    implementation "androidx.compose.ui:ui:1.3.0"
    implementation "androidx.compose.material3:material3:1.0.0"
    implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.6.0"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"
    testImplementation "junit:junit:4.13.2"
    testImplementation "org.mockito:mockito-core:4.0.0"
    androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.3.0"
    androidTestImplementation "androidx.test.ext:junit:1.1.5"
    androidTestImplementation "androidx.test.espresso:espresso-core:3.5.0"
}

Step 2: Implement the Feature

Let’s assume the feature involves a button that, when clicked, fetches user data from an API.

2.1: Create the User Data Model

data class User(val id: Int, val name: String)

2.2: Create the UserRepository to Fetch Data

class UserRepository {
    // Simulate network request with delay
    suspend fun fetchUserData(): User {
        delay(1000)  // Simulating network delay
        return User(id = 1, name = "John Doe")
    }
}

2.3: Create the UserViewModel to Expose Data

class UserViewModel(private val repository: UserRepository) : ViewModel() {
    private val _userData = MutableLiveData<User?>()
    val userData: LiveData<User?> get() = _userData

    fun fetchUser() {
        viewModelScope.launch {
            _userData.value = repository.fetchUserData()
        }
    }
}

2.4: Create the UserScreen Composable to Display Data

@Composable
fun UserScreen(viewModel: UserViewModel) {
    val user by viewModel.userData.observeAsState()
    Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
        Button(onClick = { viewModel.fetchUser() }) {
            Text("Fetch User")
        }
        user?.let {
            Text(text = "User: ${it.name}")
        } ?: Text(text = "No user fetched yet")
    }
}

Step 3: Write Unit Tests

3.1: Unit Test for UserRepository

Write a simple unit test to test that UserRepository correctly fetches user data.

@RunWith(MockitoJUnitRunner::class)
class UserRepositoryTest {
    private lateinit var repository: UserRepository

    @Before
    fun setup() {
        repository = UserRepository()
    }

    @Test
    fun testFetchUserData() = runBlocking {
        val result = repository.fetchUserData()
        assertEquals(1, result.id)
        assertEquals("John Doe", result.name)
    }
}

3.2: Unit Test for UserViewModel

Next, test that the UserViewModel correctly interacts with the repository and updates the UI state.

class UserViewModelTest {
    private lateinit var viewModel: UserViewModel
    private lateinit var repository: UserRepository

    @Before
    fun setup() {
        repository = mock(UserRepository::class.java)
        viewModel = UserViewModel(repository)
    }

    @Test
    fun testFetchUser() = runBlocking {
        val user = User(1, "John Doe")
        `when`(repository.fetchUserData()).thenReturn(user)

        viewModel.fetchUser()

        val value = viewModel.userData.getOrAwaitValue()  // Use LiveData testing extensions
        assertEquals(user, value)
    }
}

Step 4: Write UI Tests with Jetpack Compose

4.1: Write Compose UI Test

Use ComposeTestRule to test the UserScreen composable.

@get:Rule
val composeTestRule = createComposeRule()

@Test
fun testFetchUserButton_click() {
    val viewModel = UserViewModel(UserRepository())

    composeTestRule.setContent {
        UserScreen(viewModel)
    }

    // Assert that the UI initially shows the "No user fetched yet" text
    composeTestRule.onNodeWithText("No user fetched yet").assertIsDisplayed()

    // Simulate button click
    composeTestRule.onNodeWithText("Fetch User").performClick()

    // After clicking, assert that "User: John Doe" is displayed
    composeTestRule.onNodeWithText("User: John Doe").assertIsDisplayed()
}

4.2: Write Espresso Test for Hybrid UI (if needed)

In case you’re testing a hybrid UI (Jetpack Compose + XML Views), you can also use Espresso.

@Test
fun testFetchUserButton_click() {
    onView(withId(R.id.fetchUserButton)).perform(click())
    onView(withId(R.id.resultText)).check(matches(withText("User: John Doe")))
}

Step 5: Run All Tests in CI/CD

  • Set up GitHub Actions or Jenkins to automatically run the tests on every push to the repository.

Here’s an example configuration for GitHub Actions:

name: Android CI

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Set up JDK
        uses: actions/setup-java@v2
        with:
          java-version: '11'

      - name: Build and test
        run: ./gradlew build testDebugUnitTest connectedDebugAndroidTest

Step 6: Code Review and Refactor

With your code and tests in place:

  •  Refactor for better architecture

  •  Add new states like loading and error

  •  Add tests for edge cases

  •  Merge changes with confidence

  •  Release to staging/production


Step 7: Deploy to Staging or Production

After successful tests, deploy the feature to a staging environment or directly to production, depending on your CI/CD pipeline.


Key Takeaways:

  1. Iterative development: Implement small, testable features and enhance them with every iteration.

  2. Automate testing: Unit tests, UI tests, and integration tests should be automated and run on every code change.

  3. Focus on user behavior: Write tests that mimic real user behavior (e.g., pressing buttons and verifying UI changes).

  4. Continuous integration: Set up CI/CD pipelines to run tests automatically and ensure the stability of your app at all times.

By following this cycle, you ensure that the app is always in a deployable state, with tests proving that each feature works as expected.

Final Thoughts

Iterative development backed by strong testing practices ensures you can deliver robust Android features without fear of breaking the app. With tools like Jetpack Compose, JUnit, and CI/CD, it’s never been easier to ship confidently.

💡 “The best code is not only written, it's verified and trusted by tests.”



📢 Feedback: Did you find this article helpful? Let me know your thoughts or suggestions for improvements! 😊 please leave a comment below. I’d love to hear from you! 👇

Happy coding! 💻✨

Design Patterns in Android App Development

In Android app development, design patterns are reusable solutions to common problems. They help ensure code maintainability, scalability, and flexibility. Here’s an overview of key design patterns used in Android app development, with examples:

1. Model-View-ViewModel (MVVM)

  • Purpose: MVVM separates the UI (View) from the business logic (ViewModel), making the code more modular and easier to test.
  • Components:
    • Model: Represents the data and business logic.
    • View: Displays the UI and interacts with the user.
    • ViewModel: Holds the logic for preparing data for the View and manages UI-related data.
  • Example: In an Android app that fetches a list of users from a REST API:
    • Model: UserRepository makes the API call.
    • ViewModel: UserViewModel holds the user data and state.
    • View: UserActivity observes the UserViewModel and updates the UI.

Example Code (MVVM):

// Model
data class User(val id: Int, val name: String)
interface UserRepository {
    suspend fun getUsers(): List<User>
}

// ViewModel
class UserViewModel(private val repository: UserRepository) : ViewModel() {
    private val _users = MutableLiveData<List<User>>()
    val users: LiveData<List<User>> = _users

    fun fetchUsers() {
        viewModelScope.launch {
            _users.value = repository.getUsers()
        }
    }
}

// View (Activity)
class UserActivity : AppCompatActivity() {
    private lateinit var userViewModel: UserViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val userRepository = UserRepositoryImpl()
        val viewModelFactory = UserViewModelFactory(userRepository)
        userViewModel = ViewModelProvider(this, viewModelFactory).get(UserViewModel::class.java)

        userViewModel.users.observe(this, Observer { users ->
            // Update UI with users
        })

        userViewModel.fetchUsers()
    }
}

2. Singleton

  • Purpose: Ensures a class has only one instance throughout the application.
  • Example: Used for classes like network clients (e.g., Retrofit, OkHttpClient), databases (Room), etc.
  • Example Code:
object RetrofitClient {
    val retrofit: Retrofit by lazy {
        Retrofit.Builder()
            .baseUrl("https://api.example.com/")
            .build()
    }
}

3. Factory

  • Purpose: Provides a way to create objects without specifying the exact class of object that will be created. It's useful for dependency injection or when you have complex object creation logic.
  • Example: Used in DI (Dependency Injection) frameworks like Hilt or Dagger.
  • Example Code:
interface Button {
    fun render()
}

class WindowsButton : Button {
    override fun render() {
        println("Rendering Windows button")
    }
}

class MacButton : Button {
    override fun render() {
        println("Rendering Mac button")
    }
}

class ButtonFactory {
    fun createButton(os: String): Button {
        return if (os == "Windows") WindowsButton() else MacButton()
    }
}

4. Observer

  • Purpose: Allows a subject (e.g., ViewModel or data model) to notify all its observers (e.g., UI components) about changes.
  • Example: This is commonly used in LiveData in Android, where the UI observes changes in data, and updates automatically when the data changes.
  • Example Code:
// Model
class UserModel {
    private val _name = MutableLiveData<String>()
    val name: LiveData<String> = _name

    fun setName(name: String) {
        _name.value = name
    }
}

// Observer (Activity or Fragment)
class UserFragment : Fragment() {
    private lateinit var userModel: UserModel

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        userModel.name.observe(viewLifecycleOwner, Observer { name ->
            // Update UI
            userNameTextView.text = name
        })
        return inflater.inflate(R.layout.fragment_user, container, false)
    }
}

5. Adapter

  • Purpose: Adapts one interface to another, often used in connecting a data source to a UI component, such as RecyclerView.Adapter.
  • Example: Adapter pattern is used in RecyclerView to display lists of data.
  • Example Code:
class UserAdapter(private val users: List<User>) : RecyclerView.Adapter<UserAdapter.UserViewHolder>() {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserViewHolder {
        val binding = ListItemUserBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return UserViewHolder(binding)
    }

    override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
        val user = users[position]
        holder.bind(user)
    }

    override fun getItemCount(): Int = users.size

    inner class UserViewHolder(private val binding: ListItemUserBinding) : RecyclerView.ViewHolder(binding.root) {
        fun bind(user: User) {
            binding.userName.text = user.name
        }
    }
}

6. Decorator

  • Purpose: Adds behavior to an object dynamically. It’s useful for scenarios where inheritance is not flexible enough.
  • Example: This can be used for adding functionalities like logging, security checks, etc., to existing objects.
  • Example Code:
interface Notifier {
    fun send(message: String)
}

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

class SmsNotifier(private val notifier: Notifier) : Notifier {
    override fun send(message: String) {
        println("Sending SMS: $message")
        notifier.send(message)
    }
}

7. Command

  • Purpose: Encapsulates a request as an object, thereby letting users parameterize clients with queues, requests, and operations.
  • Example: Used in implementing Undo/Redo functionality.
  • Example Code:
interface Command {
    fun execute()
}

class LightOnCommand(private val light: Light) : Command {
    override fun execute() {
        light.turnOn()
    }
}

class LightOffCommand(private val light: Light) : Command {
    override fun execute() {
        light.turnOff()
    }
}

class RemoteControl {
    private var command: Command? = null

    fun setCommand(command: Command) {
        this.command = command
    }

    fun pressButton() {
        command?.execute()
    }
}

class Light {
    fun turnOn() {
        println("Light is ON")
    }

    fun turnOff() {
        println("Light is OFF")
    }
}

8. Strategy

  • Purpose: Allows a family of algorithms to be defined and encapsulated, making them interchangeable. The Strategy pattern lets the algorithm vary independently from clients that use it.
  • Example: Used for switching between different types of sorting algorithms or network request strategies.
  • Example Code:
interface SortStrategy {
    fun sort(list: List<Int>): List<Int>
}

class QuickSort : SortStrategy {
    override fun sort(list: List<Int>): List<Int> {
        // Quick sort logic
        return list.sorted()
    }
}

class MergeSort : SortStrategy {
    override fun sort(list: List<Int>): List<Int> {
        // Merge sort logic
        return list.sorted()
    }
}

class SortContext(private var strategy: SortStrategy) {
    fun setStrategy(strategy: SortStrategy) {
        this.strategy = strategy
    }

    fun executeStrategy(list: List<Int>): List<Int> {
        return strategy.sort(list)
    }
}

Summary

Design patterns like MVVM, Singleton, Factory, Observer, and others can help structure Android applications efficiently. They enhance modularity, reusability, testability, and scalability, ultimately leading to better maintainable codebases. Understanding when and how to apply these patterns is key to building robust Android apps.

Thanks for reading! ðŸŽ‰ I'd love to know what you think about the article. Did it resonate with you? ðŸ’­ Any suggestions for improvement? I’m always open to hearing your feedback to improve my posts! ðŸ‘‡ðŸš€. Happy coding! ðŸ’»✨


MVVM vs MVI vs MVP: Which Architecture Fits Your Android Kotlin Compose Project?

When developing Android apps using Kotlin and Jetpack Compose, the architecture you choose should align with your application's needs, scalability, and maintainability. Let's explore the best architecture and discuss other alternatives with examples to help you make the best decision.

1. MVVM (Model-View-ViewModel) Architecture

Overview:

MVVM is the most commonly recommended architecture for Android apps using Jetpack Compose. It works seamlessly with Compose’s declarative UI structure and supports unidirectional data flow.

  • Model: Represents the data and business logic (e.g., network requests, database calls, etc.).
  • View: Composed of composable functions in Jetpack Compose. It displays the UI and reacts to state changes.
  • ViewModel: Holds UI-related state and business logic. It is lifecycle-aware and acts as a bridge between the View and Model.

How MVVM Works:

  • The View is responsible for presenting data using Compose. It observes the state exposed by the ViewModel via StateFlow or LiveData.
  • The ViewModel holds and processes the data and updates the state in response to user actions or external data changes.
  • The Model handles data fetching and business logic and communicates with repositories or data sources.

Benefits:

  • Separation of concerns: The View and Model are decoupled, making the app easier to maintain.
  • Reactivity: With Compose's state-driven UI, the View updates automatically when data changes in the ViewModel.
  • Scalability: MVVM works well for larger, complex apps.

Example:

// ViewModel
class MyViewModel : ViewModel() {
    private val _state = MutableStateFlow(MyState())
    val state: StateFlow<MyState> get() = _state

    fun fetchData() {
        // Simulate network request
        _state.value = _state.value.copy(data = "Fetched Data")
    }
}

// Composable View
@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
    val state by viewModel.state.collectAsState()

    Column {
        Text(text = state.data)
        Button(onClick = { viewModel.fetchData() }) {
            Text("Fetch Data")
        }
    }
}

Best For:

  • Real-time applications (e.g., chat apps, social media, etc.)
  • Apps with dynamic and complex UI requiring frequent backend updates.
  • Enterprise-level applications where clear separation of concerns and scalability are required.

2. MVI (Model-View-Intent) Architecture

Overview:

MVI focuses on unidirectional data flow and immutable state. It's more reactive than MVVM and ensures that the View always displays the latest state.

  • Model: Represents the application’s state, typically immutable.
  • View: Displays the UI and reacts to state changes.
  • Intent: Represents the actions that the View triggers (e.g., button clicks, user input).

How MVI Works:

  • The View sends Intents (user actions) to the Presenter (or ViewModel).
  • The Presenter updates the Model (state) based on these actions and then triggers a state change.
  • The View observes the state and re-renders itself accordingly.

Benefits:

  • Unidirectional data flow: The state is always predictable as changes propagate in one direction.
  • Immutable state: Reduces bugs associated with mutable state and ensures UI consistency.
  • Reactive: Well-suited for applications with frequent UI updates based on state changes.

Example:

// MVI - State, ViewModel
data class MyState(val data: String = "")

class MyViewModel : ViewModel() {
    private val _state = MutableStateFlow(MyState())
    val state: StateFlow<MyState> get() = _state

    fun processIntent(intent: MyIntent) {
        when (intent) {
            is MyIntent.FetchData -> {
                _state.value = MyState("Fetched Data")
            }
        }
    }
}

// Composable View
@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
    val state by viewModel.state.collectAsState()

    Column {
        Text(text = state.data)
        Button(onClick = { viewModel.processIntent(MyIntent.FetchData) }) {
            Text("Fetch Data")
        }
    }
}

Best For:

  • Complex UI interactions: Apps with multiple states and actions that must be tightly controlled.
  • Real-time data-driven apps where state changes must be captured and handled immutably.
  • Apps that require a highly reactive UI, such as games or media streaming apps.

3. MVP (Model-View-Presenter) Architecture

Overview:

MVP is a simpler architecture often used in legacy apps. In MVP, the Presenter controls the logic and updates the View, which is passive and only responsible for displaying data.

  • Model: Represents the data and business logic.
  • View: Displays UI and delegates user interactions to the Presenter.
  • Presenter: Acts as a middleman, processing user input and updating the View.

How MVP Works:

  • The View delegates all user actions (clicks, input, etc.) to the Presenter.
  • The Presenter fetches data from the Model and updates the View accordingly.

Benefits:

  • Simple and easy to implement for small applications.
  • Decouples UI logic from the data layer.

Example:

// MVP - Presenter
interface MyView {
    fun showData(data: String)
}

class MyPresenter(private val view: MyView) {
    fun fetchData() {
        // Simulate fetching data
        view.showData("Fetched Data")
    }
}

// Composable View
@Composable
fun MyScreen(view: MyView) {
    val presenter = remember { MyPresenter(view) }

    Column {
        Button(onClick = { presenter.fetchData() }) {
            Text("Fetch Data")
        }
    }
}

class MyViewImpl : MyView {
    override fun showData(data: String) {
        println("Data: $data")
    }
}

Best For:

  • Simple apps with minimal business logic.
  • Legacy projects that already follow the MVP pattern.
  • Applications with simple user interactions that don’t require complex state management.

Conclusion: Which Architecture to Choose?

Architecture Strengths Best For Example Use Cases
MVVM Seamless integration with Jetpack ComposeClear separation of concernsScalable and testable Large, complex appsReal-time appsTeam-based projects E-commerce apps, banking apps, social apps
MVI Immutable stateUnidirectional data flowReactive UI Highly interactive appsReal-time dataComplex state management Messaging apps, live score apps, media apps
MVP Simple to implementGood for small appsEasy to test Small appsLegacy appsSimple UI interactions Note-taking apps, simple tools, legacy apps

Best Recommendation:

  • MVVM is generally the best architecture for most Android Kotlin Compose apps due to its scalability, maintainability, and seamless integration with Compose.
  • MVI is ideal for apps that require complex state management and reactive UI updates.
  • MVP is still useful for simple apps or projects that already follow MVP.

Thanks for reading! 🎉 I'd love to know what you think about the article. Did it resonate with you? 💭 Any suggestions for improvement? I’m always open to hearing your feedback to improve my posts! 👇🚀. Happy coding! 💻✨

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!

How to Implement Ktor in Android Apps Using MVVM Clean Architecture, Jetpack Compose, and Kotlin Coroutines

Ktor is a Kotlin-native framework that enables developers to create asynchronous HTTP clients and servers. In this article, we'll walk you through implementing Ktor in an Android project structured around MVVM Clean Architecture, leveraging Jetpack Compose for UI and Kotlin Coroutines and Flow for data handling.



Prerequisites

Before starting, ensure you are familiar with:

  • Kotlin programming
  • MVVM Clean Architecture
  • Jetpack Compose for UI
  • Dependency Injection with Hilt
  • Coroutines and Flows

Step 1: Set Up Your Android Project

  1. Create a new Android project in Android Studio.

    • Choose Jetpack Compose in the project setup wizard.
    • Select Kotlin as the programming language.
  2. Add Dependencies
    Open your app/build.gradle file and include the following libraries:

    
dependencies {
    // Ktor for HTTP requests
    implementation("io.ktor:ktor-client-core:2.0.0")
    implementation("io.ktor:ktor-client-cio:2.0.0")
    implementation("io.ktor:ktor-client-content-negotiation:2.0.0")
    implementation("io.ktor:ktor-serialization-kotlinx-json:2.0.0")

    // Kotlin Coroutines
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")

    // Jetpack Compose
    implementation("androidx.compose.ui:ui:1.5.0")
    implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.1")

    // Hilt for Dependency Injection
    implementation("com.google.dagger:hilt-android:2.47")
    kapt("com.google.dagger:hilt-compiler:2.47")

    // Testing libraries (optional)
    testImplementation("junit:junit:4.13.2")
    androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.5.0")
}
  1. Enable Kotlin Serialization
    In your build.gradle (project-level), enable the Kotlin serialization plugin:
plugins {
    id("org.jetbrains.kotlin.plugin.serialization") version "1.9.10"
}

Step 2: Define the Clean Architecture Layers

Clean Architecture organizes the code into layers to improve scalability and maintainability. These layers include Presentation, Domain, and Data.


Data Layer

The Data Layer handles communication with APIs and maps responses into domain models.

  1. Define API Endpoints

Create a KtorService interface to abstract Ktor HTTP calls:

interface KtorService {
    suspend fun fetchItems(): List<ItemDto>
}
  1. Implement KtorService

Here, we configure Ktor and handle the API interaction:

class KtorServiceImpl : KtorService {
    private val client = HttpClient(CIO) {
        install(ContentNegotiation) {
            json() // Enable JSON serialization
        }
    }

    override suspend fun fetchItems(): List&lt;ItemDto&gt; {
        return client.get("https://api.example.com/items").body()
    }
}
  1. Define DTO and Mappers

Define a Data Transfer Object (DTO) to represent API responses and map it to a domain model.

@Serializable
data class ItemDto(
    val id: String,
    val name: String
)

fun ItemDto.toDomain(): Item = Item(id, name)
  1. Repository

Create a repository to abstract data sources and expose flows:

class ItemRepository(private val service: KtorService) {
    suspend fun getItems(): Flow<List<Item>> = flow {
        try {
            val response = service.fetchItems()
            emit(response.map { it.toDomain() }) // Convert DTO to domain model
        } catch (e: Exception) {
            emit(emptyList()) // Handle errors gracefully
        }
    }
}

Domain Layer

The Domain Layer contains business logic and is independent of the framework.

  1. Define Domain Model

Create the domain model for your app:

data class Item(
    val id: String,
    val name: String
)
  1. Implement Use Case

A use case encapsulates a single piece of functionality:

class GetItemsUseCase(private val repository: ItemRepository) {
    operator fun invoke(): Flow<List<Item>> {
        return repository.getItems()
    }
}

Presentation Layer

The Presentation Layer manages UI state and user interactions.

  1. Create ViewModel

Use ViewModel to expose data to the UI:

@HiltViewModel
class ItemViewModel @Inject constructor(
    private val getItemsUseCase: GetItemsUseCase
) : ViewModel() {
    private val _itemsState = MutableStateFlow<List<Item>>(emptyList())
    val itemsState: StateFlow<List<Item>> = _itemsState

    init {
        fetchItems()
    }

    private fun fetchItems() {
        viewModelScope.launch {
            getItemsUseCase().collect { items ->
                _itemsState.value = items
            }
        }
    }
}
  1. Compose UI

Use Jetpack Compose to create the UI:

@Composable
fun ItemListScreen(viewModel: ItemViewModel = hiltViewModel()) {
    val items by viewModel.itemsState.collectAsState()

    LazyColumn {
        items(items) { item ->
            Text(
                text = item.name,
                style = MaterialTheme.typography.body1,
                modifier = Modifier.padding(16.dp)
            )
        }
    }
}

Step 3: Set Up Dependency Injection

Use Hilt to inject dependencies across layers.

  1. Setup Hilt

Annotate your application class:

@HiltAndroidApp
class MyApp : Application()
  1. Provide Dependencies

Create an Hilt module:

@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    @Provides
    fun provideKtorService(): KtorService = KtorServiceImpl()

    @Provides
    fun provideRepository(service: KtorService): ItemRepository = ItemRepository(service)

    @Provides
    fun provideGetItemsUseCase(repository: ItemRepository): GetItemsUseCase = GetItemsUseCase(repository)
}

Step 4: Handle State and Error Management

In a real-world app, you must handle API states (loading, success, error) gracefully.

  1. Update ViewModel

Add a state to track the API status:

data class UiState(
    val isLoading: Boolean = false,
    val items: List<Item> = emptyList(),
    val error: String? = null
)

@HiltViewModel
class ItemViewModel @Inject constructor(
    private val getItemsUseCase: GetItemsUseCase
) : ViewModel() {
    private val _uiState = MutableStateFlow(UiState())
    val uiState: StateFlow<UiState> = _uiState

    init {
        fetchItems()
    }

    private fun fetchItems() {
        viewModelScope.launch {
            _uiState.value = UiState(isLoading = true)
            getItemsUseCase().collect { items ->
                _uiState.value = UiState(items = items)
            }
        }
    }
}
  1. Compose UI with State

Display states in the UI:

@Composable
fun ItemListScreen(viewModel: ItemViewModel = hiltViewModel()) {
    val state by viewModel.uiState.collectAsState()

    when {
        state.isLoading -> CircularProgressIndicator()
        state.error != null -> Text("Error: ${state.error}")
        else -> LazyColumn {
            items(state.items) { item ->
                Text(text = item.name)
            }
        }
    }
}

Step 5: Test Your App

Run the app and verify that:

  • Items load from the API.
  • UI updates automatically when data changes.

Conclusion

By combining Ktor, MVVM Clean Architecture, Jetpack Compose, and Kotlin Coroutines, you create a scalable, testable, and maintainable Android app. Expand on this foundation by adding advanced features like offline caching, user authentication, or detailed error reporting.

Happy coding! 🚀