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:
Kirill Kamakin
2023-11-23 07:23:30 +01:00
committed by GitHub
parent 4301c623c9
commit f6f44c7592
72 changed files with 935 additions and 1131 deletions

View File

@@ -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
}

View File

@@ -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()
}
}

View File

@@ -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")
}
}

View File

@@ -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"
)
}
}

View File

@@ -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()
}
}
}

View File

@@ -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
)
}
}

View File

@@ -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,
)
}
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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(

View File

@@ -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

View File

@@ -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() {

View File

@@ -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() {

View File

@@ -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

View File

@@ -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(

View File

@@ -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

View File

@@ -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>
}

View File

@@ -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>
}

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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)
}