Skip to content
Snippets Groups Projects
Commit c1857eb4 authored by Baryshnikov Oleg's avatar Baryshnikov Oleg
Browse files

Add the Android Room Kotlin Extensions.

Add the separate method for all characters retrieving to hte RickAndMortyApi.
Add main database classes, dao and tables.
Add remote mediator for characters load.
Split the characters retrieving method into two methods (with and without search).
Fix error displaying bug.
Remove UiAction pattern from view models.
parent 793f7d9e
No related branches found
No related tags found
No related merge requests found
Showing
with 338 additions and 64 deletions
......@@ -98,6 +98,7 @@ dependencies {
implementation "androidx.room:room-paging:$room_version"
annotationProcessor "androidx.room:room-compiler:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version"
// Compose paging library
implementation "androidx.paging:paging-runtime:3.1.1"
......
......@@ -15,4 +15,9 @@ interface RickAndMortyApi {
@Query("page") page: Int,
): CharacterListResponse
@GET("character")
suspend fun getAllCharacters(
@Query("page") page: Int,
): CharacterListResponse
}
\ No newline at end of file
package cz.fel.barysole.ackeetesttask.db
import android.content.Context
import androidx.room.Room
import cz.fel.barysole.ackeetesttask.db.room.AppDatabase
import cz.fel.barysole.ackeetesttask.db.room.dao.CharacterDao
import cz.fel.barysole.ackeetesttask.db.room.dao.PaginationDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class AppDbModule {
@Singleton
@Provides
fun provideAppDb(@ApplicationContext context: Context): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java, DB_NAME
).fallbackToDestructiveMigration().build()
}
@Singleton
@Provides
fun provideCharacterDao(appDatabase: AppDatabase): CharacterDao {
return appDatabase.characterDao()
}
@Singleton
@Provides
fun providePaginationDao(appDatabase: AppDatabase): PaginationDao {
return appDatabase.paginationDao()
}
companion object {
const val DB_NAME = "cz.fel.barysole.ackeetesttask.db"
}
}
\ No newline at end of file
package cz.fel.barysole.ackeetesttask.db.room
import androidx.room.Database
import androidx.room.RoomDatabase
import cz.fel.barysole.ackeetesttask.db.room.dao.CharacterDao
import cz.fel.barysole.ackeetesttask.db.room.dao.PaginationDao
import cz.fel.barysole.ackeetesttask.model.CharacterInfo
import cz.fel.barysole.ackeetesttask.model.PaginationInfo
@Database(
entities = [CharacterInfo::class, PaginationInfo::class],
version = 4,
exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {
abstract fun characterDao(): CharacterDao
abstract fun paginationDao(): PaginationDao
}
\ No newline at end of file
package cz.fel.barysole.ackeetesttask.db.room.dao
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import cz.fel.barysole.ackeetesttask.model.CharacterInfo
@Dao
interface CharacterDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(repos: CharacterInfo)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(repos: List<CharacterInfo>)
@Query("SELECT * FROM CharacterInfo")
fun getAll(): PagingSource<Int, CharacterInfo>
@Query("SELECT * FROM CharacterInfo WHERE id=:id")
fun getById(id: Long): CharacterInfo
@Query(
"SELECT * FROM CharacterInfo WHERE " +
"name LIKE :characterName " +
"ORDER BY id ASC"
)
fun getCharactersByName(characterName: String): PagingSource<Int, CharacterInfo>
@Query("DELETE FROM CharacterInfo WHERE isFavorite = 'false'")
suspend fun deleteNotFavorite()
}
\ No newline at end of file
package cz.fel.barysole.ackeetesttask.db.room.dao
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import cz.fel.barysole.ackeetesttask.model.PaginationInfo
@Dao
interface PaginationDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(paginationInfo: PaginationInfo)
@Query(
"SELECT * FROM PaginationInfo WHERE " +
"paginationName LIKE :paginationName"
)
suspend fun getPaginationInfoFor(paginationName: String): PaginationInfo?
}
\ No newline at end of file
......@@ -7,10 +7,17 @@ import com.google.gson.annotations.SerializedName
/**
* Immutable model class for the Retrofit and the Room.
*/
@Entity(tableName = "repos")
@Entity(tableName = "CharacterInfo")
data class CharacterInfo(
@PrimaryKey @field:SerializedName("id") val id: Long,
@field:SerializedName("name") val name: String?,
@field:SerializedName("status") val status: String?,
@field:SerializedName("image") val image: String?,
@field:SerializedName("species") val species: String?,
@field:SerializedName("type") val type: String?,
@field:SerializedName("gender") val gender: String?,
@field:SerializedName("origin.name") val origin: String?,
@field:SerializedName("location.name") val location: String?,
// Locally saved favorite state
var isFavorite: Boolean = false,
)
package cz.fel.barysole.ackeetesttask.model
import androidx.room.Entity
import androidx.room.PrimaryKey
// Info to track pagination
@Entity(tableName = "PaginationInfo")
data class PaginationInfo(
@PrimaryKey val paginationName: String,
val previousNotDownloadedPage: Int?,
val nextNotDownloadedPage: Int?
)
package cz.fel.barysole.ackeetesttask.repository.characters
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import androidx.room.withTransaction
import cz.fel.barysole.ackeetesttask.api.RickAndMortyApi
import cz.fel.barysole.ackeetesttask.db.room.AppDatabase
import cz.fel.barysole.ackeetesttask.model.CharacterInfo
import cz.fel.barysole.ackeetesttask.model.PaginationInfo
import cz.fel.barysole.ackeetesttask.repository.pagination.PaginationDataType
import retrofit2.HttpException
import java.io.IOException
@OptIn(ExperimentalPagingApi::class)
class CharacterRemoteMediator(
private val rickAndMortyApi: RickAndMortyApi,
private val appDatabase: AppDatabase
) : RemoteMediator<Int, CharacterInfo>() {
override suspend fun initialize(): InitializeAction {
// The initial refresh setting
return InitializeAction.LAUNCH_INITIAL_REFRESH
}
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, CharacterInfo>
): MediatorResult {
// retrieve cached pagination info
val currentPaginationInfo =
appDatabase.paginationDao()
.getPaginationInfoFor(PaginationDataType.SearchCharacterPagination.paginationName)
// compute required page index
val requiredPage = when (loadType) {
LoadType.REFRESH -> {
STARTING_PAGE_NUMBER
}
// REFRESH will always load the first page in the list, so prepend is no longer needed.
LoadType.PREPEND -> {
return MediatorResult.Success(endOfPaginationReached = true)
}
LoadType.APPEND -> {
val lastPage = state.pages.lastOrNull()
val nextKey = lastPage?.nextKey
// if next key is null, then there is no more items in DB
if (nextKey == null) {
// if we can get page from network
if (currentPaginationInfo?.nextNotDownloadedPage == null) {
return MediatorResult.Success(endOfPaginationReached = true)
} else {
currentPaginationInfo.nextNotDownloadedPage
}
} else {
nextKey
}
}
}
try {
val response = rickAndMortyApi.getAllCharacters(requiredPage)
val endOfPaginationReached = response.paginationInfo?.next == null
appDatabase.withTransaction {
appDatabase.paginationDao().insert(
PaginationInfo(
PaginationDataType.SearchCharacterPagination.paginationName,
// we always start at first page
null,
if (response.paginationInfo?.next == null) null else requiredPage + 1
)
)
insertCharacterItems(response.items)
}
return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
} catch (exception: IOException) {
return MediatorResult.Error(exception)
} catch (exception: HttpException) {
return MediatorResult.Error(exception)
}
}
// Save isFavorite flag during items update in the DB
private suspend fun insertCharacterItems(items: List<CharacterInfo>) {
// clear the character table in the database
appDatabase.characterDao().deleteNotFavorite()
for (item in items) {
val itemInDb = appDatabase.characterDao().getById(item.id)
if (itemInDb.isFavorite) {
appDatabase.characterDao().insert(item.copy(isFavorite = true))
} else {
appDatabase.characterDao().insert(item)
}
}
}
companion object {
const val STARTING_PAGE_NUMBER = 1
}
}
\ No newline at end of file
......@@ -6,8 +6,8 @@ import kotlinx.coroutines.flow.Flow
interface CharacterRepository {
fun getCharactersSearchResultStream(query: String): Flow<PagingData<CharacterInfo>>
fun getCharactersByNameFlow(characterNameQuery: String): Flow<PagingData<CharacterInfo>>
fun getCharacters(): Flow<PagingData<CharacterInfo>>
fun getCharactersFlow(): Flow<PagingData<CharacterInfo>>
}
\ No newline at end of file
package cz.fel.barysole.ackeetesttask.repository.characters
import androidx.paging.ExperimentalPagingApi
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import cz.fel.barysole.ackeetesttask.api.RickAndMortyApi
import cz.fel.barysole.ackeetesttask.db.room.AppDatabase
import cz.fel.barysole.ackeetesttask.model.CharacterInfo
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
......@@ -11,21 +13,31 @@ import javax.inject.Singleton
@Singleton
class CharacterRepositoryImpl @Inject constructor(
private val rickAndMortyApi: RickAndMortyApi
private val rickAndMortyApi: RickAndMortyApi,
private val appDatabase: AppDatabase
) : CharacterRepository {
override fun getCharactersSearchResultStream(query: String): Flow<PagingData<CharacterInfo>> {
// Find chracters by name query. Works without DB.
override fun getCharactersByNameFlow(characterNameQuery: String): Flow<PagingData<CharacterInfo>> {
return Pager(
config = PagingConfig(
pageSize = NETWORK_PAGE_SIZE,
enablePlaceholders = false
),
pagingSourceFactory = { CharactersPagingSource(rickAndMortyApi, query) }
pagingSourceFactory = { CharactersPagingSource(rickAndMortyApi, characterNameQuery) }
).flow
}
override fun getCharacters(): Flow<PagingData<CharacterInfo>> {
return getCharactersSearchResultStream("")
override fun getCharactersFlow(): Flow<PagingData<CharacterInfo>> {
@OptIn(ExperimentalPagingApi::class)
return Pager(
config = PagingConfig(pageSize = NETWORK_PAGE_SIZE, enablePlaceholders = false),
remoteMediator = CharacterRemoteMediator(
rickAndMortyApi,
appDatabase
),
pagingSourceFactory = { appDatabase.characterDao().getAll() }
).flow
}
companion object {
......
......@@ -9,13 +9,13 @@ import java.io.IOException
class CharactersPagingSource(
private val rickAndMortyApi: RickAndMortyApi,
private val apiQuery: String
private val characterName: String
) : PagingSource<Int, CharacterInfo>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, CharacterInfo> {
val pageNumber = params.key ?: STARTING_PAGE_NUMBER
return try {
val response = rickAndMortyApi.searchCharacters(apiQuery, pageNumber)
val response = rickAndMortyApi.searchCharacters(characterName, pageNumber)
val nextKey = if (response.paginationInfo?.next == null) null else pageNumber + 1
LoadResult.Page(
data = response.items,
......@@ -44,7 +44,7 @@ class CharactersPagingSource(
}
companion object {
const val STARTING_PAGE_NUMBER = 0;
const val STARTING_PAGE_NUMBER = 1;
}
}
\ No newline at end of file
package cz.fel.barysole.ackeetesttask.repository.pagination
enum class PaginationDataType(val paginationName: String) {
SearchCharacterPagination("SearchCharacterPagination"),
}
\ No newline at end of file
......@@ -4,26 +4,35 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.LaunchedEffect
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.paging.LoadState
import androidx.paging.compose.collectAsLazyPagingItems
import cz.fel.barysole.ackeetesttask.ui.uielement.character.CharacterList
import kotlinx.coroutines.launch
@Composable
fun CharacterListScreen(snackbar: SnackbarHostState, characterListViewModel: CharacterListViewModel = hiltViewModel()) {
fun CharacterListScreen(
snackbar: SnackbarHostState,
characterListViewModel: CharacterListViewModel = hiltViewModel()
) {
Surface(
color = MaterialTheme.colorScheme.background
) {
characterListViewModel.pagingDataFlow.collectAsLazyPagingItems().let {
if (it.loadState.refresh is LoadState.Error) {
rememberCoroutineScope().launch {
snackbar.showSnackbar("Network error!")
}
if (it.loadState.mediator?.refresh is LoadState.Error) {
characterListViewModel.showLoadingError(true)
} else {
CharacterList(it)
characterListViewModel.showLoadingError(false)
}
// If the UI state contains an error, show snackbar
if (characterListViewModel.uiState.value.showError) {
LaunchedEffect(snackbar) {
snackbar.showSnackbar(
message = "Data cannot be loaded!"
)
}
}
CharacterList(it)
}
}
}
......
......@@ -6,15 +6,17 @@ import androidx.paging.PagingData
import androidx.paging.cachedIn
import cz.fel.barysole.ackeetesttask.model.CharacterInfo
import cz.fel.barysole.ackeetesttask.repository.characters.CharacterRepository
import cz.fel.barysole.ackeetesttask.ui.uielement.main.UiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
......@@ -23,30 +25,30 @@ class CharacterListViewModel @Inject constructor(
private val characterRepository: CharacterRepository
) : ViewModel() {
val acceptState: (UiAction) -> Unit
// The UI state
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
val pagingDataFlow: Flow<PagingData<CharacterInfo>>
private val getAllCharacters = MutableSharedFlow<Unit>()
init {
val actionStateFlow = MutableSharedFlow<UiAction>()
val getCharacters = actionStateFlow
.filterIsInstance<UiAction.DisplayCharacterList>()
pagingDataFlow = getAllCharacters
.distinctUntilChanged()
.onStart { emit(UiAction.DisplayCharacterList) }
pagingDataFlow = getCharacters
.flatMapLatest { searchCharacter() }
.onStart { emit(Unit) }
.flatMapLatest { getCharactersPagingData() }
.cachedIn(viewModelScope)
}
acceptState = { action ->
viewModelScope.launch { actionStateFlow.emit(action) }
}
fun showLoadingError(hasError: Boolean) {
_uiState.value = UiState(showError = hasError)
}
private fun searchCharacter(): Flow<PagingData<CharacterInfo>> =
characterRepository.getCharacters()
private fun getCharactersPagingData(): Flow<PagingData<CharacterInfo>> =
characterRepository.getCharactersFlow()
}
data class UiState(
val showError: Boolean = false
)
sealed class UiAction {
object DisplayCharacterList : UiAction()
}
\ No newline at end of file
......@@ -14,7 +14,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.onEach
......@@ -32,36 +31,33 @@ constructor(
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
val pagingDataFlow: Flow<PagingData<CharacterInfo>>
val acceptAction: (UiAction) -> Unit
private val searchCharacterByName = MutableSharedFlow<String>()
init {
val actionStateFlow = MutableSharedFlow<UiAction>()
val searchCharacter = actionStateFlow
.filterIsInstance<UiAction.SearchCharacters>()
pagingDataFlow = searchCharacterByName
.distinctUntilChanged()
pagingDataFlow = searchCharacter
.onEach { action ->
_uiState.value = UiState(query = action.searchQuery)
.onEach { name ->
_uiState.value = UiState(query = name)
}
.filterNot { action -> action.searchQuery.isBlank() }
.flatMapLatest { action ->
searchCharacter(queryString = action.searchQuery)
.filterNot { name -> name.isBlank() }
.flatMapLatest { name ->
searchCharacter(characterName = name)
}
.cachedIn(viewModelScope)
acceptAction = { action ->
viewModelScope.launch { actionStateFlow.emit(action) }
}
}
private fun searchCharacter(queryString: String): Flow<PagingData<CharacterInfo>> =
characterRepository.getCharactersSearchResultStream(queryString)
private fun searchCharacter(characterName: String): Flow<PagingData<CharacterInfo>> =
characterRepository.getCharactersByNameFlow(characterName)
}
fun onCharacterSearch(characterName: String) {
viewModelScope.launch { searchCharacterByName.emit(characterName) };
}
fun onFieldClean() {
_uiState.value = UiState(query = "")
}
sealed class UiAction {
data class SearchCharacters(val searchQuery: String) : UiAction()
}
data class UiState(
......
package cz.fel.barysole.ackeetesttask.ui.uielement.main
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
......@@ -16,8 +19,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.paging.LoadState
......@@ -37,14 +42,15 @@ fun MySearchBar(
mutableStateOf(false)
}
val searchBarState by characterSearchViewModel.uiState.collectAsState()
// clean the search field after bar closing
if (!isSearchBarShowing.value) {
characterSearchViewModel.acceptAction(UiAction.SearchCharacters(""))
characterSearchViewModel.onFieldClean()
}
SearchBar(
modifier = Modifier.padding(if (isSearchBarIsActive.value) 0.dp else 8.dp),
query = searchBarState.query,
onQueryChange = { characterSearchViewModel.acceptAction(UiAction.SearchCharacters(it)) },
onSearch = { characterSearchViewModel.acceptAction(UiAction.SearchCharacters(it)) },
onQueryChange = { characterSearchViewModel.onCharacterSearch(it) },
onSearch = { characterSearchViewModel.onCharacterSearch(it) },
active = isSearchBarIsActive.value,
onActiveChange = { isSearchBarIsActive.value = it },
placeholder = {
......@@ -63,7 +69,7 @@ fun MySearchBar(
contentDescription = "Close icon",
modifier = Modifier.clickable {
if (searchBarState.query.isNotBlank()) {
characterSearchViewModel.acceptAction(UiAction.SearchCharacters(""))
characterSearchViewModel.onFieldClean()
} else {
isSearchBarShowing.value = false
}
......@@ -77,8 +83,8 @@ fun MySearchBar(
// do not show elements when the query is empty
if (searchBarState.query.isNotBlank()) {
if (it.loadState.refresh is LoadState.Error) {
rememberCoroutineScope().launch {
snackbar.showSnackbar("Network error!")
Box (modifier = Modifier.fillMaxWidth(1f).padding(vertical = 64.dp), contentAlignment = Alignment.Center) {
Text("Data cannot be loaded!", style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center)
}
} else {
CharacterList(it)
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment