Skip to content

Commit 90e93d3

Browse files
authored
Merge pull request #11 Update UI and ViewModel events
Update UI and ViewModel events
2 parents a6f5530 + 6701753 commit 90e93d3

File tree

17 files changed

+470
-59
lines changed

17 files changed

+470
-59
lines changed

.idea/compiler.xml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.idea/misc.xml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/src/main/java/ercanduman/recipeapplication/domain/usecase/SearchRecipeUseCase.kt

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@ class SearchRecipeUseCase @Inject constructor(
2121
searchQuery: String
2222
): RecipeListUiState {
2323
return when (val searchResult = repository.searchRecipes(page, searchQuery)) {
24-
RecipeResult.Loading -> RecipeListUiState.Loading
24+
RecipeResult.Loading -> RecipeListUiState()
2525

2626
is RecipeResult.Error -> {
2727
val errorMessage: String = searchResult.message
28-
RecipeListUiState.Error(errorMessage)
28+
RecipeListUiState(errorMessage = errorMessage)
2929
}
3030

3131
is RecipeResult.Success -> {
@@ -44,9 +44,12 @@ class SearchRecipeUseCase @Inject constructor(
4444
val appendedRecipeList: List<Recipe> = currentRecipeList.toList()
4545
return if (appendedRecipeList.isEmpty()) {
4646
val errorMessage = appResourcesProvider.getString(R.string.error_no_data_found)
47-
RecipeListUiState.Error(errorMessage = errorMessage)
47+
RecipeListUiState(errorMessage = errorMessage)
4848
} else {
49-
RecipeListUiState.Success(appendedRecipeList)
49+
RecipeListUiState(
50+
recipes = appendedRecipeList,
51+
isLoading = false
52+
)
5053
}
5154
}
5255

app/src/main/java/ercanduman/recipeapplication/ui/recipe/detail/RecipeDetailsFragment.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import ercanduman.recipeapplication.ui.recipe.detail.model.RecipeDetailsUiState
2323

2424
const val KEY_RECIPE_ID: String = "RecipeDetailsFragment.recipeId"
2525
const val INVALID_RECIPE_ID: Int = -1
26+
const val INVALID_ERROR_MESSAGE: String = ""
2627

2728
@AndroidEntryPoint
2829
class RecipeDetailsFragment : Fragment() {

app/src/main/java/ercanduman/recipeapplication/ui/recipe/list/RecipeListFragment.kt

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import ercanduman.recipeapplication.ui.common.compose.showErrorMessageInSnackbar
3131
import ercanduman.recipeapplication.ui.common.theme.AppDimenDefaultDistance
3232
import ercanduman.recipeapplication.ui.common.theme.AppDimenSmallDistance
3333
import ercanduman.recipeapplication.ui.common.theme.AppTheme
34+
import ercanduman.recipeapplication.ui.recipe.detail.INVALID_ERROR_MESSAGE
3435
import ercanduman.recipeapplication.ui.recipe.detail.INVALID_RECIPE_ID
3536
import ercanduman.recipeapplication.ui.recipe.detail.KEY_RECIPE_ID
3637
import ercanduman.recipeapplication.ui.recipe.list.compose.RecipeListItemComposable
@@ -74,21 +75,20 @@ class RecipeListFragment : Fragment() {
7475
topBar = { ToolbarContentComposable() },
7576
snackbarHostState = snackbarHostState
7677
) {
77-
when (val recipeListUiState = viewModel.recipeListUiState.value) {
78-
RecipeListUiState.Loading -> RecipeListShimmerComposable()
79-
80-
is RecipeListUiState.Error -> {
81-
showErrorMessageInSnackbar(
82-
errorMessage = recipeListUiState.errorMessage,
83-
coroutineScope = coroutineScope,
84-
snackbarHostState = snackbarHostState
85-
)
86-
}
87-
88-
is RecipeListUiState.Success -> {
89-
RecipeContentComposable(recipeListUiState.recipes)
90-
checkUiStateForNavigatingToRecipeDetails(recipeListUiState)
91-
}
78+
val uiState: RecipeListUiState = viewModel.recipeListUiState
79+
80+
if (uiState.isLoading) RecipeListShimmerComposable()
81+
82+
if (uiState.recipes.isNotEmpty()) RecipeContentComposable(uiState.recipes)
83+
84+
if (uiState.recipeId != INVALID_RECIPE_ID) navigateToRecipeDetailFragment(uiState.recipeId)
85+
86+
if (uiState.errorMessage != INVALID_ERROR_MESSAGE) {
87+
showErrorMessageInSnackbar(
88+
errorMessage = uiState.errorMessage,
89+
coroutineScope = coroutineScope,
90+
snackbarHostState = snackbarHostState
91+
)
9292
}
9393
}
9494
}
@@ -143,17 +143,14 @@ class RecipeListFragment : Fragment() {
143143
}
144144
}
145145

146-
private fun checkUiStateForNavigatingToRecipeDetails(recipeListUiState: RecipeListUiState.Success) {
147-
val recipeId = recipeListUiState.recipeId
148-
if (recipeId != INVALID_RECIPE_ID) {
149-
val bundle = bundleOf(KEY_RECIPE_ID to recipeId)
150-
findNavController().navigate(
151-
args = bundle,
152-
resId = R.id.action_navigate_to_recipeDetailFragment
153-
)
154-
155-
// After navigation, reset the UiState for the clicked item
156-
viewModel.navigatedToDetails(recipeListUiState)
157-
}
146+
private fun navigateToRecipeDetailFragment(recipeId: Int) {
147+
val bundle = bundleOf(KEY_RECIPE_ID to recipeId)
148+
findNavController().navigate(
149+
args = bundle,
150+
resId = R.id.action_navigate_to_recipeDetailFragment
151+
)
152+
153+
// After navigation, reset the UiState for the clicked item
154+
viewModel.navigationProcessed()
158155
}
159156
}

app/src/main/java/ercanduman/recipeapplication/ui/recipe/list/RecipeListViewModel.kt

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package ercanduman.recipeapplication.ui.recipe.list
22

33
import androidx.compose.runtime.MutableState
4+
import androidx.compose.runtime.getValue
45
import androidx.compose.runtime.mutableStateOf
6+
import androidx.compose.runtime.setValue
57
import androidx.lifecycle.ViewModel
68
import androidx.lifecycle.viewModelScope
79
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -33,7 +35,7 @@ class RecipeListViewModel @Inject constructor(
3335
private val foodCategoryProvider: FoodCategoryProvider
3436
) : ViewModel() {
3537

36-
var recipeListUiState: MutableState<RecipeListUiState> = mutableStateOf(RecipeListUiState.Loading)
38+
var recipeListUiState: RecipeListUiState by mutableStateOf(RecipeListUiState())
3739
private set
3840

3941
var searchQuery: MutableState<String> = mutableStateOf(INITIAL_SEARCH_QUERY)
@@ -66,7 +68,7 @@ class RecipeListViewModel @Inject constructor(
6668

6769
private fun resetSearchState() {
6870
cancelRunningJob()
69-
recipeListUiState.value = RecipeListUiState.Loading
71+
recipeListUiState = RecipeListUiState()
7072
currentPage.value = PAGING_INITIAL_PAGE
7173
recipeListScrollPosition = INITIAL_POSITION
7274
searchRecipeUseCase.clearCurrentRecipeList()
@@ -91,7 +93,7 @@ class RecipeListViewModel @Inject constructor(
9193

9294
// Request next page items if the $recipeListScrollPosition has reached to the end of the list and
9395
// UiState is not currently loading
94-
if (isEligibleToMakeNextPageSearch() && !isUiStateLoading) {
96+
if (isEligibleToMakeNextPageSearch() && !recipeListUiState.isLoading) {
9597
executeNextPageSearch()
9698
}
9799
}
@@ -110,13 +112,10 @@ class RecipeListViewModel @Inject constructor(
110112
return recipeListScrollPosition + PAGING_NEXT_PAGE_INTERVAL >= currentPage.value * PAGING_PAGE_SIZE
111113
}
112114

113-
private val isUiStateLoading: Boolean
114-
get() = recipeListUiState.value is RecipeListUiState.Loading
115-
116115
private fun fetchRecipes() {
117116
fetchRecipesJob = viewModelScope.launch {
118117
delayApiCall()
119-
recipeListUiState.value = searchRecipeUseCase(
118+
recipeListUiState = searchRecipeUseCase(
120119
page = currentPage.value,
121120
searchQuery = searchQuery.value
122121
)
@@ -147,13 +146,10 @@ class RecipeListViewModel @Inject constructor(
147146
}
148147

149148
fun onRecipeClicked(recipeId: Int) {
150-
val currentUiState: RecipeListUiState = recipeListUiState.value
151-
if (currentUiState is RecipeListUiState.Success) {
152-
recipeListUiState.value = currentUiState.copy(recipeId = recipeId)
153-
}
149+
recipeListUiState = recipeListUiState.copy(recipeId = recipeId)
154150
}
155151

156-
fun navigatedToDetails(success: RecipeListUiState.Success) {
157-
recipeListUiState.value = success.copy(recipeId = INVALID_RECIPE_ID)
152+
fun navigationProcessed() {
153+
recipeListUiState = recipeListUiState.copy(recipeId = INVALID_RECIPE_ID)
158154
}
159155
}
Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
package ercanduman.recipeapplication.ui.recipe.list.model
22

33
import ercanduman.recipeapplication.domain.model.Recipe
4+
import ercanduman.recipeapplication.ui.recipe.detail.INVALID_ERROR_MESSAGE
45
import ercanduman.recipeapplication.ui.recipe.detail.INVALID_RECIPE_ID
56

6-
sealed class RecipeListUiState {
7-
object Loading : RecipeListUiState()
8-
data class Error(
9-
val errorMessage: String
10-
) : RecipeListUiState()
11-
12-
data class Success(
13-
val recipes: List<Recipe>,
14-
val recipeId: Int = INVALID_RECIPE_ID
15-
) : RecipeListUiState()
16-
}
7+
/**
8+
* UI events:
9+
* https://developer.android.com/topic/architecture/ui-layer/events#compose
10+
*
11+
* Handle ViewModel events:
12+
* https://developer.android.com/topic/architecture/ui-layer/events#handle-viewmodel-events
13+
*/
14+
data class RecipeListUiState(
15+
val isLoading: Boolean = true,
16+
val recipes: List<Recipe> = emptyList(),
17+
val recipeId: Int = INVALID_RECIPE_ID,
18+
val errorMessage: String = INVALID_ERROR_MESSAGE
19+
)

app/src/test/java/ercanduman/recipeapplication/ExampleUnitTest.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package ercanduman.recipeapplication
22

3+
import kotlinx.coroutines.flow.flow
34
import org.junit.Assert.assertEquals
45
import org.junit.Test
56

@@ -9,6 +10,10 @@ import org.junit.Test
910
* See [testing documentation](http://d.android.com/tools/testing).
1011
*/
1112
class ExampleUnitTest {
13+
private fun observeCount() = flow<Int> {
14+
emit(1)
15+
}
16+
1217
@Test
1318
fun addition_isCorrect() {
1419
assertEquals(4, 2 + 2)
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
@file:Suppress("HardCodedStringLiteral")
2+
@file:OptIn(ExperimentalCoroutinesApi::class)
3+
4+
package ercanduman.recipeapplication.data.repository
5+
6+
import com.google.common.truth.Truth.assertThat
7+
import ercanduman.recipeapplication.data.api.RecipeService
8+
import ercanduman.recipeapplication.data.api.model.RecipeDto
9+
import ercanduman.recipeapplication.data.api.model.SearchRecipesResponse
10+
import ercanduman.recipeapplication.util.RecipeResult
11+
import io.mockk.coEvery
12+
import io.mockk.mockk
13+
import kotlinx.coroutines.ExperimentalCoroutinesApi
14+
import kotlinx.coroutines.test.runTest
15+
import okhttp3.MediaType.Companion.toMediaTypeOrNull
16+
import okhttp3.ResponseBody
17+
import okhttp3.ResponseBody.Companion.toResponseBody
18+
import org.junit.Test
19+
import retrofit2.Response
20+
21+
class RecipeRepositoryTest {
22+
23+
private val service: RecipeService = mockk()
24+
private val repository: RecipeRepository = RecipeRepositoryImpl(service)
25+
26+
@Test
27+
fun `If RecipeService responds successfully, RecipeRepository should show success result for searchRecipes{}`() {
28+
runTest {
29+
val searchRecipesResponse = SearchRecipesResponse(
30+
count = 1,
31+
next = "",
32+
previous = "",
33+
recipes = listOf(testRecipeDto)
34+
)
35+
val successResponse: Response<SearchRecipesResponse> = Response.success(searchRecipesResponse)
36+
coEvery { service.searchRecipes(any(), any()) } returns successResponse
37+
38+
val successResult: RecipeResult<SearchRecipesResponse> = repository.searchRecipes(1, "")
39+
40+
assertThat(successResult).isInstanceOf(RecipeResult.Success::class.java)
41+
}
42+
}
43+
44+
@Test
45+
fun `If RecipeService responds successfully, RecipeRepository should show success result with correct content for searchRecipes{}`() {
46+
runTest {
47+
val searchRecipesResponse = SearchRecipesResponse(
48+
count = 1,
49+
next = "",
50+
previous = "",
51+
recipes = listOf(testRecipeDto)
52+
)
53+
val successResponse: Response<SearchRecipesResponse> = Response.success(searchRecipesResponse)
54+
coEvery { service.searchRecipes(any(), any()) } returns successResponse
55+
56+
val successResult: RecipeResult<SearchRecipesResponse> = repository.searchRecipes(1, "")
57+
58+
assertThat(successResult).isInstanceOf(RecipeResult.Success::class.java)
59+
60+
val actualRecipeDtoContent = (successResult as RecipeResult.Success).data.recipes.first()
61+
assertThat(actualRecipeDtoContent).isEqualTo(testRecipeDto)
62+
}
63+
}
64+
65+
@Test
66+
fun `If RecipeService responds with an Error, RecipeRepository should show Error result with error message for searchRecipes{}`() {
67+
runTest {
68+
val errorResponseBody: ResponseBody = "Some JSON content".toResponseBody("application/json".toMediaTypeOrNull())
69+
val errorResponse: Response<SearchRecipesResponse> = Response.error(404, errorResponseBody)
70+
71+
coEvery { service.searchRecipes(any(), any()) } returns errorResponse
72+
val errorResult: RecipeResult<SearchRecipesResponse> = repository.searchRecipes(1, "")
73+
assertThat(errorResult).isInstanceOf(RecipeResult.Error::class.java)
74+
75+
val actualErrorMessage = (errorResult as RecipeResult.Error).message
76+
// This must be same as in SafeApiCall
77+
val generatedErrorMessage = "No data found. Error: ${errorResponse.code()} - ${errorResponse.message()}"
78+
79+
assertThat(actualErrorMessage).isEqualTo(generatedErrorMessage)
80+
}
81+
}
82+
83+
@Test
84+
fun `If RecipeService responds successfully, RecipeRepository should show success result for fetchRecipeDetails{}`() {
85+
runTest {
86+
val searchRecipesResponse: RecipeDto = testRecipeDto
87+
88+
val successResponse: Response<RecipeDto> = Response.success(searchRecipesResponse)
89+
coEvery { service.fetchRecipeDetails(any()) } returns successResponse
90+
91+
val successResult: RecipeResult<RecipeDto> = repository.fetchRecipeDetails(recipeId = 1)
92+
93+
assertThat(successResult).isInstanceOf(RecipeResult.Success::class.java)
94+
}
95+
}
96+
97+
@Test
98+
fun `If RecipeService responds successfully, RecipeRepository should show success result with correct content for fetchRecipeDetails{}`() {
99+
runTest {
100+
val searchRecipesResponse: RecipeDto = testRecipeDto
101+
102+
val successResponse: Response<RecipeDto> = Response.success(searchRecipesResponse)
103+
coEvery { service.fetchRecipeDetails(any()) } returns successResponse
104+
105+
val successResult: RecipeResult<RecipeDto> = repository.fetchRecipeDetails(recipeId = 1)
106+
107+
assertThat(successResult).isInstanceOf(RecipeResult.Success::class.java)
108+
109+
val actualRecipeDtoContent = (successResult as RecipeResult.Success).data
110+
assertThat(actualRecipeDtoContent).isEqualTo(testRecipeDto)
111+
}
112+
}
113+
114+
@Test
115+
fun `If RecipeService responds with an Error, RecipeRepository should show Error result with error message for fetchRecipeDetails{}`() {
116+
runTest {
117+
val errorResponseBody: ResponseBody = "Some JSON content".toResponseBody("application/json".toMediaTypeOrNull())
118+
val errorResponse: Response<RecipeDto> = Response.error(404, errorResponseBody)
119+
120+
coEvery { service.fetchRecipeDetails(any()) } returns errorResponse
121+
122+
val errorResult: RecipeResult<RecipeDto> = repository.fetchRecipeDetails(recipeId = 1)
123+
124+
assertThat(errorResult).isInstanceOf(RecipeResult.Error::class.java)
125+
126+
val actualErrorMessage = (errorResult as RecipeResult.Error).message
127+
// This must be same as in SafeApiCall
128+
val generatedErrorMessage = "No data found. Error: ${errorResponse.code()} - ${errorResponse.message()}"
129+
130+
assertThat(actualErrorMessage).isEqualTo(generatedErrorMessage)
131+
}
132+
}
133+
}

0 commit comments

Comments
 (0)