How to write an Android application that you won’t be ashamed to put in your portfolio. Guide for beginners in Android development

Introduction

In this guide, we will write an Android application from scratch, using the best architectural approaches – Clean Architecture and MVVM with MVI elements, they will ensure maintainability, testability and scalability of applications, which is especially important for complex and long-term projects..

The stack in our project will be as follows:

  • Kotlin, Kotlin Coroutines, StateFlow – for asynchronous data processing and state management

  • Jetpack Compose – to create a modern user interface

  • Room – for local data storage

  • Dagger Hilt for dependency injection

This article is intended for developers who are familiar with the basic concepts of Android development but want to learn more structured and supported development approaches. We will create an application Just Noteswhich will allow you to manage notes: add, edit, delete and view them on the main screen.

In the process we will follow best practices such as layering data, domainAnd presentationand application of principles SOLID. We'll cover key development steps, from setting up a project and adding dependencies, to implementing complex use cases including navigation and state management using ViewModel.

This guide will help you understand how to build a competent Android application architecture. It will be especially useful for beginners, but experienced developers may also find something interesting for themselves here. Let's get started!

Getting started

To start development, we will need to set up Android Studio and create a new project. If Android Studio not installed yet, download it from the official website Android Studio.

Creating a Project

  • Opening Android Studioif you don’t have a project open in the studio yet, click New Projectif you already have a studio open with a project, click File New New Project.

  • In the window that appears New Project select project type – Empty Activity. Should remain on the left Phone and Tablet.

  • In the field Name enter the name of the application, in our case Just Notesor you can come up with and enter your own name.

  • Package name And Minimum SDK don't touch

  • Language Certainly Kotlin

  • Build configuration language – choose Kotlin DSL

A more detailed guide with images can be found in the official documentation Android.

Adding dependencies

For our project, we, like real pros, will use Gradle version catalogswhich will allow you to centrally manage dependencies and make it easier to update them.

Creation libs.versions.toml file (version directory file):

  • Check if your project has the file libs.versions.toml (in gradle/). If it doesn't exist, create it manually.

  • Add required dependencies like Jetpack Compose, Room, Dagger Hilt and others.

You can get all the necessary dependencies by link.

Setting up Dagger Hilt

Create a JustNotesApplication class and add an annotation @HiltAndroidApp:

@HiltAndroidApp
class JustNotesApplication : Application() { }

Register it in AndroidManifest.xml:

<application
  android:name=".JustNotesApplication"
  ...
/>

Project structure and architecture

If you haven't forgotten yet, the project will use everyone's favorite Clean Architecturewhich means that all modules in our project will be divided into layers data, domain, presentation:

  • data includes implementations Repository, DataSource and classes for working with Room.

  • domain will contain UseCase and interfaces Repository

  • presentation the layer will contain Composable And ViewModel.

Our application will have the following features:

  • Display all notes on the main screen (something like a home page)

  • Taking notes

  • Editing notes

  • Deleting notes

The project will consist of modules:

  • core – a module that will be used by all other features. IN core will be implemented Repository And DataSource-s, classes for working with Roomgeneral navigation and Composable-s.

  • create_and_update_note – combined features for creating and updating notes will be located here UseCase and their implementations, screens Composable And ViewModel.

  • home will be responsible for displaying all notes, it will contain all the same files as in create_and_update_note.

  • navigation – will be responsible for navigation in the application

Since our project has only one data source, data the layer will be used by all modules.

core module

The core module in the project serves as the basis for other modules. It contains common components that are used by all parts of the application, such as data manipulation, common data models, repositories, and common navigation.

Working with the local Room database

Now let's proceed directly to writing the code. As I wrote earlier, to work with the local database we will use RoomFor those who are not familiar with this library, I recommend that you familiarize yourself with documentation.

We will adhere to our intended project structure, so first we will create a package in the root of our project corefurther in core create packages datasourcelocalmodel.

IN model create a date class NoteEntity – this will be a unit of the local database, roughly speaking one line in the database table:

@Entity
data class NoteEntity(
   @PrimaryKey(autoGenerate = true)
   val id: Int,
   @ColumnInfo val title: String,
   @ColumnInfo val description: String,
)

Annotation @Entity specifies that this class will be used as a data unit in the database. This annotation can also include tableNameif you do not specify it, the table name will be default – noteEntity.

