Implement deletion of shopping list items (#163)

* Disable unstable Gradle features

* Implement deletion of shopping list items

* Hide deleted items even before they're deleted

* Check/uncheck items locally while the BE is updated
This commit is contained in:
Kirill Kamakin
2023-07-15 21:55:06 +02:00
committed by GitHub
parent 950805aa55
commit 3ae784df97
14 changed files with 192 additions and 83 deletions

View File

@@ -64,4 +64,6 @@ interface MealieDataSourceV1 {
suspend fun getShoppingList(id: String): GetShoppingListResponseV1
suspend fun updateIsShoppingListItemChecked(id: String, isChecked: Boolean)
suspend fun deleteShoppingListItem(id: String)
}

View File

@@ -188,4 +188,12 @@ class MealieDataSourceV1Impl @Inject constructor(
}
updateShoppingListItem(id, JsonObject(updatedItem))
}
override suspend fun deleteShoppingListItem(
id: String,
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.deleteShoppingListItem(id) },
logMethod = { "deleteShoppingListItem" },
logParameters = { "id = $id" }
)
}

View File

@@ -89,4 +89,9 @@ interface MealieServiceV1 {
@Path("id") id: String,
@Body request: JsonElement,
)
@DELETE("/api/groups/shopping/items/{id}")
suspend fun deleteShoppingListItem(
@Path("id") id: String,
)
}

View File

@@ -10,4 +10,6 @@ interface ShoppingListsDataSource {
suspend fun getShoppingList(id: String): FullShoppingListInfo
suspend fun updateIsShoppingListItemChecked(id: String, checked: Boolean)
suspend fun deleteShoppingListItem(id: String)
}

View File

@@ -24,5 +24,10 @@ class ShoppingListsDataSourceImpl @Inject constructor(
id: String,
checked: Boolean,
) = v1Source.updateIsShoppingListItemChecked(id, checked)
override suspend fun deleteShoppingListItem(
id: String
) = v1Source.deleteShoppingListItem(id)
}

View File

@@ -10,4 +10,6 @@ interface ShoppingListsRepo {
suspend fun getShoppingLists(): List<ShoppingListInfo>
suspend fun getShoppingList(id: String): FullShoppingListInfo
suspend fun deleteShoppingListItem(id: String)
}

View File

@@ -25,4 +25,9 @@ class ShoppingListsRepoImpl @Inject constructor(
logger.v { "getShoppingListItems() called with: id = $id" }
return dataSource.getShoppingList(id)
}
override suspend fun deleteShoppingListItem(id: String) {
logger.v { "deleteShoppingListItem() called with: id = $id" }
dataSource.deleteShoppingListItem(id)
}
}

View File

