Overview
The DashboardViewModel manages the state for the main Dashboard screen, displaying key statistics, recent alerts, recent client activity, and device breakdowns. It follows the MVVM+MVI architectural pattern with optimized data loading to minimize API calls.
Source: viewmodel/DashboardViewModel.kt:21
class DashboardViewModel(
private val repository: DashboardRepository
) : ViewModel()
UI State
The ViewModel exposes a DashboardUiState that represents the complete dashboard state.
Source: viewmodel/DashboardUiState.kt:14
data class DashboardUiState(
// Loading states
val isLoading: Boolean = true,
val isRefreshing: Boolean = false,
// Error state
val error: String? = null,
// Data states
val statCards: List<StatCard> = emptyList(),
val menuItems: List<MenuItem> = emptyList(),
// Additional dashboard data
val dashboardStats: DashboardStats? = null,
val recentAlerts: List<RecentAlert> = emptyList(),
val recentClients: List<RecentClient> = emptyList(),
val deviceBreakdown: DeviceBreakdown = DeviceBreakdown(),
// Search state
val searchQuery: String = "",
// Navigation state
val selectedMenuId: String = "dashboard"
)
State Fields
Initial loading state for the dashboard
Pull-to-refresh loading state
Error message if dashboard data fails to load
List of statistical summary cards (clients, greenhouses, devices, alerts)
Navigation menu items for the sidebar
Comprehensive dashboard statistics from the API
Most recent alerts across all clients
Recently added or updated clients
Breakdown of devices by type (sensors vs actuators)
Current search query in the top bar
Currently selected sidebar menu item (default: “dashboard”)
Computed Properties
The DashboardUiState provides computed properties for common data access:
Returns true if statCards are available to display
Returns true if in error state with no content
Total user count from dashboard stats (default: 0)
Count of critical alerts (default: 0)
Events
All user interactions are handled through the DashboardEvent sealed interface.
Source: viewmodel/DashboardEvent.kt:7
sealed interface DashboardEvent {
/**
* Initial load of dashboard data.
*/
data object LoadDashboard : DashboardEvent
/**
* Pull-to-refresh or manual refresh.
*/
data object RefreshDashboard : DashboardEvent
/**
* User typed in the search field.
*/
data class OnSearchQueryChanged(val query: String) : DashboardEvent
/**
* User selected a menu item from sidebar.
*/
data class OnMenuItemSelected(val itemId: String) : DashboardEvent
/**
* Dismiss current error message.
*/
data object DismissError : DashboardEvent
}
Event Descriptions
LoadDashboard
- Triggered automatically on ViewModel initialization
- Loads all dashboard data including stats, alerts, and recent clients
RefreshDashboard
- User-initiated refresh (pull-to-refresh or manual)
- Reloads dashboard data while preserving current state
OnSearchQueryChanged
- Updates the top bar search query
- Does not automatically filter content (handled by UI layer)
OnMenuItemSelected
- Updates the selected menu item in the sidebar
- Typically triggers navigation in the UI layer
DismissError
- Clears the current error message
- Allows user to retry or continue using the app
Methods
onEvent
fun onEvent(event: DashboardEvent)
Single entry point for all user interactions following MVI pattern. Dispatches events to appropriate private handlers.
State Management
The ViewModel uses Kotlin StateFlow for reactive state management:
val uiState: StateFlow<DashboardUiState>
Exposed as an immutable StateFlow for UI consumption:
private val _uiState = MutableStateFlow(DashboardUiState())
val uiState: StateFlow<DashboardUiState> = _uiState.asStateFlow()
Data Loading Strategy
The ViewModel implements an optimized data loading strategy that minimizes API calls:
Initial Load
private fun loadDashboard() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
// Call getDashboardStats() ONCE - expensive operation
val dashboardStatsResult = repository.getDashboardStats()
// Cheap calls (static or cached)
val menuDeferred = async { repository.getMenuItems() }
val recentAlertsDeferred = async { repository.getRecentAlerts() }
val recentClientsDeferred = async { repository.getRecentClients() }
val menuResult = menuDeferred.await()
val recentAlertsResult = recentAlertsDeferred.await()
val recentClientsResult = recentClientsDeferred.await()
val dashboardStats = dashboardStatsResult.getOrNull()
_uiState.update { currentState ->
currentState.copy(
isLoading = false,
statCards = dashboardStats?.toStatCards() ?: emptyList(),
menuItems = menuResult.getOrDefault(emptyList()),
dashboardStats = dashboardStats,
recentAlerts = recentAlertsResult.getOrDefault(emptyList()),
recentClients = recentClientsResult.getOrDefault(emptyList()),
deviceBreakdown = dashboardStats?.let {
DeviceBreakdown(
sensors = it.sensorCount,
actuators = it.actuatorCount
)
} ?: DeviceBreakdown(),
error = dashboardStatsResult.exceptionOrNull()?.message
)
}
}
}
StatCard Derivation
Instead of making a separate API call, StatCard data is derived from DashboardStats:
private fun DashboardStats.toStatCards(): List<StatCard> = listOf(
StatCard(
id = "clients",
title = "Total Clients",
value = totalClients.toString(),
subtitle = "$activeClients activos",
subtitleColor = StatCardSubtitleColor.SUCCESS,
icon = StatCardIcon.PEOPLE
),
StatCard(
id = "greenhouses",
title = "Total Greenhouses",
value = totalGreenhouses.toString(),
subtitle = if (totalGreenhouses > 0) {
val percentage = (activeGreenhouses * 100) / totalGreenhouses
"$percentage% Active"
} else "0% Active",
subtitleColor = StatCardSubtitleColor.SUCCESS,
icon = StatCardIcon.GREENHOUSE
),
StatCard(
id = "devices",
title = "Active Devices",
value = totalDevices.toString(),
subtitle = "$sensorCount sensors, $actuatorCount actuators",
subtitleColor = StatCardSubtitleColor.SUCCESS,
icon = StatCardIcon.DEVICES
),
StatCard(
id = "alerts",
title = "Active Alerts",
value = activeAlerts.toString(),
subtitle = if (criticalAlerts > 0) {
"$criticalAlerts critical"
} else "No critical alerts",
subtitleColor = if (criticalAlerts > 0) {
StatCardSubtitleColor.WARNING
} else StatCardSubtitleColor.SUCCESS,
icon = StatCardIcon.ALERT
)
)
Usage Example
import org.koin.compose.viewmodel.koinViewModel
import androidx.compose.runtime.collectAsState
@Composable
fun DashboardScreen() {
val viewModel: DashboardViewModel = koinViewModel()
val state by viewModel.uiState.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Dashboard") },
actions = {
SearchBar(
query = state.searchQuery,
onQueryChange = {
viewModel.onEvent(
DashboardEvent.OnSearchQueryChanged(it)
)
}
)
}
)
}
) { padding ->
when {
state.isLoading -> LoadingIndicator()
state.isError -> ErrorDisplay(
message = state.error,
onDismiss = {
viewModel.onEvent(DashboardEvent.DismissError)
}
)
else -> {
val pullRefreshState = rememberPullRefreshState(
refreshing = state.isRefreshing,
onRefresh = {
viewModel.onEvent(DashboardEvent.RefreshDashboard)
}
)
Box(Modifier.pullRefresh(pullRefreshState)) {
LazyColumn(modifier = Modifier.padding(padding)) {
// Stat cards section
item {
StatCardsGrid(
statCards = state.statCards,
modifier = Modifier.padding(16.dp)
)
}
// Device breakdown
item {
DeviceBreakdownCard(
breakdown = state.deviceBreakdown,
modifier = Modifier.padding(16.dp)
)
}
// Recent alerts section
item {
Text(
"Recent Alerts",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(16.dp)
)
}
items(state.recentAlerts) { alert ->
AlertItem(
alert = alert,
modifier = Modifier.padding(horizontal = 16.dp)
)
}
// Recent clients section
item {
Text(
"Recent Clients",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(16.dp)
)
}
items(state.recentClients) { client ->
RecentClientItem(
client = client,
modifier = Modifier.padding(horizontal = 16.dp)
)
}
}
PullRefreshIndicator(
refreshing = state.isRefreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter)
)
}
}
}
}
}
State Flow Pattern
Initialization
The ViewModel automatically loads dashboard data on initialization:
init {
onEvent(DashboardEvent.LoadDashboard)
}
Refresh Pattern
Refresh preserves existing data while loading:
private fun refreshDashboard() {
viewModelScope.launch {
_uiState.update { it.copy(isRefreshing = true) }
// Load fresh data
val result = repository.getDashboardStats()
_uiState.update { currentState ->
currentState.copy(
isRefreshing = false,
// Update with fresh data or preserve existing on failure
dashboardStats = result.getOrNull() ?: currentState.dashboardStats,
error = result.exceptionOrNull()?.message
)
}
}
}
Error Handling
Errors are captured and exposed through the state:
repository.getDashboardStats()
.onSuccess { stats ->
_uiState.update { it.copy(dashboardStats = stats, error = null) }
}
.onFailure { error ->
_uiState.update { it.copy(error = error.message) }
}
Dependency Injection
The ViewModel is provided via Koin:
// In di/PresentationModule.kt
val presentationModule = module {
viewModelOf(::DashboardViewModel)
}
Key optimizations implemented:
- Single API Call -
getDashboardStats() is called once, not multiple times
- Data Derivation -
StatCard list is derived from stats instead of separate API call
- Parallel Loading - Menu items, alerts, and clients are loaded in parallel using
async
- Cached Data - Repository can cache frequently accessed data like recent alerts
DashboardStats - Comprehensive statistics data model
StatCard - Individual stat card display model
StatCardIcon - Icon types for stat cards
StatCardSubtitleColor - Subtitle color variants
MenuItem - Sidebar menu item model
RecentAlert - Recent alert summary model
RecentClient - Recent client summary model
DeviceBreakdown - Device breakdown by type (sensors/actuators)
DashboardRepository - Repository interface for dashboard data