Teaching / Mobile Programming 2

HTML Manuscript

Continuation of the mobile programming course covering navigation, application architecture, local databases, external data services, Android Jetpack components and dependency injection.

HTML Manuscript

W01 - Course organization and Compose navigation

Basic course information

Course organization

The course covers a total of 75 hours of student workload, including 15 hours of lectures (one 45-minute lecture per week) and 30 hours of laboratory classes. An additional 30 hours are assigned to independent student work, which is required for self-study, project preparation and extending the material covered in class. The course is worth 3 ECTS credits.

During the course, teaching materials available at https://github.com/RafLew84/ProgUM are used. Required and recommended references:

Assessment rules

  • The laboratory part is passed by obtaining a positive grade from the assignment lists.
  • There are 6 assignment lists planned for the course.
  • Each list receives a separate grade.
  • It is not necessary to pass all lists to obtain a positive laboratory grade. It is allowed to miss or fail one list; this list receives a grade of 2.0.
  • Each list specifies the number of points required for a given grade.
  • Each list has a submission deadline.
  • For each week of delay, the received grade is reduced by 1.0.
  • Lists are submitted during laboratory classes.
  • For each list, the instructor asks 4 questions.
  • Points for a list are awarded based on the correctness of task implementation and the oral answer.
  • The final grade is the arithmetic mean of all grades from the lists.
  • A grade of 3.0 requires an average of at least 3.0.
  • During laboratory classes, three unexcused absences are allowed.

Course topics

  1. Assessment rules, course topics, advanced navigation.
  2. Introduction to multithreading: coroutines. The main thread.
  3. Coroutines. Concurrency, parallelism and asynchronicity.
  4. Fundamentals of application architecture: MVx patterns (MVC, MVP, MVVM).
  5. Reactive state management: Flow, StateFlow, SharedFlow.
  6. Advanced state management: withContext, StateIn, ShareIn, FlowOn, combine.
  7. Coroutines: channels - asynchronous data exchange between coroutines.
  8. Saving data to a file: SharedPreferences, DataStore.
  9. SQLite database + ROOM: Entity, Dao, Database, CRUD, asynchronous operations.
  10. Working with external data sources: Retrofit2, asynchronous operations.
  11. Dependency injection: Dagger, Hilt.
  12. Clean architecture - domain layer and the Use Case pattern.
  13. Single Source of Truth pattern - offline caching strategy.
  14. Cloud backend: introduction to Firebase and Firestore.

Android Studio - Starting and configuring a project - Review

During the course, we create projects in Android Studio, in Kotlin, with the interface built in Jetpack Compose (without XML views). The simplest way to start is the wizard: New Project -> Empty Activity. Set Language: Kotlin, Minimum SDK: 28+ and accept the settings. This template creates a ready project with a correct Compose configuration and an example setContent { ... } block.

NOTE!!! Do not use Empty Views Activity - it starts a project based on views, where the UI is defined in XML files.

Adding dependencies

All dependencies are added in the dependencies { ... } block of the build.gradle(.kts)(Module:App) file. Example file:

NOTE!!!! DO NOT COPY CONFIGURATION FILES - they contain information UNIQUE TO YOUR PROJECT!!!!

plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.compose)
}

android {
    namespace = "com.example.test"
    compileSdk = 36

    defaultConfig {
        applicationId = "com.example.test"
        minSdk = 28
        targetSdk = 36
        versionCode = 1
        versionName = "1.0"

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }
    kotlinOptions {
        jvmTarget = "11"
    }
    buildFeatures {
        compose = true
    }
}

dependencies {
    // add dependencies here

    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.lifecycle.runtime.ktx)
    implementation(libs.androidx.activity.compose)
    implementation(platform(libs.androidx.compose.bom))
    implementation(libs.androidx.ui)
    implementation(libs.androidx.ui.graphics)
    implementation(libs.androidx.ui.tooling.preview)
    implementation(libs.androidx.material3)
    testImplementation(libs.junit)
    androidTestImplementation(libs.androidx.junit)
    androidTestImplementation(libs.androidx.espresso.core)
    androidTestImplementation(platform(libs.androidx.compose.bom))
    androidTestImplementation(libs.androidx.ui.test.junit4)
    debugImplementation(libs.androidx.ui.tooling)
    debugImplementation(libs.androidx.ui.test.manifest)
}

NOTE!!! Remember to run project synchronization (Fig. 1.1) after any change in files from the gradle directory.

