Skip to main content
UI modules implement the presentation layer of the application. Each UI module corresponds to a feature screen and contains ViewModels, Compose UI screens, and UI-specific logic.

Module Overview

cart-ui

Shopping cart screen

main-ui

Main container with bottom navigation

money-ui

Money formatting and display

onboarding-ui

Login and authentication screens

plp-ui

Product listing page

wishlist-ui

Wishlist screen

Architecture Pattern

All UI modules follow a consistent structure:
ui-module/
├── presentation/
│   ├── view/
│   │   └── Screen.kt        # Composable UI
│   └── viewmodel/
│       ├── ViewModel.kt     # Interface
│       └── RealViewModel.kt # Implementation
├── di/
│   └── UIAssembler.kt       # DI and screen destination
├── test/
│   └── viewmodel/
│       └── RealViewModelTest.kt
└── screenshotTest/
    └── view/
        └── ScreenPreviews.kt
UI modules are Android-only (unlike component modules which are multiplatform) and depend on Android-specific libraries like Jetpack Compose.

cart-ui

Displays the user’s shopping cart and allows quantity updates.

Module Configuration

plugins {
    alias(libs.plugins.android.library)
    alias(libs.plugins.compose.compiler)
    alias(libs.plugins.screenshot)
}

dependencies {
    implementation(project(":foundations"))
    implementation(project(":money-component"))
    implementation(project(":cart-component"))
    implementation(project(":money-ui"))
    implementation(project(":viewmodel"))
    implementation(project(":designsystem"))
    implementation(libs.coroutines.core)
    implementation(libs.lifecycle.viewmodel)
    
    implementation(platform(libs.androidx.compose.bom))
    implementation(libs.androidx.ui.tooling.preview)
    implementation(libs.androidx.material3)
    // ...
}

ViewModel Layer

1

ViewModel Interface

Defines the contract for cart screen state and actions:
internal interface CartViewModel : StateViewModel<CartScreenState> {
    fun updateCartItemQuantity(cartItem: CartItem)
}

internal data class CartScreenState(val cart: Cart)
The interface extends StateViewModel from the viewmodel library module, providing the state: StateFlow<CartScreenState> property.
2

ViewModel Implementation

From cart-ui/src/main/java/com/denisbrandi/androidrealca/cart/presentation/viewmodel/RealCartViewModel.kt:
internal class RealCartViewModel(
    private val observeUserCart: ObserveUserCart,
    private val updateCartItem: UpdateCartItem,
    private val stateDelegate: StateDelegate<CartScreenState>
) : ViewModel(), CartViewModel, StateViewModel<CartScreenState> by stateDelegate {

    init {
        stateDelegate.setDefaultState(CartScreenState(Cart(emptyList())))
        viewModelScope.launch {
            observeUserCart().collect { cart ->
                stateDelegate.updateState { CartScreenState(cart) }
            }
        }
    }

    override fun updateCartItemQuantity(cartItem: CartItem) {
        updateCartItem(cartItem)
    }
}
The ViewModel uses delegation via StateDelegate to manage state, keeping the implementation focused on business logic.

View Layer

From cart-ui/src/main/java/com/denisbrandi/androidrealca/cart/presentation/view/CartScreen.kt:
@Composable
internal fun CartScreen(viewModel: CartViewModel) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    
    CartContent(
        cart = state.cart,
        onQuantityChanged = { cartItem ->
            viewModel.updateCartItemQuantity(cartItem)
        }
    )
}

@Composable
private fun CartContent(
    cart: Cart,
    onQuantityChanged: (CartItem) -> Unit
) {
    // Compose UI implementation
    // Uses components from designsystem module
}

Dependency Injection

class CartUIAssembler(
    private val cartComponentAssembler: CartComponentAssembler
) {
    @Composable
    private fun makeCartViewModel(): CartViewModel {
        return viewModel {
            RealCartViewModel(
                cartComponentAssembler.observeUserCart,
                cartComponentAssembler.updateCartItem,
                StateDelegate()
            )
        }
    }

    @Composable
    fun CartScreenDestination() {
        CartScreen(makeCartViewModel())
    }
}
The CartUIAssembler depends on CartComponentAssembler from the component module. UI assemblers should never create component assemblers - they should receive them via constructor injection.