@PrimaryKey indicates that the field id will be the primary key that will be generated automatically when adding a note. And the last thing: title – table name, and description – description.

IN localcreate a package dao and create an interface in it NoteDao is an entity that will define methods for interacting with the database:

@Dao
interface NoteDao {
   @Query("SELECT * FROM noteEntity")
   fun getAllNotes() : Flow<List<NoteEntity>>
   @Insert
   fun insertNote(note: NoteEntity)
   @Update
   fun updateNote(note: NoteEntity)
   @Query("SELECT * FROM noteEntity WHERE id=:id")
   fun getNoteById(id: Int) : Flow<NoteEntity>
   @Delete
   fun delete(note: NoteEntity)
}

Methods in NoteDao allow you to perform basic CRUD operations (create, read, update and delete) on a table noteEntity.

Next in local create a package db and there is an abstract class in it NotesRoomDatabase:

@Database(entities = [NoteEntity::class], version = 1)
abstract class NotesRoomDatabase: RoomDatabase() {
   abstract fun noteDao() : NoteDao
}

This class will define the database and connect it with NoteEntity And NoteDao.

Now we've already started Roomnow you need to create DataSourcewhich will work with it, and before that in core let's create packages domain model and in the last one we will create a date class Note for working with data outside Room:

data class Note(
   val id: Int,
   val title: String,
   val description: String
)

Now back to the package core/data and create a package in it mapperin which we will create a file NoteEntityMapperthis will be a mapper NoteEntity V Note conversely, it will help separate data storage logic from business logic and support the principles Clean Architecture:

fun NoteEntity.toNote() = Note(id, title, description)
fun Note.toNoteEntity() = NoteEntity(id, title, description)

To abstract from Room and increase flexibility, we will create an interface LocalDataSource in the package local:

interface LocalDataSource {

   fun getAllNotesFlow(): Flow<List<Note>>

   fun gelNoteByIdFlow(id: Int): Flow<Note>

   suspend fun addNote(note: Note)

   suspend fun updateNote(note: Note)

   suspend fun deleteNote(note: Note)
}

U LocalDataSource all functions are the same as NoteDaoonly instead NoteEntity used Note. You may ask why this copy-paste if you can use NoteDao directly, and then in order not to depend on the library, perhaps in the future we will want to use another lib for the local database and then we will just need to replace Room on it without touching the rest of the code.

In the same package local let's create a class RoomLocalDataSourcewhich will use NoteDao to interact with Room:

class RoomLocalDataSource @Inject constructor(
   private val noteDao: NoteDao
) : LocalDataSource {


   override fun getAllNotesFlow(): Flow<List<Note>> {
       return noteDao.getAllNotes().map { noteEntityList ->
           noteEntityList.map { noteEntity -> noteEntity.toNote() }
       }
   }
   
   override suspend fun addNote(note: Note) {
       noteDao.insertNote(note.toNoteEntity())
   }
   
   override suspend fun deleteNote(note: Note) {
       noteDao.delete(note.toNoteEntity())
   }
   
   override suspend fun updateNote(note: Note) {
       noteDao.updateNote(note.toNoteEntity())
   }
   
   override fun gelNoteByIdFlow(id: Int): Flow<Note> {
       return noteDao.getNoteById(id).map { noteEntity ->
           noteEntity.toNote()
       }
   }
}

IN core/domain create a package repository and in it the interface LocalDataSourceRepositorywhich will represent a layer between LocalDataSource and business logic, allowing you to manage data without being tied to a specific implementation of the data source:

interface LocalDataSourceRepository {
В пакете core/data создаем пакет repository и в нем создаем класс LocalDataSourceRepository:
   fun getAllNotesFlow(): Flow<List<Note>>

   fun getNoteByIdFlow(id: Int): Flow<Note>

   suspend fun addNote(note: Note)

   suspend fun updateNote(note: Note)

   suspend fun deleteNote(note: Note)
}

In the package core/data create a package repository and create a class in it LocalDataSourceRepository:

class LocalDataSourceRepositoryImpl @Inject constructor(
   private val localDataSource: LocalDataSource,
) : LocalDataSourceRepository {
  
   override fun getAllNotesFlow() = localDataSource.getAllNotesFlow()
   
   override fun getNoteByIdFlow(id: Int) = localDataSource.gelNoteByIdFlow(id)
   
   override suspend fun updateNote(note: Note) = localDataSource.updateNote(note)
   
   override suspend fun addNote(note: Note) = localDataSource.addNote(note)
   
   override suspend fun deleteNote(note: Note) = localDataSource.deleteNote(note)
}