Program window showing the configuration file. Program window showing the configuration file. Program window showing the configuration file. Program window showing the configuration file.
Fig. 1.1. Program window showing the configuration file.

Navigation drawer

In this part, we focus on the practical implementation of navigation in a Jetpack Compose application. Assuming familiarity with the basic building blocks, namely NavHost and NavController, we will look at how to integrate them with advanced Material 3 components such as the navigation drawer.

Let us analyze code that implements one of the most common navigation patterns in mobile applications: the navigation drawer, also known as the hamburger menu (Fig. 1.2).

Navigation drawer. Navigation drawer. Navigation drawer. Navigation drawer.
Fig. 1.2. Navigation drawer.

The attached code is a complete, although minimal, application demonstrating how to combine Jetpack Navigation, Scaffold and ModalNavigationDrawer. Let us go through its key elements step by step.

To work with compose navigation, we need to add the appropriate dependency to the project.

dependencies {
    implementation("androidx.navigation:navigation-compose:2.9.2")
}

The first element is the centralization of navigation routes in the data object AppDestinations object.

data object AppDestinations {
    const val HOME = "home"
    const val PROFILE = "profile"
    const val SETTINGS = "settings"
}

Centralization provides type safety and makes refactoring easier, because we change a route name in one place.

In the application, we nest several components. The hierarchy is as follows:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainApp() {
    val navController = rememberNavController()
    val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
    val scope = rememberCoroutineScope()

    ModalNavigationDrawer(
        drawerState = drawerState,
        drawerContent = {
            DrawerContent(navController = navController, drawerState = drawerState)
        }
    ) {
        Scaffold(
            topBar = {
                TopAppBar(
                    title = { Text("App with a drawer") },
                    navigationIcon = {
                        IconButton(onClick = {
                            scope.launch { drawerState.apply { if (isClosed) open() else close() } }
                        }) { Icon(Icons.Filled.Menu, contentDescription = "Menu") }
                    }
                )
            }
        ) { paddingValues ->
            NavHost(
                navController = navController,
                startDestination = AppDestinations.HOME,
                modifier = Modifier.padding(paddingValues)
            ) {
                composable(AppDestinations.HOME) { HomeScreen() }
                composable(AppDestinations.PROFILE) { ProfileScreen() }
                composable(AppDestinations.SETTINGS) { SettingsScreen() }
            }
        }
    }
}
  1. ModalNavigationDrawer: This is the top-level component that manages the logic of showing and hiding the sliding drawer. It accepts two key parameters:
    • drawerState: the drawer state (open/closed).
    • drawerContent: a composable function defining the content of the drawer itself (DrawerContent).
  2. Scaffold: Placed inside ModalNavigationDrawer, Scaffold provides the standard screen structure. In this case, we use it to define the topBar (the top app bar).
  3. NavHost: Finally, inside Scaffold, we place our NavHost. It manages the actual replacement of screen content (HomeScreen, ProfileScreen, etc.). Note the key connection: modifier = Modifier.padding(paddingValues). We pass the padding (paddingValues) obtained from Scaffold, which ensures that our content is not covered by the TopAppBar.

In the MainApp function, we can see three key state variables.

val navController = rememberNavController()
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()

The first two are straightforward: one manages the navigation stack, while the other manages the drawer state. But why do we need scope?

The answer lies in the nature of drawerState. The methods drawerState.open() and drawerState.close() are suspend functions; more about such functions will appear in later chapters. They cannot simply be called from any place; they must run inside a coroutine. Changing drawerState automatically causes recomposition and visually opens the drawer.

{scope.launch { drawerState.apply { if (isClosed) open() else close() } }})

Let us move to navigation management from inside the drawer. In the MainApp function, we added ModalNavigationDrawer, which has the drawerContent parameter. We pass the DrawerContent function as this parameter. Let us inspect its body:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DrawerContent(navController: NavController, drawerState: DrawerState) {
    val scope = rememberCoroutineScope()

    ModalDrawerSheet {
        Column(modifier = Modifier.padding(16.dp)) {
            Text("Menu", style = MaterialTheme.typography.headlineSmall)
            Spacer(modifier = Modifier.height(16.dp))

            NavigationDrawerItem(
                icon = { Icon(Icons.Default.Home, contentDescription = "Home screen") },
                label = { Text("Home screen") },
                selected = false,
                onClick = {
                    navController.navigate(AppDestinations.HOME)
                    scope.launch { drawerState.close() }
                }
            )
            NavigationDrawerItem(
                icon = { Icon(Icons.Default.Person, contentDescription = "Profile") },
                label = { Text("Profile") },
                selected = false,
                onClick = {
                    navController.navigate(AppDestinations.PROFILE)
                    scope.launch { drawerState.close() }
                }
            )
            NavigationDrawerItem(
                icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
                label = { Text("Settings") },
                selected = false,
                onClick = {
                    navController.navigate(AppDestinations.SETTINGS)
                    scope.launch { drawerState.close() }
                }
            )
        }
    }
}

Inside ModalDrawerSheet, we define the elements displayed in the drawer as NavigationDrawerItem. This component follows the same slot-based layout pattern. It defines slots where elements can be inserted (icon, label, divider), which simplifies building the whole layout. After clicking an item in the drawer, we perform two actions:

  • Navigate: We call navController.navigate() to change the content of NavHost.
  • Close the drawer: We launch a coroutine (scope.launch) to call drawerState.close().

As we can see, navigation in Jetpack Compose is much more than simply calling navController.navigate(). It is a deliberate integration of the navigation controller with other UI components, such as ModalNavigationDrawer. An important element is understanding how to manage multiple states (navController, drawerState) and how to handle asynchronous calls (the suspend functions used to open and close the drawer) with the appropriate tools, such as rememberCoroutineScope.

In the next lectures, we will go deeper into coroutines, which are the foundation of this example.

Full example code

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            MyApplicationTheme {
                MainApp()
            }
        }
    }
}

data object AppDestinations {
    const val HOME = "home"
    const val PROFILE = "profile"
    const val SETTINGS = "settings"
}

@Composable
fun HomeScreen() {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Text("Home screen", fontSize = 24.sp)
    }
}

@Composable
fun ProfileScreen() {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Text("User profile", fontSize = 24.sp)
    }
}

