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 getShoppingList(id: String): GetShoppingListResponseV1
suspend fun updateIsShoppingListItemChecked(id: String, isChecked: Boolean) 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)) 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, @Path("id") id: String,
@Body request: JsonElement, @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 getShoppingList(id: String): FullShoppingListInfo
suspend fun updateIsShoppingListItemChecked(id: String, checked: Boolean) suspend fun updateIsShoppingListItemChecked(id: String, checked: Boolean)
suspend fun deleteShoppingListItem(id: String)
} }

View File

@@ -24,5 +24,10 @@ class ShoppingListsDataSourceImpl @Inject constructor(
id: String, id: String,
checked: Boolean, checked: Boolean,
) = v1Source.updateIsShoppingListItemChecked(id, checked) ) = 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 getShoppingLists(): List<ShoppingListInfo>
suspend fun getShoppingList(id: String): FullShoppingListInfo 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" } logger.v { "getShoppingListItems() called with: id = $id" }
return dataSource.getShoppingList(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 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.itemsIndexed 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.Checkbox
import androidx.compose.material3.DismissDirection
import androidx.compose.material3.DismissValue
import androidx.compose.material3.Divider 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.Text
import androidx.compose.material3.rememberDismissState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -50,38 +65,75 @@ internal fun ShoppingListScreen(
LazyColumnWithLoadingState( LazyColumnWithLoadingState(
loadingState = loadingState.map { it.items }, loadingState = loadingState.map { it.items },
contentPadding = PaddingValues(Dimens.Medium),
verticalArrangement = Arrangement.spacedBy(Dimens.Medium),
defaultEmptyListError = defaultEmptyListError, defaultEmptyListError = defaultEmptyListError,
errorToShowInSnackbar = shoppingListViewModel.errorToShowInSnackbar, errorToShowInSnackbar = shoppingListViewModel.errorToShowInSnackbar,
onRefresh = shoppingListViewModel::refreshShoppingList, onRefresh = shoppingListViewModel::refreshShoppingList,
onSnackbarShown = shoppingListViewModel::onSnackbarShown, onSnackbarShown = shoppingListViewModel::onSnackbarShown
lazyColumnContent = { items -> ) { items ->
val firstCheckedItemIndex = items.indexOfFirst { it.item.checked } val firstCheckedItemIndex = items.indexOfFirst { it.checked }
itemsIndexed(items) { index, item -> itemsIndexed(items, { _, item -> item.id }) { index, item ->
ShoppingListItem( ShoppingListItem(
shoppingListItem = item.item, shoppingListItem = item,
isDisabled = item.isDisabled,
showDivider = index == firstCheckedItemIndex && index != 0, showDivider = index == firstCheckedItemIndex && index != 0,
) { isChecked -> modifier = Modifier.background(MaterialTheme.colorScheme.surface),
shoppingListViewModel.onItemCheckedChange(item.item, isChecked) onCheckedChange = { isChecked ->
} shoppingListViewModel.onItemCheckedChange(item, isChecked)
} },
onDismissed = {
shoppingListViewModel.deleteShoppingListItem(item)
} }
) )
} }
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ShoppingListItem( fun ShoppingListItem(
shoppingListItem: ShoppingListItemInfo, shoppingListItem: ShoppingListItemInfo,
isDisabled: Boolean,
showDivider: Boolean, showDivider: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onCheckedChange: (Boolean) -> Unit = {}, 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( Column(
modifier = modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = Dimens.Small, end = Dimens.Small, start = Dimens.Small), .background(MaterialTheme.colorScheme.surface),
) { ) {
if (showDivider) { if (showDivider) {
Divider() Divider()
@@ -93,7 +145,6 @@ fun ShoppingListItem(
Checkbox( Checkbox(
checked = shoppingListItem.checked, checked = shoppingListItem.checked,
onCheckedChange = onCheckedChange, onCheckedChange = onCheckedChange,
enabled = !isDisabled,
) )
val isFood = shoppingListItem.isFood val isFood = shoppingListItem.isFood
@@ -111,13 +162,17 @@ fun ShoppingListItem(
Text(text = text) Text(text = text)
} }
} }
},
modifier = modifier,
directions = setOf(DismissDirection.EndToStart),
)
} }
@Composable @Composable
@Preview @Preview
fun PreviewShoppingListItemChecked() { fun PreviewShoppingListItemChecked() {
AppTheme { AppTheme {
ShoppingListItem(shoppingListItem = PreviewData.milk, false, false) ShoppingListItem(shoppingListItem = PreviewData.milk, false)
} }
} }
@@ -125,23 +180,7 @@ fun PreviewShoppingListItemChecked() {
@Preview @Preview
fun PreviewShoppingListItemUnchecked() { fun PreviewShoppingListItemUnchecked() {
AppTheme { AppTheme {
ShoppingListItem(shoppingListItem = PreviewData.blackTeaBags, false, false) ShoppingListItem(shoppingListItem = PreviewData.blackTeaBags, false)
}
}
@Composable
@Preview
fun PreviewShoppingListItemCheckedDisabled() {
AppTheme {
ShoppingListItem(shoppingListItem = PreviewData.milk, true, false)
}
}
@Composable
@Preview
fun PreviewShoppingListItemUncheckedDisabled() {
AppTheme {
ShoppingListItem(shoppingListItem = PreviewData.blackTeaBags, true, false)
} }
} }

View File

@@ -4,10 +4,5 @@ import gq.kirmanak.mealient.datasource.models.ShoppingListItemInfo
internal data class ShoppingListScreenState( internal data class ShoppingListScreenState(
val name: String, val name: String,
val items: List<ShoppingListItemState>, val items: List<ShoppingListItemInfo>,
)
internal data class ShoppingListItemState(
val item: ShoppingListItemInfo,
val isDisabled: Boolean,
) )

View File

@@ -39,7 +39,9 @@ internal class ShoppingListViewModel @Inject constructor(
private val args: ShoppingListNavArgs = ShoppingListScreenDestination.argsFrom(savedStateHandle) 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) { private val loadingHelper = loadingHelperFactory.create(viewModelScope) {
shoppingListsRepo.getShoppingList(args.shoppingListId) shoppingListsRepo.getShoppingList(args.shoppingListId)
@@ -47,7 +49,8 @@ internal class ShoppingListViewModel @Inject constructor(
val loadingState: StateFlow<LoadingState<ShoppingListScreenState>> = combine( val loadingState: StateFlow<LoadingState<ShoppingListScreenState>> = combine(
loadingHelper.loadingState, loadingHelper.loadingState,
_disabledItemIds, checkedOverride,
deletedItemIds,
::buildLoadingState, ::buildLoadingState,
).stateIn(viewModelScope, SharingStarted.Eagerly, LoadingStateNoData.InitialLoad) ).stateIn(viewModelScope, SharingStarted.Eagerly, LoadingStateNoData.InitialLoad)
@@ -64,7 +67,7 @@ internal class ShoppingListViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
authRepo.isAuthorizedFlow.valueUpdatesOnly().collect { authRepo.isAuthorizedFlow.valueUpdatesOnly().collect {
logger.d { "Authorization state changed to $it" } logger.d { "Authorization state changed to $it" }
if (it) refreshShoppingList() if (it) doRefresh()
} }
} }
} }
@@ -72,19 +75,25 @@ internal class ShoppingListViewModel @Inject constructor(
fun refreshShoppingList() { fun refreshShoppingList() {
logger.v { "refreshShoppingList() called" } logger.v { "refreshShoppingList() called" }
viewModelScope.launch { viewModelScope.launch {
_errorToShowInSnackbar = loadingHelper.refresh().exceptionOrNull() doRefresh()
} }
} }
private suspend fun doRefresh() {
_errorToShowInSnackbar = loadingHelper.refresh().exceptionOrNull()
}
private fun buildLoadingState( private fun buildLoadingState(
loadingState: LoadingState<FullShoppingListInfo>, loadingState: LoadingState<FullShoppingListInfo>,
disabledItemIds: Set<String>, checkedOverrideMap: Map<String, Boolean>,
deletedItemIds: Set<String>,
): LoadingState<ShoppingListScreenState> { ): 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 -> return loadingState.map { shoppingList ->
val items = shoppingList.items val items = shoppingList.items
.filter { it.id !in deletedItemIds }
.map { it.copy(checked = checkedOverrideMap[it.id] ?: it.checked) }
.sortedBy { it.checked } .sortedBy { it.checked }
.map { ShoppingListItemState(item = it, isDisabled = it.id in disabledItemIds) }
ShoppingListScreenState(name = shoppingList.name, items = items) ShoppingListScreenState(name = shoppingList.name, items = items)
} }
} }
@@ -92,17 +101,22 @@ internal class ShoppingListViewModel @Inject constructor(
fun onItemCheckedChange(item: ShoppingListItemInfo, isChecked: Boolean) { fun onItemCheckedChange(item: ShoppingListItemInfo, isChecked: Boolean) {
logger.v { "onItemCheckedChange() called with: item = $item, isChecked = $isChecked" } logger.v { "onItemCheckedChange() called with: item = $item, isChecked = $isChecked" }
viewModelScope.launch { 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 { val result = runCatchingExceptCancel {
shoppingListsRepo.updateIsShoppingListItemChecked(item.id, isChecked) shoppingListsRepo.updateIsShoppingListItemChecked(item.id, isChecked)
}.onFailure { }.onFailure {
logger.e(it) { "Failed to update item's checked state" } logger.e(it) { "Failed to update item's checked state" }
} }
_disabledItemIds.update { it - item.id }
_errorToShowInSnackbar = result.exceptionOrNull() _errorToShowInSnackbar = result.exceptionOrNull()
if (result.isSuccess) { if (result.isSuccess) {
logger.v { "Item's checked state updated" } 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" } logger.v { "onSnackbarShown() called" }
_errorToShowInSnackbar = null _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 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.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
@@ -14,15 +16,21 @@ import androidx.compose.ui.Modifier
@Composable @Composable
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
fun LazyColumnPullRefresh( fun LazyColumnPullRefresh(
modifier: Modifier = Modifier,
refreshState: PullRefreshState, refreshState: PullRefreshState,
isRefreshing: Boolean, isRefreshing: Boolean,
lazyColumnContent: LazyListScope.() -> Unit contentPadding: PaddingValues,
verticalArrangement: Arrangement.Vertical,
lazyColumnContent: LazyListScope.() -> Unit,
modifier: Modifier = Modifier,
) { ) {
Box( Box(
modifier = modifier.pullRefresh(refreshState), modifier = modifier.pullRefresh(refreshState),
) { ) {
LazyColumn(modifier = modifier, content = lazyColumnContent) LazyColumn(
contentPadding = contentPadding,
verticalArrangement = verticalArrangement,
content = lazyColumnContent
)
PullRefreshIndicator( PullRefreshIndicator(
modifier = Modifier.align(Alignment.TopCenter), modifier = Modifier.align(Alignment.TopCenter),

View File

@@ -1,5 +1,7 @@
package gq.kirmanak.mealient.shopping_lists.ui.composables 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.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
@@ -11,6 +13,7 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier 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.LoadingState
import gq.kirmanak.mealient.shopping_lists.util.LoadingStateNoData import gq.kirmanak.mealient.shopping_lists.util.LoadingStateNoData
import gq.kirmanak.mealient.shopping_lists.util.data import gq.kirmanak.mealient.shopping_lists.util.data
@@ -24,6 +27,8 @@ fun <T> LazyColumnWithLoadingState(
loadingState: LoadingState<List<T>>, loadingState: LoadingState<List<T>>,
defaultEmptyListError: String, defaultEmptyListError: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp),
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
errorToShowInSnackbar: Throwable? = null, errorToShowInSnackbar: Throwable? = null,
onSnackbarShown: () -> Unit = {}, onSnackbarShown: () -> Unit = {},
onRefresh: () -> Unit = {}, onRefresh: () -> Unit = {},
@@ -61,10 +66,12 @@ fun <T> LazyColumnWithLoadingState(
else -> { else -> {
LazyColumnPullRefresh( LazyColumnPullRefresh(
modifier = innerModifier,
refreshState = refreshState, refreshState = refreshState,
isRefreshing = loadingState.isRefreshing, isRefreshing = loadingState.isRefreshing,
contentPadding = contentPadding,
verticalArrangement = verticalArrangement,
lazyColumnContent = { lazyColumnContent(list) }, lazyColumnContent = { lazyColumnContent(list) },
modifier = innerModifier,
) )
ErrorSnackbar( ErrorSnackbar(

View File

@@ -3,6 +3,7 @@
<string name="shopping_lists_screen_cart_icon">Shopping cart</string> <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_unknown_error">Unknown error</string>
<string name="shopping_list_screen_empty_list">%1$s is empty</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_empty">No shopping lists found</string>
<string name="shopping_lists_screen_unauthorized_error">Authentication is required</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_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.jvmargs=-Xmx6g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g
org.gradle.configureondemand=true
org.gradle.parallel=true org.gradle.parallel=true
org.gradle.caching=true org.gradle.caching=true
org.gradle.unsafe.configuration-cache=true
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=false android.enableJetifier=false
kotlin.code.style=official kotlin.code.style=official