Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions app/src/main/java/io/github/mohamedisoliman/pixapay/MVI.kt
Original file line number Diff line number Diff line change
@@ -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<E : UiEvent, S : UiState>(initState: S) : ViewModel() {


private val events = MutableSharedFlow<E>()
private val _states = MutableStateFlow(initState)
val states = _states.asStateFlow()

init {
events.flatMapMerge { it.eventToUsecase() }
.onEach { receiveState(it) }
.launchIn(viewModelScope)
}


abstract fun E.eventToUsecase(): Flow<S>


fun emitEvent(event: E) {
viewModelScope.launch { events.emit(event) }
}

open fun receiveState(state: S) {
_states.value = state
}

}

Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
val largeImageURL: String?,
val userName: String = "",
val url: String = "",
val likes: String = "",
val downloads: String = "",
val comments: String = "",
val tags: List<String> = emptyList(),
val largeImageURL: String? = "",
)
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -13,16 +14,37 @@ class SearchUsecase @Inject constructor(
operator fun invoke(query: String): Flow<SearchState> {
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<ImageModel>) : SearchState()
class Error(val throwable: Throwable) : SearchState()
object Loading : SearchState()
sealed class SearchState(
val isLoading: Boolean = false,
val result: List<ImageModel>? = null,
val searchText: String? = null,
val throwable: Throwable? = null,
) : UiState {

class IDLE(searchText: String?) : SearchState(searchText = searchText)

object EmptyResult : SearchState()

class Success(
result: List<ImageModel>?,
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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,20 @@ 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,
navController: NavHostController,
) {

val viewModel: SearchImagesViewModel = hiltViewModel()
viewModel.navigateToDetails = {
navController.navigate("${Screen.ImageDetails.route}/${it}")
}

NavHost(
navController = navController,
Expand All @@ -31,9 +36,7 @@ fun AppNavigation(
) {

composable(Screen.Search.route) {
SearchScreen(viewModel) {
navController.navigate("${Screen.ImageDetails.route}/${it}")
}
SearchScreen(viewModel)
}

composable(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SearchScreenEvent, SearchState>(EmptyResult) {

private var _searchViewState = MutableStateFlow<SearchState>(Empty)
val searchViewState: StateFlow<SearchState> = _searchViewState

private var _searchQueryState = MutableStateFlow("fruits")
val searchQueryState: StateFlow<String> = _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<SearchState> {
return when (this) {
is SearchClicked -> searchUsecase(this.query)
}
}

}
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)) },
Expand All @@ -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,
Expand Down Expand Up @@ -188,7 +210,7 @@ fun ErrorView(

@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ImageListView(
fun ImageListView(
images: List<ImageModel>,
onImageClicked: (Long) -> Unit,
) {
Expand Down
Loading