@Composable
fun SettingsScreen() {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Text("Settings", fontSize = 24.sp)
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainApp() {
    val navController = rememberNavController()
    val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
    val scope = rememberCoroutineScope()

    ModalNavigationDrawer(
        drawerState = drawerState,
        drawerContent = {
            DrawerContent(navController = navController, drawerState = drawerState)
        }
    ) {
        Scaffold(
            topBar = {
                TopAppBar(
                    title = { Text("App with a drawer") },
                    navigationIcon = {
                        IconButton(onClick = {
                            scope.launch { drawerState.apply { if (isClosed) open() else close() } }
                        }) { Icon(Icons.Filled.Menu, contentDescription = "Menu") }
                    }
                )
            }
        ) { paddingValues ->
            NavHost(
                navController = navController,
                startDestination = AppDestinations.HOME,
                modifier = Modifier.padding(paddingValues)
            ) {
                composable(AppDestinations.HOME) { HomeScreen() }
                composable(AppDestinations.PROFILE) { ProfileScreen() }
                composable(AppDestinations.SETTINGS) { SettingsScreen() }
            }
        }
    }
}



@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DrawerContent(navController: NavController, drawerState: DrawerState) {
    val scope = rememberCoroutineScope()

    ModalDrawerSheet {
        Column(modifier = Modifier.padding(16.dp)) {
            Text("Menu", style = MaterialTheme.typography.headlineSmall)
            Spacer(modifier = Modifier.height(16.dp))

            NavigationDrawerItem(
                icon = { Icon(Icons.Default.Home, contentDescription = "Home screen") },
                label = { Text("Home screen") },
                selected = false,
                onClick = {
                    navController.navigate(AppDestinations.HOME)
                    scope.launch { drawerState.close() }
                }
            )
            NavigationDrawerItem(
                icon = { Icon(Icons.Default.Person, contentDescription = "Profile") },
                label = { Text("Profile") },
                selected = false,
                onClick = {
                    navController.navigate(AppDestinations.PROFILE)
                    scope.launch { drawerState.close() }
                }
            )
            NavigationDrawerItem(
                icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
                label = { Text("Settings") },
                selected = false,
                onClick = {
                    navController.navigate(AppDestinations.SETTINGS)
                    scope.launch { drawerState.close() }
                }
            )
        }
    }
}

Nested navigation

Let us move to the second example, where we will explore the idea of creating nested navigation graphs.

Assume that our application has more than three simple screens. Almost every commercial application has at least two separate flows:

  • Authentication flow: login, registration, password reset.
  • Main application flow: home screen, profile, settings, etc.

The problem is that these two flows have completely different rules. Most importantly, when the user logs in successfully, they should move to the home screen, and the entire authentication flow should disappear from history. Pressing the Back button on the home screen should not return to the login screen, but should close the application. This can be achieved with nested navigation.

Let us analyze a simple example that shows this solution. Instead of treating NavHost as one large container, we treat it as a folder that can contain both individual files (screens) and other folders (nested graphs).

Instead of using raw strings directly in the code, a good practice is to create an object that stores all routes in one place:

data object AppDestinations {
    // Graphs
    const val AUTH_GRAPH = "auth_graph"
    const val MAIN_APP_GRAPH = "main_app_graph"

    // Authentication screens
    const val LOGIN = "login"
    const val REGISTER = "register"
    const val FORGOT_PASSWORD = "forgot_password"

    // Main application screens
    const val WELCOME = "welcome"
    const val PROFILE = "profile"
}

Notice that our application will have two main subgraphs: AUTH_GRAPH and MAIN_APP_GRAPH.

Let us look at the main NavHost in SimpleNestedNavApp. It is strikingly simple:

NavHost(
    navController = navController,
    startDestination = AppDestinations.AUTH_GRAPH
) {
    // Authentication graph (login, registration, etc.)
    authGraph(navController)

    // Main application graph after login
    mainAppGraph(navController)
}

Notice:

  1. startDestination is not a screen. It is AUTH_GRAPH, meaning the entire nested graph. The application starts by entering the authentication folder.
  2. Inside NavHost, there is not a single composable() defining a screen. Instead, there are only two functions (authGraph and mainAppGraph) that define whole groups of screens.

The main NavHost does not need to know anything about the login screen or the profile screen; it only needs to know about the existence of the authentication flow and the main flow.

As the application grows, NavHost becomes harder to read. The solution is to split it into nested graphs using extension functions for NavGraphBuilder:

fun NavGraphBuilder.authGraph(navController: NavController) {
    navigation(
        startDestination = AppDestinations.LOGIN,
        route = AppDestinations.AUTH_GRAPH
    ) {
        composable(AppDestinations.LOGIN) {
            LoginScreen(navController) }
        composable(AppDestinations.REGISTER) {
            RegisterScreen(navController) }
        composable(AppDestinations.FORGOT_PASSWORD) {
            ForgotPasswordScreen(navController) }
    }
}

fun NavGraphBuilder.mainAppGraph(navController: NavController) {
    navigation(
        startDestination = AppDestinations.WELCOME,
        route = AppDestinations.MAIN_APP_GRAPH
    ) {
        composable(AppDestinations.WELCOME) {
            WelcomeScreen(navController) }
        composable(AppDestinations.PROFILE) {
            ProfileScreen(navController) }
    }
}

Understanding this block is essential:

  • fun NavGraphBuilder.authGraph(...): This is pure code organization. Instead of cluttering NavHost, we group the logic in a separate function.
  • navigation(...): This is the actual constructor of a nested graph.
  • route = AppDestinations.AUTH_GRAPH: We give the entire folder a name. Now we can navigate to it.
  • startDestination = AppDestinations.LOGIN: We define which screen is the default inside this graph.

mainAppGraph has the same structure and groups the WELCOME and PROFILE screens.

We have two separate worlds: AUTH_GRAPH and MAIN_APP_GRAPH. How do we jump from one to the other and, most importantly, clean up after ourselves?

Let us look at the navigateToMainApp() function, called after successful login:

fun NavController.navigateToMainApp() {
    this.navigate(AppDestinations.MAIN_APP_GRAPH) {
        popUpTo(AppDestinations.AUTH_GRAPH) {
            inclusive = true
        }
    }
}

This is the most important code fragment in the whole example. Let us break it down:

  • navigate(AppDestinations.MAIN_APP_GRAPH): We say navigate to the main graph. NavController automatically directs us to that graph's startDestination, which is WELCOME.
  • popUpTo(AppDestinations.AUTH_GRAPH): This is the cleanup command. It moves up the back stack until it finds AUTH_GRAPH.
  • inclusive = true: After AUTH_GRAPH is found on the stack, it is removed as well.

Let us trace the execution. The user clicks Log in. Then NavController:

  • finds the AUTH_GRAPH graph on the back stack,
  • removes LOGIN, REGISTER, FORGOT_PASSWORD and AUTH_GRAPH itself from the stack,
  • adds MAIN_APP_GRAPH with the WELCOME screen to the stack.

The back stack is clean. It contains only MAIN_APP_GRAPH. If the user presses the Back button now, they will not return to the login screen. They will leave the application. We have achieved exactly the flow users expect.

Nested navigation is a fundamental organization tool in every application that has more than one logical flow. As we saw in the code, this pattern provides three key benefits:

  1. Organization: it groups related screens.
  2. Modularity: it keeps the main NavHost clean and lets us define flows in separate functions.
  3. Back stack control: it allows navigation between entire flows and removes them from history with one command.

Full example code

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            NestedComposeGraphTheme {
                SimpleNestedNavApp()
            }
        }
    }
}