@@ -1,16 +1,31 @@
package gq.kirmanak.mealient.shopping_lists.ui
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DismissDirection
import androidx.compose.material3.DismissValue
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismiss
import androidx.compose.material3.Text
import androidx.compose.material3.rememberDismissState
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.stringResource
@@ -50,38 +65,75 @@ internal fun ShoppingListScreen(
LazyColumnWithLoadingState(
loadingState = loadingState.map { it.items },
contentPadding = PaddingValues(Dimens.Medium),
verticalArrangement = Arrangement.spacedBy(Dimens.Medium),
defaultEmptyListError = defaultEmptyListError,
errorToShowInSnackbar = shoppingListViewModel.errorToShowInSnackbar,
onRefresh = shoppingListViewModel::refreshShoppingList,
onSnackbarShown = shoppingListViewModel::onSnackbarShown,
lazyColumnContent = { items ->
val firstCheckedItemIndex = items.indexOfFirst { it.item.checked }
onSnackbarShown = shoppingListViewModel::onSnackbarShown
) { items ->
val firstCheckedItemIndex = items.indexOfFirst { it.checked }
itemsIndexed(items) { index, item ->
itemsIndexed(items, { _, item -> item.id }) { index, item ->
ShoppingListItem(
shoppingListItem = item.item,
isDisabled = item.isDisabled,
shoppingListItem = item,
showDivider = index == firstCheckedItemIndex && index != 0,
) { isChecked ->
shoppingListViewModel.onItemCheckedChange(item.item, isChecked)
}
}
modifier = Modifier.background(MaterialTheme.colorScheme.surface),
onCheckedChange = { isChecked ->
shoppingListViewModel.onItemCheckedChange(item, isChecked)
},
onDismissed = {
shoppingListViewModel.deleteShoppingListItem(item)
}
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShoppingListItem(
shoppingListItem: ShoppingListItemInfo,
isDisabled: Boolean,
showDivider: Boolean,
modifier: Modifier = Modifier,
onCheckedChange: (Boolean) -> Unit = {},
onDismissed: () -> Unit = {},
) {
val dismissState = rememberDismissState(
confirmValueChange = {
if (it == DismissValue.DismissedToStart) {
onDismissed()
}
true
}
)
SwipeToDismiss(
state = dismissState,
background = {
if (dismissState.targetValue == DismissValue.DismissedToStart) {
val color by animateColorAsState(MaterialTheme.colorScheme.error)
val iconColor by animateColorAsState(MaterialTheme.colorScheme.onSurface)
Box(
modifier = Modifier
.fillMaxSize()
.background(color)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.shopping_list_screen_delete_icon_content_description),
tint = iconColor,
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = Dimens.Small)
)
}
}
},
dismissContent = {
Column(
modifier = modifier
modifier = Modifier
.fillMaxWidth()
.padding(top = Dimens.Small, end = Dimens.Small, start = Dimens.Small),
.background(MaterialTheme.colorScheme.surface),
) {
if (showDivider) {
Divider()
@@ -93,7 +145,6 @@ fun ShoppingListItem(
Checkbox(
checked = shoppingListItem.checked,
onCheckedChange = onCheckedChange,
enabled = !isDisabled,
)
val isFood = shoppingListItem.isFood
@@ -111,13 +162,17 @@ fun ShoppingListItem(
Text(text = text)
}
}
},
modifier = modifier,
directions = setOf(DismissDirection.EndToStart),
)
}
@Composable
@Preview
fun PreviewShoppingListItemChecked() {
AppTheme {
ShoppingListItem(shoppingListItem = PreviewData.milk, false, false)
ShoppingListItem(shoppingListItem = PreviewData.milk, false)
}
}
@@ -125,23 +180,7 @@ fun PreviewShoppingListItemChecked() {
@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)
ShoppingListItem(shoppingListItem = PreviewData.blackTeaBags, false)
}
}

View File

@@ -4,10 +4,5 @@ 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,
val items: List<ShoppingListItemInfo>,
)

View File

