Skip to main content

Overview

The utilities module provides essential helper classes for managing UI state and formatting data consistently across the TecMeli app.

UiState

Sealed class that represents different UI states, facilitating reactive data flow between ViewModels and UI components.

Purpose

UiState provides a type-safe way to manage asynchronous operations in the UI layer. It allows UI components to react to loading states, success states with data, errors, and empty results.

Implementation

package com.alcalist.tecmeli.core.util

sealed class UiState<out T> {
    /** Indicates an asynchronous operation is in progress (e.g., network call) */
    object Loading : UiState<Nothing>()

    /**
     * Represents a successful state with data
     * @property data The content obtained after successful operation
     */
    data class Success<T>(val data: T) : UiState<T>()

    /**
     * Represents an operation failure
     * @property message User-friendly error description
     * @property exception Optional technical cause of the error
     */
    data class Error(val message: String, val exception: Throwable? = null) : UiState<Nothing>()

    /** Indicates the operation completed successfully but returned no results (empty list, etc.) */
    object Empty : UiState<Nothing>()
}

States

Loading
object
Indicates an asynchronous operation is in progress (e.g., network loading)
Success<T>
data class
Represents successful completion with data of type T
Error
data class
Represents a failure with user-friendly message and optional exception
Empty
object
Indicates successful completion but with no results

Usage in ViewModel

@HiltViewModel
class ProductSearchViewModel @Inject constructor(
    private val productRepository: ProductRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow<UiState<List<Product>>>(UiState.Empty)
    val uiState: StateFlow<UiState<List<Product>>> = _uiState.asStateFlow()

    fun searchProducts(query: String) {
        viewModelScope.launch {
            _uiState.value = UiState.Loading
            
            val result = productRepository.searchProducts(query)
            
            _uiState.value = result.fold(
                onSuccess = { products ->
                    if (products.isEmpty()) {
                        UiState.Empty
                    } else {
                        UiState.Success(products)
                    }
                },
                onFailure = { error ->
                    UiState.Error(
                        message = error.message ?: "Unknown error",
                        exception = error
                    )
                }
            )
        }
    }
}

Usage in Composable UI

@Composable
fun ProductSearchScreen(
    viewModel: ProductSearchViewModel = hiltViewModel()
) {
    val uiState by viewModel.uiState.collectAsState()

    when (uiState) {
        is UiState.Loading -> {
            Box(modifier = Modifier.fillMaxSize()) {
                CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
            }
        }
        
        is UiState.Success -> {
            val products = (uiState as UiState.Success<List<Product>>).data
            LazyColumn {
                items(products) { product ->
                    ProductCard(product = product)
                }
            }
        }
        
        is UiState.Error -> {
            val error = uiState as UiState.Error
            ErrorMessage(
                message = error.message,
                onRetry = { viewModel.searchProducts("query") }
            )
        }
        
        is UiState.Empty -> {
            EmptyState(message = "No products found")
        }
    }
}

Mapping AppError to UiState

fun <T> Result<T>.toUiState(): UiState<T> {
    return fold(
        onSuccess = { data -> UiState.Success(data) },
        onFailure = { error ->
            when (error) {
                is AppError.Network -> UiState.Error(
                    message = "No internet connection",
                    exception = error
                )
                is AppError.Timeout -> UiState.Error(
                    message = "Request timed out. Please try again.",
                    exception = error
                )
                is AppError.Server -> UiState.Error(
                    message = "Server error: ${error.msg}",
                    exception = error
                )
                is AppError.Unknown -> UiState.Error(
                    message = "An unexpected error occurred",
                    exception = error.throwable
                )
                else -> UiState.Error(
                    message = error.message ?: "Unknown error",
                    exception = error
                )
            }
        }
    )
}

// Usage in ViewModel
fun searchProducts(query: String) {
    viewModelScope.launch {
        _uiState.value = UiState.Loading
        val result = productRepository.searchProducts(query)
        _uiState.value = result.toUiState()
    }
}

DateUtils

Utility object for formatting date strings consistently across the application.

Purpose

Provides a centralized way to parse and format ISO 8601 date strings (from API responses) into human-readable format.

Implementation

package com.alcalist.tecmeli.core.util

import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale

object DateUtils {
    fun formatDate(dateString: String?): String {
        if (dateString.isNullOrBlank()) return "N/A"
        return try {
            val zonedDateTime = ZonedDateTime.parse(dateString)
            val formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm", Locale.getDefault())
            zonedDateTime.format(formatter)
        } catch (e: Exception) {
            dateString
        }
    }
}

Method: formatDate()

Formats an ISO 8601 date string to a localized readable format.
dateString
String?
ISO 8601 formatted date string (e.g., “2024-03-15T10:30:00Z”)
String
String
Formatted date string in “dd/MM/yyyy HH:mm” format, or “N/A” if null/blank, or original string if parsing fails

Behavior

  1. Null or blank input: Returns "N/A"
  2. Valid ISO 8601 date: Parses and formats to "dd/MM/yyyy HH:mm"
  3. Invalid format: Returns the original string (graceful degradation)

