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:
@@ -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
|
||||
)
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
|
||||
@@ -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,13 +147,17 @@ 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) {
|
||||
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(
|
||||
@@ -168,14 +174,15 @@ private fun ShoppingListScreen(
|
||||
} else {
|
||||
ShoppingListItem(
|
||||
itemState = itemState,
|
||||
showDivider = index == firstCheckedItemIndex && index != 0,
|
||||
showDivider = firstCheckedItemIndex == index,
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.surface),
|
||||
onCheckedChange = { onItemCheckedChange(itemState, it) },
|
||||
onDismissed = { onDeleteItem(itemState) },
|
||||
onEditStart = { onEditStart(itemState) },
|
||||
)
|
||||
}
|
||||
} else if (itemState is ShoppingListItemState.NewItem) {
|
||||
}
|
||||
is ShoppingListItemState.NewItem -> {
|
||||
ShoppingListItemEditor(
|
||||
state = itemState.item,
|
||||
onEditCancelled = { onAddCancel(itemState) },
|
||||
@@ -184,6 +191,30 @@ private fun ShoppingListScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -92,4 +92,3 @@ fun <T> LazyColumnWithLoadingState(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user