data object AppDestinations {
    // Graphs
    const val AUTH_GRAPH = "auth_graph"
    const val MAIN_APP_GRAPH = "main_app_graph"

    // Authentication screens
    const val LOGIN = "login"
    const val REGISTER = "register"
    const val FORGOT_PASSWORD = "forgot_password"

    // Main application screens
    const val WELCOME = "welcome"
    const val PROFILE = "profile"
}

@Composable
fun SimpleNestedNavApp() {
    val navController = rememberNavController()
    NavHost(
        navController = navController,
        startDestination = AppDestinations.AUTH_GRAPH
    ) {
        // Authentication graph (login, registration, etc.)
        authGraph(navController)

        // Main application graph after login
        mainAppGraph(navController)
    }
}

fun NavGraphBuilder.authGraph(navController: NavController) {
    navigation(
        startDestination = AppDestinations.LOGIN,
        route = AppDestinations.AUTH_GRAPH
    ) {
        composable(AppDestinations.LOGIN) {
            LoginScreen(navController) }
        composable(AppDestinations.REGISTER) {
            RegisterScreen(navController) }
        composable(AppDestinations.FORGOT_PASSWORD) {
            ForgotPasswordScreen(navController) }
    }
}

fun NavGraphBuilder.mainAppGraph(navController: NavController) {
    navigation(
        startDestination = AppDestinations.WELCOME,
        route = AppDestinations.MAIN_APP_GRAPH
    ) {
        composable(AppDestinations.WELCOME) {
            WelcomeScreen(navController) }
        composable(AppDestinations.PROFILE) {
            ProfileScreen(navController) }
    }
}

@Composable
fun LoginScreen(navController: NavController) {
    Column(
        modifier = Modifier.fillMaxSize().padding(16.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Login", fontSize = 28.sp)
        Spacer(Modifier.height(24.dp))
        Button(onClick = { navController.navigateToMainApp() }) {
            Text("Log in")
        }
        Spacer(Modifier.height(12.dp))
        Button(onClick = { navController.navigate(AppDestinations.REGISTER) }) {
            Text("Go to registration")
        }
        Spacer(Modifier.height(12.dp))
        TextButton(onClick = { navController.navigate(AppDestinations.FORGOT_PASSWORD) }) {
            Text("I forgot my password")
        }
    }
}

@Composable
fun RegisterScreen(navController: NavController) {
    Column(
        modifier = Modifier.fillMaxSize().padding(16.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Registration", fontSize = 28.sp)
        Spacer(Modifier.height(24.dp))
        Button(onClick = { navController.navigateToMainApp() }) {
            Text("Register and log in")
        }
        Spacer(Modifier.height(12.dp))
        TextButton(onClick = { navController.popBackStack() }) {
            Text("Back to login")
        }
    }
}

@Composable
fun ForgotPasswordScreen(navController: NavController) {
    Column(
        modifier = Modifier.fillMaxSize().padding(16.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Password reset", fontSize = 24.sp, textAlign = TextAlign.Center)
        Spacer(Modifier.height(24.dp))
        Button(onClick = { navController.popBackStack() }) {
            Text("Back")
        }
    }
}
@Composable
fun WelcomeScreen(navController: NavController) {
    Column(
        modifier = Modifier.fillMaxSize().padding(16.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Welcome to the app! 🎉", fontSize = 28.sp)
        Spacer(Modifier.height(24.dp))
        Button(onClick = { navController.navigate(AppDestinations.PROFILE) }) {
            Text("View my profile")
        }
    }
}

@Composable
fun ProfileScreen(navController: NavController) {
    Column(
        modifier = Modifier.fillMaxSize().padding(16.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Profile screen 🧑‍💻", fontSize = 28.sp)
        Spacer(Modifier.height(24.dp))
        Button(onClick = { navController.popBackStack() }) {
            Text("Back to the welcome screen")
        }
    }
}

fun NavController.navigateToMainApp() {
    this.navigate(AppDestinations.MAIN_APP_GRAPH) {
        popUpTo(AppDestinations.AUTH_GRAPH) {
            inclusive = true
        }
    }
}