Implement adding and modifying shopping list items (#165)

* Add dismissed shopping list item preview

* Implement editing of note and quantity

* Add new editor row for food

* Implement loading units and foods

* Display dropdown for foods

* Display dropdown for units

* Implement updating food and units

* Create secondary editor state constructor

* Display "Add" button

* Combine editing state to an object

* Implement showing editor for new items

* Implement saving new items

* Log final screen state

* Fix ordering of foods

* Show keyboard when editing starts

* Add bottom padding to the list

* Show new items above checked
This commit is contained in:
Kirill Kamakin
2023-07-22 18:02:45 +02:00
committed by GitHub
parent 3ae784df97
commit be51a5c00a
27 changed files with 1020 additions and 118 deletions

View File

@@ -1,7 +1,11 @@
package gq.kirmanak.mealient.shopping_lists.network
import gq.kirmanak.mealient.datasource.models.FoodInfo
import gq.kirmanak.mealient.datasource.models.FullShoppingListInfo
import gq.kirmanak.mealient.datasource.models.NewShoppingListItemInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListItemInfo
import gq.kirmanak.mealient.datasource.models.UnitInfo
interface ShoppingListsDataSource {
@@ -9,7 +13,13 @@ interface ShoppingListsDataSource {
suspend fun getShoppingList(id: String): FullShoppingListInfo
suspend fun updateIsShoppingListItemChecked(id: String, checked: Boolean)
suspend fun deleteShoppingListItem(id: String)
suspend fun updateShoppingListItem(item: ShoppingListItemInfo)
suspend fun getFoods(): List<FoodInfo>
suspend fun getUnits(): List<UnitInfo>
suspend fun addShoppingListItem(item: NewShoppingListItemInfo)
}

View File

@@ -1,7 +1,11 @@
package gq.kirmanak.mealient.shopping_lists.network
import gq.kirmanak.mealient.datasource.models.FoodInfo
import gq.kirmanak.mealient.datasource.models.FullShoppingListInfo
import gq.kirmanak.mealient.datasource.models.NewShoppingListItemInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListItemInfo
import gq.kirmanak.mealient.datasource.models.UnitInfo
import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1
import gq.kirmanak.mealient.model_mapper.ModelMapper
import javax.inject.Inject
@@ -20,14 +24,20 @@ class ShoppingListsDataSourceImpl @Inject constructor(
id: String
): FullShoppingListInfo = modelMapper.toFullShoppingListInfo(v1Source.getShoppingList(id))
override suspend fun updateIsShoppingListItemChecked(
id: String,
checked: Boolean,
) = v1Source.updateIsShoppingListItemChecked(id, checked)
override suspend fun deleteShoppingListItem(
id: String
) = v1Source.deleteShoppingListItem(id)
override suspend fun updateShoppingListItem(
item: ShoppingListItemInfo
) = v1Source.updateShoppingListItem(item)
override suspend fun getFoods(): List<FoodInfo> = modelMapper.toFoodInfo(v1Source.getFoods())
override suspend fun getUnits(): List<UnitInfo> = modelMapper.toUnitInfo(v1Source.getUnits())
override suspend fun addShoppingListItem(
item: NewShoppingListItemInfo
) = v1Source.addShoppingListItem(modelMapper.toV1CreateRequest(item))
}

View File

@@ -1,15 +1,25 @@
package gq.kirmanak.mealient.shopping_lists.repo
import gq.kirmanak.mealient.datasource.models.FoodInfo
import gq.kirmanak.mealient.datasource.models.FullShoppingListInfo
import gq.kirmanak.mealient.datasource.models.NewShoppingListItemInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListItemInfo
import gq.kirmanak.mealient.datasource.models.UnitInfo
interface ShoppingListsRepo {
suspend fun updateIsShoppingListItemChecked(id: String, isChecked: Boolean)
suspend fun getShoppingLists(): List<ShoppingListInfo>
suspend fun getShoppingList(id: String): FullShoppingListInfo
suspend fun deleteShoppingListItem(id: String)
suspend fun updateShoppingListItem(item: ShoppingListItemInfo)
suspend fun getFoods(): List<FoodInfo>
suspend fun getUnits(): List<UnitInfo>
suspend fun addShoppingListItem(item: NewShoppingListItemInfo)
}

View File

@@ -1,7 +1,11 @@
package gq.kirmanak.mealient.shopping_lists.repo
import gq.kirmanak.mealient.datasource.models.FoodInfo
import gq.kirmanak.mealient.datasource.models.FullShoppingListInfo
import gq.kirmanak.mealient.datasource.models.NewShoppingListItemInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListItemInfo
import gq.kirmanak.mealient.datasource.models.UnitInfo
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.shopping_lists.network.ShoppingListsDataSource
import javax.inject.Inject
@@ -11,11 +15,6 @@ class ShoppingListsRepoImpl @Inject constructor(
private val logger: Logger,
) : ShoppingListsRepo {
override suspend fun updateIsShoppingListItemChecked(id: String, isChecked: Boolean) {
logger.v { "updateIsShoppingListItemChecked() called with: id = $id, isChecked = $isChecked" }
dataSource.updateIsShoppingListItemChecked(id, isChecked)
}
override suspend fun getShoppingLists(): List<ShoppingListInfo> {
logger.v { "getShoppingLists() called" }
return dataSource.getAllShoppingLists()
@@ -30,4 +29,24 @@ class ShoppingListsRepoImpl @Inject constructor(
logger.v { "deleteShoppingListItem() called with: id = $id" }
dataSource.deleteShoppingListItem(id)
}
override suspend fun updateShoppingListItem(item: ShoppingListItemInfo) {
logger.v { "updateShoppingListItem() called with: item = $item" }
dataSource.updateShoppingListItem(item)
}
override suspend fun getFoods(): List<FoodInfo> {
logger.v { "getFoods() called" }
return dataSource.getFoods()
}
override suspend fun getUnits(): List<UnitInfo> {
logger.v { "getUnits() called" }
return dataSource.getUnits()
}
override suspend fun addShoppingListItem(item: NewShoppingListItemInfo) {
logger.v { "addShoppingListItem() called with: item = $item" }
dataSource.addShoppingListItem(item)
}
}

View File

@@ -0,0 +1,11 @@
package gq.kirmanak.mealient.shopping_lists.ui
import gq.kirmanak.mealient.datasource.models.FoodInfo
import gq.kirmanak.mealient.datasource.models.FullShoppingListInfo
import gq.kirmanak.mealient.datasource.models.UnitInfo
data class ShoppingListData(
val foods: List<FoodInfo>,
val units: List<UnitInfo>,
val shoppingList: FullShoppingListInfo,
)

View File

@@ -0,0 +1,10 @@
package gq.kirmanak.mealient.shopping_lists.ui
import gq.kirmanak.mealient.datasource.models.ShoppingListItemInfo
data class ShoppingListEditingState(
val deletedItemIds: Set<String> = emptySet(),
val editingItemIds: Set<String> = emptySet(),
val modifiedItems: Map<String, ShoppingListItemInfo> = emptyMap(),
val newItems: List<ShoppingListItemState.NewItem> = emptyList(),
)

View File

@@ -11,45 +11,69 @@ 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.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.NoMeals
import androidx.compose.material.icons.filled.Restaurant
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DismissDirection
import androidx.compose.material3.DismissState
import androidx.compose.material3.DismissValue
import androidx.compose.material3.Divider
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SwipeToDismiss
import androidx.compose.material3.Text
import androidx.compose.material3.rememberDismissState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel
import com.ramcosta.composedestinations.annotation.Destination
import gq.kirmanak.mealient.AppTheme
import gq.kirmanak.mealient.Dimens
import gq.kirmanak.mealient.datasource.models.FoodInfo
import gq.kirmanak.mealient.datasource.models.FullRecipeInfo
import gq.kirmanak.mealient.datasource.models.RecipeIngredientInfo
import gq.kirmanak.mealient.datasource.models.RecipeInstructionInfo
import gq.kirmanak.mealient.datasource.models.RecipeSettingsInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListItemInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListItemRecipeReferenceInfo
import gq.kirmanak.mealient.datasource.models.UnitInfo
import gq.kirmanak.mealient.shopping_list.R
import gq.kirmanak.mealient.shopping_lists.ui.composables.LazyColumnWithLoadingState
import gq.kirmanak.mealient.shopping_lists.util.data
import gq.kirmanak.mealient.shopping_lists.util.map
import kotlinx.coroutines.android.awaitFrame
import java.text.DecimalFormat
data class ShoppingListNavArgs(
val shoppingListId: String,
)
@OptIn(ExperimentalMaterial3Api::class)
@Destination(
navArgsDelegate = ShoppingListNavArgs::class,
)
@@ -65,54 +89,415 @@ internal fun ShoppingListScreen(
LazyColumnWithLoadingState(
loadingState = loadingState.map { it.items },
contentPadding = PaddingValues(Dimens.Medium),
contentPadding = PaddingValues(
start = Dimens.Medium,
end = Dimens.Medium,
top = Dimens.Medium,
bottom = Dimens.Large * 4,
),
verticalArrangement = Arrangement.spacedBy(Dimens.Medium),
defaultEmptyListError = defaultEmptyListError,
errorToShowInSnackbar = shoppingListViewModel.errorToShowInSnackbar,
onRefresh = shoppingListViewModel::refreshShoppingList,
onSnackbarShown = shoppingListViewModel::onSnackbarShown
onSnackbarShown = shoppingListViewModel::onSnackbarShown,
floatingActionButton = {
FloatingActionButton(onClick = shoppingListViewModel::onAddItemClicked) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(id = R.string.shopping_list_screen_add_icon_content_description),
)
}
}
) { items ->
val firstCheckedItemIndex = items.indexOfFirst { it.checked }
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)
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(),
)
}
ShoppingListItemEditor(
state = state,
onEditCancelled = { shoppingListViewModel.onEditCancel(itemState) },
onEditConfirmed = { shoppingListViewModel.onEditConfirm(itemState, state) }
)
} else {
ShoppingListItem(
itemState = itemState,
showDivider = index == firstCheckedItemIndex && index != 0,
modifier = Modifier.background(MaterialTheme.colorScheme.surface),
onCheckedChange = {
shoppingListViewModel.onItemCheckedChange(itemState, it)
},
onDismissed = { shoppingListViewModel.deleteShoppingListItem(itemState) },
onEditStart = { shoppingListViewModel.onEditStart(itemState) },
)
}
} else if (itemState is ShoppingListItemState.NewItem) {
ShoppingListItemEditor(
state = itemState.item,
onEditCancelled = { shoppingListViewModel.onAddCancel(itemState) },
onEditConfirmed = { shoppingListViewModel.onAddConfirm(itemState) }
)
}
}
}
}
@Composable
fun ShoppingListItemEditor(
state: ShoppingListItemEditorState,
modifier: Modifier = Modifier,
onEditCancelled: () -> Unit = {},
onEditConfirmed: () -> Unit = {},
) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(Dimens.Small),
horizontalAlignment = Alignment.End,
) {
ShoppingListItemEditorFirstRow(
state = state
)
if (state.isFood) {
ShoppingListItemEditorFoodRow(state = state)
}
ShoppingListItemEditorButtonRow(
state = state,
onEditCancelled = onEditCancelled,
onEditConfirmed = onEditConfirmed
)
}
}
@Composable
private fun ShoppingListItemEditorFirstRow(
state: ShoppingListItemEditorState,
modifier: Modifier = Modifier,
) {
val focusRequester = remember { FocusRequester() }
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(Dimens.Small),
) {
OutlinedTextField(
value = state.quantity,
onValueChange = { newValue ->
val input = newValue.trim()
.let {
if (state.quantity == "0") {
it.removeSuffix("0").removePrefix("0")
} else {
it
}
}
.ifEmpty { "0" }
if (input.toDoubleOrNull() != null) {
state.quantity = input
}
},
modifier = Modifier.weight(1f),
textStyle = MaterialTheme.typography.bodyMedium,
label = {
Text(
text = stringResource(id = R.string.shopping_list_screen_editor_quantity_label),
style = MaterialTheme.typography.labelMedium,
)
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
),
singleLine = true,
)
OutlinedTextField(
value = state.note,
onValueChange = { state.note = it },
textStyle = MaterialTheme.typography.bodyMedium,
label = {
Text(
text = stringResource(id = R.string.shopping_list_screen_editor_note_label),
style = MaterialTheme.typography.labelMedium,
)
},
singleLine = true,
modifier = Modifier
.weight(3f, true)
.focusRequester(focusRequester),
)
}
LaunchedEffect(focusRequester) {
awaitFrame()
focusRequester.requestFocus()
}
}
@Composable
private fun ShoppingListItemEditorButtonRow(
state: ShoppingListItemEditorState,
modifier: Modifier = Modifier,
onEditCancelled: () -> Unit = {},
onEditConfirmed: () -> Unit = {},
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(Dimens.Small)
) {
IconButton(onClick = {
state.isFood = !state.isFood
}) {
val stringId = if (state.isFood) {
R.string.shopping_list_screen_editor_not_food_button
} else {
R.string.shopping_list_screen_editor_food_button
}
val icon = if (state.isFood) {
Icons.Default.NoMeals
} else {
Icons.Default.Restaurant
}
Icon(
imageVector = icon,
contentDescription = stringResource(id = stringId)
)
}
IconButton(onClick = onEditCancelled) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(id = R.string.shopping_list_screen_editor_cancel_button)
)
}
IconButton(onClick = onEditConfirmed) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = stringResource(id = R.string.shopping_list_screen_editor_save_button)
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ShoppingListItemEditorFoodRow(
state: ShoppingListItemEditorState,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(Dimens.Small),
) {
ExposedDropdownMenuBox(
expanded = state.foodsExpanded,
onExpandedChange = { state.foodsExpanded = it },
modifier = Modifier.weight(1f),
) {
OutlinedTextField(
value = state.food?.name.orEmpty(),
onValueChange = { },
modifier = Modifier.menuAnchor(),
readOnly = true,
textStyle = MaterialTheme.typography.bodyMedium,
label = {
Text(
text = stringResource(id = R.string.shopping_list_screen_editor_food_label),
style = MaterialTheme.typography.labelMedium,
)
},
singleLine = true,
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = state.foodsExpanded)
},
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
)
ExposedDropdownMenu(
expanded = state.foodsExpanded,
onDismissRequest = { state.foodsExpanded = false }
) {
state.foods.forEach {
DropdownMenuItem(
text = {
Text(text = it.name, style = MaterialTheme.typography.bodyMedium)
},
onClick = {
state.food = it
state.foodsExpanded = false
},
trailingIcon = {
if (it == state.food) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = stringResource(id = R.string.shopping_list_screen_editor_checked_unit_content_description),
)
}
},
contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
)
}
}
}
ExposedDropdownMenuBox(
expanded = state.unitsExpanded,
onExpandedChange = { state.unitsExpanded = it },
modifier = Modifier.weight(1f),
) {
OutlinedTextField(
value = state.unit?.name.orEmpty(),
onValueChange = { },
modifier = Modifier.menuAnchor(),
readOnly = true,
textStyle = MaterialTheme.typography.bodyMedium,
label = {
Text(
text = stringResource(id = R.string.shopping_list_screen_editor_unit_label),
style = MaterialTheme.typography.labelMedium,
)
},
singleLine = true,
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = state.unitsExpanded)
},
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
)
ExposedDropdownMenu(
expanded = state.unitsExpanded,
onDismissRequest = { state.unitsExpanded = false }
) {
state.units.forEach {
DropdownMenuItem(
text = {
Text(text = it.name, style = MaterialTheme.typography.bodyMedium)
},
onClick = {
state.unit = it
state.unitsExpanded = false
},
trailingIcon = {
if (it == state.unit) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = stringResource(id = R.string.shopping_list_screen_editor_checked_unit_content_description),
)
}
},
contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
)
}
}
}
}
}
class ShoppingListItemEditorState(
val foods: List<FoodInfo>,
val units: List<UnitInfo>,
val position: Int,
val listId: String,
note: String = "",
quantity: String = "1.0",
isFood: Boolean = false,
food: FoodInfo? = null,
unit: UnitInfo? = null,
) {
constructor(
state: ShoppingListItemState.ExistingItem,
foods: List<FoodInfo>,
units: List<UnitInfo>,
) : this(
foods = foods,
units = units,
position = state.item.position,
listId = state.item.shoppingListId,
note = state.item.note,
quantity = state.item.quantity.toString(),
isFood = state.item.isFood,
food = state.item.food,
unit = state.item.unit,
)
var note: String by mutableStateOf(note)
var quantity: String by mutableStateOf(quantity)
var isFood: Boolean by mutableStateOf(isFood)
var food: FoodInfo? by mutableStateOf(food)
var unit: UnitInfo? by mutableStateOf(unit)
var foodsExpanded: Boolean by mutableStateOf(false)
var unitsExpanded: Boolean by mutableStateOf(false)
}
@Preview
@Composable
fun ShoppingListItemEditorPreview() {
AppTheme {
ShoppingListItemEditor(
state = ShoppingListItemEditorState(
state = ShoppingListItemState.ExistingItem(PreviewData.milk),
foods = emptyList(),
units = emptyList(),
)
)
}
}
@Preview
@Composable
fun ShoppingListItemEditorNonFoodPreview() {
AppTheme {
ShoppingListItemEditor(
state = ShoppingListItemEditorState(
state = ShoppingListItemState.ExistingItem(PreviewData.blackTeaBags),
foods = emptyList(),
units = emptyList(),
)
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShoppingListItem(
shoppingListItem: ShoppingListItemInfo,
itemState: ShoppingListItemState.ExistingItem,
showDivider: Boolean,
modifier: Modifier = Modifier,
onCheckedChange: (Boolean) -> Unit = {},
onDismissed: () -> Unit = {},
) {
val dismissState = rememberDismissState(
onEditStart: () -> Unit = {},
dismissState: DismissState = rememberDismissState(
confirmValueChange = {
if (it == DismissValue.DismissedToStart) {
onDismissed()
when (it) {
DismissValue.DismissedToStart -> onDismissed()
DismissValue.DismissedToEnd -> onEditStart()
DismissValue.Default -> Unit
}
true
}
)
) {
val shoppingListItem = itemState.item
SwipeToDismiss(
state = dismissState,
background = {
if (dismissState.targetValue == DismissValue.DismissedToStart) {
val color by animateColorAsState(MaterialTheme.colorScheme.error)
val iconColor by animateColorAsState(MaterialTheme.colorScheme.onSurface)
val iconColor by animateColorAsState(MaterialTheme.colorScheme.onError)
Box(
modifier = Modifier
.fillMaxSize()
@@ -127,6 +512,23 @@ fun ShoppingListItem(
.padding(end = Dimens.Small)
)
}
} else if (dismissState.targetValue == DismissValue.DismissedToEnd) {
val color by animateColorAsState(MaterialTheme.colorScheme.primary)
val iconColor by animateColorAsState(MaterialTheme.colorScheme.onPrimary)
Box(
modifier = Modifier
.fillMaxSize()
.background(color)
) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = stringResource(R.string.shopping_list_screen_edit_icon_content_description),
tint = iconColor,
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = Dimens.Small)
)
}
}
},
dismissContent = {
@@ -143,7 +545,7 @@ fun ShoppingListItem(
horizontalArrangement = Arrangement.Start,
) {
Checkbox(
checked = shoppingListItem.checked,
checked = itemState.item.checked,
onCheckedChange = onCheckedChange,
)
@@ -154,8 +556,8 @@ fun ShoppingListItem(
?.let { DecimalFormat.getInstance().format(it) }
val text = listOfNotNull(
quantity,
shoppingListItem.unit.takeIf { isFood },
shoppingListItem.food.takeIf { isFood },
shoppingListItem.unit.takeIf { isFood }?.name,
shoppingListItem.food.takeIf { isFood }?.name,
shoppingListItem.note,
).filter { it.isNotBlank() }.joinToString(" ")
@@ -164,23 +566,60 @@ fun ShoppingListItem(
}
},
modifier = modifier,
directions = setOf(DismissDirection.EndToStart),
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@Preview
fun PreviewShoppingListItemChecked() {
AppTheme {
ShoppingListItem(shoppingListItem = PreviewData.milk, false)
ShoppingListItem(
itemState = ShoppingListItemState.ExistingItem(PreviewData.milk),
showDivider = false
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@Preview
fun PreviewShoppingListItemUnchecked() {
AppTheme {
ShoppingListItem(shoppingListItem = PreviewData.blackTeaBags, false)
ShoppingListItem(
itemState = ShoppingListItemState.ExistingItem(PreviewData.blackTeaBags),
showDivider = true
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@Preview
fun PreviewShoppingListItemDismissed() {
AppTheme {
ShoppingListItem(
itemState = ShoppingListItemState.ExistingItem(PreviewData.blackTeaBags),
showDivider = false,
dismissState = rememberDismissState(
initialValue = DismissValue.DismissedToStart,
),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@Preview
fun PreviewShoppingListItemEditing() {
AppTheme {
ShoppingListItem(
itemState = ShoppingListItemState.ExistingItem(PreviewData.blackTeaBags),
showDivider = false,
dismissState = rememberDismissState(
initialValue = DismissValue.DismissedToEnd,
),
)
}
}
@@ -226,8 +665,8 @@ private object PreviewData {
isFood = false,
note = "Black tea bags",
quantity = 30.0,
unit = "",
food = "",
unit = null,
food = null,
recipeReferences = listOf(
ShoppingListItemRecipeReferenceInfo(
shoppingListId = "1",
@@ -243,12 +682,12 @@ private object PreviewData {
id = "2",
shoppingListId = "1",
checked = true,
position = 1,
position = 0,
isFood = true,
note = "Cold",
quantity = 500.0,
unit = "ml",
food = "Milk",
unit = UnitInfo("ml", ""),
food = FoodInfo("Milk", ""),
recipeReferences = listOf(
ShoppingListItemRecipeReferenceInfo(
shoppingListId = "1",

View File

@@ -1,8 +1,45 @@
package gq.kirmanak.mealient.shopping_lists.ui
import gq.kirmanak.mealient.datasource.models.FoodInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListItemInfo
import gq.kirmanak.mealient.datasource.models.UnitInfo
import java.util.UUID
internal data class ShoppingListScreenState(
val name: String,
val items: List<ShoppingListItemInfo>,
val listId: String,
val items: List<ShoppingListItemState>,
val foods: List<FoodInfo>,
val units: List<UnitInfo>,
)
sealed class ShoppingListItemState {
data class ExistingItem(
val item: ShoppingListItemInfo,
val isEditing: Boolean = false,
) : ShoppingListItemState()
data class NewItem(
val item: ShoppingListItemEditorState,
val id: String = UUID.randomUUID().toString(),
) : ShoppingListItemState()
}
val ShoppingListItemState.id: String
get() = when (this) {
is ShoppingListItemState.ExistingItem -> item.id
is ShoppingListItemState.NewItem -> id
}
val ShoppingListItemState.checked: Boolean
get() = when (this) {
is ShoppingListItemState.ExistingItem -> item.checked
is ShoppingListItemState.NewItem -> false
}
val ShoppingListItemState.position: Int
get() = when (this) {
is ShoppingListItemState.ExistingItem -> item.position
is ShoppingListItemState.NewItem -> item.position
}

View File

@@ -8,7 +8,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.architecture.valueUpdatesOnly
import gq.kirmanak.mealient.datasource.models.FullShoppingListInfo
import gq.kirmanak.mealient.datasource.models.NewShoppingListItemInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListItemInfo
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger
@@ -18,7 +18,10 @@ import gq.kirmanak.mealient.shopping_lists.ui.destinations.ShoppingListScreenDes
import gq.kirmanak.mealient.shopping_lists.util.LoadingHelperFactory
import gq.kirmanak.mealient.shopping_lists.util.LoadingState
import gq.kirmanak.mealient.shopping_lists.util.LoadingStateNoData
import gq.kirmanak.mealient.shopping_lists.util.data
import gq.kirmanak.mealient.shopping_lists.util.map
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -39,18 +42,15 @@ internal class ShoppingListViewModel @Inject constructor(
private val args: ShoppingListNavArgs = ShoppingListScreenDestination.argsFrom(savedStateHandle)
private val checkedOverride = MutableStateFlow<MutableMap<String, Boolean>>(mutableMapOf())
private val deletedItemIds = MutableStateFlow<Set<String>>(mutableSetOf())
private val editingStateFlow = MutableStateFlow(ShoppingListEditingState())
private val loadingHelper = loadingHelperFactory.create(viewModelScope) {
shoppingListsRepo.getShoppingList(args.shoppingListId)
loadShoppingListData()
}
val loadingState: StateFlow<LoadingState<ShoppingListScreenState>> = combine(
loadingHelper.loadingState,
checkedOverride,
deletedItemIds,
editingStateFlow,
::buildLoadingState,
).stateIn(viewModelScope, SharingStarted.Eagerly, LoadingStateNoData.InitialLoad)
@@ -79,46 +79,70 @@ internal class ShoppingListViewModel @Inject constructor(
}
}
private suspend fun loadShoppingListData(): ShoppingListData = coroutineScope {
val foodsDeferred = async {
runCatchingExceptCancel {
shoppingListsRepo.getFoods()
}.getOrDefault(emptyList())
}
val unitsDeferred = async {
runCatchingExceptCancel {
shoppingListsRepo.getUnits()
}.getOrDefault(emptyList())
}
val shoppingListDeferred = async {
shoppingListsRepo.getShoppingList(args.shoppingListId)
}
ShoppingListData(
foods = foodsDeferred.await(),
units = unitsDeferred.await(),
shoppingList = shoppingListDeferred.await(),
)
}
private suspend fun doRefresh() {
_errorToShowInSnackbar = loadingHelper.refresh().exceptionOrNull()
}
private fun buildLoadingState(
loadingState: LoadingState<FullShoppingListInfo>,
checkedOverrideMap: Map<String, Boolean>,
deletedItemIds: Set<String>,
loadingState: LoadingState<ShoppingListData>,
editingState: ShoppingListEditingState,
): LoadingState<ShoppingListScreenState> {
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 }
ShoppingListScreenState(name = shoppingList.name, items = items)
logger.v { "buildLoadingState() called with: loadingState = $loadingState, editingState = $editingState" }
return loadingState.map { data ->
val existingItems = data.shoppingList.items
.filter { it.id !in editingState.deletedItemIds }
.map {
ShoppingListItemState.ExistingItem(
item = editingState.modifiedItems[it.id] ?: it,
isEditing = it.id in editingState.editingItemIds,
)
}
val items = (existingItems + editingState.newItems).sortedWith(
compareBy(
{ it.checked },
{ it.position },
)
)
ShoppingListScreenState(
name = data.shoppingList.name,
listId = data.shoppingList.id,
items = items,
foods = data.foods.sortedBy { it.name },
units = data.units.sortedBy { it.name },
)
}.also {
logger.v { "buildLoadingState() returned: $it" }
}
}
fun onItemCheckedChange(item: ShoppingListItemInfo, isChecked: Boolean) {
logger.v { "onItemCheckedChange() called with: item = $item, isChecked = $isChecked" }
viewModelScope.launch {
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" }
}
_errorToShowInSnackbar = result.exceptionOrNull()
if (result.isSuccess) {
logger.v { "Item's checked state updated" }
doRefresh()
}
checkedOverride.update { originalMap ->
originalMap.toMutableMap().also { newMap -> newMap.remove(item.id) }
}
}
fun onItemCheckedChange(state: ShoppingListItemState.ExistingItem, isChecked: Boolean) {
logger.v { "onItemCheckedChange() called with: state = $state, isChecked = $isChecked" }
val updatedItem = state.item.copy(checked = isChecked)
updateItemInformation(updatedItem)
}
fun onSnackbarShown() {
@@ -126,10 +150,13 @@ internal class ShoppingListViewModel @Inject constructor(
_errorToShowInSnackbar = null
}
fun deleteShoppingListItem(item: ShoppingListItemInfo) {
logger.v { "deleteShoppingListItem() called with: item = $item" }
fun deleteShoppingListItem(state: ShoppingListItemState.ExistingItem) {
logger.v { "deleteShoppingListItem() called with: state = $state" }
val item = state.item
viewModelScope.launch {
deletedItemIds.update { it + item.id }
editingStateFlow.update {
it.copy(deletedItemIds = it.deletedItemIds + item.id)
}
val result = runCatchingExceptCancel {
shoppingListsRepo.deleteShoppingListItem(item.id)
}.onFailure {
@@ -140,7 +167,121 @@ internal class ShoppingListViewModel @Inject constructor(
logger.v { "Item deleted" }
doRefresh()
}
deletedItemIds.update { it - item.id }
editingStateFlow.update {
it.copy(deletedItemIds = it.deletedItemIds - item.id)
}
}
}
fun onEditStart(state: ShoppingListItemState.ExistingItem) {
logger.v { "onEditStart() called with: state = $state" }
val item = state.item
editingStateFlow.update {
it.copy(editingItemIds = it.editingItemIds + item.id)
}
}
fun onEditCancel(state: ShoppingListItemState.ExistingItem) {
logger.v { "onEditCancel() called with: state = $state" }
val item = state.item
editingStateFlow.update {
it.copy(editingItemIds = it.editingItemIds - item.id)
}
}
fun onEditConfirm(
state: ShoppingListItemState.ExistingItem,
input: ShoppingListItemEditorState
) {
logger.v { "onEditConfirm() called with: state = $state, input = $input" }
val id = state.item.id
editingStateFlow.update {
it.copy(editingItemIds = it.editingItemIds - id)
}
val updatedItem = state.item.copy(
note = input.note,
quantity = input.quantity.toDouble(),
isFood = input.isFood,
unit = input.unit,
food = input.food,
)
updateItemInformation(updatedItem)
}
private fun updateItemInformation(updatedItem: ShoppingListItemInfo) {
logger.v { "updateItemInformation() called with: updatedItem = $updatedItem" }
val id = updatedItem.id
viewModelScope.launch {
editingStateFlow.update { state ->
state.copy(modifiedItems = state.modifiedItems + (id to updatedItem))
}
val result = runCatchingExceptCancel {
shoppingListsRepo.updateShoppingListItem(updatedItem)
}.onFailure {
logger.e(it) { "Failed to update item" }
}
_errorToShowInSnackbar = result.exceptionOrNull()
if (result.isSuccess) {
logger.v { "Item updated" }
doRefresh()
}
editingStateFlow.update { state ->
state.copy(modifiedItems = state.modifiedItems - id)
}
}
}
fun onAddItemClicked() {
logger.v { "onAddItemClicked() called" }
val shoppingListScreenState = loadingState.value.data ?: return
val maxPosition = shoppingListScreenState.items.maxOfOrNull { it.position } ?: 0
val editorState = ShoppingListItemEditorState(
foods = shoppingListScreenState.foods,
units = shoppingListScreenState.units,
position = maxPosition + 1,
listId = shoppingListScreenState.listId,
)
val item = ShoppingListItemState.NewItem(editorState)
editingStateFlow.update {
it.copy(newItems = it.newItems + item)
}
}
fun onAddCancel(state: ShoppingListItemState.NewItem) {
logger.v { "onAddCancel() called with: state = $state" }
editingStateFlow.update {
it.copy(newItems = it.newItems - state)
}
}
fun onAddConfirm(
state: ShoppingListItemState.NewItem,
) {
logger.v { "onAddConfirm() called with: state = $state" }
val item = state.item
val newItem = NewShoppingListItemInfo(
shoppingListId = item.listId,
note = item.note,
quantity = item.quantity.toDouble(),
isFood = item.isFood,
unit = item.unit,
food = item.food,
position = item.position,
)
viewModelScope.launch {
val result = runCatchingExceptCancel {
shoppingListsRepo.addShoppingListItem(newItem)
}.onFailure {
logger.e(it) { "Failed to add item" }
}
_errorToShowInSnackbar = result.exceptionOrNull()
if (result.isSuccess) {
logger.v { "Item added" }
doRefresh()
}
editingStateFlow.update {
it.copy(newItems = it.newItems - state)
}
}
}
}

View File

@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.FabPosition
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
@@ -32,6 +33,8 @@ fun <T> LazyColumnWithLoadingState(
errorToShowInSnackbar: Throwable? = null,
onSnackbarShown: () -> Unit = {},
onRefresh: () -> Unit = {},
floatingActionButton: @Composable () -> Unit = {},
floatingActionButtonPosition: FabPosition = FabPosition.End,
lazyColumnContent: LazyListScope.(List<T>) -> Unit = {},
) {
val refreshState = rememberPullRefreshState(
@@ -42,6 +45,8 @@ fun <T> LazyColumnWithLoadingState(
Scaffold(
modifier = modifier,
floatingActionButton = floatingActionButton,
floatingActionButtonPosition = floatingActionButtonPosition,
snackbarHost = { SnackbarHost(snackbarHostState) },
) { paddingValues ->
val innerModifier = Modifier

View File

@@ -1,12 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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>
<string name="shopping_lists_screen_unknown_error">Unknown error</string>
<string name="shopping_lists_screen_empty_button_refresh">Try again</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_empty_list">%1$s is empty</string>
<string name="shopping_list_screen_delete_icon_content_description">Delete</string>
<string name="shopping_list_screen_editor_quantity_label">Qty</string>
<string name="shopping_list_screen_editor_note_label">Note</string>
<string name="shopping_list_screen_editor_food_label">Food</string>
<string name="shopping_list_screen_editor_unit_label">Unit</string>
<string name="shopping_list_screen_editor_save_button">Save</string>
<string name="shopping_list_screen_editor_not_food_button">Not food</string>
<string name="shopping_list_screen_editor_food_button">Food</string>
<string name="shopping_list_screen_editor_cancel_button">Cancel</string>
<string name="shopping_list_screen_edit_icon_content_description">Edit</string>
<string name="shopping_list_screen_editor_checked_unit_content_description">Selected unit</string>
<string name="shopping_list_screen_editor_checked_food_content_description">Selected food</string>
<string name="shopping_list_screen_add_icon_content_description">Add item</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>
<string name="shopping_lists_screen_unknown_error">Unknown error</string>
<string name="shopping_lists_screen_empty_button_refresh">Try again</string>
</resources>