-
Notifications
You must be signed in to change notification settings - Fork 0
Lab 14
Let's add support for creating, retrieving, updating, and deleting custom notes. To support this, we'll leverage that Android Architecture Component library Room.
- Room provides an abstraction layer over the underlying SQLite database supported by Android.
- Room allows us to define database entity classes, and database interaction interfaces and generates the required SQLite database and interaction code.
- Room supports querying for data using a number of apis including coroutines,
Flow
,LiveData
, and RxJava. - We will be making use of a feature of the Navigation Component named Safe Args. The Safe Args plugin generates type-safe navigation action classes for us that help ensure we pass the correct arguments to our navigation destinations. Rather than passing a resource id to a
navigate()
call, we can pass an instance of aDirections
class that enforces the arguments we need.
Β
-
Update
Note
model to be a Room@Entity
- Can remove the @Parcelize setup as we will no longer pass
Note
directly but instead load it from DB - Add the
@Entity
annotation to theNote
class. Room will now create a database table for this model. - Add a class-body
var
property namedid
of typeInt
with default value0
- Annotate
id
with@PrimaryKey(autoGenerate = true)
- This
@PrimaryKey
annotation will be used by Room to auto-generate unique ids for eachNote
added to the database
- Can remove the @Parcelize setup as we will no longer pass
-
Create a new package
db
-
Within the
db
package, create aNoteDao
interface with the following methods- Annotate
NoteDao
with@Dao
. This will tell Room to generate the needed code. @Query("SELECT * FROM note") fun getAll(): Flow<List<Note>>
@Query("SELECT * FROM note where id=:noteId") suspend fun get(noteId: Int): Note
@Insert suspend fun save(note: Note)
@Update(onConflict = OnConflictStrategy.REPLACE) suspend fun update(note: Note)
@Delete suspend fun delete(note: Note)
- Room will use these methods and their annotations to generate database access code
- Annotate
-
Within the
db
package, create anAppDatabase
class to accessNoteDao
and interact with the database-
AppDatabase
should extendRoomDatabase
- Add a method
abstract fun noteDao(): NoteDao
to the class. This will result in a generated implementation that provides an instance ofNoteDao
. - Annotate the
AppDatabase
class with@Database(entities = [Note::class], version = 1)
. This informs Room about which database tables/entities to care about, and what our database schema version should be. If we were to change the schema of our database, we would likely want to increase this version number as well and provide a migration (migrations are out of scope of this lab).
-
-
Create a custom
Application
class namedAndroidStudyGuideApplication
- Name the new class
AndroidStudyGuideApplication
- It should extend
Application
- Within
AndroidManifest.xml
addandroid:name=".AndroidStudyGuideApplication"
to the<application>
element - This will result in our custom
Application
class being instantiated when our app is started
- Name the new class
-
Make
AppDatabase
globally available by creating a public, lazy property on theAndroidStudyGuideApplication
class -
Create an extension function on
Activity
for easy access toAndroidStudyGuideApplication
- This will require casting
Activity.application
toAndroidStudyGuideApplication
- This will require casting
Β
Once you have access to the database, it's time to implement our CRUD workflow across our application. We'll update our app to support the following:
- Displaying saved notes (from
MyNotesFragment
) - Saving notes to the database (from
CreateNoteFragment
) - Viewing saved note details (from
NoteDetailsFragment
)
-
Update
CreateNoteFragment
andCreateNoteViewModel
to save aNote
to the database- Update
CreateNoteViewModel
to take an instance ofNoteDao
as a parameter. You'll need to updateCreateNoteViewModelFactory
to achieve this. - Add a method to
CreateNoteViewModel
namedfun save(title: String, categoryIndex: Int, content: String)
and implement usingNoteDao
to perform the save operation. - In
CreateNoteFragment
, callviewModel.save()
when the save button is clicked
- Update
-
Update
MyNotesFragment
andMyNotesViewModel
to populateMyNotesListAdapter
based on items saved to the database- Update
MyNotesViewModel
to take an instance ofNoteDao
as a parameter. You'll need to create a class namedMyNotesViewModelFactory
to achieve this. UseCreateNotViewModelFactory
as an example. - Remove the
title
property fromUiState
. We will no longer need it. - Refactor the implementation of the
state
property to instead usenoteDao.getAll().map { UiState(it) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), UiState()
- Update
-
Update how we handle
Note
click handling- We need to pass the
id
of a selectedNote
when navigating toNoteDetailsFragment
- Update the click handler we pass to
MyNotesListAdapter
- Rather than passing a resource id to our call to
findNavController.navigate()
we will use a Navigation Safe Args generated class namedMyNotesFragmentDirections
to create an instance of a navigation acton usingMyNotesFragmentDirections.actionMyNotesFragmentToNoteDetailsFragment(note.id)
- We need to pass the
-
Update
NoteDetailFragment
andNoteDetailViewModel
to loadNote
data from the database based on a passed note id- Update
NoteDetailViewModel
to take anid
instead of aNote
- Update
NoteDetailViewModel
to take an instance ofNoteDao
- In the
init{}
block ofNoteDetailViewModel
, launch a new coroutine on a background thread - Within the launched coroutine, call
noteDao.get(id)
- Update the
UiState
using the loadedNote
- Update
Β
- Add the ability to delete a note
- Add the ability to edit a note
Β
Β
Β
If we want our Room model data class to have an auto-incrementing id that is not managed by us, then we can't put the property in the constructor. But if we can't put the data class property in the constructor, where can we put it?
Well, we can still add properties to a data class that are outside the constructor. The only caveat, is that those properties won't be considered as part of the auto-generated equals/hashcode & copy() methods
@Entity
data class Note(
val title: String,
val category: String,
val content: String,
) {
@PrimaryKey(autoGenerate = true)
var id: Int = 0
}
Β
@Dao
interface NoteDao {
@Query("SELECT * FROM note")
fun getAll(): Flow<List<Note>>
@Query("SELECT * FROM note where id=:noteId")
suspend fun get(noteId: Int): Note
@Insert
suspend fun save(note: Note)
@Update(onConflict = OnConflictStrategy.REPLACE)
suspend fun update(note: Note)
@Delete
suspend fun delete(note: Note)
}
Β
@Database(entities = [Note::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun noteDao(): NoteDao
}
Β
To make accessing a single common instance of our database easier, we will use a custom Application
class to store an instance of the database as a property. That property will be evaluated lazily; only being created the first time it's accessed. To make accessing this custom application class easier, we create an extension method on the Activity
type to get the current Application
instance and cast it to an instance of AndroidStudyGuideApplication
.
fun Activity.studyGuideApplication(): AndroidStudyGuideApplication = application as AndroidStudyGuideApplication
class AndroidStudyGuideApplication : Application() {
val database: AppDatabase by lazy {
Room.databaseBuilder(this, AppDatabase::class.java, "app-database").build()
}
}
Β
<application
...
android:name=".AndroidStudyGuideApplication"
>
</application>
Β
class MyNotesViewModelFactory(
private val noteDao: NoteDao
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return MyNotesViewModel(noteDao) as T
}
}
Β
Now that we have database access, we can initialize our UiState
Flow
based on the result of calling noteDao.getAll()
. We can observe those results, and convert each result into a UiState
instance that is then observed by MyNotesFragment
. The stateIn()
call here converts the Flow
returned from NoteDao
into an instance of a StateFlow
.
val state: StateFlow<UiState> = noteDao
.getAll()
.map { UiState(it) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), UiState()
)
Β
Within MyNotesFragment
we need to update the MyNotesListAdpater
click handler. This update makes use of Navigation Safe Args to use generated, type-safe navigation classes that help enforce we pass the correct navigation arguments to our destinations.
private val notesAdapter = MyNotesListAdapter() { note ->
findNavController().navigate(MyNotesFragmentDirections.actionMyNotesFragmentToNoteDetailsFragment(note.id))
}
Β
π‘ If we are using suspend, and Flow as part of our Dao, how do we call those methods from a Fragment?
Because suspend
and Flow
are part of the Kotlin coroutines package, we must sometimes call those methods from a coroutine.
From within a Fragment, the best place to start is to call lifecycleScope.launch {}
and perform any coroutine executions within the launch{}
lambda.