And the last thing we need to do is create a module Hilt for dependency injection. In the package core/data/source/local create a package di and there is a file in it LocalSourceModule:

@Module
@InstallIn(SingletonComponent::class)
class LocalSourceModuleProvider {
  
   @Provides
   fun provideNoteDao(database: NotesRoomDatabase) = database.noteDao()
   
   @Provides
   @Singleton
   fun providesLocalDatabase(
       @ApplicationContext context: Context
   ) = Room.databaseBuilder(
       context,
       NotesRoomDatabase::class.java,
       "just_notes-database"
   ).build()
}

@Module
@InstallIn(SingletonComponent::class)
abstract class LocalSourceModuleBinder {
  
   @Binds
   abstract fun bindRoomLocalDataSource(
       roomLocalDataSource: RoomLocalDataSource
   ) : LocalDataSource

   @Binds
   abstract fun bindDefaultJustNotesRepository(
       defaultJustNotesRepository: LocalDataSourceRepositoryImpl
   ) : LocalDataSourceRepository
}

ui_kit

IN core Let's create a package ui_kit it will be used to store common UI components, extension functions and other entities useful for the UI. This module will allow you to concentrate all common UI elements in one place, making it easier to maintain, reuse and style your application.

For now this package will only contain Composable UI components.

So we will create components like:

  • BasicFilledButtonJustNotes — a basic button with a filled background. It can be used in different parts of the application, for example as a button to save or update a note

  • CreateNoteFloatingActionButton – fab (floating action button), a button for creating a note, for now it will be located only in one place – on the main screen, but it is likely that it may be needed somewhere else.

  • JustNotesOutlinedTextField — transparent text field with a frame. This component can also be used in many parts of the application, but for now it is only needed for the Title and Description fields.

  • JustNotesTopBartop bar applications

  • NoteItem – will be a note

You can get all the code for these elements from the repository at link.

Implementation of the home screen. Home module

home will be responsible for the functionality of the main screen, where all notes created by the user will be displayed. On the main screen, the user will be able to view, delete, create and edit notes. We will divide the module into domain, presentation And di, data – we don’t need it here, since there is enough of it in core.

Create a package home at the root of the project, then we will look at each layer in more detail.

domain

IN domain will contain the business logic of the module. IN home create a package domain.

Let's start with creating UseCase-ov (hereinafter simply use case). We need only 2 functions for the module home – Retrieving all notes and deleting a note. In separate files in the package domain create interfaces DeleteNoteUseCase And GetAllNotesUseCase:

interface DeleteNoteUseCase {

   suspend operator fun invoke(note: Note)

}

interface GetAllNotesUseCase {

   operator fun invoke(): Flow<List<Note>>

}

You might think that this is overkill for such a small application and you can just use LocalDataSourceReporitorybut we are developing the application adhering to the best architectural patterns and taking into account the possible scaling of the application, so we continue to use use cases.

Next in domain create a package impland in it we create classes in separate files: DeleteNoteUseCaseImpl And GetAllNotesUseCaseImplwhich will implement our use cases:

class DeleteNoteUseCaseImpl @Inject constructor(
   private val localDataSourceRepository: LocalDataSourceRepository
): DeleteNoteUseCase {

   override suspend operator fun invoke(note: Note) {
       localDataSourceRepository.deleteNote(note)
   }
}

class GetAllNotesUseCaseImpl @Inject constructor(
   private val localDataSourceRepository: LocalDataSourceRepository
): GetAllNotesUseCase {

   override operator fun invoke() = localDataSourceRepository.getAllNotesFlow()
}

These use cases will be used in HomeScreenViewModelwhich we will create a little later.

di

IN home create a package dihe will be responsible for dependency injection.

For use cases, we will use the @Binds annotation, which links abstraction with implementation; unlike @Provides, @Binds improves performance and reduces code generation. IN di create an abstract class HomeScreenModule:

@Module
@InstallIn(SingletonComponent::class)
abstract class HomeScreenModule {

   @Binds
   abstract fun bindDeleteNoteUseCase(
       deleteNoteUseCaseImpl: DeleteNoteUseCaseImpl
   ) : DeleteNoteUseCase

