How to Add a New Screen
Step-by-step guide to creating a new UI screen in Archery Apprentice.
Overview
This guide covers creating a complete new screen with:
- Jetpack Compose UI
- ViewModel for state management
- Navigation integration
- Repository/Service integration
Time: ~30-60 minutes Difficulty: Intermediate
Step 1: Create the ViewModel
// File: ui/viewmodels/MyFeatureViewModel.kt
package com.archeryapprentice.ui.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class MyFeatureUiState(
val data: List<Item> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
class MyFeatureViewModel(
private val repository: MyRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(MyFeatureUiState())
val uiState: StateFlow<MyFeatureUiState> = _uiState.asStateFlow()
init {
loadData()
}
fun loadData() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
repository.getData()
.onSuccess { data ->
_uiState.update { it.copy(data = data, isLoading = false) }
}
.onFailure { error ->
_uiState.update {
it.copy(error = error.message, isLoading = false)
}
}
}
}
fun onAction(action: MyAction) {
// Handle user actions
}
}Step 2: Create the Composable Screen
// File: ui/screens/MyFeatureScreen.kt
package com.archeryapprentice.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun MyFeatureScreen(
onNavigateBack: () -> Unit,
viewModel: MyFeatureViewModel = viewModel()
) {
val state by viewModel.uiState.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("My Feature") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, "Back")
}
}
)
}
) { padding ->
Box(modifier = Modifier.padding(padding)) {
when {
state.isLoading -> {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
state.error != null -> {
ErrorView(
error = state.error!!,
onRetry = { viewModel.loadData() }
)
}
else -> {
ContentView(
data = state.data,
onAction = { viewModel.onAction(it) }
)
}
}
}
}
}
@Composable
private fun ContentView(
data: List<Item>,
onAction: (MyAction) -> Unit
) {
LazyColumn {
items(data) { item ->
ItemCard(
item = item,
onClick = { onAction(MyAction.ItemClicked(item.id)) }
)
}
}
}Step 3: Add Navigation Route
// File: navigation/NavGraph.kt
sealed class Screen(val route: String) {
object Home : Screen("home")
object MyFeature : Screen("my_feature") // Add new route
}
@Composable
fun NavGraph(
navController: NavHostController
) {
NavHost(
navController = navController,
startDestination = Screen.Home.route
) {
composable(Screen.Home.route) {
HomeScreen(
onNavigateToMyFeature = {
navController.navigate(Screen.MyFeature.route)
}
)
}
// Add new screen
composable(Screen.MyFeature.route) {
MyFeatureScreen(
onNavigateBack = {
navController.navigateUp()
}
)
}
}
}Step 4: Create ViewModel Factory (if needed)
// File: ui/viewmodels/MyFeatureViewModelFactory.kt
class MyFeatureViewModelFactory(
private val repository: MyRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(MyFeatureViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return MyFeatureViewModel(repository) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
// Usage in Composable
@Composable
fun MyFeatureScreen(
repository: MyRepository = LocalRepository.current,
viewModel: MyFeatureViewModel = viewModel(
factory = MyFeatureViewModelFactory(repository)
)
) {
// ...
}Step 5: Add Tests
ViewModel Test
// File: test/ui/viewmodels/MyFeatureViewModelTest.kt
class MyFeatureViewModelTest {
private lateinit var viewModel: MyFeatureViewModel
private lateinit var mockRepository: MyRepository
@Before
fun setup() {
mockRepository = mockk()
viewModel = MyFeatureViewModel(mockRepository)
}
@Test
fun `loadData updates state with success`() = runTest {
// Arrange
val testData = listOf(Item(1, "Test"))
coEvery { mockRepository.getData() } returns Result.success(testData)
// Act
viewModel.loadData()
advanceUntilIdle()
// Assert
val state = viewModel.uiState.value
assertEquals(testData, state.data)
assertFalse(state.isLoading)
assertNull(state.error)
}
@Test
fun `loadData updates state with error`() = runTest {
// Arrange
val error = Exception("Test error")
coEvery { mockRepository.getData() } returns Result.failure(error)
// Act
viewModel.loadData()
advanceUntilIdle()
// Assert
val state = viewModel.uiState.value
assertEquals("Test error", state.error)
assertFalse(state.isLoading)
}
}UI Test
// File: androidTest/ui/screens/MyFeatureScreenTest.kt
@RunWith(AndroidJUnit4::class)
class MyFeatureScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun displaysLoadingState() {
val viewModel = MyFeatureViewModel(mockRepository)
// Set loading state
viewModel._uiState.value = MyFeatureUiState(isLoading = true)
composeTestRule.setContent {
MyFeatureScreen(
onNavigateBack = {},
viewModel = viewModel
)
}
composeTestRule
.onNodeWithContentDescription("Loading")
.assertIsDisplayed()
}
}Step 6: Update Navigation Links
Add navigation from existing screens:
// In HomeScreen.kt or wherever you want to navigate from
Button(
onClick = {
navController.navigate(Screen.MyFeature.route)
}
) {
Text("Go to My Feature")
}Complete Example
Here’s a complete minimal example:
// MyFeatureScreen.kt
@Composable
fun MyFeatureScreen(
onNavigateBack: () -> Unit,
viewModel: MyFeatureViewModel = viewModel()
) {
val state by viewModel.uiState.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("My Feature") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, "Back")
}
}
)
}
) { padding ->
Column(modifier = Modifier.padding(padding)) {
if (state.isLoading) {
CircularProgressIndicator()
} else {
LazyColumn {
items(state.data) { item ->
Text(item.name)
}
}
}
}
}
}Best Practices
- Separate Concerns: Keep UI, ViewModel, and business logic separate
- State Management: Use StateFlow for reactive UI updates
- Error Handling: Always handle loading and error states
- Testing: Write tests for ViewModel logic
- Navigation: Use type-safe navigation arguments
- Accessibility: Add content descriptions for screen readers
Common Issues
Issue: ViewModel survives configuration changes
Solution: ViewModels automatically survive rotations. Don’t recreate them manually.
Issue: State not updating in UI
Solution: Make sure you’re collecting StateFlow with collectAsState()
Issue: Memory leaks
Solution: Use viewModelScope for coroutines, they’re automatically cancelled
Related Documentation
Last Updated: 2025-11-01