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

Add the get characters method to the character repository.

Implement application search bar.
Move the top app bar to the separate file.
Move the composable character list to the separate file.
parent b11fa960
No related branches found
No related tags found
No related merge requests found
Showing
with 365 additions and 161 deletions
......@@ -6,8 +6,11 @@ import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
......@@ -16,12 +19,12 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import cz.fel.barysole.ackeetesttask.ui.mainuielement.MyNavigationBar
import cz.fel.barysole.ackeetesttask.ui.mainuielement.MyTopAppBar
import cz.fel.barysole.ackeetesttask.ui.screen.Screen
import cz.fel.barysole.ackeetesttask.ui.screen.appScreenList
import cz.fel.barysole.ackeetesttask.ui.screen.characterlist.CharacterListScreen
import cz.fel.barysole.ackeetesttask.ui.theme.AckeeTestTaskTheme
import cz.fel.barysole.ackeetesttask.ui.uielement.main.MyNavigationBar
import cz.fel.barysole.ackeetesttask.ui.uielement.main.MyTopAppBar
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
......@@ -45,11 +48,14 @@ fun MainScreen() {
// We can set selectedScreen by using callbacks from navigation composable, but it not guarantee any performance improvements in this small app
val selectedScreen =
appScreenList.find { item -> currentDestination?.hierarchy?.first()?.route == item.route }
val snackbarHostState = remember { SnackbarHostState() }
// Main content
AckeeTestTaskTheme {
Scaffold(modifier = Modifier.fillMaxSize(1f),
Scaffold(
modifier = Modifier.fillMaxSize(1f),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
MyTopAppBar(selectedScreen)
MyTopAppBar(selectedScreen, snackbarHostState)
},
content = { innerPadding ->
NavHost(
......@@ -58,7 +64,7 @@ fun MainScreen() {
Modifier.padding(innerPadding)
) {
composable(Screen.Characters.route) {
CharacterListScreen()
CharacterListScreen(snackbarHostState)
}
composable(Screen.Favorite.route) { }
composable(Screen.CharacterDetail.route) { }
......
......@@ -8,4 +8,6 @@ interface CharacterRepository {
fun getCharactersSearchResultStream(query: String): Flow<PagingData<CharacterInfo>>
fun getCharacters(): Flow<PagingData<CharacterInfo>>
}
\ No newline at end of file
......@@ -24,6 +24,10 @@ class CharacterRepositoryImpl @Inject constructor(
).flow
}
override fun getCharacters(): Flow<PagingData<CharacterInfo>> {
return getCharactersSearchResultStream("")
}
companion object {
//based on this description - https://rickandmortyapi.com/documentation/#info-and-pagination
const val NETWORK_PAGE_SIZE = 20
......
......@@ -25,6 +25,13 @@ class CharactersPagingSource(
} catch (exception: IOException) {
return LoadResult.Error(exception)
} catch (exception: HttpException) {
if (exception.code() == 404) {
return LoadResult.Page(
data = emptyList(),
prevKey = null,
nextKey = null
)
}
return LoadResult.Error(exception)
}
}
......
package cz.fel.barysole.ackeetesttask.ui.screen.characterlist
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.rememberCoroutineScope
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.paging.compose.LazyPagingItems
import androidx.paging.LoadState
import androidx.paging.compose.collectAsLazyPagingItems
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
import com.bumptech.glide.integration.compose.GlideImage
import cz.fel.barysole.ackeetesttask.model.CharacterInfo
import cz.fel.barysole.ackeetesttask.ui.uielement.character.CharacterList
import kotlinx.coroutines.launch
@Composable
fun CharacterListScreen(characterListViewModel: CharacterListViewModel = hiltViewModel()) {
fun CharacterListScreen(snackbar: SnackbarHostState, characterListViewModel: CharacterListViewModel = hiltViewModel()) {
Surface(
color = MaterialTheme.colorScheme.background
) {
characterListViewModel.pagingDataFlow.collectAsLazyPagingItems().let {
CharacterList(it)
}
}
}
@Composable
fun CharacterList(characterList: LazyPagingItems<CharacterInfo>) {
LazyColumn(modifier = Modifier.padding(vertical = 4.dp)) {
items(
count = characterList.itemCount,
key = { characterList[it]?.id ?: it },
contentType = { if (characterList[it] == null) 1 else 0 }
) { index ->
characterList[index]?.let {
CharacterItem(it)
}
}
}
}
@OptIn(ExperimentalGlideComposeApi::class)
@Composable
fun CharacterItem(character: CharacterInfo) {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surface,
shadowElevation = 4.dp,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Row(
modifier = Modifier
.padding(all = 10.dp)
.fillMaxWidth(1f),
verticalAlignment = Alignment.CenterVertically
) {
character.image?.let {
GlideImage(
model = it,
contentDescription = "Character image",
modifier = Modifier
.size(48.dp)
.clip(MaterialTheme.shapes.small)
)
}
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = "${character.name}",
style = MaterialTheme.typography.titleMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
character.status?.let {
Text(
text = character.status,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (it.loadState.refresh is LoadState.Error) {
rememberCoroutineScope().launch {
snackbar.showSnackbar("Network error!")
}
} else {
CharacterList(it)
}
}
}
......
package cz.fel.barysole.ackeetesttask.ui.screen.characterlist
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
......@@ -8,103 +7,46 @@ import androidx.paging.cachedIn
import cz.fel.barysole.ackeetesttask.model.CharacterInfo
import cz.fel.barysole.ackeetesttask.repository.characters.CharacterRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class CharacterListViewModel @Inject constructor(
private val characterRepository: CharacterRepository,
private val savedStateHandle: SavedStateHandle
class CharacterListViewModel @Inject constructor(
private val characterRepository: CharacterRepository
) : ViewModel() {
// The UI state
val uiState: StateFlow<UiState>
val acceptState: (UiAction) -> Unit
val pagingDataFlow: Flow<PagingData<CharacterInfo>>
/**
* Processor of side effects from the UI which in turn feedback into [uiState]
*/
val accept: (UiAction) -> Unit
init {
val initialQuery: String = savedStateHandle[LAST_SEARCH_QUERY] ?: ""
val lastQueryScrolled: String = savedStateHandle[LAST_QUERY_SCROLLED] ?: ""
val actionStateFlow = MutableSharedFlow<UiAction>()
val searches = actionStateFlow
.filterIsInstance<UiAction.Search>()
val getCharacters = actionStateFlow
.filterIsInstance<UiAction.DisplayCharacterList>()
.distinctUntilChanged()
.onStart { emit(UiAction.Search(query = initialQuery)) }
val queriesScrolled = actionStateFlow
.filterIsInstance<UiAction.Scroll>()
.distinctUntilChanged()
// This is shared to keep the flow "hot" while caching the last query scrolled,
// otherwise each flatMapLatest invocation would lose the last query scrolled,
.shareIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
replay = 1
)
.onStart { emit(UiAction.Scroll(currentQuery = lastQueryScrolled)) }
.onStart { emit(UiAction.DisplayCharacterList) }
pagingDataFlow = searches
.flatMapLatest { searchCharacter(queryString = it.query) }
pagingDataFlow = getCharacters
.flatMapLatest { searchCharacter() }
.cachedIn(viewModelScope)
uiState = combine(
searches,
queriesScrolled,
::Pair
).map { (search, scroll) ->
UiState(
query = search.query,
lastQueryScrolled = scroll.currentQuery,
// If the search query matches the scroll query, the user has scrolled
hasNotScrolledForCurrentSearch = search.query != scroll.currentQuery
)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
initialValue = UiState()
)
accept = { action ->
acceptState = { action ->
viewModelScope.launch { actionStateFlow.emit(action) }
}
}
private fun searchCharacter(queryString: String): Flow<PagingData<CharacterInfo>> =
characterRepository.getCharactersSearchResultStream(queryString)
override fun onCleared() {
savedStateHandle[LAST_SEARCH_QUERY] = uiState.value.query
savedStateHandle[LAST_QUERY_SCROLLED] = uiState.value.lastQueryScrolled
super.onCleared()
}
private fun searchCharacter(): Flow<PagingData<CharacterInfo>> =
characterRepository.getCharacters()
}
sealed class UiAction {
data class Search(val query: String) : UiAction()
data class Scroll(val currentQuery: String) : UiAction()
}
data class UiState(
val query: String = "",
val lastQueryScrolled: String = "",
val hasNotScrolledForCurrentSearch: Boolean = false
)
private const val LAST_SEARCH_QUERY: String = "last_search_query"
private const val LAST_QUERY_SCROLLED: String = "last_query_scrolled"
\ No newline at end of file
object DisplayCharacterList : UiAction()
}
\ No newline at end of file
package cz.fel.barysole.ackeetesttask.ui.uielement.character
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.paging.compose.LazyPagingItems
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
import com.bumptech.glide.integration.compose.GlideImage
import cz.fel.barysole.ackeetesttask.model.CharacterInfo
@Composable
fun CharacterList(characterList: LazyPagingItems<CharacterInfo>) {
LazyColumn(modifier = Modifier.padding(vertical = 4.dp)) {
items(
count = characterList.itemCount,
contentType = { if (characterList[it] == null) 1 else 0 }
) { index ->
characterList[index]?.let {
CharacterListItem(it)
}
}
}
}
@OptIn(ExperimentalGlideComposeApi::class)
@Composable
fun CharacterListItem(character: CharacterInfo) {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surface,
shadowElevation = 4.dp,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Row(
modifier = Modifier
.padding(all = 10.dp)
.fillMaxWidth(1f),
verticalAlignment = Alignment.CenterVertically
) {
character.image?.let {
GlideImage(
model = it,
contentDescription = "Character image",
modifier = Modifier
.size(48.dp)
.clip(MaterialTheme.shapes.small)
)
}
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = "${character.name}",
style = MaterialTheme.typography.titleMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
character.status?.let {
Text(
text = character.status,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
\ No newline at end of file
package cz.fel.barysole.ackeetesttask.ui.uielement.main
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import cz.fel.barysole.ackeetesttask.model.CharacterInfo
import cz.fel.barysole.ackeetesttask.repository.characters.CharacterRepository
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.filterNot
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class CharacterSearchViewModel @Inject
constructor(
private val characterRepository: CharacterRepository
) : ViewModel() {
// The UI state
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
val pagingDataFlow: Flow<PagingData<CharacterInfo>>
val acceptAction: (UiAction) -> Unit
init {
val actionStateFlow = MutableSharedFlow<UiAction>()
val searchCharacter = actionStateFlow
.filterIsInstance<UiAction.SearchCharacters>()
.distinctUntilChanged()
pagingDataFlow = searchCharacter
.onEach { action ->
_uiState.value = UiState(query = action.searchQuery)
}
.filterNot { action -> action.searchQuery.isBlank() }
.flatMapLatest { action ->
searchCharacter(queryString = action.searchQuery)
}
.cachedIn(viewModelScope)
acceptAction = { action ->
viewModelScope.launch { actionStateFlow.emit(action) }
}
}
private fun searchCharacter(queryString: String): Flow<PagingData<CharacterInfo>> =
characterRepository.getCharactersSearchResultStream(queryString)
}
sealed class UiAction {
data class SearchCharacters(val searchQuery: String) : UiAction()
}
data class UiState(
val query: String = ""
)
\ No newline at end of file
package cz.fel.barysole.ackeetesttask.ui.mainuielement
package cz.fel.barysole.ackeetesttask.ui.uielement.main
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
......
package cz.fel.barysole.ackeetesttask.ui.uielement.main
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
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.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.paging.LoadState
import androidx.paging.compose.collectAsLazyPagingItems
import cz.fel.barysole.ackeetesttask.R
import cz.fel.barysole.ackeetesttask.ui.uielement.character.CharacterList
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MySearchBar(
isSearchBarShowing: MutableState<Boolean>,
snackbar: SnackbarHostState,
characterSearchViewModel: CharacterSearchViewModel = hiltViewModel()
) {
val isSearchBarIsActive = rememberSaveable {
mutableStateOf(false)
}
val searchBarState by characterSearchViewModel.uiState.collectAsState()
AnimatedVisibility(visible = isSearchBarShowing.value) {
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)) },
active = isSearchBarIsActive.value,
onActiveChange = { isSearchBarIsActive.value = it },
placeholder = {
Text(text = "Start enter a name...")
},
leadingIcon = {
Icon(
painterResource(R.drawable.baseline_arrow_back_ios_new_24),
modifier = Modifier.clickable { isSearchBarShowing.value = false },
contentDescription = "Arrow back icon"
)
},
trailingIcon = {
Icon(
painterResource(R.drawable.baseline_close_24),
contentDescription = "Close icon",
modifier = Modifier.clickable {
if (searchBarState.query.isNotBlank()) {
characterSearchViewModel.acceptAction(UiAction.SearchCharacters(""))
} else {
isSearchBarShowing.value = false
}
}
)
},
tonalElevation = 0.dp,
colors = if (isSearchBarIsActive.value) SearchBarDefaults.colors(containerColor = MaterialTheme.colorScheme.background) else SearchBarDefaults.colors()
) {
characterSearchViewModel.pagingDataFlow.collectAsLazyPagingItems().let {
// 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!")
}
} else {
CharacterList(it)
}
}
}
}
}
}
package cz.fel.barysole.ackeetesttask.ui.mainuielement
package cz.fel.barysole.ackeetesttask.ui.uielement.main
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
......@@ -6,10 +6,13 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.res.stringResource
......@@ -19,33 +22,43 @@ import cz.fel.barysole.ackeetesttask.ui.screen.Screen
import cz.fel.barysole.ackeetesttask.ui.screen.screenWithTopAppBarList
//Main screen contains the TopAppBar, which is experimental and is likely to change or to be removed in the future.
//Main screen contains the TopAppBar and other functions, which are experimental and are likely to change or to be removed in the future.
//But in the small test app it looks nice, i guess.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MyTopAppBar(selectedScreen: Screen?) {
fun MyTopAppBar(
selectedScreen: Screen?,
snackbar: SnackbarHostState
) {
if (screenWithTopAppBarList.contains(selectedScreen)) {
TopAppBar(
modifier = Modifier.shadow(16.dp),
title = {
Text(
//there is no null in the screenWithTopAppBarList
stringResource(selectedScreen!!.nameResId),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
actions = {
if (selectedScreen == Screen.Characters) {
IconButton(onClick = { /* doSomething() */ }) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search button"
)
val isSearchBarShowing = rememberSaveable {
mutableStateOf(false)
}
// showing iff isSearchBarShowing is true
MySearchBar(isSearchBarShowing, snackbar)
if (!isSearchBarShowing.value) {
TopAppBar(
modifier = Modifier.shadow(16.dp),
title = {
Text(
//there is no null in the screenWithTopAppBarList
stringResource(selectedScreen!!.nameResId),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
actions = {
if (selectedScreen == Screen.Characters) {
IconButton(onClick = { isSearchBarShowing.value = true }) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search button"
)
}
}
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.background)
)
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.background)
)
}
}
}
\ No newline at end of file
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"/>
</vector>
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