Use Compose to draw the list of recipes (#187)
* Add paging-compose dependency * Move progress indicator to separate module * Introduce color scheme preview * Move loading helper to UI module * Move helper composables to UI module * Rearrange shopping lists module * Add LazyPagingColumnPullRefresh Composable * Add BaseComposeFragment * Add pagingDataRecipeState * Add showFavoriteIcon to recipe state * Disable unused placeholders * Make "Try again" button optional * Fix example email * Wrap recipe info into a Scaffold * Add dialog to confirm deletion * Add RecipeItem Composable * Add RecipeListError Composable * Add RecipeList Composable * Replace recipes list Views with Compose * Update UI test * Remove application from ViewModel
This commit is contained in:
@@ -8,8 +8,6 @@ import gq.kirmanak.mealient.shopping_lists.network.ShoppingListsDataSource
|
||||
import gq.kirmanak.mealient.shopping_lists.network.ShoppingListsDataSourceImpl
|
||||
import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsRepo
|
||||
import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsRepoImpl
|
||||
import gq.kirmanak.mealient.shopping_lists.util.LoadingHelperFactory
|
||||
import gq.kirmanak.mealient.shopping_lists.util.LoadingHelperFactoryImpl
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
@@ -20,7 +18,4 @@ interface ShoppingListsModule {
|
||||
|
||||
@Binds
|
||||
fun bindShoppingListsRepo(impl: ShoppingListsRepoImpl): ShoppingListsRepo
|
||||
|
||||
@Binds
|
||||
fun bindLoadingHelperFactory(impl: LoadingHelperFactoryImpl): LoadingHelperFactory
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.ui.composables
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import gq.kirmanak.mealient.ui.AppTheme
|
||||
|
||||
@Composable
|
||||
fun CenteredProgressIndicator(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewCenteredProgressIndicator() {
|
||||
AppTheme {
|
||||
CenteredProgressIndicator()
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.ui.composables
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import gq.kirmanak.mealient.ui.AppTheme
|
||||
|
||||
@Composable
|
||||
fun CenteredText(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(text = text)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewCenteredText() {
|
||||
AppTheme {
|
||||
CenteredText(text = "Hello World")
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.ui.composables
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
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.compose.ui.tooling.preview.Preview
|
||||
import gq.kirmanak.mealient.shopping_list.R
|
||||
import gq.kirmanak.mealient.ui.AppTheme
|
||||
import gq.kirmanak.mealient.ui.Dimens
|
||||
|
||||
@Composable
|
||||
fun EmptyListError(
|
||||
loadError: Throwable?,
|
||||
onRetry: () -> Unit,
|
||||
defaultError: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val text = loadError?.let { getErrorMessage(it) } ?: defaultError
|
||||
Box(
|
||||
modifier = modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.padding(top = Dimens.Medium),
|
||||
text = text,
|
||||
)
|
||||
Button(
|
||||
modifier = Modifier.padding(top = Dimens.Medium),
|
||||
onClick = onRetry,
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.shopping_lists_screen_empty_button_refresh))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun PreviewEmptyListError() {
|
||||
AppTheme {
|
||||
EmptyListError(
|
||||
loadError = null,
|
||||
onRetry = {},
|
||||
defaultError = "No items in the list"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.ui.composables
|
||||
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ErrorSnackbar(
|
||||
error: Throwable?,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
onSnackbarShown: () -> Unit,
|
||||
) {
|
||||
if (error == null) {
|
||||
snackbarHostState.currentSnackbarData?.dismiss()
|
||||
return
|
||||
}
|
||||
|
||||
val text = getErrorMessage(error = error)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(snackbarHostState) {
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(message = text)
|
||||
onSnackbarShown()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.ui.composables
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||
import androidx.compose.material.pullrefresh.PullRefreshState
|
||||
import androidx.compose.material.pullrefresh.pullRefresh
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
fun LazyColumnPullRefresh(
|
||||
refreshState: PullRefreshState,
|
||||
isRefreshing: Boolean,
|
||||
contentPadding: PaddingValues,
|
||||
verticalArrangement: Arrangement.Vertical,
|
||||
lazyColumnContent: LazyListScope.() -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.pullRefresh(refreshState),
|
||||
) {
|
||||
LazyColumn(
|
||||
contentPadding = contentPadding,
|
||||
verticalArrangement = verticalArrangement,
|
||||
content = lazyColumnContent
|
||||
)
|
||||
|
||||
PullRefreshIndicator(
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
refreshing = isRefreshing,
|
||||
state = refreshState
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.ui.composables
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||
import androidx.compose.material3.FabPosition
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import gq.kirmanak.mealient.shopping_lists.util.LoadingState
|
||||
import gq.kirmanak.mealient.shopping_lists.util.LoadingStateNoData
|
||||
import gq.kirmanak.mealient.shopping_lists.util.data
|
||||
import gq.kirmanak.mealient.shopping_lists.util.error
|
||||
import gq.kirmanak.mealient.shopping_lists.util.isLoading
|
||||
import gq.kirmanak.mealient.shopping_lists.util.isRefreshing
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun <T> LazyColumnWithLoadingState(
|
||||
loadingState: LoadingState<List<T>>,
|
||||
defaultEmptyListError: String,
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
|
||||
errorToShowInSnackbar: Throwable? = null,
|
||||
onSnackbarShown: () -> Unit = {},
|
||||
onRefresh: () -> Unit = {},
|
||||
floatingActionButton: @Composable () -> Unit = {},
|
||||
floatingActionButtonPosition: FabPosition = FabPosition.End,
|
||||
lazyColumnContent: LazyListScope.(List<T>) -> Unit = {},
|
||||
) {
|
||||
val refreshState = rememberPullRefreshState(
|
||||
refreshing = loadingState.isRefreshing,
|
||||
onRefresh = onRefresh,
|
||||
)
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
floatingActionButton = floatingActionButton,
|
||||
floatingActionButtonPosition = floatingActionButtonPosition,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { paddingValues ->
|
||||
val innerModifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.fillMaxSize()
|
||||
|
||||
val list = loadingState.data ?: emptyList()
|
||||
|
||||
when {
|
||||
loadingState is LoadingStateNoData.InitialLoad -> {
|
||||
CenteredProgressIndicator(modifier = innerModifier)
|
||||
}
|
||||
|
||||
!loadingState.isLoading && list.isEmpty() -> {
|
||||
EmptyListError(
|
||||
loadError = loadingState.error,
|
||||
onRetry = onRefresh,
|
||||
defaultError = defaultEmptyListError,
|
||||
modifier = innerModifier,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
LazyColumnPullRefresh(
|
||||
refreshState = refreshState,
|
||||
isRefreshing = loadingState.isRefreshing,
|
||||
contentPadding = contentPadding,
|
||||
verticalArrangement = verticalArrangement,
|
||||
lazyColumnContent = { lazyColumnContent(list) },
|
||||
modifier = innerModifier,
|
||||
)
|
||||
|
||||
ErrorSnackbar(
|
||||
error = errorToShowInSnackbar,
|
||||
snackbarHostState = snackbarHostState,
|
||||
onSnackbarShown = onSnackbarShown,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.ui
|
||||
package gq.kirmanak.mealient.shopping_lists.ui.details
|
||||
|
||||
import gq.kirmanak.mealient.datasource.models.GetFoodResponse
|
||||
import gq.kirmanak.mealient.datasource.models.GetShoppingListResponse
|
||||
@@ -1,4 +1,4 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.ui
|
||||
package gq.kirmanak.mealient.shopping_lists.ui.details
|
||||
|
||||
import gq.kirmanak.mealient.datasource.models.GetShoppingListItemResponse
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.ui
|
||||
package gq.kirmanak.mealient.shopping_lists.ui.details
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.background
|
||||
@@ -49,7 +49,6 @@ import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import gq.kirmanak.mealient.datasource.models.GetFoodResponse
|
||||
@@ -57,11 +56,14 @@ import gq.kirmanak.mealient.datasource.models.GetShoppingListItemRecipeReference
|
||||
import gq.kirmanak.mealient.datasource.models.GetShoppingListItemResponse
|
||||
import gq.kirmanak.mealient.datasource.models.GetUnitResponse
|
||||
import gq.kirmanak.mealient.shopping_list.R
|
||||
import gq.kirmanak.mealient.shopping_lists.ui.composables.LazyColumnWithLoadingState
|
||||
import gq.kirmanak.mealient.shopping_lists.util.data
|
||||
import gq.kirmanak.mealient.shopping_lists.util.map
|
||||
import gq.kirmanak.mealient.shopping_lists.ui.composables.getErrorMessage
|
||||
import gq.kirmanak.mealient.ui.AppTheme
|
||||
import gq.kirmanak.mealient.ui.Dimens
|
||||
import gq.kirmanak.mealient.ui.components.LazyColumnWithLoadingState
|
||||
import gq.kirmanak.mealient.ui.preview.ColorSchemePreview
|
||||
import gq.kirmanak.mealient.ui.util.data
|
||||
import gq.kirmanak.mealient.ui.util.error
|
||||
import gq.kirmanak.mealient.ui.util.map
|
||||
import kotlinx.coroutines.android.awaitFrame
|
||||
import java.text.DecimalFormat
|
||||
|
||||
@@ -77,7 +79,7 @@ data class ShoppingListNavArgs(
|
||||
internal fun ShoppingListScreen(
|
||||
shoppingListViewModel: ShoppingListViewModel = hiltViewModel(),
|
||||
) {
|
||||
val loadingState = shoppingListViewModel.loadingState.collectAsState().value
|
||||
val loadingState by shoppingListViewModel.loadingState.collectAsState()
|
||||
val defaultEmptyListError = stringResource(
|
||||
R.string.shopping_list_screen_empty_list,
|
||||
loadingState.data?.name.orEmpty()
|
||||
@@ -85,6 +87,8 @@ internal fun ShoppingListScreen(
|
||||
|
||||
LazyColumnWithLoadingState(
|
||||
loadingState = loadingState.map { it.items },
|
||||
emptyListError = loadingState.error?.let { getErrorMessage(it) } ?: defaultEmptyListError,
|
||||
retryButtonText = stringResource(id = R.string.shopping_lists_screen_empty_button_refresh),
|
||||
contentPadding = PaddingValues(
|
||||
start = Dimens.Medium,
|
||||
end = Dimens.Medium,
|
||||
@@ -92,10 +96,9 @@ internal fun ShoppingListScreen(
|
||||
bottom = Dimens.Large * 4,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(Dimens.Medium),
|
||||
defaultEmptyListError = defaultEmptyListError,
|
||||
errorToShowInSnackbar = shoppingListViewModel.errorToShowInSnackbar,
|
||||
onRefresh = shoppingListViewModel::refreshShoppingList,
|
||||
snackbarText = shoppingListViewModel.errorToShowInSnackbar?.let { getErrorMessage(error = it) },
|
||||
onSnackbarShown = shoppingListViewModel::onSnackbarShown,
|
||||
onRefresh = shoppingListViewModel::refreshShoppingList,
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = shoppingListViewModel::onAddItemClicked) {
|
||||
Icon(
|
||||
@@ -120,7 +123,12 @@ internal fun ShoppingListScreen(
|
||||
ShoppingListItemEditor(
|
||||
state = state,
|
||||
onEditCancelled = { shoppingListViewModel.onEditCancel(itemState) },
|
||||
onEditConfirmed = { shoppingListViewModel.onEditConfirm(itemState, state) }
|
||||
onEditConfirmed = {
|
||||
shoppingListViewModel.onEditConfirm(
|
||||
itemState,
|
||||
state
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
ShoppingListItem(
|
||||
@@ -439,7 +447,7 @@ class ShoppingListItemEditorState(
|
||||
var unitsExpanded: Boolean by mutableStateOf(false)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@ColorSchemePreview
|
||||
@Composable
|
||||
fun ShoppingListItemEditorPreview() {
|
||||
AppTheme {
|
||||
@@ -453,7 +461,7 @@ fun ShoppingListItemEditorPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@ColorSchemePreview
|
||||
@Composable
|
||||
fun ShoppingListItemEditorNonFoodPreview() {
|
||||
AppTheme {
|
||||
@@ -567,7 +575,7 @@ fun ShoppingListItem(
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@Preview
|
||||
@ColorSchemePreview
|
||||
fun PreviewShoppingListItemChecked() {
|
||||
AppTheme {
|
||||
ShoppingListItem(
|
||||
@@ -579,7 +587,7 @@ fun PreviewShoppingListItemChecked() {
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@Preview
|
||||
@ColorSchemePreview
|
||||
fun PreviewShoppingListItemUnchecked() {
|
||||
AppTheme {
|
||||
ShoppingListItem(
|
||||
@@ -591,7 +599,7 @@ fun PreviewShoppingListItemUnchecked() {
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@Preview
|
||||
@ColorSchemePreview
|
||||
fun PreviewShoppingListItemDismissed() {
|
||||
AppTheme {
|
||||
ShoppingListItem(
|
||||
@@ -606,7 +614,7 @@ fun PreviewShoppingListItemDismissed() {
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@Preview
|
||||
@ColorSchemePreview
|
||||
fun PreviewShoppingListItemEditing() {
|
||||
AppTheme {
|
||||
ShoppingListItem(
|
||||
@@ -1,4 +1,4 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.ui
|
||||
package gq.kirmanak.mealient.shopping_lists.ui.details
|
||||
|
||||
import gq.kirmanak.mealient.datasource.models.GetFoodResponse
|
||||
import gq.kirmanak.mealient.datasource.models.GetShoppingListItemResponse
|
||||
@@ -1,4 +1,4 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.ui
|
||||
package gq.kirmanak.mealient.shopping_lists.ui.details
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -15,11 +15,11 @@ import gq.kirmanak.mealient.logging.Logger
|
||||
import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsAuthRepo
|
||||
import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsRepo
|
||||
import gq.kirmanak.mealient.shopping_lists.ui.destinations.ShoppingListScreenDestination
|
||||
import gq.kirmanak.mealient.shopping_lists.util.LoadingHelperFactory
|
||||
import gq.kirmanak.mealient.shopping_lists.util.LoadingState
|
||||
import gq.kirmanak.mealient.shopping_lists.util.LoadingStateNoData
|
||||
import gq.kirmanak.mealient.shopping_lists.util.data
|
||||
import gq.kirmanak.mealient.shopping_lists.util.map
|
||||
import gq.kirmanak.mealient.ui.util.LoadingHelperFactory
|
||||
import gq.kirmanak.mealient.ui.util.LoadingState
|
||||
import gq.kirmanak.mealient.ui.util.LoadingStateNoData
|
||||
import gq.kirmanak.mealient.ui.util.data
|
||||
import gq.kirmanak.mealient.ui.util.map
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -79,7 +79,7 @@ internal class ShoppingListViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadShoppingListData(): ShoppingListData = coroutineScope {
|
||||
private suspend fun loadShoppingListData(): Result<ShoppingListData> = coroutineScope {
|
||||
val foodsDeferred = async {
|
||||
runCatchingExceptCancel {
|
||||
shoppingListsRepo.getFoods()
|
||||
@@ -93,14 +93,18 @@ internal class ShoppingListViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
val shoppingListDeferred = async {
|
||||
shoppingListsRepo.getShoppingList(args.shoppingListId)
|
||||
runCatchingExceptCancel {
|
||||
shoppingListsRepo.getShoppingList(args.shoppingListId)
|
||||
}
|
||||
}
|
||||
|
||||
ShoppingListData(
|
||||
foods = foodsDeferred.await(),
|
||||
units = unitsDeferred.await(),
|
||||
shoppingList = shoppingListDeferred.await(),
|
||||
)
|
||||
shoppingListDeferred.await().map {
|
||||
ShoppingListData(
|
||||
foods = foodsDeferred.await(),
|
||||
units = unitsDeferred.await(),
|
||||
shoppingList = it
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doRefresh() {
|
||||
@@ -1,8 +1,9 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.ui
|
||||
package gq.kirmanak.mealient.shopping_lists.ui.list
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.ramcosta.composedestinations.DestinationsNavHost
|
||||
import com.ramcosta.composedestinations.rememberNavHostEngine
|
||||
import gq.kirmanak.mealient.shopping_lists.ui.NavGraphs
|
||||
|
||||
@Composable
|
||||
fun MealientApp() {
|
||||
@@ -1,4 +1,4 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.ui
|
||||
package gq.kirmanak.mealient.shopping_lists.ui.list
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
@@ -1,4 +1,4 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.ui
|
||||
package gq.kirmanak.mealient.shopping_lists.ui.list
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -11,21 +11,24 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
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.tooling.preview.Preview
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootNavGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import gq.kirmanak.mealient.datasource.models.GetShoppingListsSummaryResponse
|
||||
import gq.kirmanak.mealient.shopping_list.R
|
||||
import gq.kirmanak.mealient.shopping_lists.ui.composables.LazyColumnWithLoadingState
|
||||
import gq.kirmanak.mealient.shopping_lists.ui.composables.getErrorMessage
|
||||
import gq.kirmanak.mealient.shopping_lists.ui.destinations.ShoppingListScreenDestination
|
||||
import gq.kirmanak.mealient.ui.AppTheme
|
||||
import gq.kirmanak.mealient.ui.Dimens
|
||||
import gq.kirmanak.mealient.ui.components.LazyColumnWithLoadingState
|
||||
import gq.kirmanak.mealient.ui.preview.ColorSchemePreview
|
||||
import gq.kirmanak.mealient.ui.util.error
|
||||
|
||||
@RootNavGraph(start = true)
|
||||
@Destination(start = true)
|
||||
@@ -34,31 +37,32 @@ fun ShoppingListsScreen(
|
||||
navigator: DestinationsNavigator,
|
||||
shoppingListsViewModel: ShoppingListsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val loadingState = shoppingListsViewModel.loadingState.collectAsState()
|
||||
val loadingState by shoppingListsViewModel.loadingState.collectAsState()
|
||||
val errorToShowInSnackbar = shoppingListsViewModel.errorToShowInSnackBar
|
||||
|
||||
LazyColumnWithLoadingState(
|
||||
loadingState = loadingState.value,
|
||||
errorToShowInSnackbar = errorToShowInSnackbar,
|
||||
loadingState = loadingState,
|
||||
emptyListError = loadingState.error?.let { getErrorMessage(it) }
|
||||
?: stringResource(R.string.shopping_lists_screen_empty),
|
||||
retryButtonText = stringResource(id = R.string.shopping_lists_screen_empty_button_refresh),
|
||||
snackbarText = errorToShowInSnackbar?.let { getErrorMessage(error = it) },
|
||||
onSnackbarShown = shoppingListsViewModel::onSnackbarShown,
|
||||
onRefresh = shoppingListsViewModel::refresh,
|
||||
defaultEmptyListError = stringResource(R.string.shopping_lists_screen_empty),
|
||||
lazyColumnContent = { items ->
|
||||
items(items) { shoppingList ->
|
||||
ShoppingListCard(
|
||||
shoppingList = shoppingList,
|
||||
onItemClick = { clickedEntity ->
|
||||
val shoppingListId = clickedEntity.id
|
||||
navigator.navigate(ShoppingListScreenDestination(shoppingListId))
|
||||
}
|
||||
)
|
||||
}
|
||||
onRefresh = shoppingListsViewModel::refresh
|
||||
) { items ->
|
||||
items(items) { shoppingList ->
|
||||
ShoppingListCard(
|
||||
shoppingList = shoppingList,
|
||||
onItemClick = { clickedEntity ->
|
||||
val shoppingListId = clickedEntity.id
|
||||
navigator.navigate(ShoppingListScreenDestination(shoppingListId))
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
@ColorSchemePreview
|
||||
private fun PreviewShoppingListCard() {
|
||||
AppTheme {
|
||||
ShoppingListCard(
|
||||
@@ -1,4 +1,4 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.ui
|
||||
package gq.kirmanak.mealient.shopping_lists.ui.list
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -8,12 +8,13 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import gq.kirmanak.mealient.architecture.valueUpdatesOnly
|
||||
import gq.kirmanak.mealient.datasource.models.GetShoppingListsSummaryResponse
|
||||
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
|
||||
import gq.kirmanak.mealient.logging.Logger
|
||||
import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsAuthRepo
|
||||
import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsRepo
|
||||
import gq.kirmanak.mealient.shopping_lists.util.LoadingHelper
|
||||
import gq.kirmanak.mealient.shopping_lists.util.LoadingHelperFactory
|
||||
import gq.kirmanak.mealient.shopping_lists.util.LoadingState
|
||||
import gq.kirmanak.mealient.ui.util.LoadingHelper
|
||||
import gq.kirmanak.mealient.ui.util.LoadingHelperFactory
|
||||
import gq.kirmanak.mealient.ui.util.LoadingState
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
@@ -27,7 +28,9 @@ class ShoppingListsViewModel @Inject constructor(
|
||||
) : ViewModel() {
|
||||
|
||||
private val loadingHelper: LoadingHelper<List<GetShoppingListsSummaryResponse>> =
|
||||
loadingHelperFactory.create(viewModelScope) { shoppingListsRepo.getShoppingLists() }
|
||||
loadingHelperFactory.create(viewModelScope) {
|
||||
runCatchingExceptCancel { shoppingListsRepo.getShoppingLists() }
|
||||
}
|
||||
|
||||
val loadingState: StateFlow<LoadingState<List<GetShoppingListsSummaryResponse>>> =
|
||||
loadingHelper.loadingState
|
||||
@@ -1,10 +0,0 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.util
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface LoadingHelper<T> {
|
||||
|
||||
val loadingState: StateFlow<LoadingState<T>>
|
||||
|
||||
suspend fun refresh(): Result<T>
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.util
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
interface LoadingHelperFactory {
|
||||
|
||||
fun <T> create(coroutineScope: CoroutineScope, fetch: suspend () -> T): LoadingHelper<T>
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.util
|
||||
|
||||
import gq.kirmanak.mealient.logging.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import javax.inject.Inject
|
||||
|
||||
// @AssistedFactory does not currently support type parameters in the creator method.
|
||||
// See https://github.com/google/dagger/issues/2279
|
||||
class LoadingHelperFactoryImpl @Inject constructor(
|
||||
private val logger: Logger
|
||||
) : LoadingHelperFactory {
|
||||
|
||||
override fun <T> create(
|
||||
coroutineScope: CoroutineScope,
|
||||
fetch: suspend () -> T
|
||||
): LoadingHelper<T> = LoadingHelperImpl(logger, fetch)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.util
|
||||
|
||||
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
|
||||
import gq.kirmanak.mealient.logging.Logger
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
class LoadingHelperImpl<T>(
|
||||
private val logger: Logger,
|
||||
private val fetch: suspend () -> T,
|
||||
) : LoadingHelper<T> {
|
||||
|
||||
private val _loadingState = MutableStateFlow<LoadingState<T>>(LoadingStateNoData.InitialLoad)
|
||||
override val loadingState: StateFlow<LoadingState<T>> = _loadingState
|
||||
|
||||
override suspend fun refresh(): Result<T> {
|
||||
logger.v { "refresh() called" }
|
||||
_loadingState.update { currentState ->
|
||||
when (currentState) {
|
||||
is LoadingStateWithData -> LoadingStateWithData.Refreshing(currentState.data)
|
||||
is LoadingStateNoData -> LoadingStateNoData.InitialLoad
|
||||
}
|
||||
}
|
||||
val result = runCatchingExceptCancel { fetch() }
|
||||
_loadingState.update { currentState ->
|
||||
result.fold(
|
||||
onSuccess = { data ->
|
||||
LoadingStateWithData.Success(data)
|
||||
},
|
||||
onFailure = { error ->
|
||||
when (currentState) {
|
||||
is LoadingStateWithData -> LoadingStateWithData.Success(currentState.data)
|
||||
is LoadingStateNoData -> LoadingStateNoData.LoadError(error)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.util
|
||||
|
||||
sealed class LoadingState<out T>
|
||||
|
||||
sealed class LoadingStateWithData<out T> : LoadingState<T>() {
|
||||
|
||||
abstract val data: T
|
||||
|
||||
data class Refreshing<T>(override val data: T) : LoadingStateWithData<T>()
|
||||
|
||||
data class Success<T>(override val data: T) : LoadingStateWithData<T>()
|
||||
|
||||
}
|
||||
|
||||
sealed class LoadingStateNoData<out T> : LoadingState<T>() {
|
||||
|
||||
object InitialLoad : LoadingStateNoData<Nothing>()
|
||||
|
||||
data class LoadError<T>(val error: Throwable) : LoadingStateNoData<T>()
|
||||
}
|
||||
|
||||
val <T> LoadingState<T>.isLoading: Boolean
|
||||
get() = when (this) {
|
||||
is LoadingStateNoData.LoadError,
|
||||
is LoadingStateWithData.Success -> false
|
||||
|
||||
is LoadingStateNoData.InitialLoad,
|
||||
is LoadingStateWithData.Refreshing -> true
|
||||
}
|
||||
|
||||
val <T> LoadingState<T>.error: Throwable?
|
||||
get() = when (this) {
|
||||
is LoadingStateNoData.LoadError -> error
|
||||
is LoadingStateNoData.InitialLoad,
|
||||
is LoadingStateWithData.Refreshing,
|
||||
is LoadingStateWithData.Success -> null
|
||||
}
|
||||
|
||||
val <T> LoadingState<T>.data: T?
|
||||
get() = when (this) {
|
||||
is LoadingStateWithData -> data
|
||||
is LoadingStateNoData -> null
|
||||
}
|
||||
|
||||
val <T> LoadingState<T>.isRefreshing: Boolean
|
||||
get() = when (this) {
|
||||
is LoadingStateWithData.Refreshing -> true
|
||||
is LoadingStateWithData.Success,
|
||||
is LoadingStateNoData.InitialLoad,
|
||||
is LoadingStateNoData.LoadError -> false
|
||||
}
|
||||
|
||||
inline fun <T, E> LoadingState<T>.map(block: (T) -> E) = when (this) {
|
||||
is LoadingStateWithData.Success -> LoadingStateWithData.Success(block(data))
|
||||
is LoadingStateWithData.Refreshing -> LoadingStateWithData.Refreshing(block(data))
|
||||
is LoadingStateNoData.InitialLoad -> LoadingStateNoData.InitialLoad
|
||||
is LoadingStateNoData.LoadError -> LoadingStateNoData.LoadError(error)
|
||||
}
|
||||
Reference in New Issue
Block a user