   @Binds
   abstract fun bindGetAllNotesUseCase(
       getAllNotesUseCaseImpl: GetAllNotesUseCaseImpl
   ) : GetAllNotesUseCase
}

presentation

I think you already guess that this module will contain the UI and its logic. IN home create a package presentation.

Let's start with ViewModel. ViewModel acts as an intermediary between the UI and business logic. It enables asynchronous loading of data and propagation of changes to the UI, which helps maintain a clean and responsive interface.

IN presentation create a class HomeScreenViewModel. Since we're using MVVM with MVI elements, let's start by creating events and application states first. We will have only one event – deleting a note, and 2 states – a screen with notes and a blank screen. You can create events and states in the same file HomeScreenViewModel or you can create separate files for them:

internal sealed interface HomeScreenUiEvent {
   data class OnDeleteClick(val note: Note) : HomeScreenUiEvent
}

internal sealed interface HomeScreenUiState {

   data object Empty: HomeScreenUiState
   data class Content(val notes: List<Note>): HomeScreenUiState
}

Events and states can be either with or without data. For OnDeleteClick, we need to understand what note the user wants to delete, so we need to pass the note, for the Content state we need to take a collection of notes to display them on the screen.

Now let's continue creating HomeScreenViewModelcreate:

  • handleEvent() – method for handling events

  • notes – a field that will receive a collection of notes

  • uiState – screen state hot source

  • deleteNote() – method for deleting a note

@HiltViewModel

internal class HomeViewModel @Inject constructor(
   private val deleteNoteUseCase: DeleteNoteUseCase,
   getAllNotesUseCase: GetAllNotesUseCase
) : ViewModel() {

   fun handleEvent(event: HomeScreenUiEvent) {
       when (event) {
           is HomeScreenUiEvent.OnDeleteClick -> deleteNote(event.note)
       }
   }

   private val notes = getAllNotesUseCase()

   val uiState: StateFlow<HomeScreenUiState> = notes.map { notesList ->
       if (notesList.isNotEmpty())
           HomeScreenUiState.Content(notesList)
       else HomeScreenUiState.Empty
   }.stateIn(
       scope = viewModelScope,
       started = SharingStarted.WhileSubscribed(5000),
       initialValue = HomeScreenUiState.Empty
   )

   private fun deleteNote(note: Note) {
       viewModelScope.launch(Dispatchers.IO) {
           deleteNoteUseCase(note)
       }
   }
}

Create a file HomeScreenwhich will contain the main screen UI. At the base of the screen there will be Scaffoldthis is the element in Composablewhich allows you to specify topbar, fab and other components:

@Composable

internal fun HomeScreen(
   modifier: Modifier = Modifier,
   uiState: HomeScreenUiState,
   onCreateNoteFloatingActionButtonClick: () -> Unit,
   onDeleteNoteButtonClick: (Note) -> Unit,
   onNoteClick: (String) -> Unit
) {

   Scaffold(
       modifier = modifier.fillMaxSize(),
       topBar = {
           JustNotesTopBar(
               modifier = Modifier.shadow(4.dp),
               title = stringResource(R.string.just_notes_main_topbar_title)
           )
       },
       floatingActionButton = {
           CreateNoteFloatingActionButton {
               onCreateNoteFloatingActionButtonClick()
           }
       }
   ) { paddingValues ->

       Column(
           modifier = Modifier
               .fillMaxSize()
               .padding(paddingValues)
       ) {
           when(uiState) {

               is HomeScreenUiState.Empty -> HomeScreenEmpty()

               is HomeScreenUiState.Content -> HomeScreenContent(
                   notes = uiState.notes,
                   onDeleteNoteButtonClick = onDeleteNoteButtonClick,
                   onNoteClick = onNoteClick
               )
           }
       }
   }
}

We have the screen, all that remains is to create it HomeScreenEmpty And HomeScreenContent. We create them in the same file below:

@Composable
private fun HomeScreenContent(
    modifier: Modifier = Modifier,
    notes: List<Note>,
    onDeleteNoteButtonClick: (Note) -> Unit,
    onNoteClick: (String) -> Unit
) {
    LazyColumn(modifier = modifier.fillMaxSize()) {
        // itemsIndexed is used for UI-tests in the future
        itemsIndexed(notes) { index, note ->
            Spacer(modifier = Modifier.height(20.dp))
            NoteItem(
                modifier = Modifier
                    .padding(horizontal = 15.dp)
                    .clickable { onNoteClick(note.id.toString()) },
                title = note.title,
                description = note.description,
                onDeleteButtonClick = { onDeleteNoteButtonClick(note) }
            )
        }
    }
}

