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

@@ -0,0 +1,6 @@
package gq.kirmanak.mealient.datasource.models
data class FoodInfo(
val name: String,
val id: String
)

View File

@@ -14,8 +14,8 @@ data class ShoppingListItemInfo(
val isFood: Boolean,
val note: String,
val quantity: Double,
val unit: String,
val food: String,
val unit: UnitInfo?,
val food: FoodInfo?,
val recipeReferences: List<ShoppingListItemRecipeReferenceInfo>,
)

View File

@@ -0,0 +1,11 @@
package gq.kirmanak.mealient.datasource.models
data class NewShoppingListItemInfo(
val shoppingListId: String,
val isFood: Boolean,
val note: String,
val quantity: Double,
val unit: UnitInfo?,
val food: FoodInfo?,
val position: Int,
)

View File

@@ -0,0 +1,6 @@
package gq.kirmanak.mealient.datasource.models
data class UnitInfo(
val name: String,
val id: String
)

View File

@@ -1,12 +1,16 @@
package gq.kirmanak.mealient.datasource.v1
import gq.kirmanak.mealient.datasource.models.ShoppingListItemInfo
import gq.kirmanak.mealient.datasource.v1.models.CreateApiTokenRequestV1
import gq.kirmanak.mealient.datasource.v1.models.CreateApiTokenResponseV1
import gq.kirmanak.mealient.datasource.v1.models.CreateRecipeRequestV1
import gq.kirmanak.mealient.datasource.v1.models.CreateShoppingListItemRequestV1
import gq.kirmanak.mealient.datasource.v1.models.GetFoodsResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeSummaryResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetShoppingListResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetShoppingListsResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetUnitsResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetUserInfoResponseV1
import gq.kirmanak.mealient.datasource.v1.models.ParseRecipeURLRequestV1
import gq.kirmanak.mealient.datasource.v1.models.UpdateRecipeRequestV1
@@ -63,7 +67,13 @@ interface MealieDataSourceV1 {
suspend fun getShoppingList(id: String): GetShoppingListResponseV1
suspend fun updateIsShoppingListItemChecked(id: String, isChecked: Boolean)
suspend fun deleteShoppingListItem(id: String)
suspend fun updateShoppingListItem(item: ShoppingListItemInfo)
suspend fun getFoods(): GetFoodsResponseV1
suspend fun getUnits(): GetUnitsResponseV1
suspend fun addShoppingListItem(request: CreateShoppingListItemRequestV1)
}

View File

@@ -3,14 +3,18 @@ package gq.kirmanak.mealient.datasource.v1
import gq.kirmanak.mealient.datasource.NetworkError
import gq.kirmanak.mealient.datasource.NetworkRequestWrapper
import gq.kirmanak.mealient.datasource.decode
import gq.kirmanak.mealient.datasource.models.ShoppingListItemInfo
import gq.kirmanak.mealient.datasource.v1.models.CreateApiTokenRequestV1
import gq.kirmanak.mealient.datasource.v1.models.CreateApiTokenResponseV1
import gq.kirmanak.mealient.datasource.v1.models.CreateRecipeRequestV1
import gq.kirmanak.mealient.datasource.v1.models.CreateShoppingListItemRequestV1
import gq.kirmanak.mealient.datasource.v1.models.ErrorDetailV1
import gq.kirmanak.mealient.datasource.v1.models.GetFoodsResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeSummaryResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetShoppingListResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetShoppingListsResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetUnitsResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetUserInfoResponseV1
import gq.kirmanak.mealient.datasource.v1.models.ParseRecipeURLRequestV1
import gq.kirmanak.mealient.datasource.v1.models.UpdateRecipeRequestV1
@@ -20,9 +24,7 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import retrofit2.HttpException
import java.net.ConnectException
import java.net.SocketTimeoutException
@@ -175,20 +177,6 @@ class MealieDataSourceV1Impl @Inject constructor(
logParameters = { "id = $id, request = $request" }
)
override suspend fun updateIsShoppingListItemChecked(
id: String,
isChecked: Boolean
) {
// Has to be done in two steps because the API doesn't support updating the checked state
val item = getShoppingListItem(id)
val wasChecked = item.jsonObject.getValue("checked").jsonPrimitive.boolean
if (wasChecked == isChecked) return
val updatedItem = item.jsonObject.toMutableMap().apply {
put("checked", JsonPrimitive(isChecked))
}
updateShoppingListItem(id, JsonObject(updatedItem))
}
override suspend fun deleteShoppingListItem(
id: String,
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
@@ -196,4 +184,44 @@ class MealieDataSourceV1Impl @Inject constructor(
logMethod = { "deleteShoppingListItem" },
logParameters = { "id = $id" }
)
override suspend fun updateShoppingListItem(
item: ShoppingListItemInfo
) {
// Has to be done in two steps because we can't specify only the changed fields
val remoteItem = getShoppingListItem(item.id)
val updatedItem = remoteItem.jsonObject.toMutableMap().apply {
put("checked", JsonPrimitive(item.checked))
put("isFood", JsonPrimitive(item.isFood))
put("note", JsonPrimitive(item.note))
put("quantity", JsonPrimitive(item.quantity))
put("foodId", JsonPrimitive(item.food?.id))
put("unitId", JsonPrimitive(item.unit?.id))
remove("unit")
remove("food")
}
updateShoppingListItem(item.id, JsonObject(updatedItem))
}
override suspend fun getFoods(): GetFoodsResponseV1 {
return networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getFoods(perPage = -1) },
logMethod = { "getFoods" },
)
}
override suspend fun getUnits(): GetUnitsResponseV1 {
return networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getUnits(perPage = -1) },
logMethod = { "getUnits" },
)
}
override suspend fun addShoppingListItem(
request: CreateShoppingListItemRequestV1
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createShoppingListItem(request) },
logMethod = { "addShoppingListItem" },
logParameters = { "request = $request" }
)
}

