Various addtions to ShoppingList page (#325)

* Improve design of ShoppingListItems

* Improve scrolling behaviour of ShoppingListItemEditor

* Add search functionality to food and unit fields in ShoppingListItemEditor

* Add functionality to display plurals of units in shopping lists

* Fix tests

* Ensure editor displays correctly when switching to food mode.

- Fixed a bug causing the editor to display incorrectly when switching to food mode while at the bottom of the screen

* Fix regressions caused in 5dff173

- Fixed a regression introducing incorrect scrolling behaviour when editing items at the bottom of the page
- Fixed a regression causing the add button to not be hidden

* Prefill fields in ShoppingListItemEditorFoodRow if possible

* Remove unnecessary trigger for bringIntoView function

- Remove unnecessary trigger for bringIntoView function of ShoppingListItemEditor

* Display showAddButton dynamically

* Add support for food plurals

* Extract plural functionality to function

* Remember filtering options

* Use ExposedDropdownMenu instead of DropdownMenu

- Updated selection of foods and units to use ExposedDropdownMenu
- Updated composeBom to 2024.09.02
- Updated composeBom to 2024.09.02 to increment androidx.compose.material3 to 1.3.0 needed for androidx.compose.material3.MenuAnchorType

* Only allow one edit menu to be open at a time
This commit is contained in:
Erik
2024-09-29 08:22:50 +02:00
committed by GitHub
parent 7c825970ea
commit b68cf9a1ae
5 changed files with 144 additions and 47 deletions

View File

@@ -12,4 +12,5 @@ data class GetFoodsResponse(
data class GetFoodResponse( data class GetFoodResponse(
@SerialName("name") val name: String, @SerialName("name") val name: String,
@SerialName("id") val id: String, @SerialName("id") val id: String,
@SerialName("pluralName") val pluralName: String? = null
) )

View File

@@ -11,5 +11,6 @@ data class GetUnitsResponse(
@Serializable @Serializable
data class GetUnitResponse( data class GetUnitResponse(
@SerialName("name") val name: String, @SerialName("name") val name: String,
@SerialName("pluralName") val pluralName: String? = null,
@SerialName("id") val id: String @SerialName("id") val id: String
) )

View File

@@ -1,5 +1,6 @@
package gq.kirmanak.mealient.shopping_lists.ui.details package gq.kirmanak.mealient.shopping_lists.ui.details
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -9,6 +10,8 @@ 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.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
@@ -26,6 +29,7 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -38,10 +42,12 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.withStyle
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.Destination
import gq.kirmanak.mealient.datasource.models.GetFoodResponse import gq.kirmanak.mealient.datasource.models.GetFoodResponse
@@ -61,7 +67,6 @@ import gq.kirmanak.mealient.ui.util.LoadingState
import gq.kirmanak.mealient.ui.util.data import gq.kirmanak.mealient.ui.util.data
import gq.kirmanak.mealient.ui.util.error import gq.kirmanak.mealient.ui.util.error
import gq.kirmanak.mealient.ui.util.map import gq.kirmanak.mealient.ui.util.map
import kotlinx.coroutines.android.awaitFrame
import java.text.DecimalFormat import java.text.DecimalFormat
data class ShoppingListNavArgs( data class ShoppingListNavArgs(
@@ -118,6 +123,11 @@ private fun ShoppingListScreen(
) )
var lastAddedItemIndex by remember { mutableIntStateOf(-1) } var lastAddedItemIndex by remember { mutableIntStateOf(-1) }
// Show the add button only if there are no items being edited or added
val itemBeingEdited = !loadingState.data?.items.orEmpty().none {
(it as? ShoppingListItemState.ExistingItem)?.isEditing == true
|| it is ShoppingListItemState.NewItem
}
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
LaunchedEffect(lastAddedItemIndex) { LaunchedEffect(lastAddedItemIndex) {
if (lastAddedItemIndex >= 0) lazyListState.animateScrollToItem(lastAddedItemIndex) if (lastAddedItemIndex >= 0) lazyListState.animateScrollToItem(lastAddedItemIndex)
@@ -131,19 +141,22 @@ private fun ShoppingListScreen(
contentPadding = PaddingValues( contentPadding = PaddingValues(
start = Dimens.Medium, start = Dimens.Medium,
end = Dimens.Medium, end = Dimens.Medium,
top = Dimens.Medium, top = Dimens.Large,
bottom = Dimens.Large * 4, bottom = Dimens.Large,
), ),
verticalArrangement = Arrangement.spacedBy(Dimens.Medium), verticalArrangement = Arrangement.spacedBy(Dimens.Medium),
snackbarText = errorToShowInSnackbar?.let { getErrorMessage(error = it) }, snackbarText = errorToShowInSnackbar?.let { getErrorMessage(error = it) },
onSnackbarShown = onSnackbarShown, onSnackbarShown = onSnackbarShown,
onRefresh = onRefreshRequest, onRefresh = onRefreshRequest,
floatingActionButton = { floatingActionButton = {
FloatingActionButton(onClick = onAddItemClicked) { // Only show the button if the editor is not active to avoid overlapping
Icon( if (!itemBeingEdited) {
imageVector = Icons.Default.Add, FloatingActionButton(onClick = onAddItemClicked) {
contentDescription = stringResource(id = R.string.shopping_list_screen_add_icon_content_description), Icon(
) imageVector = Icons.Default.Add,
contentDescription = stringResource(id = R.string.shopping_list_screen_add_icon_content_description),
)
}
} }
}, },
lazyListState = lazyListState lazyListState = lazyListState
@@ -178,7 +191,12 @@ private fun ShoppingListScreen(
modifier = Modifier.background(MaterialTheme.colorScheme.surface), modifier = Modifier.background(MaterialTheme.colorScheme.surface),
onCheckedChange = { onItemCheckedChange(itemState, it) }, onCheckedChange = { onItemCheckedChange(itemState, it) },
onDismissed = { onDeleteItem(itemState) }, onDismissed = { onDeleteItem(itemState) },
onEditStart = { onEditStart(itemState) }, onEditStart = {
// Only allow one item to be edited at a time
if (!itemBeingEdited) {
onEditStart(itemState)
}
},
) )
} }
} }
@@ -186,7 +204,7 @@ private fun ShoppingListScreen(
ShoppingListItemEditor( ShoppingListItemEditor(
state = itemState.item, state = itemState.item,
onEditCancelled = { onAddCancel(itemState) }, onEditCancelled = { onAddCancel(itemState) },
onEditConfirmed = { onAddConfirm(itemState) } onEditConfirmed = { onAddConfirm(itemState) },
) )
} }
} }
@@ -217,30 +235,42 @@ fun ShoppingListSectionHeader(state: ShoppingListItemState.ItemLabel) {
} }
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun ShoppingListItemEditor( fun ShoppingListItemEditor(
state: ShoppingListItemEditorState, state: ShoppingListItemEditorState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onEditCancelled: () -> Unit = {}, onEditCancelled: () -> Unit = {},
onEditConfirmed: () -> Unit = {}, onEditConfirmed: () -> Unit = {}
) { ) {
val bringIntoViewRequester = remember { BringIntoViewRequester() }
var shouldBringIntoView by remember { mutableStateOf(true) }
Column( Column(
modifier = modifier.fillMaxWidth(), modifier = modifier
.fillMaxWidth()
.bringIntoViewRequester(bringIntoViewRequester),
verticalArrangement = Arrangement.spacedBy(Dimens.Small), verticalArrangement = Arrangement.spacedBy(Dimens.Small),
horizontalAlignment = Alignment.End, horizontalAlignment = Alignment.End,
) { ) {
ShoppingListItemEditorFirstRow( ShoppingListItemEditorFirstRow(state = state)
state = state
)
if (state.isFood) { if (state.isFood) {
ShoppingListItemEditorFoodRow(state = state) ShoppingListItemEditorFoodRow(state = state)
} }
ShoppingListItemEditorButtonRow( ShoppingListItemEditorButtonRow(
state = state, state = state,
onEditCancelled = onEditCancelled, onEditCancelled = onEditCancelled,
onEditConfirmed = onEditConfirmed onEditConfirmed = onEditConfirmed,
// Bring editor back into view when the user switches between food and non-food items
onIconButtonClick = { shouldBringIntoView = true }
) )
} }
LaunchedEffect (shouldBringIntoView) {
bringIntoViewRequester.bringIntoView()
shouldBringIntoView = false
}
} }
@Composable @Composable
@@ -249,8 +279,6 @@ private fun ShoppingListItemEditorFirstRow(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val focusRequester = remember { FocusRequester() }
Row( Row(
modifier = modifier, modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(Dimens.Small), horizontalArrangement = Arrangement.spacedBy(Dimens.Small),
@@ -298,14 +326,8 @@ private fun ShoppingListItemEditorFirstRow(
singleLine = true, singleLine = true,
modifier = Modifier modifier = Modifier
.weight(3f, true) .weight(3f, true)
.focusRequester(focusRequester),
) )
} }
LaunchedEffect(focusRequester) {
awaitFrame()
focusRequester.requestFocus()
}
} }
@Composable @Composable
@@ -314,6 +336,7 @@ private fun ShoppingListItemEditorButtonRow(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onEditCancelled: () -> Unit = {}, onEditCancelled: () -> Unit = {},
onEditConfirmed: () -> Unit = {}, onEditConfirmed: () -> Unit = {},
onIconButtonClick: () -> Unit,
) { ) {
Row( Row(
modifier = modifier, modifier = modifier,
@@ -321,6 +344,7 @@ private fun ShoppingListItemEditorButtonRow(
) { ) {
IconButton(onClick = { IconButton(onClick = {
state.isFood = !state.isFood state.isFood = !state.isFood
onIconButtonClick()
}) { }) {
val stringId = if (state.isFood) { val stringId = if (state.isFood) {
R.string.shopping_list_screen_editor_not_food_button R.string.shopping_list_screen_editor_not_food_button
@@ -360,6 +384,9 @@ private fun ShoppingListItemEditorFoodRow(
state: ShoppingListItemEditorState, state: ShoppingListItemEditorState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var foodSearchQuery by remember { mutableStateOf(state.food?.name ?: "") }
var unitSearchQuery by remember { mutableStateOf(state.unit?.name ?: "") }
Row( Row(
modifier = modifier, modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(Dimens.Small), horizontalArrangement = Arrangement.spacedBy(Dimens.Small),
@@ -370,10 +397,11 @@ private fun ShoppingListItemEditorFoodRow(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) { ) {
OutlinedTextField( OutlinedTextField(
value = state.food?.name.orEmpty(), value = foodSearchQuery,
onValueChange = { }, onValueChange = {
modifier = Modifier.menuAnchor(), foodSearchQuery = it
readOnly = true, },
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryEditable, true),
textStyle = MaterialTheme.typography.bodyMedium, textStyle = MaterialTheme.typography.bodyMedium,
label = { label = {
Text( Text(
@@ -388,11 +416,16 @@ private fun ShoppingListItemEditorFoodRow(
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
) )
val foodFilteringOptions = remember(state.foods, foodSearchQuery) {
state.foods.filter {
it.name.contains(foodSearchQuery, ignoreCase = true)
}
}
ExposedDropdownMenu( ExposedDropdownMenu(
expanded = state.foodsExpanded, expanded = state.foodsExpanded,
onDismissRequest = { state.foodsExpanded = false } onDismissRequest = { state.foodsExpanded = false }
) { ) {
state.foods.forEach { foodFilteringOptions.forEach {
DropdownMenuItem( DropdownMenuItem(
text = { text = {
Text(text = it.name, style = MaterialTheme.typography.bodyMedium) Text(text = it.name, style = MaterialTheme.typography.bodyMedium)
@@ -400,6 +433,7 @@ private fun ShoppingListItemEditorFoodRow(
onClick = { onClick = {
state.food = it state.food = it
state.foodsExpanded = false state.foodsExpanded = false
foodSearchQuery = it.name
}, },
trailingIcon = { trailingIcon = {
if (it == state.food) { if (it == state.food) {
@@ -421,10 +455,11 @@ private fun ShoppingListItemEditorFoodRow(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) { ) {
OutlinedTextField( OutlinedTextField(
value = state.unit?.name.orEmpty(), value = unitSearchQuery,
onValueChange = { }, onValueChange = {
modifier = Modifier.menuAnchor(), unitSearchQuery = it
readOnly = true, state.unitsExpanded = true },
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryEditable, true),
textStyle = MaterialTheme.typography.bodyMedium, textStyle = MaterialTheme.typography.bodyMedium,
label = { label = {
Text( Text(
@@ -439,11 +474,16 @@ private fun ShoppingListItemEditorFoodRow(
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
) )
ExposedDropdownMenu( val unitFilteringOptions = remember(state.foods, unitSearchQuery) {
state.units.filter {
it.name.contains(unitSearchQuery, ignoreCase = true)
}
}
ExposedDropdownMenu (
expanded = state.unitsExpanded, expanded = state.unitsExpanded,
onDismissRequest = { state.unitsExpanded = false } onDismissRequest = { state.unitsExpanded = false }
) { ) {
state.units.forEach { unitFilteringOptions.forEach {
DropdownMenuItem( DropdownMenuItem(
text = { text = {
Text(text = it.name, style = MaterialTheme.typography.bodyMedium) Text(text = it.name, style = MaterialTheme.typography.bodyMedium)
@@ -451,6 +491,7 @@ private fun ShoppingListItemEditorFoodRow(
onClick = { onClick = {
state.unit = it state.unit = it
state.unitsExpanded = false state.unitsExpanded = false
unitSearchQuery = it.name
}, },
trailingIcon = { trailingIcon = {
if (it == state.unit) { if (it == state.unit) {
@@ -578,14 +619,68 @@ fun ShoppingListItem(
.takeUnless { it == 0.0 } .takeUnless { it == 0.0 }
.takeUnless { it == 1.0 && !isFood } .takeUnless { it == 1.0 && !isFood }
?.let { DecimalFormat.getInstance().format(it) } ?.let { DecimalFormat.getInstance().format(it) }
val text = listOfNotNull(
quantity,
shoppingListItem.unit.takeIf { isFood }?.name,
shoppingListItem.food.takeIf { isFood }?.name,
shoppingListItem.note,
).filter { it.isNotBlank() }.joinToString(" ")
Text(text = text) val primaryText = buildAnnotatedString {
fun appendWithSpace(text: String?) {
text?.let {
append(it)
append(" ")
}
}
fun appendBold(text: String?) {
text?.let {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(it)
}
}
}
fun appendWithPlural(
name: String,
pluralName: String?,
quantity: Double,
append: (String) -> Unit
) {
if (pluralName.isNullOrEmpty() || quantity <= 1) {
append(name)
} else {
append(pluralName)
}
}
appendWithSpace(quantity)
if (!isFood) {
appendBold(shoppingListItem.note)
} else {
// Add plural unit and food name if available
shoppingListItem.unit?.let { unit ->
appendWithPlural(unit.name, unit.pluralName,
shoppingListItem.quantity, ::appendWithSpace)
}
shoppingListItem.food?.let { food ->
appendWithPlural(food.name, food.pluralName,
shoppingListItem.quantity, ::appendBold)
}
}
}
// only show note in secondary text if it's a food item due
// to the note already being displayed in the primary text otherwise
val secondaryText = shoppingListItem.takeIf { isFood }?.note.orEmpty()
Column {
Text(
text = primaryText,
style = MaterialTheme.typography.bodyLarge,
)
if (secondaryText.isNotBlank()) {
Text(
text = secondaryText,
style = MaterialTheme.typography.bodyMedium,
)
}
}
} }
} }
}, },
@@ -642,7 +737,7 @@ private object PreviewData {
isFood = true, isFood = true,
note = "Cold", note = "Cold",
quantity = 500.0, quantity = 500.0,
unit = GetUnitResponse("ml", ""), unit = GetUnitResponse(name= "ml", id= ""),
food = GetFoodResponse("Milk", ""), food = GetFoodResponse("Milk", ""),
recipeReferences = listOf( recipeReferences = listOf(
GetShoppingListItemRecipeReferenceResponse( GetShoppingListItemRecipeReferenceResponse(

View File

@@ -134,7 +134,7 @@ internal class ShoppingListViewModelTest : BaseUnitTest() {
} }
private val mlUnit = GetUnitResponse("ml", "") private val mlUnit = GetUnitResponse(name="ml", id="")
private val milkLabel = GetItemLabelResponse("Milk", "#FF0000", "1", "0") private val milkLabel = GetItemLabelResponse("Milk", "#FF0000", "1", "0")
private val milkFood = GetFoodResponse(name = "Milk", id ="") private val milkFood = GetFoodResponse(name = "Milk", id ="")

View File

@@ -66,7 +66,7 @@ androidXTestRunner = "1.6.2"
androidXTestOrchestrator = "1.5.0" androidXTestOrchestrator = "1.5.0"
junitKtx = "1.2.1" junitKtx = "1.2.1"
# https://mvnrepository.com/artifact/androidx.compose/compose-bom # https://mvnrepository.com/artifact/androidx.compose/compose-bom
composeBom = "2024.06.00" composeBom = "2024.09.02"
# https://google.github.io/accompanist/ # https://google.github.io/accompanist/
accompanistVersion = "0.34.0" accompanistVersion = "0.34.0"
# https://developer.android.com/jetpack/androidx/releases/compose-material # https://developer.android.com/jetpack/androidx/releases/compose-material