@Composable
private fun HomeScreenEmpty(modifier: Modifier = Modifier) {
    Box(modifier = modifier.fillMaxSize()) {
        Text(
            modifier = Modifier.align(Alignment.Center),
            text = stringResource(R.string.dont_have_any_notes_banner),
            color = MaterialTheme.colorScheme.primary,
            fontSize = 27.sp,
            textAlign = TextAlign.Center,
        )
    }
}

Next in presentation create a file HomeScreenRoutewhich will be like a wrapper HomeScreen:

@Composable
internal fun HomeScreenRoute(
    modifier: Modifier = Modifier,
    viewModel: HomeViewModel = hiltViewModel(),
    navigateToCreateNoteScreen: () -> Unit,
    navigateToUpdateNote: (String) -> Unit
) {

    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    HomeScreen(
        modifier = modifier,
        uiState = uiState,
        onCreateNoteFloatingActionButtonClick = navigateToCreateNoteScreen,
        onDeleteNoteButtonClick = { viewModel.handleEvent(HomeScreenUiEvent.OnDeleteClick(it)) },
        onNoteClick = navigateToUpdateNote
    )
}

And the last element presentation we will have HomeScreenNavigationthis is the file that will be responsible for navigation. We create in presentation plastic bag navigation and there is a file in it HomeScreenNavigation:

const val HOME_SCREEN_ROUTE = "home_screen"

fun NavController.navigateToHomeScreen() = navigate(HOME_SCREEN_ROUTE) {
    popUpTo(HOME_SCREEN_ROUTE) {
        inclusive = true
    }
}

fun NavGraphBuilder.homeScreen(
    navigateToCreateNoteScreen: () -> Unit,
    navigateToUpdateNote: (String) -> Unit
) {
    composable(route = HOME_SCREEN_ROUTE) {
        HomeScreenRoute(
            navigateToCreateNoteScreen = navigateToCreateNoteScreen,
            navigateToUpdateNote = navigateToUpdateNote
        )
    }
}
  • HOME_SCREEN_ROUTE – name or key HomeScreenRouteit is assigned in the composable() function using the route argument.

  • fun NavController.navigateToHomeScreen() – function for navigating to HomeScreenRoutepopUpTo() – does not allow you to go back on the screen HomeScreenfor example from HomeScreen to the editing screen if the user went there.

  • fun NavGraphBuilder.homeScreen() is a function that is called in the main navigation file Navigation in the module core. This is a function that adds HomeScreenRoute V NavHostwhich allows you to navigate from and to HomeScreenRoute.

This concludes the creation of the module home and proceed to creating the module create_and_update_note.

Creating and editing notes. create_and_update_note module

This module will be responsible for all the logic associated with adding and editing notes. Create a package in the root of the project create_and_update_note.

domain

Let's start with creating use cases, in create_and_update_note create a package usecaseand in it we create use cases for creating, editing and receiving notes, each use case in a separate file:

interface AddNoteUseCase {
    suspend operator fun invoke(note: Note)
}

interface GetNoteByIdUseCase {
    operator fun invoke(id: Int): Flow<Note>
}

interface UpdateNoteUseCase {
    suspend operator fun invoke(note: Note)
}

Next in usecase create a package impland in it we create implementations of use cases, each in a separate file:

class AddNoteUseCaseImpl @Inject constructor(
    private val localDataSourceRepository: LocalDataSourceRepository
): AddNoteUseCase {

    override suspend operator fun invoke(note: Note) {
        localDataSourceRepository.addNote(note)
    }
}

class GetNoteByIdUseCaseImpl @Inject constructor(
    private val localDataSourceRepository: LocalDataSourceRepository
): GetNoteByIdUseCase {

    override operator fun invoke(id: Int) =
        localDataSourceRepository.getNoteByIdFlow(id)
}

class UpdateNoteUseCaseImpl @Inject constructor(
    private val localDataSourceRepository: LocalDataSourceRepository
): UpdateNoteUseCase {

    override suspend operator fun invoke(note: Note) = localDataSourceRepository.updateNote(note)
}