main-ui

Provides the main container screen with bottom navigation for switching between PLP, Wishlist, and Cart.

ViewModel Layer

internal interface MainViewModel : StateViewModel<MainScreenState>

internal data class MainScreenState(
    val wishlistBadge: Int = 0,
    val cartBadge: Int = 0
)
Implementation:
internal class RealMainViewModel(
    observeUserWishlistIds: ObserveUserWishlistIds,
    observeUserCart: ObserveUserCart,
    stateDelegate: StateDelegate<MainScreenState>
) : ViewModel(), MainViewModel, StateViewModel<MainScreenState> by stateDelegate {

    init {
        stateDelegate.setDefaultState(MainScreenState())
        
        viewModelScope.launch {
            observeUserWishlistIds().collect { ids ->
                stateDelegate.updateState { it.copy(wishlistBadge = ids.size) }
            }
        }
        
        viewModelScope.launch {
            observeUserCart().collect { cart ->
                stateDelegate.updateState { 
                    it.copy(cartBadge = cart.getNumberOfItems()) 
                }
            }
        }
    }
}
The MainViewModel observes both wishlist and cart to display badge counts on the bottom navigation tabs.

View Layer

From main-ui/src/main/java/com/denisbrandi/androidrealca/main/presentation/view/MainScreen.kt:
@Composable
internal fun MainScreen(
    viewModel: MainViewModel,
    bottomNavRouter: BottomNavRouter
) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    
    Scaffold(
        bottomBar = {
            BottomNavigationBar(
                wishlistBadge = state.wishlistBadge,
                cartBadge = state.cartBadge,
                onTabSelected = { /* ... */ }
            )
        }
    ) {
        // Display selected tab content using bottomNavRouter
    }
}

Bottom Navigation Router

From main-ui/src/main/java/com/denisbrandi/androidrealca/main/presentation/view/navigation/BottomNavRouter.kt:
interface BottomNavRouter {
    @Composable
    fun OpenPLPScreen()

    @Composable
    fun OpenWishlistScreen()

    @Composable
    fun OpenCartScreen()
}
The BottomNavRouter interface allows the main-ui module to delegate screen creation to the app module, avoiding direct dependencies on other UI modules.

money-ui

Provides presentation logic for formatting and displaying monetary values.

Module Structure

Unlike other UI modules, money-ui doesn’t have a full screen - it provides reusable components:
money-ui/
├── presentation/
│   ├── presenter/
│   │   └── MoneyPresenter.kt
│   └── view/
│       └── PriceText.kt
└── test/
    └── presenter/
        └── MoneyPresenterTest.kt

Presenter

class MoneyPresenter {
    fun formatPrice(money: Money): String {
        return "${money.currencySymbol}${String.format("%.2f", money.amount)}"
    }
}

Composable Component

@Composable
fun PriceText(
    money: Money,
    modifier: Modifier = Modifier
) {
    val presenter = remember { MoneyPresenter() }
    
    Text(
        text = presenter.formatPrice(money),
        modifier = modifier,
        style = MaterialTheme.typography.bodyLarge
    )
}
The money-ui module demonstrates the Passive View pattern - the presenter contains all formatting logic, making the view purely declarative.

onboarding-ui

Handles user authentication flow including the login screen.

ViewModel Layer

internal interface LoginViewModel : 
    StateViewModel<LoginScreenState>,
    EventViewModel<LoginViewEvent> {
    
    fun login(email: String, password: String)
}

internal data class LoginScreenState(
    val isLoading: Boolean = false,
    val errorMessage: String? = null
)

