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
Indicates an asynchronous operation is in progress (e.g., network loading)
Represents successful completion with data of type T
Represents a failure with user-friendly message and optional exception
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
}
}
}
Formats an ISO 8601 date string to a localized readable format.
ISO 8601 formatted date string (e.g., “2024-03-15T10:30:00Z”)
Formatted date string in “dd/MM/yyyy HH:mm” format, or “N/A” if null/blank, or original string if parsing fails
Behavior
Null or blank input : Returns "N/A"
Valid ISO 8601 date : Parses and formats to "dd/MM/yyyy HH:mm"
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)
)
}
}
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
Always use UiState in ViewModels
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).
Provide user-friendly error messages
Convert technical AppError messages to user-friendly strings when creating UiState.Error instances.
Use DateUtils consistently
Always use DateUtils.formatDate() for date formatting to ensure consistency across the app.
Consider timezone handling
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
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