Usage Examples

// Basic usage
val apiDate = "2024-03-15T10:30:00Z"
val formatted = DateUtils.formatDate(apiDate)
println(formatted) // Output: "15/03/2024 10:30"

// Null handling
val nullDate: String? = null
val result = DateUtils.formatDate(nullDate)
println(result) // Output: "N/A"

// Invalid format handling
val invalidDate = "not-a-date"
val fallback = DateUtils.formatDate(invalidDate)
println(fallback) // Output: "not-a-date"

In Composable UI

@Composable
fun ProductCard(product: Product) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = product.title,
            style = MaterialTheme.typography.titleMedium
        )
        Text(
            text = "Listed: ${DateUtils.formatDate(product.dateCreated)}",
            style = MaterialTheme.typography.bodySmall,
            color = MaterialTheme.colorScheme.onSurfaceVariant
        )
    }
}

In Domain Mappers

class ProductMapper @Inject constructor() {
    
    fun toDomain(dto: ProductDto): Product {
        return Product(
            id = dto.id,
            title = dto.title,
            price = dto.price,
            dateCreated = DateUtils.formatDate(dto.dateCreated),
            lastUpdated = DateUtils.formatDate(dto.lastUpdated)
        )
    }
}

Customizing Date Format

To support different date formats, you can extend DateUtils:
object DateUtils {
    
    fun formatDate(dateString: String?): String {
        return formatDate(dateString, "dd/MM/yyyy HH:mm")
    }
    
    fun formatDate(dateString: String?, pattern: String): String {
        if (dateString.isNullOrBlank()) return "N/A"
        return try {
            val zonedDateTime = ZonedDateTime.parse(dateString)
            val formatter = DateTimeFormatter.ofPattern(pattern, Locale.getDefault())
            zonedDateTime.format(formatter)
        } catch (e: Exception) {
            dateString
        }
    }
    
    fun formatDateShort(dateString: String?): String {
        return formatDate(dateString, "dd/MM/yyyy")
    }
    
    fun formatDateTime(dateString: String?): String {
        return formatDate(dateString, "dd/MM/yyyy HH:mm:ss")
    }
}

Best Practices

Expose UI state as StateFlow<UiState<T>> from ViewModels to provide reactive, type-safe state management to your UI.
Always implement exhaustive when statements in Composables to handle all UiState variants (Loading, Success, Error, Empty).
Convert technical AppError messages to user-friendly strings when creating UiState.Error instances.
Always use DateUtils.formatDate() for date formatting to ensure consistency across the app.
DateUtils uses ZonedDateTime which preserves timezone information. Ensure your API returns ISO 8601 dates with timezone.

State Flow Diagram

┌─────────────┐
│   Initial   │
│    Empty    │
└──────┬──────┘

       │ User Action (search, load, etc.)

┌─────────────┐
│   Loading   │ ◄──────────┐
└──────┬──────┘            │
       │                   │
       │ API Call          │ Retry
       ▼                   │
┌─────────────┐            │
│   Result    │            │
└──────┬──────┘            │
       │                   │
       ├─── Success ────► ┌─────────────┐
       │                  │  Success<T> │
       │                  └─────────────┘

       ├─── Empty ──────► ┌─────────────┐
       │                  │    Empty    │
       │                  └─────────────┘

       └─── Failure ────► ┌─────────────┐
                          │    Error    │ ─────┘
                          └─────────────┘

Testing

Testing UiState Transformations

class ProductSearchViewModelTest {

    @Test
    fun `searchProducts emits Loading then Success`() = runTest {
        val mockRepository = mockk<ProductRepository>()
        val products = listOf(Product(id = "1", title = "Phone"))
        
        coEvery { mockRepository.searchProducts(any()) } returns Result.success(products)
        
        val viewModel = ProductSearchViewModel(mockRepository)
        val states = mutableListOf<UiState<List<Product>>>()
        
        viewModel.uiState.test {
            states.add(awaitItem()) // Initial Empty
            viewModel.searchProducts("phone")
            states.add(awaitItem()) // Loading
            states.add(awaitItem()) // Success
        }
        
        assertEquals(UiState.Empty, states[0])
        assertEquals(UiState.Loading, states[1])
        assertEquals(UiState.Success(products), states[2])
    }
}

Testing DateUtils

class DateUtilsTest {

    @Test
    fun `formatDate returns formatted date for valid ISO 8601 string`() {
        val isoDate = "2024-03-15T10:30:00Z"
        val result = DateUtils.formatDate(isoDate)
        assertEquals("15/03/2024 10:30", result)
    }

    @Test
    fun `formatDate returns N_A for null input`() {
        val result = DateUtils.formatDate(null)
        assertEquals("N/A", result)
    }

    @Test
    fun `formatDate returns original string for invalid format`() {
        val invalidDate = "invalid-date"
        val result = DateUtils.formatDate(invalidDate)
        assertEquals(invalidDate, result)
    }
}

See Also

Build docs developers (and LLMs) love