Next we will implement dependency injection for module create_and_update_notein principle it will be almost the same as in home:

@Module
@InstallIn(SingletonComponent::class)
abstract class CreateUpdateNoteDomainModule {

    @Binds
    abstract fun bindAddNoteUseCase(
        addNoteUseCaseImpl: AddNoteUseCaseImpl
    ): AddNoteUseCase

    @Binds
    abstract fun bindGetNoteByIdUseCase(
        getNoteByIdUseCaseImpl: GetNoteByIdUseCaseImpl
    ): GetNoteByIdUseCase

    @Binds
    abstract fun bindUpdateNoteUseCase(
        updateNoteUseCaseImpl: UpdateNoteUseCaseImpl
    ): UpdateNoteUseCase
}

presentation

Let's start with creating ViewModelcreate a class with the name CreateAndUpdateNoteViewModel.

First, let's create events and states, and then move on to the class itself. We will have 3 events:

  • OnTitleChanged – change the title of a note

  • OnDescriptionChanged – change the note description

  • OnSaveClicked – clicking a button Saveor Update

There will be only one state – Contentwhich will contain idtitle and description of the note.

internal sealed interface CreateAndUpdateNoteUiEvent {

    data class OnTitleChanged(val title: String): CreateAndUpdateNoteUiEvent
    data class OnDescriptionChanged(val description: String): CreateAndUpdateNoteUiEvent
    data object OnSaveClicked: CreateAndUpdateNoteUiEvent
}

sealed interface CreateAndUpdateNoteUiState {
    data class Content(
        val id: Int = 0,
        val title: String = "",
        val description: String = ""
    ): CreateAndUpdateNoteUiState
}

Now let's move on to CreateAndUpdateNoteViewModel:

@HiltViewModel
internal class CreateAndUpdateNoteViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val updateNoteUseCase: UpdateNoteUseCase,
    private val getNoteByIdUseCase: GetNoteByIdUseCase,
    private val addNoteUseCase: AddNoteUseCase
) : ViewModel() {

    private val noteId: String? = savedStateHandle[NOTE_ID_ARG]

    init {
        if (noteId != null) loadNote(noteId = noteId.toInt())
    }

    private val _uiState = MutableStateFlow<CreateAndUpdateNoteUiState>(
        CreateAndUpdateNoteUiState.Content()
    )
    val uiState = _uiState.asStateFlow()

    fun handleEvent(event: CreateAndUpdateNoteUiEvent) {
        when (event) {
            is CreateAndUpdateNoteUiEvent.OnTitleChanged -> setTitle(event.title)
            is CreateAndUpdateNoteUiEvent.OnDescriptionChanged -> setDescription(event.description)
            is CreateAndUpdateNoteUiEvent.OnSaveClicked -> addOrUpdateNote(noteId)
        }
    }

    private fun loadNote(noteId: Int) {
        viewModelScope.launch(Dispatchers.IO) {
            val note = getNoteByIdUseCase(noteId).first()
            _uiState.update {
                CreateAndUpdateNoteUiState.Content(
                    id = noteId,
                    title = note.title,
                    description = note.description
                )
            }
        }
    }

    private fun setTitle(title: String) {
        _uiState.update {
            (it as CreateAndUpdateNoteUiState.Content).copy(title = title)
        }
    }

    private fun setDescription(description: String) {
        _uiState.update {
            (it as CreateAndUpdateNoteUiState.Content).copy(description = description)
        }
    }

    private fun addOrUpdateNote(noteId: String?) {
        viewModelScope.launch(Dispatchers.IO) {
            val state = _uiState.value as CreateAndUpdateNoteUiState.Content
            val note = Note(
                id = state.id,
                title = state.title,
                description = state.description
            )

            if (noteId != null) {
                updateNoteUseCase(note)
            } else addNoteUseCase(note)
        }
    }
}

noteIdid notes that is sent from HomeScreen and is used to get a note with the given id and download the edit of this note, id transmitted using Navigation Componenta little later we will look in more detail at how it is transmitted id.

IN init { } we check whether we have received the id of the note, if we have received it, then we load the editing of the note.

IN handleEvent() we process all events by calling certain methods.

Let's move on to creating the screen – CreateAndUpdateNoteScreenbut first let's create Composable state Content:

