Group items in shopping lists based on labels (#319)

* Add data classes to hold food label information

- Created `GetFoodLabelResponse` data class to represent food label details
- Updated `GetFoodResponse` to include `label` property of type `GetFoodLabelResponse`

* Add backend to sort items in shopping lists by label

* Add UI code to sort items in shopping lists by label

* Use label from ShoppingListItem instead of Food

* Use list for ShoppingListItems and labels storage

* Fix incorrect routing code

* Only add DefaultLabel if there are items with a label

* Small improvements to comments and formatting
This commit is contained in:
Erik
2024-09-07 22:01:02 +02:00
committed by GitHub
parent 5aa2327c01
commit 7c825970ea
15 changed files with 250 additions and 35 deletions

View File

@@ -0,0 +1,12 @@
package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GetItemLabelResponse(
@SerialName("name") val name: String,
@SerialName("color") val color: String,
@SerialName("groupId") val grpId: String,
@SerialName("id") val id: String
)

View File

@@ -23,6 +23,7 @@ data class GetShoppingListItemResponse(
@SerialName("quantity") val quantity: Double = 0.0,
@SerialName("unit") val unit: GetUnitResponse? = null,
@SerialName("food") val food: GetFoodResponse? = null,
@SerialName("label") val label: GetItemLabelResponse? = null,
@SerialName("recipeReferences") val recipeReferences: List<GetShoppingListItemRecipeReferenceResponse> = emptyList(),
)

View File

@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardOptions
@@ -50,6 +51,7 @@ import gq.kirmanak.mealient.datasource.models.GetUnitResponse
import gq.kirmanak.mealient.shopping_list.R
import gq.kirmanak.mealient.shopping_lists.ui.composables.EditableItemBox
import gq.kirmanak.mealient.shopping_lists.ui.composables.getErrorMessage
import gq.kirmanak.mealient.shopping_lists.util.ItemLabelGroup
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.Dimens
import gq.kirmanak.mealient.ui.components.BaseScreen
@@ -145,47 +147,76 @@ private fun ShoppingListScreen(
}
},
lazyListState = lazyListState
) { items ->
val firstCheckedItemIndex = items.indexOfFirst { it.checked }
lastAddedItemIndex =
items.indexOfLast { it is ShoppingListItemState.NewItem }
) { sortedItems ->
itemsIndexed(items, { _, item -> item.id }) { index, itemState ->
if (itemState is ShoppingListItemState.ExistingItem) {
if (itemState.isEditing) {
val state = remember {
ShoppingListItemEditorState(
state = itemState,
foods = loadingState.data?.foods.orEmpty(),
units = loadingState.data?.units.orEmpty(),
lastAddedItemIndex = sortedItems.indexOfLast { it is ShoppingListItemState.NewItem }
val firstCheckedItemIndex = sortedItems.indexOfFirst { it.checked }
itemsIndexed(sortedItems, { _, item -> item.id}) { index, itemState ->
when (itemState) {
is ShoppingListItemState.ItemLabel -> {
ShoppingListSectionHeader(state = itemState)
}
is ShoppingListItemState.ExistingItem -> {
if (itemState.isEditing) {
val state = remember {
ShoppingListItemEditorState(
state = itemState,
foods = loadingState.data?.foods.orEmpty(),
units = loadingState.data?.units.orEmpty(),
)
}
ShoppingListItemEditor(
state = state,
onEditCancelled = { onEditCancel(itemState) },
onEditConfirmed = { onEditConfirm(itemState, state) },
)
} else {
ShoppingListItem(
itemState = itemState,
showDivider = firstCheckedItemIndex == index,
modifier = Modifier.background(MaterialTheme.colorScheme.surface),
onCheckedChange = { onItemCheckedChange(itemState, it) },
onDismissed = { onDeleteItem(itemState) },
onEditStart = { onEditStart(itemState) },
)
}
}
is ShoppingListItemState.NewItem -> {
ShoppingListItemEditor(
state = state,
onEditCancelled = { onEditCancel(itemState) },
onEditConfirmed = { onEditConfirm(itemState, state) },
)
} else {
ShoppingListItem(
itemState = itemState,
showDivider = index == firstCheckedItemIndex && index != 0,
modifier = Modifier.background(MaterialTheme.colorScheme.surface),
onCheckedChange = { onItemCheckedChange(itemState, it) },
onDismissed = { onDeleteItem(itemState) },
onEditStart = { onEditStart(itemState) },
state = itemState.item,
onEditCancelled = { onAddCancel(itemState) },
onEditConfirmed = { onAddConfirm(itemState) }
)
}
} else if (itemState is ShoppingListItemState.NewItem) {
ShoppingListItemEditor(
state = itemState.item,
onEditCancelled = { onAddCancel(itemState) },
onEditConfirmed = { onAddConfirm(itemState) }
)
}
}
}
}
@Composable
fun ShoppingListSectionHeader(state: ShoppingListItemState.ItemLabel) {
// Skip displaying checked items group and otherwise display the label name
val displayLabel = when (state.group) {
is ItemLabelGroup.DefaultLabel -> stringResource(
R.string.shopping_lists_screen_default_label)
is ItemLabelGroup.Label -> state.group.label.name
is ItemLabelGroup.CheckedItems -> return
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface),
) {
Text(
text = displayLabel,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(horizontal = Dimens.Small)
)
}
}
@Composable
fun ShoppingListItemEditor(
state: ShoppingListItemEditorState,

View File

@@ -3,6 +3,7 @@ package gq.kirmanak.mealient.shopping_lists.ui.details
import gq.kirmanak.mealient.datasource.models.GetFoodResponse
import gq.kirmanak.mealient.datasource.models.GetShoppingListItemResponse
import gq.kirmanak.mealient.datasource.models.GetUnitResponse
import gq.kirmanak.mealient.shopping_lists.util.ItemLabelGroup
import java.util.UUID
internal data class ShoppingListScreenState(
@@ -14,6 +15,9 @@ internal data class ShoppingListScreenState(
)
sealed class ShoppingListItemState {
data class ItemLabel(
val group: ItemLabelGroup,
) : ShoppingListItemState()
data class ExistingItem(
val item: GetShoppingListItemResponse,
@@ -30,16 +34,23 @@ val ShoppingListItemState.id: String
get() = when (this) {
is ShoppingListItemState.ExistingItem -> item.id
is ShoppingListItemState.NewItem -> id
is ShoppingListItemState.ItemLabel -> when (group) {
is ItemLabelGroup.Label -> group.label.id
is ItemLabelGroup.DefaultLabel -> "defaultLabelId"
is ItemLabelGroup.CheckedItems -> "checkedLabelId"
}
}
val ShoppingListItemState.checked: Boolean
get() = when (this) {
is ShoppingListItemState.ExistingItem -> item.checked
is ShoppingListItemState.NewItem -> false
is ShoppingListItemState.ItemLabel -> false
}
val ShoppingListItemState.position: Int
get() = when (this) {
is ShoppingListItemState.ExistingItem -> item.position
is ShoppingListItemState.NewItem -> item.position
is ShoppingListItemState.ItemLabel -> -1
}

View File

@@ -15,6 +15,7 @@ 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.groupItemsByLabel
import gq.kirmanak.mealient.ui.util.LoadingHelperFactory
import gq.kirmanak.mealient.ui.util.LoadingState
import gq.kirmanak.mealient.ui.util.LoadingStateNoData
@@ -134,7 +135,7 @@ internal class ShoppingListViewModel @Inject constructor(
ShoppingListScreenState(
name = data.shoppingList.name,
listId = data.shoppingList.id,
items = items,
items = groupItemsByLabel(items),
foods = data.foods.sortedBy { it.name },
units = data.units.sortedBy { it.name },
)

View File

@@ -0,0 +1,89 @@
package gq.kirmanak.mealient.shopping_lists.util
import gq.kirmanak.mealient.datasource.models.GetItemLabelResponse
import gq.kirmanak.mealient.shopping_lists.ui.details.ShoppingListItemState
import gq.kirmanak.mealient.shopping_lists.ui.details.checked
sealed class ItemLabelGroup {
data object DefaultLabel : ItemLabelGroup()
data object CheckedItems : ItemLabelGroup()
data class Label(val label: GetItemLabelResponse) : ItemLabelGroup()
}
/**
* Function that sorts items by label. The function returns a list of ShoppingListItemStates
* where items are grouped by label. The list is sorted in the following way:
* 1. Unchecked items are grouped by label.
* 2. Items without a label.
* 3. Checked items regardless of label information.
*
* The List contains `ShoppingListItemStates` with each new label group starting with an
* `ItemLabel` state and followed by the items with that label.
* The items within a group are alphabetically sorted by name.
*/
fun groupItemsByLabel(
items: List<ShoppingListItemState>
): List<ShoppingListItemState> {
// Group unchecked items by label and sort each group by label name
val uncheckedItemsGroupedByLabel = items
.filterNot { it.checked }
.groupBy { item ->
(item as? ShoppingListItemState.ExistingItem)?.item?.label?.let {
ItemLabelGroup.Label(it)
} ?: ItemLabelGroup.DefaultLabel
}.mapValues { (_, groupedItems) ->
groupedItems.sortedBy { item ->
(item as? ShoppingListItemState.ExistingItem)?.item?.label?.name ?: ""
}
}.toMutableMap()
// Remove items with no label from grouped items to prevent them from being displayed first.
// Store these items in a separate list to add them to the end of the list later.
val uncheckedItemsNoLabel = uncheckedItemsGroupedByLabel[ItemLabelGroup.DefaultLabel].orEmpty()
uncheckedItemsGroupedByLabel.remove(ItemLabelGroup.DefaultLabel)
// Put all checked items into a single group to display them without label-specific headers
val checkedItems = items.filter { it.checked }
.sortedBy { item ->
(item as? ShoppingListItemState.ExistingItem)?.item?.label?.name ?: ""
}
/**
* Helper function to add a group of items with a label to a list.
*/
fun addLabeledGroupToList(
result: MutableList<ShoppingListItemState>,
items: List<ShoppingListItemState>,
label: ItemLabelGroup?
) {
if (label != null) {
result.add(
ShoppingListItemState.ItemLabel(
group = label,
)
)
}
result.addAll(items)
}
// Add groups to the result list in the correct order
val result = mutableListOf<ShoppingListItemState>()
uncheckedItemsGroupedByLabel.forEach { (labelGroup, items) ->
addLabeledGroupToList(result, items, labelGroup)
}
if (uncheckedItemsNoLabel.isNotEmpty()) {
// Only add DefaultLabel if there are items with a label to avoid cluttering the UI
if (result.isNotEmpty()) {
addLabeledGroupToList(result, uncheckedItemsNoLabel, ItemLabelGroup.DefaultLabel)
} else {
addLabeledGroupToList(result, uncheckedItemsNoLabel, null)
}
}
if (checkedItems.isNotEmpty()) {
addLabeledGroupToList(result, checkedItems, ItemLabelGroup.CheckedItems)
}
return result
}

View File

@@ -33,4 +33,5 @@
<string name="shopping_lists_dialog_name_clear_input">Eingabe löschen</string>
<string name="shopping_lists_dialog_delete_confirm_title">Entfernen von %1$s</string>
<string name="shopping_lists_dialog_delete_confirm_text">Bestätigen Sie, dass Sie die Einkaufsliste mit allen Artikeln entfernen möchten.</string>
<string name="shopping_lists_screen_default_label">Sonstiges</string>
</resources>

View File

@@ -33,4 +33,5 @@
<string name="shopping_lists_dialog_name_clear_input">Borrar entrada</string>
<string name="shopping_lists_dialog_delete_confirm_title">Eliminación de %1$s</string>
<string name="shopping_lists_dialog_delete_confirm_text">Confirme que desea eliminar la lista de la compra incluyendo todos los artículos.</string>
<string name="shopping_lists_screen_default_label">Otros</string>
</resources>

View File

@@ -33,4 +33,5 @@
<string name="shopping_lists_dialog_name_clear_input">Effacer l\'entrée</string>
<string name="shopping_lists_dialog_delete_confirm_title">Suppression de %1$s</string>
<string name="shopping_lists_dialog_delete_confirm_text">Confirmez que vous souhaitez supprimer la liste d\'achats, y compris tous les articles.</string>
<string name="shopping_lists_screen_default_label">Autre</string>
</resources>

View File

@@ -33,4 +33,5 @@
<string name="shopping_lists_dialog_name_clear_input">Invoer wissen</string>
<string name="shopping_lists_dialog_delete_confirm_title">Verwijderen %1$s</string>
<string name="shopping_lists_dialog_delete_confirm_text">Bevestig dat je de boodschappenlijst inclusief alle items wilt verwijderen.</string>
<string name="shopping_lists_screen_default_label">Overig</string>
</resources>

View File

@@ -33,4 +33,5 @@
<string name="shopping_lists_dialog_name_clear_input">Limpar entrada</string>
<string name="shopping_lists_dialog_delete_confirm_title">Remoção de %1$s</string>
<string name="shopping_lists_dialog_delete_confirm_text">Confirme que pretende remover a lista de compras, incluindo todos os artigos.</string>
<string name="shopping_lists_screen_default_label">Outros</string>
</resources>

View File

@@ -33,4 +33,5 @@
<string name="shopping_lists_dialog_name_clear_input">Очистить ввод</string>
<string name="shopping_lists_dialog_delete_confirm_title">Удаление %1$s</string>
<string name="shopping_lists_dialog_delete_confirm_text">Подтвердите, что вы хотите удалить список покупок, включая все товары.</string>
<string name="shopping_lists_screen_default_label">Другое</string>
</resources>

View File

@@ -35,5 +35,6 @@
<string name="shopping_lists_dialog_name_clear_input">Clear input</string>
<string name="shopping_lists_dialog_delete_confirm_title">Removing %1$s</string>
<string name="shopping_lists_dialog_delete_confirm_text">Confirm that you want to remove the shopping list including all items.</string>
<string name="shopping_lists_screen_default_label">Other</string>
</resources>

View File

@@ -1,6 +1,7 @@
package gq.kirmanak.mealient.shopping_lists.ui.details
import androidx.lifecycle.SavedStateHandle
import gq.kirmanak.mealient.datasource.models.GetItemLabelResponse
import gq.kirmanak.mealient.datasource.models.GetFoodResponse
import gq.kirmanak.mealient.datasource.models.GetShoppingListItemRecipeReferenceResponse
import gq.kirmanak.mealient.datasource.models.GetShoppingListItemResponse
@@ -8,6 +9,7 @@ import gq.kirmanak.mealient.datasource.models.GetShoppingListResponse
import gq.kirmanak.mealient.datasource.models.GetUnitResponse
import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsAuthRepo
import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsRepo
import gq.kirmanak.mealient.shopping_lists.util.ItemLabelGroup
import gq.kirmanak.mealient.test.BaseUnitTest
import gq.kirmanak.mealient.ui.util.LoadingHelper
import gq.kirmanak.mealient.ui.util.LoadingHelperFactory
@@ -134,7 +136,50 @@ internal class ShoppingListViewModelTest : BaseUnitTest() {
private val mlUnit = GetUnitResponse("ml", "")
private val milkFood = GetFoodResponse("Milk", "")
private val milkLabel = GetItemLabelResponse("Milk", "#FF0000", "1", "0")
private val milkFood = GetFoodResponse(name = "Milk", id ="")
private val breadFood = GetFoodResponse(name = "Bread", id = "")
private val appleLabel = GetItemLabelResponse("Fruit", "#FF0000", "1", "1")
private val appleFood = GetFoodResponse(name = "Apple", id = "")
private val apple = GetShoppingListItemResponse(
id = "4",
shoppingListId = "1",
checked = false,
position = 0,
isFood = true,
note = "Apple",
quantity = 1.0,
unit = null,
food = appleFood,
label = appleLabel,
recipeReferences = listOf(
GetShoppingListItemRecipeReferenceResponse(
recipeId = "1",
recipeQuantity = 1.0,
),
),
)
private val bread = GetShoppingListItemResponse(
id = "3",
shoppingListId = "1",
checked = false,
position = 0,
isFood = false,
note = "Bread",
quantity = 1.0,
unit = null,
food = breadFood,
recipeReferences = listOf(
GetShoppingListItemRecipeReferenceResponse(
recipeId = "1",
recipeQuantity = 1.0,
),
),
)
private val blackTeaBags = GetShoppingListItemResponse(
id = "1",
@@ -164,6 +209,7 @@ private val milk = GetShoppingListItemResponse(
quantity = 500.0,
unit = mlUnit,
food = milkFood,
label = milkLabel,
recipeReferences = listOf(
GetShoppingListItemRecipeReferenceResponse(
recipeId = "1",
@@ -176,7 +222,7 @@ private val shoppingListResponse = GetShoppingListResponse(
id = "shoppingListId",
groupId = "shoppingListGroupId",
name = "shoppingListName",
listItems = listOf(blackTeaBags, milk),
listItems = listOf(blackTeaBags, milk, bread, apple),
recipeReferences = listOf()
)
@@ -190,14 +236,32 @@ private val shoppingListScreen = ShoppingListScreenState(
name = "shoppingListName",
listId = "shoppingListId",
items = listOf(
ShoppingListItemState.ItemLabel(
group = ItemLabelGroup.Label(apple.label
?: GetItemLabelResponse(name = "", "", "", "")),
),
ShoppingListItemState.ExistingItem(
item = apple,
isEditing = false
),
ShoppingListItemState.ItemLabel(
group = ItemLabelGroup.DefaultLabel,
),
ShoppingListItemState.ExistingItem(
item = blackTeaBags,
isEditing = false
),
ShoppingListItemState.ExistingItem(
item = bread,
isEditing = false
),
ShoppingListItemState.ItemLabel(
group = ItemLabelGroup.CheckedItems
),
ShoppingListItemState.ExistingItem(
item = milk,
isEditing = false
)
),
),
foods = listOf(milkFood),
units = listOf(mlUnit)

View File

@@ -92,4 +92,3 @@ fun <T> LazyColumnWithLoadingState(
}
}
}