internal sealed interface LoginViewEvent {
    data object NavigateToMain : LoginViewEvent
}
This ViewModel implements both StateViewModel (for screen state) and EventViewModel (for one-time navigation events).
internal class RealLoginViewModel(
    private val login: Login,
    private val stateDelegate: StateDelegate<LoginScreenState>,
    private val eventDelegate: EventDelegate<LoginViewEvent>
) : ViewModel(),
    LoginViewModel,
    StateViewModel<LoginScreenState> by stateDelegate,
    EventViewModel<LoginViewEvent> by eventDelegate {

    init {
        stateDelegate.setDefaultState(LoginScreenState())
    }

    override fun login(email: String, password: String) {
        stateDelegate.updateState { it.copy(isLoading = true, errorMessage = null) }
        
        viewModelScope.launch {
            val emailObj = Email(email)
            val passwordObj = Password(password)
            
            if (!emailObj.isValid() || !passwordObj.isValid()) {
                stateDelegate.updateState { 
                    it.copy(isLoading = false, errorMessage = "Invalid credentials")
                }
                return@launch
            }
            
            login(LoginRequest(emailObj, passwordObj)).fold(
                success = {
                    stateDelegate.updateState { it.copy(isLoading = false) }
                    eventDelegate.sendEvent(viewModelScope, LoginViewEvent.NavigateToMain)
                },
                error = { error ->
                    stateDelegate.updateState { 
                        it.copy(isLoading = false, errorMessage = error.message)
                    }
                }
            )
        }
    }
}

View Layer

@Composable
internal fun LoginScreen(
    viewModel: LoginViewModel,
    onLoggedIn: () -> Unit
) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    
    LaunchedEffect(Unit) {
        viewModel.viewEvent.collect { event ->
            when (event) {
                is LoginViewEvent.NavigateToMain -> onLoggedIn()
            }
        }
    }
    
    LoginContent(
        state = state,
        onLoginClick = { email, password ->
            viewModel.login(email, password)
        }
    )
}
Always handle one-time events (like navigation) using EventViewModel rather than state. This prevents events from being replayed on configuration changes.

plp-ui

Displays the product listing page where users can browse products and add them to wishlist or cart.

ViewModel Layer

internal interface PLPViewModel : StateViewModel<PLPScreenState> {
    fun loadProducts()
    fun isFavourite(productId: String): Boolean
    fun addProductToWishlist(product: Product)
    fun removeProductFromWishlist(productId: String)
    fun addProductToCart(product: Product)
}

internal data class PLPScreenState(
    val fullName: String,
    val wishlistIds: List<String> = emptyList(),
    val displayState: DisplayState? = null
)

internal sealed interface DisplayState {
    data object Loading : DisplayState
    data object Error : DisplayState
    data class Content(val products: List<Product>) : DisplayState
}
Using a sealed interface for DisplayState ensures the UI handles all possible states: Loading, Error, and Content.

Implementation Highlights

From plp-ui/src/main/java/com/denisbrandi/androidrealca/plp/presentation/viewmodel/RealPLPViewModel.kt:
internal class RealPLPViewModel(
    private val getUser: GetUser,
    private val getProducts: GetProducts,
    private val addToWishlist: AddToWishlist,
    private val removeFromWishlist: RemoveFromWishlist,
    private val observeUserWishlistIds: ObserveUserWishlistIds,
    private val addCartItem: AddCartItem,
    stateDelegate: StateDelegate<PLPScreenState>
) : ViewModel(), PLPViewModel, StateViewModel<PLPScreenState> by stateDelegate {

    init {
        val user = getUser()
        stateDelegate.setDefaultState(PLPScreenState(fullName = user.username))
        
        viewModelScope.launch {
            observeUserWishlistIds().collect { ids ->
                stateDelegate.updateState { it.copy(wishlistIds = ids) }
            }
        }
    }

    override fun loadProducts() {
        stateDelegate.updateState { it.copy(displayState = DisplayState.Loading) }
        
        viewModelScope.launch {
            getProducts().fold(
                success = { products ->
                    stateDelegate.updateState { 
                        it.copy(displayState = DisplayState.Content(products))
                    }
                },
                error = {
                    stateDelegate.updateState { 
                        it.copy(displayState = DisplayState.Error)
                    }
                }
            )
        }
    }

    override fun addProductToCart(product: Product) {
        val cartItem = CartItem(
            id = product.id,
            name = product.name,
            money = product.money,
            imageUrl = product.imageUrl,
            quantity = 1
        )
        addCartItem(cartItem)
    }
}
The PLP ViewModel coordinates multiple use cases from different components: user-component, product-component, wishlist-component, and cart-component.