@@ -39,7 +39,9 @@ internal class ShoppingListViewModel @Inject constructor(
private val args: ShoppingListNavArgs = ShoppingListScreenDestination.argsFrom(savedStateHandle)
private val _disabledItemIds: MutableStateFlow<Set<String>> = MutableStateFlow(mutableSetOf())
private val checkedOverride = MutableStateFlow<MutableMap<String, Boolean>>(mutableMapOf())
private val deletedItemIds = MutableStateFlow<Set<String>>(mutableSetOf())
private val loadingHelper = loadingHelperFactory.create(viewModelScope) {
shoppingListsRepo.getShoppingList(args.shoppingListId)
@@ -47,7 +49,8 @@ internal class ShoppingListViewModel @Inject constructor(
val loadingState: StateFlow<LoadingState<ShoppingListScreenState>> = combine(
loadingHelper.loadingState,
_disabledItemIds,
checkedOverride,
deletedItemIds,
::buildLoadingState,
).stateIn(viewModelScope, SharingStarted.Eagerly, LoadingStateNoData.InitialLoad)
@@ -64,7 +67,7 @@ internal class ShoppingListViewModel @Inject constructor(
viewModelScope.launch {
authRepo.isAuthorizedFlow.valueUpdatesOnly().collect {
logger.d { "Authorization state changed to $it" }
if (it) refreshShoppingList()
if (it) doRefresh()
}
}
}
@@ -72,19 +75,25 @@ internal class ShoppingListViewModel @Inject constructor(
fun refreshShoppingList() {
logger.v { "refreshShoppingList() called" }
viewModelScope.launch {
_errorToShowInSnackbar = loadingHelper.refresh().exceptionOrNull()
doRefresh()
}
}
private suspend fun doRefresh() {
_errorToShowInSnackbar = loadingHelper.refresh().exceptionOrNull()
}
private fun buildLoadingState(
loadingState: LoadingState<FullShoppingListInfo>,
disabledItemIds: Set<String>,
checkedOverrideMap: Map<String, Boolean>,
deletedItemIds: Set<String>,
): LoadingState<ShoppingListScreenState> {
logger.v { "buildLoadingState() called with: loadingState = $loadingState, disabledItems = $disabledItemIds" }
logger.v { "buildLoadingState() called with: loadingState = $loadingState, checkedOverrideMap = $checkedOverrideMap, deletedItemIds = $deletedItemIds" }
return loadingState.map { shoppingList ->
val items = shoppingList.items
.filter { it.id !in deletedItemIds }
.map { it.copy(checked = checkedOverrideMap[it.id] ?: it.checked) }
.sortedBy { it.checked }
.map { ShoppingListItemState(item = it, isDisabled = it.id in disabledItemIds) }
ShoppingListScreenState(name = shoppingList.name, items = items)
}
}
@@ -92,17 +101,22 @@ internal class ShoppingListViewModel @Inject constructor(
fun onItemCheckedChange(item: ShoppingListItemInfo, isChecked: Boolean) {
logger.v { "onItemCheckedChange() called with: item = $item, isChecked = $isChecked" }
viewModelScope.launch {
_disabledItemIds.update { it + item.id }
checkedOverride.update { originalMap ->
originalMap.toMutableMap().also { newMap -> newMap[item.id] = isChecked }
}
checkedOverride.value[item.id] = isChecked
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()
doRefresh()
}
checkedOverride.update { originalMap ->
originalMap.toMutableMap().also { newMap -> newMap.remove(item.id) }
}
}
}
@@ -111,4 +125,22 @@ internal class ShoppingListViewModel @Inject constructor(
logger.v { "onSnackbarShown() called" }
_errorToShowInSnackbar = null
}
fun deleteShoppingListItem(item: ShoppingListItemInfo) {
logger.v { "deleteShoppingListItem() called with: item = $item" }
viewModelScope.launch {
deletedItemIds.update { it + item.id }
val result = runCatchingExceptCancel {
shoppingListsRepo.deleteShoppingListItem(item.id)
}.onFailure {
logger.e(it) { "Failed to delete item" }
}
_errorToShowInSnackbar = result.exceptionOrNull()
if (result.isSuccess) {
logger.v { "Item deleted" }
doRefresh()
}
deletedItemIds.update { it - item.id }
}
}
}

View File

@@ -1,6 +1,8 @@
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
@@ -14,15 +16,21 @@ import androidx.compose.ui.Modifier
@Composable
@OptIn(ExperimentalMaterialApi::class)
fun LazyColumnPullRefresh(
modifier: Modifier = Modifier,
refreshState: PullRefreshState,
isRefreshing: Boolean,
lazyColumnContent: LazyListScope.() -> Unit
contentPadding: PaddingValues,
verticalArrangement: Arrangement.Vertical,
lazyColumnContent: LazyListScope.() -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.pullRefresh(refreshState),
) {
LazyColumn(modifier = modifier, content = lazyColumnContent)
LazyColumn(
contentPadding = contentPadding,
verticalArrangement = verticalArrangement,
content = lazyColumnContent
)
PullRefreshIndicator(
modifier = Modifier.align(Alignment.TopCenter),

View File

@@ -1,5 +1,7 @@
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
@@ -11,6 +13,7 @@ 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
@@ -24,6 +27,8 @@ 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 = {},
@@ -61,10 +66,12 @@ fun <T> LazyColumnWithLoadingState(
else -> {
LazyColumnPullRefresh(
modifier = innerModifier,
refreshState = refreshState,
isRefreshing = loadingState.isRefreshing,
contentPadding = contentPadding,
verticalArrangement = verticalArrangement,
lazyColumnContent = { lazyColumnContent(list) },
modifier = innerModifier,
)
ErrorSnackbar(

View File

@@ -3,6 +3,7 @@
<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_list_screen_delete_icon_content_description">Delete</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>

View File

@@ -1,8 +1,6 @@
org.gradle.jvmargs=-Xmx6g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g
org.gradle.configureondemand=true
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.unsafe.configuration-cache=true
android.useAndroidX=true
android.enableJetifier=false
kotlin.code.style=official