@Composable
private fun Content(
    modifier: Modifier = Modifier,
    title: String,
    description: String,
    onTitleChanged: (String) -> Unit,
    onDescriptionChanged: (String) -> Unit,
    onSaveButtonClick: () -> Unit,
    navigateToHomeScreen: () -> Unit
) {
    val context = LocalContext.current

    Column(
        modifier = modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        JustNotesTextField(
            modifier = Modifier
                .width(300.dp)
                .padding(top = 40.dp),
            text = title,
            placeHolderText = stringResource(id = R.string.title_text_field_placeholder),
            singleLine = true,
            onValueChange = onTitleChanged
        )

        Spacer(modifier = Modifier.height(22.dp))

        JustNotesTextField(
            modifier = Modifier
                .width(300.dp)
                .height(400.dp),
            text = description,
            placeHolderText = stringResource(id = R.string.create_note_description),
            onValueChange = onDescriptionChanged
        )

        Spacer(modifier = Modifier.height(20.dp))

        BasicFilledButtonJustNotes(
            modifier = Modifier
                .width(160.dp)
                .height(50.dp),
            text = stringResource(R.string.save_button_text),
            onClick = {
                if (title.isEmpty()) {
                    Toast.makeText(
                        context, context.getText(R.string.please_fill_out_the_title_field), Toast.LENGTH_LONG
                    ).show()
                    return@BasicFilledButtonJustNotes
                }
                onSaveButtonClick()
                navigateToHomeScreen()
            }
        )
    }
}

AND CreateAndUpdateNoteScreen:

@Composable
internal fun CreateAndUpdateNoteScreen(
    modifier: Modifier = Modifier,
    uiState: CreateAndUpdateNoteUiState,
    topBarTitle: Int?,
    onSaveButtonClick: () -> Unit,
    navigateToHomeScreen: () -> Unit,
    onTitleChanged: (String) -> Unit,
    onDescriptionChanged: (String) -> Unit,
) {
    Scaffold(
        modifier = modifier
            .fillMaxSize()
            .imePadding(),
        topBar = { JustNotesTopBar(title = stringResource(topBarTitle!!)) }
    ) { paddingValues ->
        Box(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues),
            contentAlignment = Alignment.Center
        ) {
            when(uiState) {
                is CreateAndUpdateNoteUiState.Content -> Content(
                    title = uiState.title,
                    description = uiState.description,
                    onTitleChanged = onTitleChanged,
                    onDescriptionChanged = onDescriptionChanged,
                    onSaveButtonClick = onSaveButtonClick,
                    navigateToHomeScreen = navigateToHomeScreen
                )
            }
        }
    }
}

And the last thing we have left is – CreateAndUpdateNoteRoute:

@Composable
internal fun CreateAndUpdateNoteRoute(
    modifier: Modifier = Modifier,
    viewModel: CreateAndUpdateNoteViewModel = hiltViewModel(),
    topBarTitle: Int?,
    navigateToHomeScreen: () -> Unit
) {

    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    CreateAndUpdateNoteScreen(
        modifier = modifier,
        uiState = uiState,
        topBarTitle = topBarTitle,
        onSaveButtonClick = { viewModel.handleEvent(CreateAndUpdateNoteUiEvent.OnSaveClicked) },
        navigateToHomeScreen = navigateToHomeScreen,
        onTitleChanged = { viewModel.handleEvent(CreateAndUpdateNoteUiEvent.OnTitleChanged(it)) },
        onDescriptionChanged = { viewModel.handleEvent(CreateAndUpdateNoteUiEvent.OnDescriptionChanged(it)) }
    )
}

Let's finish this section with the implementation of navigation, in presentation create a package navigationwhere the navigation will be stored and create a file in it CreateAndUpdateNoteNavigationin which we create 3 constants and one enum Class:

const val CREATE_AND_UPDATE_NOTE_ROUTE = "create_and_update_note_route"
const val NOTE_ID_ARG = "note_id"
const val TOP_BAR_TITLE_ARG = "top_bar_title"
  • CREATE_AND_UPDATE_NOTE_ROUTE — name or id of the route to the screen for creating or editing a note

  • NOTE_ID_ARG — argument for passing note id

  • TOP_BAR_TITLE_ARG — argument for passing the title of the top bar (TopBar)

Next, below the constants we create the enum class CreateAndUpdateNoteResArg:

enum class CreateAndUpdateNoteResArg {
    CREATE_NOTE, UPDATE_NOTE
}

