Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • barysole/ackee-test-task
1 result
Show changes
Commits on Source (2)
Showing
with 251 additions and 201 deletions
......@@ -14,9 +14,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.navigation.NavController
import androidx.navigation.NavDestination.Companion.hierarchy
......@@ -39,6 +37,8 @@ class MainActivity : ComponentActivity() {
val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState)
setContent {
//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.
MainScreen()
}
}
......@@ -46,7 +46,7 @@ class MainActivity : ComponentActivity() {
}
// Main handler for screens changing. Perform every (!) screen change operation in the app.
// Main handler for navigation performing. Perform every (!) navigation operation in the app.
// todo: split into separate navigation methods
fun navigate(
navController: NavController,
......@@ -70,49 +70,64 @@ fun navigate(
} else if (nextScreen == Screen.Previous) {
navController.navigateUp()
}
}
}
@Composable
fun MainScreen() {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
val selectedScreen = remember {
// the initial screen
mutableStateOf(appScreenList.find { item -> currentDestination?.hierarchy?.first()?.route == item.route }
?: Screen.Characters)
}
val snackbarHostState = remember { SnackbarHostState() }
val actionFlow = MutableSharedFlow<ScreenAction>(extraBufferCapacity = 1)
val onActionClick = { action: ScreenAction ->
actionFlow.tryEmit(action)
}
var topBarTitle: String by rememberSaveable {
// Top bar settings block
// Top bar will set the topBarTitle if current screen doesn't have title
val topBarTitle = rememberSaveable {
mutableStateOf("")
}
var isFavoriteIconActive by rememberSaveable {
// Favorite icon state for the detail screen
val isFavoriteIconActive = rememberSaveable {
mutableStateOf(false)
}
val onTopBarTitleChangeFun = { newBarTitle: String -> topBarTitle = newBarTitle }
val onTopBarFavoriteIconChangeFun =
{ isFavoriteActive: Boolean -> isFavoriteIconActive = isFavoriteActive }
val onTopBarTitleChange =
remember { { newBarTitle: String -> topBarTitle.value = newBarTitle } }
val onTopBarFavoriteIconStateChange =
remember { { isFavoriteActive: Boolean -> isFavoriteIconActive.value = isFavoriteActive } }
val isSearchBarShowing = rememberSaveable {
mutableStateOf(false)
}
// Top bar action flow
val actionFlow = remember { MutableSharedFlow<ScreenAction>(extraBufferCapacity = 1) }
val onActionClick = remember {
{ action: ScreenAction ->
actionFlow.tryEmit(action)
}
}
// Navigation block.
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
// Current screen.
val selectedScreen = remember {
// The initial screen.
mutableStateOf(appScreenList.find { item -> navBackStackEntry?.destination?.hierarchy?.first()?.route == item.route }
?: Screen.Characters)
}
// Changes selectedScreen when the route changes
navController.addOnDestinationChangedListener { _, destination, _ ->
// Close search bar when navigation was performed
isSearchBarShowing.value = false
if (selectedScreen.value == Screen.CharacterDetail) {
onTopBarTitleChange("")
}
// Take current screen from the the top of the current navigation stack after navigation
selectedScreen.value =
appScreenList.find { item -> destination.hierarchy.first().route == item.route }
?: Screen.Characters
}
val onScreenSelected = { screen: Screen, args: List<Any>? ->
navigate(navController, selectedScreen, screen, args)
// onScreenSelected perform all navigation in the app
val onSelectScreen = remember {
{ screen: Screen, args: List<Any>? ->
navigate(navController, selectedScreen, screen, args)
}
}
// Snackbar
val snackbarHostState = remember { SnackbarHostState() }
// Main content
AckeeTestTaskTheme {
Scaffold(
......@@ -120,33 +135,30 @@ fun MainScreen() {
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
MyTopAppBar(
selectedScreen,
{ action -> onActionClick(action) },
barTitle = topBarTitle,
isFavoriteIconEnabled = isFavoriteIconActive,
selectedScreen = selectedScreen.value,
onActionClick = { action -> onActionClick(action) },
barTitle = topBarTitle.value,
isFavoriteIconEnabled = isFavoriteIconActive.value,
isSearchBarShowing = isSearchBarShowing,
onScreenSelected = onScreenSelected
onSelectScreen = onSelectScreen
)
},
content = { innerPadding ->
MyNavHost(
navController,
snackbarHostState,
Modifier.padding(innerPadding),
actionFlow,
onTopBarTitleChange = onTopBarTitleChangeFun,
onTopBarFavoriteIconChange = onTopBarFavoriteIconChangeFun,
onScreenSelected = onScreenSelected
navController = navController,
snackbarHostState = snackbarHostState,
modifier = Modifier.padding(innerPadding),
screenActionFlow = actionFlow,
onTopBarTitleChange = onTopBarTitleChange,
onTopBarFavoriteIconChange = onTopBarFavoriteIconStateChange,
onSelectScreen = onSelectScreen
)
},
bottomBar = {
MyNavigationBar(selectedScreen, onScreenSelected)
MyNavigationBar(
selectedScreen = selectedScreen.value,
onSelectScreen = onSelectScreen
)
})
}
}
@Preview
@Composable
fun MainScreenPreview() {
MainScreen()
}
\ No newline at end of file
......@@ -24,7 +24,7 @@ fun MyNavHost(
screenActionFlow: Flow<ScreenAction>,
onTopBarTitleChange: (String) -> Unit,
onTopBarFavoriteIconChange: (Boolean) -> Unit,
onScreenSelected: (Screen, List<Any>?) -> Unit
onSelectScreen: (Screen, List<Any>?) -> Unit
) {
NavHost(
navController,
......@@ -32,28 +32,28 @@ fun MyNavHost(
modifier = modifier
) {
composable(Screen.Characters.route) {
CustomBackHandler(onScreenSelected)
CustomBackHandler(onSelectScreen)
CharacterListScreen(
snackbarHostState,
onItemSelected = { characterId ->
onScreenSelected(Screen.CharacterDetail, listOf(characterId))
onSelectItem = { characterId ->
onSelectScreen(Screen.CharacterDetail, listOf(characterId))
}
)
}
composable(Screen.Favorite.route) {
CustomBackHandler(onScreenSelected)
CustomBackHandler(onSelectScreen)
FavoriteCharacterListScreen(
onItemSelected = { characterId ->
onScreenSelected(Screen.CharacterDetail, listOf(characterId))
})
onSelectItem = { characterId ->
onSelectScreen(Screen.CharacterDetail, listOf(characterId))
})
}
composable(
Screen.CharacterDetail.route,
arguments = listOf(navArgument("characterId") { type = NavType.LongType })
) { backStackEntry ->
CustomBackHandler(onScreenSelected)
CustomBackHandler(onSelectScreen)
CharacterDetailScreen(
backStackEntry.arguments?.getLong("characterId"),
characterId = backStackEntry.arguments?.getLong("characterId"),
screenActionFlow,
onTopBarTitleChange,
onTopBarFavoriteIconChange
......@@ -63,9 +63,9 @@ fun MyNavHost(
}
@Composable
fun CustomBackHandler(onScreenSelected: (Screen, List<Any>?) -> Unit) {
fun CustomBackHandler(onSelectScreen: (Screen, List<Any>?) -> Unit) {
BackHandler(true) {
onScreenSelected(Screen.Previous, null)
onSelectScreen(Screen.Previous, null)
}
}
package cz.fel.barysole.ackeetesttask.db.room.dao
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
......
......@@ -5,9 +5,6 @@ import androidx.room.Entity
import androidx.room.PrimaryKey
import com.google.gson.annotations.SerializedName
/**
* Immutable model class for the Retrofit and the Room.
*/
@Entity(tableName = "CharacterInfo")
data class CharacterInfo(
@PrimaryKey @field:SerializedName("id") val id: Long,
......
package cz.fel.barysole.ackeetesttask.repository
class RepositoryResult {
}
\ No newline at end of file
......@@ -37,7 +37,7 @@ class CharacterRemoteMediator(
// compute required page index
val requiredPage = when (loadType) {
LoadType.REFRESH -> {
STARTING_PAGE_NUMBER
state.anchorPosition ?: STARTING_PAGE_NUMBER
}
// REFRESH will always load the first page in the list, so prepend is no longer needed.
LoadType.PREPEND -> {
......
......@@ -19,14 +19,19 @@ class CharacterRepositoryImpl @Inject constructor(
private val appDatabase: AppDatabase
) : CharacterRepository {
// Find chracters by name query. Works without DB.
// Find characters 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 = { CharactersSearchPagingSource(rickAndMortyApi, characterNameQuery) }
pagingSourceFactory = {
CharactersSearchPagingSource(
rickAndMortyApi,
characterNameQuery
)
}
).flow
}
......@@ -53,11 +58,11 @@ class CharacterRepositoryImpl @Inject constructor(
}
override suspend fun getCharacter(characterId: Long): CharacterInfo? {
val response = rickAndMortyApi.getCharacterById(characterId)
appDatabase.withTransaction {
insertCharacterItems(listOf(response), appDatabase.characterDao())
}
return appDatabase.characterDao().getById(characterId)
val response = rickAndMortyApi.getCharacterById(characterId)
appDatabase.withTransaction {
insertCharacterItems(listOf(response), appDatabase.characterDao())
}
return appDatabase.characterDao().getById(characterId)
}
override suspend fun changeCharacterFavoriteStatus(characterId: Long) {
......
package cz.fel.barysole.ackeetesttask.ui.screen
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import cz.fel.barysole.ackeetesttask.R
sealed class Screen(
val route: String = "",
val nameResId: Int? = null,
@StringRes val nameResId: Int? = null,
@DrawableRes val iconResId: Int? = null,
val routeWithoutArgument: String? = null,
val isBackButtonShowing: Boolean = false,
......@@ -46,10 +47,4 @@ val appScreenList = listOf(
Screen.Characters,
Screen.Favorite,
Screen.CharacterDetail
)
val screenWithTopAppBarList = listOf(
Screen.Characters,
Screen.Favorite,
Screen.CharacterDetail
)
\ No newline at end of file
......@@ -21,7 +21,12 @@ fun CharacterDetailScreen(
onTopBarFavoriteIconChange: (Boolean) -> Unit,
viewModel: CharacterDetailViewModel = hiltViewModel()
) {
viewModel.initialize(characterId, onTopBarTitleChange, onTopBarFavoriteIconChange, screenActionFlow)
viewModel.initialize(
characterId,
onTopBarTitleChange,
onTopBarFavoriteIconChange,
screenActionFlow
)
val characterDetailScreenState by viewModel.uiState.collectAsState()
Surface(
shape = MaterialTheme.shapes.large,
......
......@@ -6,12 +6,14 @@ import cz.fel.barysole.ackeetesttask.model.CharacterInfo
import cz.fel.barysole.ackeetesttask.repository.characters.CharacterRepository
import cz.fel.barysole.ackeetesttask.ui.screen.ScreenAction
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@HiltViewModel
......@@ -20,13 +22,19 @@ class CharacterDetailViewModel @Inject constructor(
) : ViewModel() {
private var isInit: Boolean = false
// The UI state
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
var onTopBarTitleChange: (String) -> Unit = {}
var onTopBarFavoriteIconChange: (Boolean) -> Unit = {}
fun initialize(characterId: Long?, onTopBarTitleChange: (String) -> Unit = {}, onTopBarFavoriteIconChange: (Boolean) -> Unit = {}, screenFlow: Flow<ScreenAction>? = null) {
fun initialize(
characterId: Long?,
onTopBarTitleChange: (String) -> Unit = {},
onTopBarFavoriteIconChange: (Boolean) -> Unit = {},
screenFlow: Flow<ScreenAction>? = null
) {
if (!isInit) {
this.onTopBarTitleChange = onTopBarTitleChange
this.onTopBarFavoriteIconChange = onTopBarFavoriteIconChange
......@@ -46,9 +54,11 @@ class CharacterDetailViewModel @Inject constructor(
showLoadingError(false)
characterId?.let {
viewModelScope.launch {
val characterInfo = characterRepository.getCharacter(
characterId
)
val characterInfo = withContext(Dispatchers.IO) {
characterRepository.getCharacter(
characterId
)
}
onTopBarTitleChange(characterInfo?.name ?: "")
onTopBarFavoriteIconChange(characterInfo?.isFavorite ?: false)
_uiState.update { state ->
......
......@@ -15,6 +15,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
......@@ -22,6 +23,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
import com.bumptech.glide.integration.compose.GlideImage
import cz.fel.barysole.ackeetesttask.R
import cz.fel.barysole.ackeetesttask.model.CharacterInfo
import cz.fel.barysole.ackeetesttask.model.Location
import cz.fel.barysole.ackeetesttask.model.Origin
......@@ -43,7 +45,7 @@ fun CharacterInfo(
character.image?.let {
GlideImage(
model = it,
contentDescription = "Character image",
contentDescription = stringResource(R.string.character_image_description),
modifier = Modifier
.padding(top = topPaddingForElements, start = startPaddingForElements)
.size(128.dp)
......@@ -57,7 +59,7 @@ fun CharacterInfo(
)
) {
Text(
text = "Name",
text = stringResource(R.string.name_field_title),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.secondary,
maxLines = 1,
......@@ -80,7 +82,7 @@ fun CharacterInfo(
Row(modifier = Modifier.padding(start = startPaddingForElements)) {
Column {
Text(
text = "Status",
text = stringResource(R.string.status_field_title),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary,
maxLines = 1,
......@@ -97,7 +99,7 @@ fun CharacterInfo(
)
Spacer(modifier = Modifier.height(itemSpaceSize))
Text(
text = "Type",
text = stringResource(R.string.type_field_title),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary,
maxLines = 1,
......@@ -114,7 +116,7 @@ fun CharacterInfo(
)
Spacer(modifier = Modifier.height(itemSpaceSize))
Text(
text = "Gender",
text = stringResource(R.string.gender_field_title),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary,
maxLines = 1,
......@@ -131,7 +133,7 @@ fun CharacterInfo(
)
Spacer(modifier = Modifier.height(itemSpaceSize))
Text(
text = "Origin",
text = stringResource(R.string.origin_field_title),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary,
maxLines = 1,
......@@ -148,7 +150,7 @@ fun CharacterInfo(
)
Spacer(modifier = Modifier.height(itemSpaceSize))
Text(
text = "Location",
text = stringResource(R.string.location_field_title),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary,
maxLines = 1,
......@@ -181,7 +183,7 @@ fun CharacterInfoPreview() {
gender = "Male",
origin = Origin("Earth"),
location = Location("Earth"),
image = "https://rickandmortyapi.com/api/character/avatar/2.jpeg"
image = null
)
)
}
\ No newline at end of file
......@@ -14,21 +14,23 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
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
@Composable
fun CharacterListScreen(
snackbar: SnackbarHostState,
onItemSelected: (id: Long) -> Unit,
snackbarHostState: SnackbarHostState,
onSelectItem: (id: Long) -> Unit,
characterListViewModel: CharacterListViewModel = hiltViewModel()
) {
Surface(
color = MaterialTheme.colorScheme.background
) {
characterListViewModel.pagingDataFlow.collectAsLazyPagingItems().let {
characterListViewModel.charactersPDFlow.collectAsLazyPagingItems().let {
if (it.loadState.mediator?.refresh is LoadState.Error || it.loadState.mediator?.append is LoadState.Error) {
characterListViewModel.showLoadingError(true)
} else {
......@@ -36,44 +38,35 @@ fun CharacterListScreen(
}
// If the UI state contains an error, show snackbar
if (characterListViewModel.uiState.value.showError) {
LaunchedEffect(snackbar) {
snackbar.showSnackbar(
message = "Data cannot be loaded!"
val errorMessage = stringResource(R.string.data_cannot_be_loaded_error)
LaunchedEffect(snackbarHostState) {
snackbarHostState.showSnackbar(
message = errorMessage
)
}
}
// todo: there is no pullToRefresh in MD3 at this moment :c Add it later.
if (it.itemCount == 0) {
// todo: there is no pullToRefresh in MD3 at this moment. Add it later. :c
if (it.itemCount == 0 && it.loadState.refresh != LoadState.Loading) {
Column(
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(
onClick = { characterListViewModel.refreshList() },
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surface, contentColor = MaterialTheme.colorScheme.primary)
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.primary
)
) {
Text("Reload")
Text(stringResource(R.string.reload_text))
}
}
} else {
CharacterList(it, onItemSelected)
CharacterList(it, onSelectItem)
}
}
}
}
/*
@Preview
@Composable
fun CharacterListPreview() {
CharacterList(
(
CharacterInfo(
"1",
"Rick Sanchez",
"Alive"
), CharacterInfo("2", "Morty", "Alive")
)
)
}*/
}
\ No newline at end of file
......@@ -6,8 +6,8 @@ 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.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
......@@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
......@@ -28,14 +29,18 @@ class CharacterListViewModel @Inject constructor(
// The UI state
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
val pagingDataFlow: Flow<PagingData<CharacterInfo>>
val charactersPDFlow: Flow<PagingData<CharacterInfo>>
private val getAllCharacters = MutableSharedFlow<Unit>()
init {
pagingDataFlow = getAllCharacters
charactersPDFlow = getAllCharacters
.onStart { emit(Unit) }
.flatMapLatest { getCharactersPagingData() }
.flatMapLatest {
withContext(Dispatchers.IO) {
characterRepository.getCharactersFlow()
}
}
.cachedIn(viewModelScope)
}
......@@ -44,13 +49,12 @@ class CharacterListViewModel @Inject constructor(
}
fun refreshList() {
showLoadingError(false)
viewModelScope.launch { getAllCharacters.emit(Unit) }
viewModelScope.launch {
showLoadingError(false)
getAllCharacters.emit(Unit)
}
}
private fun getCharactersPagingData(): Flow<PagingData<CharacterInfo>> =
characterRepository.getCharactersFlow()
data class UiState(
val showError: Boolean = false
)
......
......@@ -4,26 +4,26 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
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.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.paging.compose.collectAsLazyPagingItems
import cz.fel.barysole.ackeetesttask.R
import cz.fel.barysole.ackeetesttask.ui.uielement.character.CharacterList
@Composable
fun FavoriteCharacterListScreen(
onItemSelected: (id: Long) -> Unit,
onSelectItem: (id: Long) -> Unit,
favoriteCharacterListViewModel: FavoriteCharacterListViewModel = hiltViewModel()
) {
favoriteCharacterListViewModel.refreshList()
val favoriteCharacters =
favoriteCharacterListViewModel.pagingDataFlow.collectAsLazyPagingItems()
favoriteCharacterListViewModel.favoriteCharactersPDFlow.collectAsLazyPagingItems()
Surface(
color = MaterialTheme.colorScheme.background
) {
......@@ -35,10 +35,10 @@ fun FavoriteCharacterListScreen(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("There is no favorite characters yet.")
Text(stringResource(R.string.no_favorite_characters_text))
}
} else {
CharacterList(favoriteCharacters, onItemSelected)
CharacterList(favoriteCharacters, onSelectItem)
}
}
}
\ No newline at end of file
......@@ -4,40 +4,37 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.room.withTransaction
import cz.fel.barysole.ackeetesttask.db.room.AppDatabase
import cz.fel.barysole.ackeetesttask.model.CharacterInfo
import cz.fel.barysole.ackeetesttask.repository.characters.CharacterRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
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.flatMapLatest
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class FavoriteCharacterListViewModel @Inject constructor(
private val characterRepository: CharacterRepository,
private val appDatabase: AppDatabase
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 favoriteCharactersPDFlow: Flow<PagingData<CharacterInfo>>
private val getFavoriteCharacters = MutableSharedFlow<Unit>()
init {
pagingDataFlow = getFavoriteCharacters
favoriteCharactersPDFlow = getFavoriteCharacters
.onStart { emit(Unit) }
.flatMapLatest { characterRepository.getFavoriteCharactersFlow() }
.flatMapLatest {
withContext(Dispatchers.IO) {
characterRepository.getFavoriteCharactersFlow()
}
}
.cachedIn(viewModelScope)
}
......@@ -45,12 +42,4 @@ class FavoriteCharacterListViewModel @Inject constructor(
viewModelScope.launch { getFavoriteCharacters.emit(Unit) }
}
fun showLoadingError(hasError: Boolean) {
_uiState.value = UiState(showError = hasError)
}
data class UiState(
val showError: Boolean = false
)
}
\ No newline at end of file
......@@ -12,7 +12,7 @@ import cz.fel.barysole.ackeetesttask.model.CharacterInfo
@Composable
fun CharacterList(
characterList: LazyPagingItems<CharacterInfo>,
onItemSelected: (id: Long) -> Unit
onSelectItem: (id: Long) -> Unit
) {
LazyColumn(modifier = Modifier.padding(vertical = 4.dp)) {
items(
......@@ -20,7 +20,7 @@ fun CharacterList(
contentType = { if (characterList[it] == null) 1 else 0 }
) { index ->
characterList[index]?.let {
CharacterListItem(it, onItemSelected)
CharacterListItem(it, onSelectItem)
}
}
}
......
......@@ -19,25 +19,28 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
import com.bumptech.glide.integration.compose.GlideImage
import cz.fel.barysole.ackeetesttask.R
import cz.fel.barysole.ackeetesttask.model.CharacterInfo
import cz.fel.barysole.ackeetesttask.ui.screen.Screen
import cz.fel.barysole.ackeetesttask.model.Location
import cz.fel.barysole.ackeetesttask.model.Origin
@OptIn(ExperimentalGlideComposeApi::class)
@Composable
fun CharacterListItem(character: CharacterInfo, onItemSelected: (id: Long) -> Unit) {
fun CharacterListItem(character: CharacterInfo, onSelectItem: (id: Long) -> Unit) {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surface,
shadowElevation = 4.dp,
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.clickable { onItemSelected(character.id) }
.clickable { onSelectItem(character.id) }
) {
Row(
modifier = Modifier
......@@ -48,7 +51,7 @@ fun CharacterListItem(character: CharacterInfo, onItemSelected: (id: Long) -> Un
character.image?.let {
GlideImage(
model = it,
contentDescription = "Character image",
contentDescription = stringResource(R.string.character_image_description),
modifier = Modifier
.size(48.dp)
.clip(MaterialTheme.shapes.small)
......@@ -56,7 +59,7 @@ fun CharacterListItem(character: CharacterInfo, onItemSelected: (id: Long) -> Un
}
Spacer(modifier = Modifier.width(12.dp))
Column {
Row (verticalAlignment = Alignment.CenterVertically) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "${character.name}",
style = MaterialTheme.typography.titleMedium,
......@@ -71,7 +74,7 @@ fun CharacterListItem(character: CharacterInfo, onItemSelected: (id: Long) -> Un
.padding(horizontal = 8.dp)
.size(16.dp),
tint = Color.Blue,
contentDescription = "Arrow back icon"
contentDescription = stringResource(R.string.arrow_back_icon_description)
)
}
}
......@@ -88,4 +91,22 @@ fun CharacterListItem(character: CharacterInfo, onItemSelected: (id: Long) -> Un
}
}
}
}
@Preview
@Composable
fun CharacterInfoPreview() {
CharacterListItem(
character = CharacterInfo(
id = 2,
name = "Morty Smith",
status = "Alive",
species = "Human",
type = "",
gender = "Male",
origin = Origin("Earth"),
location = Location("Earth"),
image = null
)
) {}
}
\ No newline at end of file
package cz.fel.barysole.ackeetesttask.ui.uielement.main
package cz.fel.barysole.ackeetesttask.ui.uielement.charactersearch
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
......@@ -7,6 +7,7 @@ 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.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
......@@ -18,6 +19,7 @@ import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
......@@ -37,21 +39,20 @@ constructor(
init {
pagingDataFlow = searchCharacterByName
.distinctUntilChanged()
.onEach { name ->
_uiState.value = UiState(query = name)
.onEach { characterName ->
_uiState.value = UiState(query = characterName)
}
.filterNot { name -> name.isBlank() }
.flatMapLatest { name ->
searchCharacter(characterName = name)
.filterNot { characterName -> characterName.isBlank() }
.flatMapLatest { characterName ->
withContext(Dispatchers.IO) {
characterRepository.getCharactersByNameFlow(characterName)
}
}
.cachedIn(viewModelScope)
}
private fun searchCharacter(characterName: String): Flow<PagingData<CharacterInfo>> =
characterRepository.getCharactersByNameFlow(characterName)
fun onCharacterSearch(characterName: String) {
viewModelScope.launch { searchCharacterByName.emit(characterName) };
viewModelScope.launch { searchCharacterByName.emit(characterName) }
}
fun onFieldClean() {
......
package cz.fel.barysole.ackeetesttask.ui.uielement.main
package cz.fel.barysole.ackeetesttask.ui.uielement.charactersearch
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
......@@ -10,18 +9,17 @@ 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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
......@@ -30,13 +28,12 @@ import androidx.paging.compose.collectAsLazyPagingItems
import cz.fel.barysole.ackeetesttask.R
import cz.fel.barysole.ackeetesttask.ui.screen.Screen
import cz.fel.barysole.ackeetesttask.ui.uielement.character.CharacterList
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MySearchBar(
isSearchBarShowing: MutableState<Boolean>,
onScreenSelected: (Screen, List<Any>?) -> Unit,
onSelectScreen: (Screen, List<Any>?) -> Unit,
characterSearchViewModel: CharacterSearchViewModel = hiltViewModel()
) {
val isSearchBarIsActive = rememberSaveable {
......@@ -55,19 +52,19 @@ fun MySearchBar(
active = isSearchBarIsActive.value,
onActiveChange = { isSearchBarIsActive.value = it },
placeholder = {
Text(text = "Start enter a name...")
Text(text = stringResource(R.string.search_bar_placeholder))
},
leadingIcon = {
Icon(
painterResource(R.drawable.baseline_arrow_back_ios_new_24),
modifier = Modifier.clickable { isSearchBarShowing.value = false },
contentDescription = "Arrow back icon"
contentDescription = stringResource(R.string.arrow_back_icon_description)
)
},
trailingIcon = {
Icon(
painterResource(R.drawable.baseline_close_24),
contentDescription = "Close icon",
contentDescription = stringResource(R.string.close_icon_description),
modifier = Modifier.clickable {
if (searchBarState.query.isNotBlank()) {
characterSearchViewModel.onFieldClean()
......@@ -84,12 +81,21 @@ fun MySearchBar(
// do not show elements when the query is empty
if (searchBarState.query.isNotBlank()) {
if (it.loadState.refresh is LoadState.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)
Box(
modifier = Modifier
.fillMaxWidth(1f)
.padding(vertical = 64.dp),
contentAlignment = Alignment.Center
) {
Text(
stringResource(R.string.data_cannot_be_loaded_error),
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center
)
}
} else {
CharacterList(it) { characterId ->
onScreenSelected(
onSelectScreen(
Screen.CharacterDetail,
listOf(characterId)
)
......@@ -98,4 +104,4 @@ fun MySearchBar(
}
}
}
}
}
\ No newline at end of file
......@@ -5,29 +5,33 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import cz.fel.barysole.ackeetesttask.R
import cz.fel.barysole.ackeetesttask.ui.screen.Screen
@Composable
fun MyNavigationBar(
selectedScreen: MutableState<Screen>,
onScreenSelected: (Screen, List<Any>?) -> Unit
selectedScreen: Screen,
onSelectScreen: (Screen, List<Any>?) -> Unit
) {
val navBarItems = listOf(
Screen.Characters,
Screen.Favorite
)
if (selectedScreen.value.showBottomBar == true) {
val navBarItems = remember {
listOf(
Screen.Characters,
Screen.Favorite
)
}
if (selectedScreen.showBottomBar) {
NavigationBar(
modifier = Modifier.shadow(8.dp),
containerColor = MaterialTheme.colorScheme.surface,
......@@ -39,18 +43,29 @@ fun MyNavigationBar(
if (screen.iconResId != null) {
Icon(
painterResource(screen.iconResId),
contentDescription = stringResource(screen.nameResId?: R.string.empty_string),
contentDescription = stringResource(
screen.nameResId ?: R.string.empty_string
),
modifier = Modifier.size(24.dp)
)
}
},
label = { Text(stringResource(screen.nameResId?: R.string.empty_string)) },
selected = selectedScreen.value == screen,
colors = NavigationBarItemDefaults.colors(selectedIconColor = Color.Blue),
label = { Text(stringResource(screen.nameResId ?: R.string.empty_string)) },
selected = selectedScreen == screen,
onClick = {
onScreenSelected(screen, null)
onSelectScreen(screen, null)
}
)
}
}
}
}
@Preview
@Composable
fun MyNavigationBarPreview() {
MyNavigationBar(
Screen.Characters
) { _, _ -> }
}
\ No newline at end of file