wishlist-ui

Displays the user’s wishlist with options to remove items or add them to cart.

ViewModel Layer

internal interface WishlistViewModel : StateViewModel<WishlistScreenState> {
    fun removeFromWishlist(wishlistItemId: String)
    fun addToCart(wishlistItem: WishlistItem)
}

internal data class WishlistScreenState(
    val wishlistItems: List<WishlistItem>
)

Dependency Injection

class WishlistUIAssembler(
    private val wishlistComponentAssembler: WishlistComponentAssembler,
    private val addCartItem: AddCartItem
) {
    @Composable
    private fun makeWishlistViewModel(): WishlistViewModel {
        return viewModel {
            RealWishlistViewModel(
                wishlistComponentAssembler.observeUserWishlist,
                wishlistComponentAssembler.removeFromWishlist,
                addCartItem,
                StateDelegate()
            )
        }
    }

    @Composable
    fun WishlistScreenDestination() {
        WishlistScreen(makeWishlistViewModel())
    }
}
The WishlistUIAssembler receives addCartItem from the cart component, demonstrating cross-component use case composition.

UI Module Dependencies

UI modules can depend on:
1

Component Modules

UI modules depend on corresponding component modules for use cases and domain models.
2

Library Modules

All UI modules use viewmodel, designsystem, and sometimes foundations.
3

Other UI Modules (Limited)

UI modules can depend on other UI modules for shared components (e.g., cart-ui depends on money-ui).
4

Android Libraries

UI modules use Android-specific libraries like Jetpack Compose, Material3, Coil, etc.

Testing UI Modules

UI modules include two types of tests:

Unit Tests

Test ViewModels in isolation:
class RealCartViewModelTest {
    @get:Rule
    val mainCoroutineRule = MainCoroutineRule()

    @Test
    fun `observes cart updates`() = runTest {
        // Arrange
        val cartFlow = MutableStateFlow(Cart(emptyList()))
        val observeUserCart = ObserveUserCart { cartFlow }
        val updateCartItem = UpdateCartItem { }
        
        // Act
        val viewModel = RealCartViewModel(
            observeUserCart,
            updateCartItem,
            StateDelegate()
        )
        
        // Assert
        assertEquals(Cart(emptyList()), viewModel.state.value.cart)
    }
}

Screenshot Tests

Test UI appearance:
@Preview
@Composable
fun CartScreenPreview() {
    PreviewTheme {
        CartContent(
            cart = Cart(listOf(/* preview data */)),
            onQuantityChanged = { }
        )
    }
}
The project uses Android’s screenshot testing framework (configured via libs.plugins.screenshot) to validate UI rendering.

Best Practices

Separate State and EventsUse StateViewModel for persistent screen state and EventViewModel for one-time events like navigation or showing toasts.
Use Sealed Interfaces for Display StatesModel loading, error, and content states using sealed interfaces to ensure exhaustive handling in the UI.
Keep Views DumbViews should be pure functions of state. All logic, including formatting and validation, belongs in ViewModels or Presenters.
Use Delegation for ViewModelsLeverage StateDelegate and EventDelegate to reduce boilerplate and focus ViewModel implementations on business logic.
1

Constructor Inject Dependencies

Always receive use cases via constructor injection, never create them in the ViewModel.
2

Initialize State in init Block

Set default state and start collecting flows in the ViewModel’s init block.
3

Expose Minimal Interface

ViewModels should only expose the methods needed by the view, nothing more.
4

Test ViewModels, Preview Views

Write unit tests for ViewModels and screenshot tests for Compose views.

Build docs developers (and LLMs) love