Mobile Dashboard Integration
Guide to the Android dashboard implementation using Clean Architecture and MVI — unified data source, widget aggregation, and card state management.
Overview
Successfully migrated the mobile app (Android) to use a unified dashboard data source, following Clean Architecture principles and MVI (Model-View-Intent) pattern, consistent with mobile best practices.
Architecture
Clean Architecture layers for the Android dashboard — ViewModel injects DashboardUseCases, never the repository or service directly.
✅ What Was Created
0. Clean Architecture Layers
Following the established pattern in the mobile app (similar to tasks module), the home feature is structured in three layers:
Data Layer (home_data)
Location: /home/home_data/src/main/java/.../
- DashboardApi.kt: Retrofit interface for API endpoints
- DashboardDataSource.kt: Interface defining data operations
- DashboardRemoteDataSource.kt: Implementation fetching from server API
- DashboardRepositoryImpl.kt: Repository implementation (implements domain interface)
- HomeDataModule.kt: Dagger module providing data layer dependencies
@Module
@InstallIn(SingletonComponent::class)
object HomeDataModule {
@Provides
fun provideDashboardApi(client: OkHttpClient): DashboardApi
@Provides
fun provideDashboardRepository(
@Remote remoteDataSource: DashboardDataSource,
networkHelper: NetworkHelper
): DashboardRepository
}Domain Layer (home_domain)
Location: /home/home_domain/src/main/java/.../
- DashboardRepository.kt: Repository interface (no implementation details)
- GetDashboard.kt: Use case for fetching dashboard data
- RefreshDashboard.kt: Use case for refreshing dashboard
- DashboardUseCases.kt: Wrapper bundling all use cases
- HomeDomainModule.kt: Dagger module providing use cases
@Module
@InstallIn(ViewModelComponent::class)
object HomeDomainModule {
@Provides
fun provideDashboardUseCases(
repository: DashboardRepository
): DashboardUseCases
}Presentation Layer (home_presentation)
Location: /home/home_presentation/src/main/java/.../
- DashboardIntent.kt: User actions (MVI Intent)
- DashboardState.kt: UI state (MVI State)
- DashboardEffect.kt: Side effects like navigation (MVI Effect)
- DashboardViewModel.kt: ViewModel using use cases (NOT repository or service)
Key Principle: ViewModel injects DashboardUseCases, NOT DashboardService or DashboardRepository
1. MVI Layer (Intent, State, Effect)
Location: /home/home_presentation/src/main/java/.../intent/
DashboardIntent.kt
sealed class DashboardIntent : UserIntent {
data object FetchDashboard
data object RefreshDashboard
data class MarkTaskDone(val task: Task)
data class MarkHabitDone(val habit: Habit)
data object NavigateToTasks
data object NavigateToHabits
data object NavigateToWorkout
// ... and more
}DashboardState.kt
data class DashboardState(
val isLoading: Boolean,
val nextTask: Task?,
val taskStats: TaskStats?,
val overdueTasks: List<Task>,
val nextHabit: Habit?,
val habitStats: HabitStats?,
val overdueHabits: List<Habit>,
val todayCalories: Int,
val todayWorkout: WorkoutDTO?,
// ... 40+ dashboard fields
) : ViewStateDashboardEffect.kt
sealed class DashboardEffect : Effect {
data object NavigateToTasks
data object NavigateToHabits
data class ShowSnackBar(message: String, type: SnackbarType)
// ... navigation effects
}2. ViewModel with MVI Pattern
Location: /home/home_presentation/src/main/java/.../viewmodel/DashboardViewModel.kt
@HiltViewModel
class DashboardViewModel @Inject constructor(
private val dashboardUseCases: DashboardUseCases,
private val tasksUseCases: TasksUseCases,
private val habitUseCases: HabitUseCases
) : BaseViewModel<DashboardIntent, DashboardState, DashboardEffect>()Key Features:
- Follows existing MVI pattern used in mobile app
- Extends
BaseViewModel<Intent, State, Effect> - Injects DashboardUseCases (Clean Architecture - domain layer)
- Uses
emitState {}for state updates - Uses
sendEffect {}for navigation - Automatic dashboard fetch on initialization
- Pull-to-refresh support
- Mark tasks/habits as done with optimistic UI updates
Example: Fetching Dashboard Data
private fun fetchDashboard() {
viewModelScope.launch {
emitState { copy(isLoading = true, error = null) }
val result = dashboardUseCases.getDashboard()
result.onSuccess { response ->
emitState { DashboardState.fromDashboardResponse(response) }
}
result.onFailure { e ->
emitState {
copy(isLoading = false, error = e.message)
}
sendErrorEffect(UiText.DynamicString(e.message ?: "Failed to load"))
}
}
}Notice: Use cases return Result<T>, enabling clean error handling with onSuccess/onFailure.
3. Dependency Injection (Clean Architecture)
The dependency injection follows Clean Architecture principles with clear separation:
HomeDataModule (Data → Domain)
Location: /home/home_data/src/main/java/.../di/HomeDataModule.kt
Provides data layer dependencies and binds repository implementation to domain interface:
@Module
@InstallIn(SingletonComponent::class)
object HomeDataModule {
@Provides
@Singleton
fun provideDashboardApi(client: OkHttpClient): DashboardApi
@Provides
@Singleton
fun provideDashboardRepository(
@Remote remoteDataSource: DashboardDataSource,
networkHelper: NetworkHelper
): DashboardRepository // Returns domain interface
}HomeDomainModule (Domain → Presentation)
Location: /home/home_domain/src/main/java/.../di/HomeDomainModule.kt
Provides use cases to ViewModels:
@Module
@InstallIn(ViewModelComponent::class)
object HomeDomainModule {
@ViewModelScoped
@Provides
fun provideDashboardUseCases(
repository: DashboardRepository
): DashboardUseCases // Injected into ViewModels
}Important: Presentation layer has NO DI module. ViewModels directly inject use cases from domain layer.
📋 How to Integrate into HomeScreen
Step 1: Add Dashboard ViewModel to HomeScreen
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun HomeScreen(
habitViewModel: HabitViewModel = hiltViewModel(),
taskViewModel: TaskViewModel = hiltViewModel(),
workoutViewModel: WorkoutDetailViewModel = hiltViewModel(),
dashboardViewModel: DashboardViewModel = hiltViewModel(), // ← ADD THIS
homeViewModel: HomeViewModel = hiltViewModel(),
onGoToTasks: () -> Unit,
onGoToWorkout: () -> Unit,
onCurrentHabitClick: (Habit?) -> Unit,
onLogout: () -> Unit = {}
) {
// Collect dashboard state
val dashboardState by dashboardViewModel.viewState.collectAsState()
// Collect dashboard effects for navigation
LaunchedEffect(Unit) {
dashboardViewModel.effect.collect { effect ->
when (effect) {
is DashboardEffect.NavigateToTasks -> onGoToTasks()
is DashboardEffect.NavigateToHabits -> { /* navigate */ }
is DashboardEffect.NavigateToWorkout -> onGoToWorkout()
// Handle other effects
else -> {}
}
}
}
// ... rest of HomeScreen
}Step 2: Update Pull-to-Refresh
Replace the manual refresh with dashboard refresh:
val pullRefreshState = rememberPullRefreshState(
refreshing = dashboardState.isRefreshing,
onRefresh = {
dashboardViewModel.performAction(DashboardIntent.RefreshDashboard)
}
)Step 3: Use Dashboard Data Instead of Separate ViewModels
Before (separate calls):
val habitState = habitViewModel.viewState.collectAsState()
val taskState = taskViewModel.viewState.collectAsState()
val workoutState = workoutViewModel.viewState.collectAsState()After (unified dashboard):
val dashboardState by dashboardViewModel.viewState.collectAsState()
// Access data from dashboard state
val nextTask = dashboardState.nextTask
val overdueTasks = dashboardState.overdueTasks
val nextHabit = dashboardState.nextHabit
val overdueHabits = dashboardState.overdueHabits
val todayWorkout = dashboardState.todayWorkoutStep 4: Update Mark As Done Actions
// For tasks
onClick = {
dashboardViewModel.performAction(
DashboardIntent.MarkTaskDone(task)
)
}
// For habits
onClick = {
dashboardViewModel.performAction(
DashboardIntent.MarkHabitDone(habit)
)
}Step 5: Update Initial Data Fetch
Remove the separate fetch calls:
// DELETE THESE:
LaunchedEffect(Unit) {
habitViewModel.performAction(HabitIntent.FetchHabits())
taskViewModel.performAction(TaskIntent.FetchTasks())
workoutViewModel.performAction(WorkoutIntent.FetchWorkoutByDay(...))
}Dashboard ViewModel fetches everything automatically on initialization!
📊 Available Dashboard Data
The DashboardState provides all dashboard data from the unified API:
Tasks
nextTask: Task?- Next upcoming tasktaskStats: TaskStats?- Total, completed, high priority countsoverdueTasks: List<Task>- All overdue tasksweeklyTaskCompletion: Float- Completion rate (0.0-1.0)tasksCompletedPerDay: List<Int>- Last 7 days
Habits
nextHabit: Habit?- Next scheduled habithabitStats: HabitStats?- Total, completed, current streakoverdueHabits: List<Habit>- All overdue habitsweeklyHabitCompletion: Float- Completion ratehabitsCompletedPerDay: List<Int>- Last 7 days
Workout
todayWorkout: WorkoutDTO?- Today’s workoutcaloriesBurned: Int- Total calories burnedworkoutStreak: Int- Current streakweeklyWorkoutCompletion: Float- Completion rateworkoutsCompletedPerDay: List<Int>- Last 7 days
Nutrition
todayCalories: Int- Total calories todaymealsLogged: Int- Number of meals loggednextMeal: MealDTO?- Next scheduled mealplannedMeals: Int- Planned meals todayunexpectedMeals: Int- Unplanned meals todayweeklyMealLogging: Float- Logging ratemealsLoggedPerDay: List<Int>- Last 7 days
Finance
recentTransactions: List<TransactionDTO>- Latest transactionsaccountBalance: Double- Current balance
Journal
journalCompleted: Boolean- Today’s journal statusjournalStreak: Int- Current streakrecentJournalEntries: List<JournalEntryDTO>- Recent entries
🎨 UI Components
You can continue using existing mobile components:
SharedSectionCard- For dashboard sectionsSharedTaskCard- For individual tasksSharedWelcomeCard- For welcome sectionOterDesignSystem- For consistent styling
Example: Display Overdue Tasks
SharedSectionCard(
title = "Overdue Tasks",
subtitle = "${dashboardState.overdueTasks.size} tasks need attention",
iconColor = OterDesignSystem.colors.Error,
onHeaderClick = {
dashboardViewModel.performAction(DashboardIntent.NavigateToTasks)
},
iconContent = {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = Color.White
)
}
) {
dashboardState.overdueTasks.take(3).forEach { task ->
SharedTaskCard(
task = task,
onTaskClick = { /* handle click */ },
onMarkDone = {
dashboardViewModel.performAction(
DashboardIntent.MarkTaskDone(task)
)
}
)
}
}🔄 Migration Benefits
- Single API Call: One
/api/v1/dashboardcall instead of multiple - Consistent Data: Server calculates everything (next task, overdue, stats)
- Clean Architecture: Clear separation of concerns (Data → Domain → Presentation)
- MVI Compliance: Follows established mobile architecture pattern
- Type Safety: Full Kotlin type safety with data classes
- Automatic Refresh: Dashboard auto-fetches on init
- Optimistic Updates: Instant UI feedback for mark done actions
- Testability: Each layer can be tested independently
- Maintainability: Domain layer is platform-agnostic and reusable
🧪 Testing
Each layer can be tested independently following Clean Architecture principles:
Testing Use Cases (Domain Layer)
@Test
fun `GetDashboard returns success when repository succeeds`() = runTest {
// Given
val mockRepository = mock<DashboardRepository>()
val expectedResponse = DashboardResponse(...)
whenever(mockRepository.getDashboardData()).thenReturn(Result.success(expectedResponse))
val useCase = GetDashboard(mockRepository)
// When
val result = useCase()
// Then
assertTrue(result.isSuccess)
assertEquals(expectedResponse, result.getOrNull())
}Testing ViewModel (Presentation Layer)
@Test
fun `fetch dashboard updates state correctly`() = runTest {
// Given
val mockUseCases = mock<DashboardUseCases>()
val mockGetDashboard = mock<GetDashboard>()
whenever(mockUseCases.getDashboard).thenReturn(mockGetDashboard)
whenever(mockGetDashboard()).thenReturn(Result.success(DashboardResponse(...)))
val viewModel = DashboardViewModel(mockUseCases, ...)
// When
viewModel.performAction(DashboardIntent.FetchDashboard)
// Then
verify(mockGetDashboard).invoke()
assertEquals(false, viewModel.currentState.isLoading)
}Testing Repository (Data Layer)
@Test
fun `DashboardRepository returns data from remote source`() = runTest {
// Given
val mockDataSource = mock<DashboardDataSource>()
val mockNetworkHelper = mock<NetworkHelper>()
whenever(mockNetworkHelper.isNetworkAvailable()).thenReturn(true)
val repository = DashboardRepositoryImpl(mockDataSource, mockNetworkHelper)
// When
val result = repository.getDashboardData()
// Then
verify(mockDataSource).getDashboardData()
assertTrue(result.isSuccess)
}📝 Next Steps
- Update HomeScreen.kt to use DashboardViewModel
- Remove old ViewModels (HabitViewModel, TaskViewModel, WorkoutViewModel from HomeScreen)
- Test pull-to-refresh functionality
- Test mark as done actions
- Verify navigation effects work correctly
- Add error handling UI for failed dashboard loads
🚀 Performance Notes
- Dashboard data is cached by the server
- Mobile app makes 1 API call vs 3+ previously
- Reduced network usage and battery consumption
- Faster initial load time
🔗 Related Files
Mobile Implementation (Clean Architecture)
- Domain Layer:
/home/home_domain/src/main/java/.../- DashboardRepository.kt (interface)
- GetDashboard.kt, RefreshDashboard.kt (use cases)
- HomeDomainModule.kt (DI)
- Data Layer:
/home/home_data/src/main/java/.../- DashboardApi.kt (Retrofit)
- DashboardRepositoryImpl.kt (implementation)
- HomeDataModule.kt (DI)
- Presentation Layer:
/home/home_presentation/src/main/java/.../- DashboardIntent.kt, DashboardState.kt, DashboardEffect.kt (MVI)
- DashboardViewModel.kt
Shared/Server
- Shared Models:
/shared/src/commonMain/.../models/dashboard/DashboardModels.kt - Server Endpoint:
/server/.../routing/DashboardRouting.kt - Server Service:
/server/.../service/DashboardService.kt
Other Platforms
- Desktop Implementation:
/composeApp/src/desktopMain/.../DashboardScreen.kt
Documentation
- API Documentation:
/docs/agents/dashboard-api-reference.md - This Guide:
docs/features/dashboard/mobile-integration.md
Status: ✅ Ready for integration Architecture: Clean Architecture + MVI (Model-View-Intent) Platform: Android Pattern: Data → Domain → Presentation Testing: Unit tests can be added for each layer