View File

@@ -94,4 +94,19 @@ interface MealieServiceV1 {
suspend fun deleteShoppingListItem(
@Path("id") id: String,
)
@GET("/api/foods")
suspend fun getFoods(
@Query("perPage") perPage: Int,
): GetFoodsResponseV1
@GET("/api/units")
suspend fun getUnits(
@Query("perPage") perPage: Int,
): GetUnitsResponseV1
@POST("/api/groups/shopping/items")
suspend fun createShoppingListItem(
@Body request: CreateShoppingListItemRequestV1,
)
}

View File

@@ -0,0 +1,16 @@
package gq.kirmanak.mealient.datasource.v1.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class CreateShoppingListItemRequestV1(
@SerialName("shopping_list_id") val shoppingListId: String,
@SerialName("checked") val checked: Boolean,
@SerialName("position") val position: Int?,
@SerialName("is_food") val isFood: Boolean,
@SerialName("note") val note: String,
@SerialName("quantity") val quantity: Double,
@SerialName("food_id") val foodId: String?,
@SerialName("unit_id") val unitId: String?,
)

View File

@@ -0,0 +1,15 @@
package gq.kirmanak.mealient.datasource.v1.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GetFoodsResponseV1(
@SerialName("items") val items: List<GetFoodResponseV1>,
)
@Serializable
data class GetFoodResponseV1(
@SerialName("name") val name: String,
@SerialName("id") val id: String,
)

View File

@@ -6,4 +6,5 @@ import kotlinx.serialization.Serializable
@Serializable
data class GetRecipeIngredientFoodResponseV1(
@SerialName("name") val name: String = "",
@SerialName("id") val id: String = "",
)

View File

@@ -6,4 +6,5 @@ import kotlinx.serialization.Serializable
@Serializable
data class GetRecipeIngredientUnitResponseV1(
@SerialName("name") val name: String = "",
@SerialName("id") val id: String = "",
)

View File

@@ -0,0 +1,15 @@
package gq.kirmanak.mealient.datasource.v1.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GetUnitsResponseV1(
@SerialName("items") val items: List<GetUnitResponseV1>
)
@Serializable
data class GetUnitResponseV1(
@SerialName("name") val name: String,
@SerialName("id") val id: String
)

View File

