Implement shopping lists screen (#129)
* Initialize shopping lists feature * Start shopping lists screen with Compose * Add icon to shopping list name * Add shopping lists to menu * Set max size for the list * Replace compose-adapter with accompanist * Remove unused fields from shopping lists response * Show list of shopping lists from BE * Hide shopping lists if Mealie is 0.5.6 * Add shopping list item click listener * Create material app theme for Compose * Use shorter names * Load shopping lists by pages and save to db * Make page handling logic match recipes * Add swipe to refresh to shopping lists * Extract SwipeToRefresh Composable * Make LazyPagingColumn generic * Show refresh only when mediator is refreshing * Do not refresh automatically * Allow controlling Activity state from modules * Implement navigating to shopping list screen * Move Compose libraries setup to a plugin * Implement loading full shopping list info * Move Storage classes to database module * Save shopping list items to DB * Use separate names for separate ids * Do only one DB version update * Use unique names for all columns * Display shopping list items * Move OperationUiState to ui module * Subscribe to shopping lists updates * Indicate progress with progress bar * Use strings from resources * Format shopping list item quantities * Hide unit/food/note/quantity if they are not set * Implement updating shopping list item checked state * Remove unnecessary null checks * Disable checkbox when it is being updated * Split shopping list screen into composables * Show items immediately if they are saved * Fix showing "list is empty" before the items * Show Snackbar when error happens * Reduce shopping list items paddings * Remove shopping lists when URL is changed * Add error/empty state handling to shopping lists * Fix empty error state * Fix tests compilation * Add margin between text and button * Add divider between checked and unchecked items * Move divider to the item * Refresh the shopping lists on authentication * Use retry when necessary * Remove excessive logging * Fix pages bounds check * Move FlowExtensionsTest * Update Compose version * Fix showing loading indicator for shopping lists * Add Russian translation * Fix SDK version lint check * Rename parameter to match interface * Add DB migration TODO * Get rid of DB migrations * Do not use pagination with shopping lists * Cleanup after the pagination removal * Load shopping list items * Remove shopping lists storage * Rethrow CancellationException in LoadingHelper * Add pull-to-refresh on shopping list screen * Extract LazyColumnWithLoadingState * Split refresh errors and loading state * Reuse LazyColumnWithLoadingState for shopping list items * Remove paging-compose dependency * Refresh shopping list items on authentication * Disable missing translation lint check * Update Compose and Kotlin versions * Fix order of checked items * Hide useless information from a shopping list item
This commit is contained in:
1
features/shopping_lists/.gitignore
vendored
Normal file
1
features/shopping_lists/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
45
features/shopping_lists/build.gradle.kts
Normal file
45
features/shopping_lists/build.gradle.kts
Normal file
@@ -0,0 +1,45 @@
|
||||
@file:Suppress("UnstableApiUsage")
|
||||
|
||||
plugins {
|
||||
id("gq.kirmanak.mealient.library")
|
||||
alias(libs.plugins.ksp)
|
||||
id("gq.kirmanak.mealient.compose")
|
||||
id("kotlin-kapt")
|
||||
id("dagger.hilt.android.plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "gq.kirmanak.mealient.shopping_list"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":architecture"))
|
||||
implementation(project(":logging"))
|
||||
implementation(project(":datasource"))
|
||||
implementation(project(":database"))
|
||||
implementation(project(":ui"))
|
||||
implementation(project(":model_mapper"))
|
||||
|
||||
implementation(libs.android.material.material)
|
||||
implementation(libs.androidx.compose.material)
|
||||
|
||||
implementation(libs.google.dagger.hiltAndroid)
|
||||
kapt(libs.google.dagger.hiltCompiler)
|
||||
kaptTest(libs.google.dagger.hiltAndroidCompiler)
|
||||
testImplementation(libs.google.dagger.hiltAndroidTesting)
|
||||
|
||||
implementation(libs.androidx.hilt.navigationCompose)
|
||||
|
||||
implementation(libs.jetbrains.kotlinx.coroutinesAndroid)
|
||||
testImplementation(libs.jetbrains.kotlinx.coroutinesTest)
|
||||
|
||||
testImplementation(libs.androidx.test.junit)
|
||||
|
||||
testImplementation(libs.google.truth)
|
||||
|
||||
testImplementation(libs.io.mockk)
|
||||
}
|
||||
|
||||
kapt {
|
||||
correctErrorTypes = true
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package gq.kirmanak.mealient
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.android.material.color.DynamicColors
|
||||
|
||||
@Composable
|
||||
fun AppTheme(
|
||||
isDarkTheme: Boolean = isSystemInDarkTheme(),
|
||||
isDynamicColor: Boolean = DynamicColors.isDynamicColorAvailable(),
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || !isDynamicColor -> {
|
||||
if (isDarkTheme) darkColorScheme() else lightColorScheme()
|
||||
}
|
||||
isDarkTheme -> {
|
||||
dynamicDarkColorScheme(LocalContext.current)
|
||||
}
|
||||
else -> {
|
||||
dynamicLightColorScheme(LocalContext.current)
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
object Dimens {
|
||||
|
||||
val Small = 8.dp
|
||||
|
||||
val Medium = 16.dp
|
||||
|
||||
val Large = 24.dp
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package gq.kirmanak.mealient.shopping_lists
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
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
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface ShoppingListsModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindShoppingListsDataSource(impl: ShoppingListsDataSourceImpl): ShoppingListsDataSource
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindShoppingListsRepo(impl: ShoppingListsRepoImpl): ShoppingListsRepo
|
||||
|
||||
@Binds
|
||||
fun bindLoadingHelperFactory(impl: LoadingHelperFactoryImpl): LoadingHelperFactory
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.network
|
||||
|
||||
import gq.kirmanak.mealient.datasource.models.FullShoppingListInfo
|
||||
import gq.kirmanak.mealient.datasource.models.ShoppingListInfo
|
||||
|
||||
interface ShoppingListsDataSource {
|
||||
|
||||
suspend fun getAllShoppingLists(): List<ShoppingListInfo>
|
||||
|
||||
suspend fun getShoppingList(id: String): FullShoppingListInfo
|
||||
|
||||
suspend fun updateIsShoppingListItemChecked(id: String, checked: Boolean)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.network
|
||||
|
||||
import gq.kirmanak.mealient.datasource.models.FullShoppingListInfo
|
||||
import gq.kirmanak.mealient.datasource.models.ShoppingListInfo
|
||||
import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1
|
||||
import gq.kirmanak.mealient.model_mapper.ModelMapper
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ShoppingListsDataSourceImpl @Inject constructor(
|
||||
private val v1Source: MealieDataSourceV1,
|
||||
private val modelMapper: ModelMapper,
|
||||
) : ShoppingListsDataSource {
|
||||
|
||||
override suspend fun getAllShoppingLists(): List<ShoppingListInfo> {
|
||||
val response = v1Source.getShoppingLists(1, -1)
|
||||
return response.items.map { modelMapper.toShoppingListInfo(it) }
|
||||
}
|
||||
|
||||
override suspend fun getShoppingList(
|
||||
id: String
|
||||
): FullShoppingListInfo = modelMapper.toFullShoppingListInfo(v1Source.getShoppingList(id))
|
||||
|
||||
override suspend fun updateIsShoppingListItemChecked(
|
||||
id: String,
|
||||
checked: Boolean,
|
||||
) = v1Source.updateIsShoppingListItemChecked(id, checked)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.repo
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface ShoppingListsAuthRepo {
|
||||
|
||||
val isAuthorizedFlow: Flow<Boolean>
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.repo
|
||||
|
||||
import gq.kirmanak.mealient.datasource.models.FullShoppingListInfo
|
||||
import gq.kirmanak.mealient.datasource.models.ShoppingListInfo
|
||||
|
||||
interface ShoppingListsRepo {
|
||||
|
||||
suspend fun updateIsShoppingListItemChecked(id: String, isChecked: Boolean)
|
||||
|
||||
suspend fun getShoppingLists(): List<ShoppingListInfo>
|
||||
|
||||
suspend fun getShoppingList(id: String): FullShoppingListInfo
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.repo
|
||||
|
||||
import gq.kirmanak.mealient.datasource.models.FullShoppingListInfo
|
||||
import gq.kirmanak.mealient.datasource.models.ShoppingListInfo
|
||||
import gq.kirmanak.mealient.logging.Logger
|
||||
import gq.kirmanak.mealient.shopping_lists.network.ShoppingListsDataSource
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ShoppingListsRepoImpl @Inject constructor(
|
||||
private val dataSource: ShoppingListsDataSource,
|
||||
private val logger: Logger,
|
||||
) : ShoppingListsRepo {
|
||||
|
||||
override suspend fun updateIsShoppingListItemChecked(id: String, isChecked: Boolean) {
|
||||
logger.v { "updateIsShoppingListItemChecked() called with: id = $id, isChecked = $isChecked" }
|
||||
dataSource.updateIsShoppingListItemChecked(id, isChecked)
|
||||
}
|
||||
|
||||
override suspend fun getShoppingLists(): List<ShoppingListInfo> {
|
||||
logger.v { "getShoppingLists() called" }
|
||||
return dataSource.getAllShoppingLists()
|
||||
}
|
||||
|
||||
override suspend fun getShoppingList(id: String): FullShoppingListInfo {
|
||||
logger.v { "getShoppingListItems() called with: id = $id" }
|
||||
return dataSource.getShoppingList(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.ramcosta.composedestinations.DestinationsNavHost
|
||||
import com.ramcosta.composedestinations.rememberNavHostEngine
|
||||
|
||||
@Composable
|
||||
fun MealientApp() {
|
||||
val engine = rememberNavHostEngine()
|
||||
val controller = engine.rememberNavController()
|
||||
|
||||
DestinationsNavHost(
|
||||
navGraph = NavGraphs.root,
|
||||
engine = engine,
|
||||
navController = controller,
|
||||
startRoute = NavGraphs.root.startRoute,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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 gq.kirmanak.mealient.AppTheme
|
||||
import gq.kirmanak.mealient.Dimens
|
||||
import gq.kirmanak.mealient.datasource.models.FullRecipeInfo
|
||||
import gq.kirmanak.mealient.datasource.models.RecipeIngredientInfo
|
||||
import gq.kirmanak.mealient.datasource.models.RecipeInstructionInfo
|
||||
import gq.kirmanak.mealient.datasource.models.RecipeSettingsInfo
|
||||
import gq.kirmanak.mealient.datasource.models.ShoppingListItemInfo
|
||||
import gq.kirmanak.mealient.datasource.models.ShoppingListItemRecipeReferenceInfo
|
||||
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 java.text.DecimalFormat
|
||||
|
||||
data class ShoppingListNavArgs(
|
||||
val shoppingListId: String,
|
||||
)
|
||||
|
||||
@Destination(
|
||||
navArgsDelegate = ShoppingListNavArgs::class,
|
||||
)
|
||||
@Composable
|
||||
internal fun ShoppingListScreen(
|
||||
shoppingListViewModel: ShoppingListViewModel = hiltViewModel(),
|
||||
) {
|
||||
val loadingState = shoppingListViewModel.loadingState.collectAsState().value
|
||||
val defaultEmptyListError = stringResource(
|
||||
R.string.shopping_list_screen_empty_list,
|
||||
loadingState.data?.name.orEmpty()
|
||||
)
|
||||
|
||||
LazyColumnWithLoadingState(
|
||||
loadingState = loadingState.map { it.items },
|
||||
defaultEmptyListError = defaultEmptyListError,
|
||||
errorToShowInSnackbar = shoppingListViewModel.errorToShowInSnackbar,
|
||||
onRefresh = shoppingListViewModel::refreshShoppingList,
|
||||
onSnackbarShown = shoppingListViewModel::onSnackbarShown,
|
||||
lazyColumnContent = { items ->
|
||||
val firstCheckedItemIndex = items.indexOfFirst { it.item.checked }
|
||||
|
||||
itemsIndexed(items) { index, item ->
|
||||
ShoppingListItem(
|
||||
shoppingListItem = item.item,
|
||||
isDisabled = item.isDisabled,
|
||||
showDivider = index == firstCheckedItemIndex && index != 0,
|
||||
) { isChecked ->
|
||||
shoppingListViewModel.onItemCheckedChange(item.item, isChecked)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShoppingListItem(
|
||||
shoppingListItem: ShoppingListItemInfo,
|
||||
isDisabled: Boolean,
|
||||
showDivider: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onCheckedChange: (Boolean) -> Unit = {},
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = Dimens.Small, end = Dimens.Small, start = Dimens.Small),
|
||||
) {
|
||||
if (showDivider) {
|
||||
Divider()
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
) {
|
||||
Checkbox(
|
||||
checked = shoppingListItem.checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
enabled = !isDisabled,
|
||||
)
|
||||
|
||||
val isFood = shoppingListItem.isFood
|
||||
val quantity = shoppingListItem.quantity
|
||||
.takeUnless { it == 0.0 }
|
||||
.takeUnless { it == 1.0 && !isFood }
|
||||
?.let { DecimalFormat.getInstance().format(it) }
|
||||
val text = listOfNotNull(
|
||||
quantity,
|
||||
shoppingListItem.unit.takeIf { isFood },
|
||||
shoppingListItem.food.takeIf { isFood },
|
||||
shoppingListItem.note,
|
||||
).filter { it.isNotBlank() }.joinToString(" ")
|
||||
|
||||
Text(text = text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun PreviewShoppingListItemChecked() {
|
||||
AppTheme {
|
||||
ShoppingListItem(shoppingListItem = PreviewData.milk, false, false)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun PreviewShoppingListItemUnchecked() {
|
||||
AppTheme {
|
||||
ShoppingListItem(shoppingListItem = PreviewData.blackTeaBags, false, false)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun PreviewShoppingListItemCheckedDisabled() {
|
||||
AppTheme {
|
||||
ShoppingListItem(shoppingListItem = PreviewData.milk, true, false)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun PreviewShoppingListItemUncheckedDisabled() {
|
||||
AppTheme {
|
||||
ShoppingListItem(shoppingListItem = PreviewData.blackTeaBags, true, false)
|
||||
}
|
||||
}
|
||||
|
||||
private object PreviewData {
|
||||
val teaWithMilkRecipe = FullRecipeInfo(
|
||||
remoteId = "1",
|
||||
name = "Tea with milk",
|
||||
recipeYield = "1 serving",
|
||||
recipeIngredients = listOf(
|
||||
RecipeIngredientInfo(
|
||||
note = "Tea bag",
|
||||
food = "",
|
||||
unit = "",
|
||||
quantity = 1.0,
|
||||
title = "",
|
||||
),
|
||||
RecipeIngredientInfo(
|
||||
note = "",
|
||||
food = "Milk",
|
||||
unit = "ml",
|
||||
quantity = 500.0,
|
||||
title = "",
|
||||
),
|
||||
),
|
||||
recipeInstructions = listOf(
|
||||
RecipeInstructionInfo("Boil water"),
|
||||
RecipeInstructionInfo("Put tea bag in a cup"),
|
||||
RecipeInstructionInfo("Pour water into the cup"),
|
||||
RecipeInstructionInfo("Wait for 5 minutes"),
|
||||
RecipeInstructionInfo("Remove tea bag"),
|
||||
RecipeInstructionInfo("Add milk"),
|
||||
),
|
||||
settings = RecipeSettingsInfo(
|
||||
disableAmounts = false
|
||||
),
|
||||
)
|
||||
|
||||
val blackTeaBags = ShoppingListItemInfo(
|
||||
id = "1",
|
||||
shoppingListId = "1",
|
||||
checked = false,
|
||||
position = 0,
|
||||
isFood = false,
|
||||
note = "Black tea bags",
|
||||
quantity = 30.0,
|
||||
unit = "",
|
||||
food = "",
|
||||
recipeReferences = listOf(
|
||||
ShoppingListItemRecipeReferenceInfo(
|
||||
shoppingListId = "1",
|
||||
id = "1",
|
||||
recipeId = "1",
|
||||
recipeQuantity = 1.0,
|
||||
recipe = teaWithMilkRecipe,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val milk = ShoppingListItemInfo(
|
||||
id = "2",
|
||||
shoppingListId = "1",
|
||||
checked = true,
|
||||
position = 1,
|
||||
isFood = true,
|
||||
note = "Cold",
|
||||
quantity = 500.0,
|
||||
unit = "ml",
|
||||
food = "Milk",
|
||||
recipeReferences = listOf(
|
||||
ShoppingListItemRecipeReferenceInfo(
|
||||
shoppingListId = "1",
|
||||
id = "2",
|
||||
recipeId = "1",
|
||||
recipeQuantity = 500.0,
|
||||
recipe = teaWithMilkRecipe,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.ui
|
||||
|
||||
import gq.kirmanak.mealient.datasource.models.ShoppingListItemInfo
|
||||
|
||||
internal data class ShoppingListScreenState(
|
||||
val name: String,
|
||||
val items: List<ShoppingListItemState>,
|
||||
)
|
||||
|
||||
internal data class ShoppingListItemState(
|
||||
val item: ShoppingListItemInfo,
|
||||
val isDisabled: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,114 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.ui
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import gq.kirmanak.mealient.architecture.valueUpdatesOnly
|
||||
import gq.kirmanak.mealient.datasource.models.FullShoppingListInfo
|
||||
import gq.kirmanak.mealient.datasource.models.ShoppingListItemInfo
|
||||
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.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.map
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
internal class ShoppingListViewModel @Inject constructor(
|
||||
private val shoppingListsRepo: ShoppingListsRepo,
|
||||
private val logger: Logger,
|
||||
private val authRepo: ShoppingListsAuthRepo,
|
||||
loadingHelperFactory: LoadingHelperFactory,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
private val args: ShoppingListNavArgs = ShoppingListScreenDestination.argsFrom(savedStateHandle)
|
||||
|
||||
private val _disabledItemIds: MutableStateFlow<Set<String>> = MutableStateFlow(mutableSetOf())
|
||||
|
||||
private val loadingHelper = loadingHelperFactory.create(viewModelScope) {
|
||||
shoppingListsRepo.getShoppingList(args.shoppingListId)
|
||||
}
|
||||
|
||||
val loadingState: StateFlow<LoadingState<ShoppingListScreenState>> = combine(
|
||||
loadingHelper.loadingState,
|
||||
_disabledItemIds,
|
||||
::buildLoadingState,
|
||||
).stateIn(viewModelScope, SharingStarted.Eagerly, LoadingStateNoData.InitialLoad)
|
||||
|
||||
private var _errorToShowInSnackbar: Throwable? by mutableStateOf(null)
|
||||
val errorToShowInSnackbar: Throwable? get() = _errorToShowInSnackbar
|
||||
|
||||
init {
|
||||
refreshShoppingList()
|
||||
listenToAuthState()
|
||||
}
|
||||
|
||||
private fun listenToAuthState() {
|
||||
logger.v { "listenToAuthState() called" }
|
||||
viewModelScope.launch {
|
||||
authRepo.isAuthorizedFlow.valueUpdatesOnly().collect {
|
||||
logger.d { "Authorization state changed to $it" }
|
||||
if (it) refreshShoppingList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshShoppingList() {
|
||||
logger.v { "refreshShoppingList() called" }
|
||||
viewModelScope.launch {
|
||||
_errorToShowInSnackbar = loadingHelper.refresh().exceptionOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildLoadingState(
|
||||
loadingState: LoadingState<FullShoppingListInfo>,
|
||||
disabledItemIds: Set<String>,
|
||||
): LoadingState<ShoppingListScreenState> {
|
||||
logger.v { "buildLoadingState() called with: loadingState = $loadingState, disabledItems = $disabledItemIds" }
|
||||
return loadingState.map { shoppingList ->
|
||||
val items = shoppingList.items
|
||||
.sortedBy { it.checked }
|
||||
.map { ShoppingListItemState(item = it, isDisabled = it.id in disabledItemIds) }
|
||||
ShoppingListScreenState(name = shoppingList.name, items = items)
|
||||
}
|
||||
}
|
||||
|
||||
fun onItemCheckedChange(item: ShoppingListItemInfo, isChecked: Boolean) {
|
||||
logger.v { "onItemCheckedChange() called with: item = $item, isChecked = $isChecked" }
|
||||
viewModelScope.launch {
|
||||
_disabledItemIds.update { it + item.id }
|
||||
val result = runCatchingExceptCancel {
|
||||
shoppingListsRepo.updateIsShoppingListItemChecked(item.id, isChecked)
|
||||
}.onFailure {
|
||||
logger.e(it) { "Failed to update item's checked state" }
|
||||
}
|
||||
_disabledItemIds.update { it - item.id }
|
||||
_errorToShowInSnackbar = result.exceptionOrNull()
|
||||
if (result.isSuccess) {
|
||||
logger.v { "Item's checked state updated" }
|
||||
refreshShoppingList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onSnackbarShown() {
|
||||
logger.v { "onSnackbarShown() called" }
|
||||
_errorToShowInSnackbar = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.fragment.app.Fragment
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import gq.kirmanak.mealient.AppTheme
|
||||
import gq.kirmanak.mealient.ui.ActivityUiStateController
|
||||
import gq.kirmanak.mealient.ui.CheckableMenuItem
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ShoppingListsFragment : Fragment() {
|
||||
|
||||
@Inject
|
||||
lateinit var activityUiStateController: ActivityUiStateController
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||
): View {
|
||||
return ComposeView(requireContext()).apply {
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||
setContent {
|
||||
AppTheme {
|
||||
MealientApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
activityUiStateController.updateUiState {
|
||||
it.copy(
|
||||
navigationVisible = true,
|
||||
searchVisible = false,
|
||||
checkedMenuItem = CheckableMenuItem.ShoppingLists,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.AppTheme
|
||||
import gq.kirmanak.mealient.Dimens
|
||||
import gq.kirmanak.mealient.datasource.models.ShoppingListInfo
|
||||
import gq.kirmanak.mealient.shopping_list.R
|
||||
import gq.kirmanak.mealient.shopping_lists.ui.composables.LazyColumnWithLoadingState
|
||||
import gq.kirmanak.mealient.shopping_lists.ui.destinations.ShoppingListScreenDestination
|
||||
|
||||
@RootNavGraph(start = true)
|
||||
@Destination(start = true)
|
||||
@Composable
|
||||
fun ShoppingListsScreen(
|
||||
navigator: DestinationsNavigator,
|
||||
shoppingListsViewModel: ShoppingListsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val loadingState = shoppingListsViewModel.loadingState.collectAsState()
|
||||
val errorToShowInSnackbar = shoppingListsViewModel.errorToShowInSnackBar
|
||||
|
||||
LazyColumnWithLoadingState(
|
||||
loadingState = loadingState.value,
|
||||
errorToShowInSnackbar = errorToShowInSnackbar,
|
||||
onSnackbarShown = shoppingListsViewModel::onSnackbarShown,
|
||||
onRefresh = shoppingListsViewModel::refresh,
|
||||
defaultEmptyListError = stringResource(R.string.shopping_lists_screen_empty),
|
||||
lazyColumnContent = { items ->
|
||||
items(items) { shoppingList ->
|
||||
ShoppingListCard(
|
||||
shoppingListEntity = shoppingList,
|
||||
onItemClick = { clickedEntity ->
|
||||
val shoppingListId = clickedEntity.id
|
||||
navigator.navigate(ShoppingListScreenDestination(shoppingListId))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
private fun PreviewShoppingListCard() {
|
||||
AppTheme {
|
||||
ShoppingListCard(shoppingListEntity = ShoppingListInfo("1", "Weekend shopping"))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShoppingListCard(
|
||||
shoppingListEntity: ShoppingListInfo?,
|
||||
modifier: Modifier = Modifier,
|
||||
onItemClick: (ShoppingListInfo) -> Unit = {},
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier
|
||||
.padding(horizontal = Dimens.Medium, vertical = Dimens.Small)
|
||||
.fillMaxWidth()
|
||||
.clickable { shoppingListEntity?.let { onItemClick(it) } },
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(Dimens.Medium),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_shopping_cart),
|
||||
contentDescription = stringResource(id = R.string.shopping_lists_screen_cart_icon),
|
||||
modifier = Modifier.height(Dimens.Large),
|
||||
)
|
||||
Text(
|
||||
text = shoppingListEntity?.name.orEmpty(),
|
||||
modifier = Modifier.padding(start = Dimens.Medium),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.ui
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import gq.kirmanak.mealient.architecture.valueUpdatesOnly
|
||||
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.LoadingHelperFactory
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ShoppingListsViewModel @Inject constructor(
|
||||
private val logger: Logger,
|
||||
private val shoppingListsRepo: ShoppingListsRepo,
|
||||
private val authRepo: ShoppingListsAuthRepo,
|
||||
loadingHelperFactory: LoadingHelperFactory,
|
||||
) : ViewModel() {
|
||||
|
||||
private val loadingHelper = loadingHelperFactory.create(viewModelScope) {
|
||||
shoppingListsRepo.getShoppingLists()
|
||||
}
|
||||
val loadingState = loadingHelper.loadingState
|
||||
|
||||
private var _errorToShowInSnackbar by mutableStateOf<Throwable?>(null)
|
||||
val errorToShowInSnackBar: Throwable? get() = _errorToShowInSnackbar
|
||||
|
||||
init {
|
||||
refresh()
|
||||
listenToAuthState()
|
||||
}
|
||||
|
||||
private fun listenToAuthState() {
|
||||
logger.v { "listenToAuthState() called" }
|
||||
viewModelScope.launch {
|
||||
authRepo.isAuthorizedFlow.valueUpdatesOnly().collect {
|
||||
logger.d { "Authorization state changed to $it" }
|
||||
if (it) refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
logger.v { "refresh() called" }
|
||||
viewModelScope.launch {
|
||||
_errorToShowInSnackbar = loadingHelper.refresh().exceptionOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
fun onSnackbarShown() {
|
||||
logger.v { "onSnackbarShown() called" }
|
||||
_errorToShowInSnackbar = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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.AppTheme
|
||||
|
||||
@Composable
|
||||
fun CenteredProgressIndicator(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewCenteredProgressIndicator() {
|
||||
AppTheme {
|
||||
CenteredProgressIndicator()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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.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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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.AppTheme
|
||||
import gq.kirmanak.mealient.Dimens
|
||||
import gq.kirmanak.mealient.shopping_list.R
|
||||
|
||||
@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"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.ui.composables
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import gq.kirmanak.mealient.datasource.NetworkError
|
||||
import gq.kirmanak.mealient.shopping_list.R
|
||||
|
||||
@Composable
|
||||
fun getErrorMessage(error: Throwable): String = when (error) {
|
||||
is NetworkError.Unauthorized -> stringResource(R.string.shopping_lists_screen_unauthorized_error)
|
||||
is NetworkError.NoServerConnection -> stringResource(R.string.shopping_lists_screen_no_connection)
|
||||
else -> error.message ?: stringResource(R.string.shopping_lists_screen_unknown_error)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.ui.composables
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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(
|
||||
modifier: Modifier = Modifier,
|
||||
refreshState: PullRefreshState,
|
||||
isRefreshing: Boolean,
|
||||
lazyColumnContent: LazyListScope.() -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.pullRefresh(refreshState),
|
||||
) {
|
||||
LazyColumn(modifier = modifier, content = lazyColumnContent)
|
||||
|
||||
PullRefreshIndicator(
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
refreshing = isRefreshing,
|
||||
state = refreshState
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.ui.composables
|
||||
|
||||
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.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 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,
|
||||
errorToShowInSnackbar: Throwable? = null,
|
||||
onSnackbarShown: () -> Unit = {},
|
||||
onRefresh: () -> Unit = {},
|
||||
lazyColumnContent: LazyListScope.(List<T>) -> Unit = {},
|
||||
) {
|
||||
val refreshState = rememberPullRefreshState(
|
||||
refreshing = loadingState.isRefreshing,
|
||||
onRefresh = onRefresh,
|
||||
)
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
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(
|
||||
modifier = innerModifier,
|
||||
refreshState = refreshState,
|
||||
isRefreshing = loadingState.isRefreshing,
|
||||
lazyColumnContent = { lazyColumnContent(list) },
|
||||
)
|
||||
|
||||
ErrorSnackbar(
|
||||
error = errorToShowInSnackbar,
|
||||
snackbarHostState = snackbarHostState,
|
||||
onSnackbarShown = onSnackbarShown,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
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>
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package gq.kirmanak.mealient.shopping_lists.util
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
interface LoadingHelperFactory {
|
||||
|
||||
fun <T> create(coroutineScope: CoroutineScope, fetch: suspend () -> T): LoadingHelper<T>
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:tint="?attr/colorPrimary"
|
||||
android:viewportWidth="48"
|
||||
android:viewportHeight="48">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M14.35,43.95Q12.85,43.95 11.8,42.9Q10.75,41.85 10.75,40.35Q10.75,38.85 11.8,37.8Q12.85,36.75 14.35,36.75Q15.85,36.75 16.9,37.8Q17.95,38.85 17.95,40.35Q17.95,41.85 16.9,42.9Q15.85,43.95 14.35,43.95ZM34.35,43.95Q32.85,43.95 31.8,42.9Q30.75,41.85 30.75,40.35Q30.75,38.85 31.8,37.8Q32.85,36.75 34.35,36.75Q35.85,36.75 36.9,37.8Q37.95,38.85 37.95,40.35Q37.95,41.85 36.9,42.9Q35.85,43.95 34.35,43.95ZM11.75,10.95 L17.25,22.35H31.65Q31.65,22.35 31.65,22.35Q31.65,22.35 31.65,22.35L37.9,10.95Q37.9,10.95 37.9,10.95Q37.9,10.95 37.9,10.95ZM10.25,7.95H39.7Q40.85,7.95 41.45,9Q42.05,10.05 41.45,11.1L34.7,23.25Q34.15,24.2 33.275,24.775Q32.4,25.35 31.35,25.35H16.2L13.4,30.55Q13.4,30.55 13.4,30.55Q13.4,30.55 13.4,30.55H37.95V33.55H13.85Q11.75,33.55 10.825,32.15Q9.9,30.75 10.85,29L14.05,23.1L6.45,7H2.55V4H8.4ZM17.25,22.35H31.65Q31.65,22.35 31.65,22.35Q31.65,22.35 31.65,22.35Z" />
|
||||
</vector>
|
||||
11
features/shopping_lists/src/main/res/values-ru/strings.xml
Normal file
11
features/shopping_lists/src/main/res/values-ru/strings.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="shopping_lists_screen_cart_icon">Корзина для покупок</string>
|
||||
<string name="shopping_list_screen_unknown_error">Неизвестная ошибка</string>
|
||||
<string name="shopping_list_screen_empty_list">%1$s пуст</string>
|
||||
<string name="shopping_lists_screen_empty">Списки покупок не найдены</string>
|
||||
<string name="shopping_lists_screen_unauthorized_error">Требуется авторизация</string>
|
||||
<string name="shopping_lists_screen_no_connection">Нет соединения с сервером</string>
|
||||
<string name="shopping_lists_screen_unknown_error">Неизвестная ошибка</string>
|
||||
<string name="shopping_lists_screen_empty_button_refresh">Попробовать снова</string>
|
||||
</resources>
|
||||
11
features/shopping_lists/src/main/res/values/strings.xml
Normal file
11
features/shopping_lists/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="shopping_lists_screen_cart_icon">Shopping cart</string>
|
||||
<string name="shopping_list_screen_unknown_error">Unknown error</string>
|
||||
<string name="shopping_list_screen_empty_list">%1$s is empty</string>
|
||||
<string name="shopping_lists_screen_empty">No shopping lists found</string>
|
||||
<string name="shopping_lists_screen_unauthorized_error">Authentication is required</string>
|
||||
<string name="shopping_lists_screen_no_connection">No server connection</string>
|
||||
<string name="shopping_lists_screen_unknown_error">Unknown error</string>
|
||||
<string name="shopping_lists_screen_empty_button_refresh">Try again</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user