Using it, we will determine what title the screen should have depending on where the user clicks, on the note or on the add note button.

Now let's create an extension function for navigation on CreateAndUpdateNoteRoute:

fun NavController.navigateToCreateAndUpdateNote(
    topBarTitleResArg: CreateAndUpdateNoteResArg,
    noteId: String?
) {
    val topBarTitleResId = when(topBarTitleResArg) {
        CreateAndUpdateNoteResArg.CREATE_NOTE -> R.string.create_note_topbar_title
        CreateAndUpdateNoteResArg.UPDATE_NOTE -> R.string.update_note_topbar_title
    }
    navigate(
        route = "$CREATE_AND_UPDATE_NOTE_ROUTE/$topBarTitleResId/$noteId",
    ) {
        launchSingleTop = true
    }
}
  • topBarTitleResArg: takes one of two values ​​- CREATE_NOTE to create a new note or UPDATE_NOTE for editing

  • noteId: this is the id of the note

This function generates a navigation route using the passed arguments and launches the screen via navigate().

For navigation to work correctly, the screen must be added to NavGraphBuilder:

fun NavGraphBuilder.createAndUpdateNoteScreen(navigateToHomeScreen: () -> Unit) {
    composable(
        route = "$CREATE_AND_UPDATE_NOTE_ROUTE/{$TOP_BAR_TITLE_ARG}/{$NOTE_ID_ARG}",
        arguments = listOf(
            navArgument(NOTE_ID_ARG) { type = NavType.StringType; nullable = true },
            navArgument(TOP_BAR_TITLE_ARG) { type = NavType.IntType }
        )
    ) { backStackEntry ->
        CreateAndUpdateNoteRoute(
            topBarTitle = backStackEntry.arguments?.getInt(TOP_BAR_TITLE_ARG),
            navigateToHomeScreen = navigateToHomeScreen
        )
    }
}

navArgument(NOTE_ID_ARG): This argument passes the note id, which may be nullable (that is, not present when a new note is created).

navArgument(TOP_BAR_TITLE_ARG): Passes the row resource id to the TopBar so it can display different titles for creation and editing.

Navigation using Navigation Compose. navigation module

In this section, we'll look at how to set up navigation in your Android app using Navigation Compose. This modern approach is integrated with Jetpack Compose and allows you to easily manage transitions between screens and the transfer of data between them. Create a package in the root of the project navigation and immediately there is a file in it Navigation.

The basis for navigation is NavHost — a container in which all screen routes are defined. Used to control transitions between screens NavController. In the Navigation file we create Composable Navigationwhich will contain NavHost:

@Composable
fun Navigation() {
    val navController: NavHostController = rememberNavController()

    NavHost(navController = navController, startDestination = HOME_SCREEN_ROUTE) {

        homeScreen(
            navigateToCreateNoteScreen = {
                navController.navigateToCreateAndUpdateNote(CreateAndUpdateNoteResArg.CREATE_NOTE,null)
            },
            navigateToUpdateNote = {
                navController.navigateToCreateAndUpdateNote(CreateAndUpdateNoteResArg.UPDATE_NOTE, it)
            }
        )

        createAndUpdateNoteScreen { navController.navigateToHomeScreen() }
    }
}

IN NavHost we defined two routes: the home screen (home) and the screen for creating and editing notes (create_and_update_note). In each of them, the corresponding application screen is called up.

For navigation and the application as a whole to work, Composable function Navigation must be called to MainActivity:

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            JustNotesTheme(dynamicColor = false) {
                Navigation()
            }
        }
    }
}

Conclusion

So we have finished developing our small but scalable project. During development, we learned to work with such architectural approaches as Clean architecture And MVVM with elements MVI.

We also worked with such modern technologies as:

  • Jetpack Compose

  • Dagger Hilt

  • Room

We did a good job, but of course we can do even better: write Unit, UI tests, turn our supposed modules into real modules. You can do all this yourself or you can wait until new articles on these topics are released 🙂

Thank you everyone for your attention, I hope this guide was useful to you. I plan to write several more articles on Android development, which will most likely be based on the same application. This way you will see all stages of development, from MVP to a feature-rich application.

I will be glad to see you in new articles! Thanks again everyone!

Oh yeah, I almost forgot, here is the link to the repository on GitLab: https://gitlab.com/just-notes/just-notes.

See you!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *