diff --git a/app/src/main/java/io/github/mohamedisoliman/pixapay/MVI.kt b/app/src/main/java/io/github/mohamedisoliman/pixapay/MVI.kt new file mode 100644 index 0000000..e27b274 --- /dev/null +++ b/app/src/main/java/io/github/mohamedisoliman/pixapay/MVI.kt @@ -0,0 +1,41 @@ +package io.github.mohamedisoliman.pixapay + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +interface UiEvent + +interface UiState + + +@OptIn(FlowPreview::class, kotlinx.coroutines.ExperimentalCoroutinesApi::class) +abstract class BaseViewModel(initState: S) : ViewModel() { + + + private val events = MutableSharedFlow() + private val _states = MutableStateFlow(initState) + val states = _states.asStateFlow() + + init { + events.flatMapMerge { it.eventToUsecase() } + .onEach { receiveState(it) } + .launchIn(viewModelScope) + } + + + abstract fun E.eventToUsecase(): Flow + + + fun emitEvent(event: E) { + viewModelScope.launch { events.emit(event) } + } + + open fun receiveState(state: S) { + _states.value = state + } + +} + diff --git a/app/src/main/java/io/github/mohamedisoliman/pixapay/data/entities/ImageModel.kt b/app/src/main/java/io/github/mohamedisoliman/pixapay/data/entities/ImageModel.kt index 31ff0d7..25e1ceb 100644 --- a/app/src/main/java/io/github/mohamedisoliman/pixapay/data/entities/ImageModel.kt +++ b/app/src/main/java/io/github/mohamedisoliman/pixapay/data/entities/ImageModel.kt @@ -2,11 +2,11 @@ package io.github.mohamedisoliman.pixapay.data.entities data class ImageModel( val imageId: Long = -1, - val userName: String, - val url: String, - val likes: String, - val downloads: String, - val comments: String, - val tags: List, - val largeImageURL: String?, + val userName: String = "", + val url: String = "", + val likes: String = "", + val downloads: String = "", + val comments: String = "", + val tags: List = emptyList(), + val largeImageURL: String? = "", ) \ No newline at end of file diff --git a/app/src/main/java/io/github/mohamedisoliman/pixapay/domain/SearchUsecase.kt b/app/src/main/java/io/github/mohamedisoliman/pixapay/domain/SearchUsecase.kt index 34aa301..cad7394 100644 --- a/app/src/main/java/io/github/mohamedisoliman/pixapay/domain/SearchUsecase.kt +++ b/app/src/main/java/io/github/mohamedisoliman/pixapay/domain/SearchUsecase.kt @@ -1,5 +1,6 @@ package io.github.mohamedisoliman.pixapay.domain +import io.github.mohamedisoliman.pixapay.UiState import io.github.mohamedisoliman.pixapay.data.ImagesRepositoryContract import io.github.mohamedisoliman.pixapay.data.entities.toImageModel import io.github.mohamedisoliman.pixapay.data.entities.ImageModel @@ -13,16 +14,37 @@ class SearchUsecase @Inject constructor( operator fun invoke(query: String): Flow { return imagesRepository.search(query) .map { list -> list.map { it.toImageModel() } } - .map { if (it.isEmpty()) SearchState.Empty else SearchState.Success(it) } - .onStart { emit(SearchState.Loading) } - .catch { emit(SearchState.Error(it)) } + .map { + if (it.isEmpty()) + SearchState.EmptyResult + else + SearchState.Success(searchText = query, result = it) + }.onStart { emit(SearchState.Loading) } + .catch { emit(SearchState.Error(searchText = query, it)) } } } -sealed class SearchState { - object Empty : SearchState() - class Success(val images: List) : SearchState() - class Error(val throwable: Throwable) : SearchState() - object Loading : SearchState() +sealed class SearchState( + val isLoading: Boolean = false, + val result: List? = null, + val searchText: String? = null, + val throwable: Throwable? = null, +) : UiState { + + class IDLE(searchText: String?) : SearchState(searchText = searchText) + + object EmptyResult : SearchState() + + class Success( + result: List?, + searchText: String?, + ) : SearchState(result = result, searchText = searchText) + + class Error( + searchText: String? = null, + throwable: Throwable? = null, + ) : SearchState(searchText = searchText, throwable = throwable) + + object Loading : SearchState(isLoading = true) } \ No newline at end of file diff --git a/app/src/main/java/io/github/mohamedisoliman/pixapay/ui/navigation.kt b/app/src/main/java/io/github/mohamedisoliman/pixapay/ui/navigation.kt index ecf6990..b581c5e 100644 --- a/app/src/main/java/io/github/mohamedisoliman/pixapay/ui/navigation.kt +++ b/app/src/main/java/io/github/mohamedisoliman/pixapay/ui/navigation.kt @@ -14,8 +14,10 @@ import io.github.mohamedisoliman.pixapay.R import io.github.mohamedisoliman.pixapay.ui.image_details.ImageDetailsScreen import io.github.mohamedisoliman.pixapay.ui.search.SearchImagesViewModel import io.github.mohamedisoliman.pixapay.ui.search.SearchScreen +import kotlinx.coroutines.ExperimentalCoroutinesApi +@OptIn(ExperimentalCoroutinesApi::class) @Composable fun AppNavigation( modifier: Modifier = Modifier, @@ -23,6 +25,9 @@ fun AppNavigation( ) { val viewModel: SearchImagesViewModel = hiltViewModel() + viewModel.navigateToDetails = { + navController.navigate("${Screen.ImageDetails.route}/${it}") + } NavHost( navController = navController, @@ -31,9 +36,7 @@ fun AppNavigation( ) { composable(Screen.Search.route) { - SearchScreen(viewModel) { - navController.navigate("${Screen.ImageDetails.route}/${it}") - } + SearchScreen(viewModel) } composable( diff --git a/app/src/main/java/io/github/mohamedisoliman/pixapay/ui/search/SearchImagesViewModel.kt b/app/src/main/java/io/github/mohamedisoliman/pixapay/ui/search/SearchImagesViewModel.kt index eb3785e..24cc637 100644 --- a/app/src/main/java/io/github/mohamedisoliman/pixapay/ui/search/SearchImagesViewModel.kt +++ b/app/src/main/java/io/github/mohamedisoliman/pixapay/ui/search/SearchImagesViewModel.kt @@ -1,49 +1,44 @@ package io.github.mohamedisoliman.pixapay.ui.search -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.mohamedisoliman.pixapay.BaseViewModel +import io.github.mohamedisoliman.pixapay.data.entities.ImageModel import io.github.mohamedisoliman.pixapay.domain.SearchState -import io.github.mohamedisoliman.pixapay.domain.SearchState.* +import io.github.mohamedisoliman.pixapay.domain.SearchState.EmptyResult +import io.github.mohamedisoliman.pixapay.domain.SearchState.Loading import io.github.mohamedisoliman.pixapay.domain.SearchUsecase -import io.github.mohamedisoliman.pixapay.data.entities.ImageModel -import kotlinx.coroutines.flow.* +import io.github.mohamedisoliman.pixapay.ui.search.SearchScreenEvent.SearchClicked +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.onStart import javax.inject.Inject @HiltViewModel class SearchImagesViewModel @Inject constructor( - val searchUsecase: SearchUsecase, -) : ViewModel() { + private val searchUsecase: SearchUsecase, +) : BaseViewModel(EmptyResult) { - private var _searchViewState = MutableStateFlow(Empty) - val searchViewState: StateFlow = _searchViewState - - private var _searchQueryState = MutableStateFlow("fruits") - val searchQueryState: StateFlow = _searchQueryState + private val _queryState = MutableStateFlow("fruits") + val query = _queryState.asStateFlow() + lateinit var navigateToDetails: (Long) -> Unit init { - onSearchClicked() + emitEvent(SearchClicked(query.value)) } - fun search(query: String) { - searchUsecase(query = query) - .onEach { _searchViewState.value = it } - .launchIn(viewModelScope) - } - - fun onSearchChange(searchText: String) { - _searchQueryState.value = searchText + fun onSearchQueryChange(query: String) { + _queryState.value = query } - - fun findImage(id: Long): ImageModel? = - (_searchViewState.value as? Success)?.images?.find { it.imageId == id } + fun findImage(id: Long): ImageModel? = states.value.result?.find { it.imageId == id } - fun onSearchClicked() { - search(_searchQueryState.value) + override fun SearchScreenEvent.eventToUsecase(): Flow { + return when (this) { + is SearchClicked -> searchUsecase(this.query) + } } - } diff --git a/app/src/main/java/io/github/mohamedisoliman/pixapay/ui/search/SearchMvi.kt b/app/src/main/java/io/github/mohamedisoliman/pixapay/ui/search/SearchMvi.kt new file mode 100644 index 0000000..343ff71 --- /dev/null +++ b/app/src/main/java/io/github/mohamedisoliman/pixapay/ui/search/SearchMvi.kt @@ -0,0 +1,9 @@ +package io.github.mohamedisoliman.pixapay.ui.search + +import io.github.mohamedisoliman.pixapay.UiEvent +import io.github.mohamedisoliman.pixapay.UiState + + +sealed class SearchScreenEvent : UiEvent { + data class SearchClicked(val query: String) : SearchScreenEvent() +} diff --git a/app/src/main/java/io/github/mohamedisoliman/pixapay/ui/search/SearchScreen.kt b/app/src/main/java/io/github/mohamedisoliman/pixapay/ui/search/SearchScreen.kt index 814c7d0..b92f1fa 100644 --- a/app/src/main/java/io/github/mohamedisoliman/pixapay/ui/search/SearchScreen.kt +++ b/app/src/main/java/io/github/mohamedisoliman/pixapay/ui/search/SearchScreen.kt @@ -29,41 +29,81 @@ import androidx.compose.ui.unit.dp import coil.annotation.ExperimentalCoilApi import coil.compose.rememberImagePainter import io.github.mohamedisoliman.pixapay.R +import io.github.mohamedisoliman.pixapay.UiState +import io.github.mohamedisoliman.pixapay.data.entities.ImageModel import io.github.mohamedisoliman.pixapay.domain.SearchState -import io.github.mohamedisoliman.pixapay.domain.SearchState.* import io.github.mohamedisoliman.pixapay.ui.common.ImageChips import io.github.mohamedisoliman.pixapay.ui.common.isPortrait import io.github.mohamedisoliman.pixapay.ui.common.toUiModel -import io.github.mohamedisoliman.pixapay.data.entities.ImageModel +import io.github.mohamedisoliman.pixapay.ui.search.SearchScreenEvent.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview @Preview @Composable fun PreviewSearch() { - SearchScreenContent(searchState = Empty) + SearchScreenContent() } +@OptIn(FlowPreview::class) +@ExperimentalCoroutinesApi @Composable -fun SearchScreen(viewModel: SearchImagesViewModel, onNavigateClicked: (Long) -> Unit = { }) { - val viewState by viewModel.searchViewState.collectAsState() - val query by viewModel.searchQueryState.collectAsState() +fun SearchScreen(viewModel: SearchImagesViewModel) { + val viewState by viewModel.states.collectAsState() + val query by viewModel.query.collectAsState() + var showDialog by remember { mutableStateOf(false) } + var imageId by remember { mutableStateOf(-1L) } + SearchScreenContent( - onImageClicked = onNavigateClicked, searchText = query, - searchState = viewState, - onSearchChange = { viewModel.onSearchChange(it) }, - onSearchClicked = { viewModel.onSearchClicked() } + searchMainView = { + viewState.StateToMainView(showDialog = { + showDialog = it + }, imageId = { + imageId = it + }) + + }, + onSearchChange = { viewModel.onSearchQueryChange(it) }, + onSearchClicked = { viewModel.emitEvent(SearchClicked(query)) }, + isLoading = viewState.isLoading ) + + ConfirmationDialog(showDialog = showDialog, onConfirm = { + showDialog = false + viewModel.navigateToDetails(imageId) + }) { + showDialog = false + } +} + +@Composable +private fun UiState.StateToMainView( + imageId: (Long) -> Unit, + showDialog: (Boolean) -> Unit, +) { + when (this) { + is SearchState.EmptyResult -> EmptyView() + is SearchState.Error -> this.throwable?.let { ErrorView(it) } + is SearchState.Success -> this.result?.let { list -> + ImageListView(list) { + imageId(it) + showDialog(true) + } + } + else -> { } + } } @Composable fun ConfirmationDialog( - showDialog: Boolean, + showDialog: Boolean = false, onConfirm: () -> Unit, onDismiss: () -> Unit, ) { - if (showDialog) { + if (showDialog) AlertDialog( modifier = Modifier.fillMaxWidth(), title = { Text(stringResource(R.string.dialog_title_open_image_details)) }, @@ -86,55 +126,37 @@ fun ConfirmationDialog( } } ) - } } @OptIn(ExperimentalComposeUiApi::class) @Composable private fun SearchScreenContent( searchText: String = "", + isLoading: Boolean = false, onSearchChange: (String) -> Unit = {}, onSearchClicked: () -> Unit = {}, - searchState: SearchState = Empty, - onImageClicked: (Long) -> Unit = {}, + searchMainView: @Composable () -> Unit = {}, ) { - var showDialog by remember { mutableStateOf(false) } - var imageId by remember { mutableStateOf(-1L) } - Box( modifier = Modifier.padding(top = 8.dp, start = 8.dp, end = 8.dp), contentAlignment = Alignment.TopCenter ) { - when (searchState) { - is Success -> ImageListView(searchState.images) { - imageId = it - showDialog = true - } - is Empty -> EmptyView(modifier = Modifier.align(Alignment.Center)) - is Error -> ErrorView(searchState.throwable) - } + searchMainView() SearchTopbar( searchText = searchText, onSearchChange = onSearchChange, onSearchClicked = onSearchClicked, - isLoading = searchState is Loading, + isLoading = isLoading, ) } - ConfirmationDialog(showDialog = showDialog, onConfirm = { - showDialog = false - onImageClicked(imageId) - }) { - showDialog = false - } - } @Composable -private fun EmptyView(modifier: Modifier) { +fun EmptyView(modifier: Modifier = Modifier) { PlaceHolderView( modifier = modifier, icon = Icons.Outlined.Pets, @@ -188,7 +210,7 @@ fun ErrorView( @OptIn(ExperimentalFoundationApi::class) @Composable -private fun ImageListView( +fun ImageListView( images: List, onImageClicked: (Long) -> Unit, ) { diff --git a/app/src/test/java/io/github/mohamedisoliman/pixapay/domain/SearchUsecaseTest.kt b/app/src/test/java/io/github/mohamedisoliman/pixapay/domain/SearchUsecaseTest.kt index b877e69..b3c4a6f 100644 --- a/app/src/test/java/io/github/mohamedisoliman/pixapay/domain/SearchUsecaseTest.kt +++ b/app/src/test/java/io/github/mohamedisoliman/pixapay/domain/SearchUsecaseTest.kt @@ -12,13 +12,13 @@ class SearchUsecaseTest { @Test - fun `search() Then start Loading`() = runBlocking { + fun `search() THEN start with Loading state`() = runBlocking { val hit = mockk() val repository = mockRepository(flowOf(listOf(hit, hit, hit))) val result = SearchUsecase(repository).invoke("flowers").first() - assert((result is SearchState.Loading)) + assert(result is SearchState.Loading) } @Test @@ -39,7 +39,7 @@ class SearchUsecaseTest { val result = SearchUsecase(repository).invoke("flowers").last() - assert((result is SearchState.Empty)) + assert((result is SearchState.EmptyResult)) } @Test @@ -51,7 +51,7 @@ class SearchUsecaseTest { val result = SearchUsecase(repository).invoke("flowers").last() - assert((result is SearchState.Success) && result.images.size == 3) + assert((result is SearchState.Success) && result.result?.size == 3) }