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:
@@ -64,4 +64,6 @@ interface MealieDataSourceV1 {
|
||||
suspend fun getShoppingList(id: String): GetShoppingListResponseV1
|
||||
|
||||
suspend fun updateIsShoppingListItemChecked(id: String, isChecked: Boolean)
|
||||
|
||||
suspend fun deleteShoppingListItem(id: String)
|
||||
}
|
||||
@@ -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" }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -10,4 +10,6 @@ interface ShoppingListsDataSource {
|
||||
suspend fun getShoppingList(id: String): FullShoppingListInfo
|
||||
|
||||
suspend fun updateIsShoppingListItemChecked(id: String, checked: Boolean)
|
||||
|
||||
suspend fun deleteShoppingListItem(id: String)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -10,4 +10,6 @@ interface ShoppingListsRepo {
|
||||
suspend fun getShoppingLists(): List<ShoppingListInfo>
|
||||
|
||||
suspend fun getShoppingList(id: String): FullShoppingListInfo
|
||||
|
||||
suspend fun deleteShoppingListItem(id: String)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,74 +65,114 @@ 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 ->
|
||||
ShoppingListItem(
|
||||
shoppingListItem = item.item,
|
||||
isDisabled = item.isDisabled,
|
||||
showDivider = index == firstCheckedItemIndex && index != 0,
|
||||
) { isChecked ->
|
||||
shoppingListViewModel.onItemCheckedChange(item.item, isChecked)
|
||||
itemsIndexed(items, { _, item -> item.id }) { index, item ->
|
||||
ShoppingListItem(
|
||||
shoppingListItem = item,
|
||||
showDivider = index == firstCheckedItemIndex && index != 0,
|
||||
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 = {},
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = Dimens.Small, end = Dimens.Small, start = Dimens.Small),
|
||||
) {
|
||||
if (showDivider) {
|
||||
Divider()
|
||||
val dismissState = rememberDismissState(
|
||||
confirmValueChange = {
|
||||
if (it == DismissValue.DismissedToStart) {
|
||||
onDismissed()
|
||||
}
|
||||
true
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
) {
|
||||
Checkbox(
|
||||
checked = shoppingListItem.checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
enabled = !isDisabled,
|
||||
)
|
||||
)
|
||||
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
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
) {
|
||||
if (showDivider) {
|
||||
Divider()
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
) {
|
||||
Checkbox(
|
||||
checked = shoppingListItem.checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
)
|
||||
|
||||
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(" ")
|
||||
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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user