@@ -22,6 +22,7 @@ dependencies {
implementation(libs.android.material.material)
implementation(libs.androidx.compose.material)
implementation(libs.androidx.compose.materialIconsExtended)
implementation(libs.google.dagger.hiltAndroid)
kapt(libs.google.dagger.hiltCompiler)

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>

View File

@@ -103,6 +103,7 @@ android-material-material = { group = "com.google.android.material", name = "mat
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-materialIconsExtended = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-compose-ui-toolingPreview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-testJunit = { group = "androidx.compose.ui", name = "ui-test-junit4" }

View File

@@ -8,8 +8,10 @@ import gq.kirmanak.mealient.datasource.models.AddRecipeInfo
import gq.kirmanak.mealient.datasource.models.AddRecipeIngredientInfo
import gq.kirmanak.mealient.datasource.models.AddRecipeInstructionInfo
import gq.kirmanak.mealient.datasource.models.AddRecipeSettingsInfo
import gq.kirmanak.mealient.datasource.models.FoodInfo
import gq.kirmanak.mealient.datasource.models.FullRecipeInfo
import gq.kirmanak.mealient.datasource.models.FullShoppingListInfo
import gq.kirmanak.mealient.datasource.models.NewShoppingListItemInfo
import gq.kirmanak.mealient.datasource.models.ParseRecipeURLInfo
import gq.kirmanak.mealient.datasource.models.RecipeIngredientInfo
import gq.kirmanak.mealient.datasource.models.RecipeInstructionInfo
@@ -19,6 +21,7 @@ import gq.kirmanak.mealient.datasource.models.ShoppingListInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListItemInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListItemRecipeReferenceInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListsInfo
import gq.kirmanak.mealient.datasource.models.UnitInfo
import gq.kirmanak.mealient.datasource.models.VersionInfo
import gq.kirmanak.mealient.datasource.v0.models.AddRecipeIngredientV0
import gq.kirmanak.mealient.datasource.v0.models.AddRecipeInstructionV0
@@ -34,6 +37,8 @@ import gq.kirmanak.mealient.datasource.v1.models.AddRecipeIngredientV1
import gq.kirmanak.mealient.datasource.v1.models.AddRecipeInstructionV1
import gq.kirmanak.mealient.datasource.v1.models.AddRecipeSettingsV1
import gq.kirmanak.mealient.datasource.v1.models.CreateRecipeRequestV1
import gq.kirmanak.mealient.datasource.v1.models.CreateShoppingListItemRequestV1
import gq.kirmanak.mealient.datasource.v1.models.GetFoodsResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeIngredientResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeInstructionResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeResponseV1
@@ -44,6 +49,7 @@ import gq.kirmanak.mealient.datasource.v1.models.GetShoppingListItemResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetShoppingListResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetShoppingListsResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetShoppingListsSummaryResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetUnitsResponseV1
import gq.kirmanak.mealient.datasource.v1.models.ParseRecipeURLRequestV1
import gq.kirmanak.mealient.datasource.v1.models.UpdateRecipeRequestV1
import gq.kirmanak.mealient.datasource.v1.models.VersionResponseV1
@@ -127,4 +133,10 @@ interface ModelMapper {
fun toV0Instruction(addRecipeInstructionInfo: AddRecipeInstructionInfo): AddRecipeInstructionV0
fun toV0Request(parseRecipeURLInfo: ParseRecipeURLInfo): ParseRecipeURLRequestV0
fun toFoodInfo(getFoodsResponseV1: GetFoodsResponseV1): List<FoodInfo>
fun toUnitInfo(getUnitsResponseV1: GetUnitsResponseV1): List<UnitInfo>
fun toV1CreateRequest(addRecipeInfo: NewShoppingListItemInfo): CreateShoppingListItemRequestV1
}

View File

@@ -8,8 +8,10 @@ import gq.kirmanak.mealient.datasource.models.AddRecipeInfo
import gq.kirmanak.mealient.datasource.models.AddRecipeIngredientInfo
import gq.kirmanak.mealient.datasource.models.AddRecipeInstructionInfo
import gq.kirmanak.mealient.datasource.models.AddRecipeSettingsInfo
import gq.kirmanak.mealient.datasource.models.FoodInfo
import gq.kirmanak.mealient.datasource.models.FullRecipeInfo
import gq.kirmanak.mealient.datasource.models.FullShoppingListInfo
import gq.kirmanak.mealient.datasource.models.NewShoppingListItemInfo
import gq.kirmanak.mealient.datasource.models.ParseRecipeURLInfo
import gq.kirmanak.mealient.datasource.models.RecipeIngredientInfo
import gq.kirmanak.mealient.datasource.models.RecipeInstructionInfo
@@ -19,6 +21,7 @@ import gq.kirmanak.mealient.datasource.models.ShoppingListInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListItemInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListItemRecipeReferenceInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListsInfo
import gq.kirmanak.mealient.datasource.models.UnitInfo
import gq.kirmanak.mealient.datasource.models.VersionInfo
import gq.kirmanak.mealient.datasource.v0.models.AddRecipeIngredientV0
import gq.kirmanak.mealient.datasource.v0.models.AddRecipeInstructionV0
@@ -34,7 +37,12 @@ import gq.kirmanak.mealient.datasource.v1.models.AddRecipeIngredientV1
import gq.kirmanak.mealient.datasource.v1.models.AddRecipeInstructionV1
import gq.kirmanak.mealient.datasource.v1.models.AddRecipeSettingsV1
import gq.kirmanak.mealient.datasource.v1.models.CreateRecipeRequestV1
import gq.kirmanak.mealient.datasource.v1.models.CreateShoppingListItemRequestV1
import gq.kirmanak.mealient.datasource.v1.models.GetFoodResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetFoodsResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeIngredientFoodResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeIngredientResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeIngredientUnitResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeInstructionResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeSettingsResponseV1
@@ -44,6 +52,8 @@ import gq.kirmanak.mealient.datasource.v1.models.GetShoppingListItemResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetShoppingListResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetShoppingListsResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetShoppingListsSummaryResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetUnitResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetUnitsResponseV1
import gq.kirmanak.mealient.datasource.v1.models.ParseRecipeURLRequestV1
import gq.kirmanak.mealient.datasource.v1.models.UpdateRecipeRequestV1
import gq.kirmanak.mealient.datasource.v1.models.VersionResponseV1
@@ -198,12 +208,26 @@ class ModelMapperImpl @Inject constructor() : ModelMapper {
isFood = getShoppingListItemResponseV1.isFood,
note = getShoppingListItemResponseV1.note,
quantity = getShoppingListItemResponseV1.quantity,
unit = getShoppingListItemResponseV1.unit?.name.orEmpty(),
food = getShoppingListItemResponseV1.food?.name.orEmpty(),
unit = getShoppingListItemResponseV1.unit?.let { toUnitInfo(it) },
food = getShoppingListItemResponseV1.food?.let { toFoodInfo(it) },
recipeReferences = getShoppingListItemResponseV1.recipeReferences.map { it.recipeId }
.mapNotNull { recipes[it] }.flatten().map { toShoppingListItemRecipeReferenceInfo(it) },
)
private fun toUnitInfo(getRecipeIngredientUnitResponseV1: GetRecipeIngredientUnitResponseV1): UnitInfo {
return UnitInfo(
name = getRecipeIngredientUnitResponseV1.name,
id = getRecipeIngredientUnitResponseV1.id,
)
}
private fun toFoodInfo(getRecipeIngredientFoodResponseV1: GetRecipeIngredientFoodResponseV1): FoodInfo {
return FoodInfo(
name = getRecipeIngredientFoodResponseV1.name,
id = getRecipeIngredientFoodResponseV1.id,
)
}
override fun toShoppingListItemRecipeReferenceInfo(
getShoppingListItemRecipeReferenceFullResponseV1: GetShoppingListItemRecipeReferenceFullResponseV1
) = ShoppingListItemRecipeReferenceInfo(
@@ -305,4 +329,39 @@ class ModelMapperImpl @Inject constructor() : ModelMapper {
override fun toV0Request(parseRecipeURLInfo: ParseRecipeURLInfo) = ParseRecipeURLRequestV0(
url = parseRecipeURLInfo.url,
)
override fun toFoodInfo(getFoodsResponseV1: GetFoodsResponseV1): List<FoodInfo> {
return getFoodsResponseV1.items.map { toFoodInfo(it) }
}
private fun toFoodInfo(getFoodResponseV1: GetFoodResponseV1): FoodInfo {
return FoodInfo(
name = getFoodResponseV1.name,
id = getFoodResponseV1.id,
)
}
override fun toUnitInfo(getUnitsResponseV1: GetUnitsResponseV1): List<UnitInfo> {
return getUnitsResponseV1.items.map { toUnitInfo(it) }
}
private fun toUnitInfo(getUnitResponseV1: GetUnitResponseV1): UnitInfo {
return UnitInfo(
name = getUnitResponseV1.name,
id = getUnitResponseV1.id,
)
}
override fun toV1CreateRequest(addRecipeInfo: NewShoppingListItemInfo): CreateShoppingListItemRequestV1 {
return CreateShoppingListItemRequestV1(
shoppingListId = addRecipeInfo.shoppingListId,
checked = false,
position = addRecipeInfo.position,
isFood = addRecipeInfo.isFood,
note = addRecipeInfo.note,
quantity = addRecipeInfo.quantity,
foodId = addRecipeInfo.food?.id,
unitId = addRecipeInfo.unit?.id,
)
}
}