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 isFood: Boolean,
val note: String, val note: String,
val quantity: Double, val quantity: Double,
val unit: String, val unit: UnitInfo?,
val food: String, val food: FoodInfo?,
val recipeReferences: List<ShoppingListItemRecipeReferenceInfo>, 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 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.CreateApiTokenRequestV1
import gq.kirmanak.mealient.datasource.v1.models.CreateApiTokenResponseV1 import gq.kirmanak.mealient.datasource.v1.models.CreateApiTokenResponseV1
import gq.kirmanak.mealient.datasource.v1.models.CreateRecipeRequestV1 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.GetRecipeResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeSummaryResponseV1 import gq.kirmanak.mealient.datasource.v1.models.GetRecipeSummaryResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetShoppingListResponseV1 import gq.kirmanak.mealient.datasource.v1.models.GetShoppingListResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetShoppingListsResponseV1 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.GetUserInfoResponseV1
import gq.kirmanak.mealient.datasource.v1.models.ParseRecipeURLRequestV1 import gq.kirmanak.mealient.datasource.v1.models.ParseRecipeURLRequestV1
import gq.kirmanak.mealient.datasource.v1.models.UpdateRecipeRequestV1 import gq.kirmanak.mealient.datasource.v1.models.UpdateRecipeRequestV1
@@ -63,7 +67,13 @@ interface MealieDataSourceV1 {
suspend fun getShoppingList(id: String): GetShoppingListResponseV1 suspend fun getShoppingList(id: String): GetShoppingListResponseV1
suspend fun updateIsShoppingListItemChecked(id: String, isChecked: Boolean)
suspend fun deleteShoppingListItem(id: String) 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.NetworkError
import gq.kirmanak.mealient.datasource.NetworkRequestWrapper import gq.kirmanak.mealient.datasource.NetworkRequestWrapper
import gq.kirmanak.mealient.datasource.decode 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.CreateApiTokenRequestV1
import gq.kirmanak.mealient.datasource.v1.models.CreateApiTokenResponseV1 import gq.kirmanak.mealient.datasource.v1.models.CreateApiTokenResponseV1
import gq.kirmanak.mealient.datasource.v1.models.CreateRecipeRequestV1 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.ErrorDetailV1
import gq.kirmanak.mealient.datasource.v1.models.GetFoodsResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeResponseV1 import gq.kirmanak.mealient.datasource.v1.models.GetRecipeResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeSummaryResponseV1 import gq.kirmanak.mealient.datasource.v1.models.GetRecipeSummaryResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetShoppingListResponseV1 import gq.kirmanak.mealient.datasource.v1.models.GetShoppingListResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetShoppingListsResponseV1 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.GetUserInfoResponseV1
import gq.kirmanak.mealient.datasource.v1.models.ParseRecipeURLRequestV1 import gq.kirmanak.mealient.datasource.v1.models.ParseRecipeURLRequestV1
import gq.kirmanak.mealient.datasource.v1.models.UpdateRecipeRequestV1 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.JsonElement
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import retrofit2.HttpException import retrofit2.HttpException
import java.net.ConnectException import java.net.ConnectException
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
@@ -175,20 +177,6 @@ class MealieDataSourceV1Impl @Inject constructor(
logParameters = { "id = $id, request = $request" } 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( override suspend fun deleteShoppingListItem(
id: String, id: String,
) = networkRequestWrapper.makeCallAndHandleUnauthorized( ) = networkRequestWrapper.makeCallAndHandleUnauthorized(
@@ -196,4 +184,44 @@ class MealieDataSourceV1Impl @Inject constructor(
logMethod = { "deleteShoppingListItem" }, logMethod = { "deleteShoppingListItem" },
logParameters = { "id = $id" } 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( suspend fun deleteShoppingListItem(
@Path("id") id: String, @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 @Serializable
data class GetRecipeIngredientFoodResponseV1( data class GetRecipeIngredientFoodResponseV1(
@SerialName("name") val name: String = "", @SerialName("name") val name: String = "",
@SerialName("id") val id: String = "",
) )

View File

@@ -6,4 +6,5 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class GetRecipeIngredientUnitResponseV1( data class GetRecipeIngredientUnitResponseV1(
@SerialName("name") val name: String = "", @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.android.material.material)
implementation(libs.androidx.compose.material) implementation(libs.androidx.compose.material)
implementation(libs.androidx.compose.materialIconsExtended)
implementation(libs.google.dagger.hiltAndroid) implementation(libs.google.dagger.hiltAndroid)
kapt(libs.google.dagger.hiltCompiler) kapt(libs.google.dagger.hiltCompiler)

View File

@@ -1,7 +1,11 @@
package gq.kirmanak.mealient.shopping_lists.network 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.FullShoppingListInfo
import gq.kirmanak.mealient.datasource.models.NewShoppingListItemInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListInfo import gq.kirmanak.mealient.datasource.models.ShoppingListInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListItemInfo
import gq.kirmanak.mealient.datasource.models.UnitInfo
interface ShoppingListsDataSource { interface ShoppingListsDataSource {
@@ -9,7 +13,13 @@ interface ShoppingListsDataSource {
suspend fun getShoppingList(id: String): FullShoppingListInfo suspend fun getShoppingList(id: String): FullShoppingListInfo
suspend fun updateIsShoppingListItemChecked(id: String, checked: Boolean)
suspend fun deleteShoppingListItem(id: String) 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 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.FullShoppingListInfo
import gq.kirmanak.mealient.datasource.models.NewShoppingListItemInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListInfo 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.datasource.v1.MealieDataSourceV1
import gq.kirmanak.mealient.model_mapper.ModelMapper import gq.kirmanak.mealient.model_mapper.ModelMapper
import javax.inject.Inject import javax.inject.Inject
@@ -20,14 +24,20 @@ class ShoppingListsDataSourceImpl @Inject constructor(
id: String id: String
): FullShoppingListInfo = modelMapper.toFullShoppingListInfo(v1Source.getShoppingList(id)) ): FullShoppingListInfo = modelMapper.toFullShoppingListInfo(v1Source.getShoppingList(id))
override suspend fun updateIsShoppingListItemChecked(
id: String,
checked: Boolean,
) = v1Source.updateIsShoppingListItemChecked(id, checked)
override suspend fun deleteShoppingListItem( override suspend fun deleteShoppingListItem(
id: String id: String
) = v1Source.deleteShoppingListItem(id) ) = 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 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.FullShoppingListInfo
import gq.kirmanak.mealient.datasource.models.NewShoppingListItemInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListInfo import gq.kirmanak.mealient.datasource.models.ShoppingListInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListItemInfo
import gq.kirmanak.mealient.datasource.models.UnitInfo
interface ShoppingListsRepo { interface ShoppingListsRepo {
suspend fun updateIsShoppingListItemChecked(id: String, isChecked: Boolean)
suspend fun getShoppingLists(): List<ShoppingListInfo> suspend fun getShoppingLists(): List<ShoppingListInfo>
suspend fun getShoppingList(id: String): FullShoppingListInfo suspend fun getShoppingList(id: String): FullShoppingListInfo
suspend fun deleteShoppingListItem(id: String) 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 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.FullShoppingListInfo
import gq.kirmanak.mealient.datasource.models.NewShoppingListItemInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListInfo 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.logging.Logger
import gq.kirmanak.mealient.shopping_lists.network.ShoppingListsDataSource import gq.kirmanak.mealient.shopping_lists.network.ShoppingListsDataSource
import javax.inject.Inject import javax.inject.Inject
@@ -11,11 +15,6 @@ class ShoppingListsRepoImpl @Inject constructor(
private val logger: Logger, private val logger: Logger,
) : ShoppingListsRepo { ) : 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> { override suspend fun getShoppingLists(): List<ShoppingListInfo> {
logger.v { "getShoppingLists() called" } logger.v { "getShoppingLists() called" }
return dataSource.getAllShoppingLists() return dataSource.getAllShoppingLists()
@@ -30,4 +29,24 @@ class ShoppingListsRepoImpl @Inject constructor(
logger.v { "deleteShoppingListItem() called with: id = $id" } logger.v { "deleteShoppingListItem() called with: id = $id" }
dataSource.deleteShoppingListItem(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.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete 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.Checkbox
import androidx.compose.material3.DismissDirection import androidx.compose.material3.DismissState
import androidx.compose.material3.DismissValue import androidx.compose.material3.DismissValue
import androidx.compose.material3.Divider import androidx.compose.material3.Divider
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api 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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SwipeToDismiss import androidx.compose.material3.SwipeToDismiss
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberDismissState import androidx.compose.material3.rememberDismissState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.Destination
import gq.kirmanak.mealient.AppTheme import gq.kirmanak.mealient.AppTheme
import gq.kirmanak.mealient.Dimens 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.FullRecipeInfo
import gq.kirmanak.mealient.datasource.models.RecipeIngredientInfo import gq.kirmanak.mealient.datasource.models.RecipeIngredientInfo
import gq.kirmanak.mealient.datasource.models.RecipeInstructionInfo import gq.kirmanak.mealient.datasource.models.RecipeInstructionInfo
import gq.kirmanak.mealient.datasource.models.RecipeSettingsInfo import gq.kirmanak.mealient.datasource.models.RecipeSettingsInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListItemInfo import gq.kirmanak.mealient.datasource.models.ShoppingListItemInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListItemRecipeReferenceInfo 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_list.R
import gq.kirmanak.mealient.shopping_lists.ui.composables.LazyColumnWithLoadingState import gq.kirmanak.mealient.shopping_lists.ui.composables.LazyColumnWithLoadingState
import gq.kirmanak.mealient.shopping_lists.util.data import gq.kirmanak.mealient.shopping_lists.util.data
import gq.kirmanak.mealient.shopping_lists.util.map import gq.kirmanak.mealient.shopping_lists.util.map
import kotlinx.coroutines.android.awaitFrame
import java.text.DecimalFormat import java.text.DecimalFormat
data class ShoppingListNavArgs( data class ShoppingListNavArgs(
val shoppingListId: String, val shoppingListId: String,
) )
@OptIn(ExperimentalMaterial3Api::class)
@Destination( @Destination(
navArgsDelegate = ShoppingListNavArgs::class, navArgsDelegate = ShoppingListNavArgs::class,
) )
@@ -65,54 +89,415 @@ internal fun ShoppingListScreen(
LazyColumnWithLoadingState( LazyColumnWithLoadingState(
loadingState = loadingState.map { it.items }, 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), verticalArrangement = Arrangement.spacedBy(Dimens.Medium),
defaultEmptyListError = defaultEmptyListError, defaultEmptyListError = defaultEmptyListError,
errorToShowInSnackbar = shoppingListViewModel.errorToShowInSnackbar, errorToShowInSnackbar = shoppingListViewModel.errorToShowInSnackbar,
onRefresh = shoppingListViewModel::refreshShoppingList, 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 -> ) { items ->
val firstCheckedItemIndex = items.indexOfFirst { it.checked } val firstCheckedItemIndex = items.indexOfFirst { it.checked }
itemsIndexed(items, { _, item -> item.id }) { index, item -> itemsIndexed(items, { _, item -> item.id }) { index, itemState ->
ShoppingListItem( if (itemState is ShoppingListItemState.ExistingItem) {
shoppingListItem = item, if (itemState.isEditing) {
showDivider = index == firstCheckedItemIndex && index != 0, val state = remember {
modifier = Modifier.background(MaterialTheme.colorScheme.surface), ShoppingListItemEditorState(
onCheckedChange = { isChecked -> state = itemState,
shoppingListViewModel.onItemCheckedChange(item, isChecked) foods = loadingState.data?.foods.orEmpty(),
}, units = loadingState.data?.units.orEmpty(),
onDismissed = { )
shoppingListViewModel.deleteShoppingListItem(item) }
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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ShoppingListItem( fun ShoppingListItem(
shoppingListItem: ShoppingListItemInfo, itemState: ShoppingListItemState.ExistingItem,
showDivider: Boolean, showDivider: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onCheckedChange: (Boolean) -> Unit = {}, onCheckedChange: (Boolean) -> Unit = {},
onDismissed: () -> Unit = {}, onDismissed: () -> Unit = {},
) { onEditStart: () -> Unit = {},
val dismissState = rememberDismissState( dismissState: DismissState = rememberDismissState(
confirmValueChange = { confirmValueChange = {
if (it == DismissValue.DismissedToStart) { when (it) {
onDismissed() DismissValue.DismissedToStart -> onDismissed()
DismissValue.DismissedToEnd -> onEditStart()
DismissValue.Default -> Unit
} }
true true
} }
) )
) {
val shoppingListItem = itemState.item
SwipeToDismiss( SwipeToDismiss(
state = dismissState, state = dismissState,
background = { background = {
if (dismissState.targetValue == DismissValue.DismissedToStart) { if (dismissState.targetValue == DismissValue.DismissedToStart) {
val color by animateColorAsState(MaterialTheme.colorScheme.error) val color by animateColorAsState(MaterialTheme.colorScheme.error)
val iconColor by animateColorAsState(MaterialTheme.colorScheme.onSurface) val iconColor by animateColorAsState(MaterialTheme.colorScheme.onError)
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -127,6 +512,23 @@ fun ShoppingListItem(
.padding(end = Dimens.Small) .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 = { dismissContent = {
@@ -143,7 +545,7 @@ fun ShoppingListItem(
horizontalArrangement = Arrangement.Start, horizontalArrangement = Arrangement.Start,
) { ) {
Checkbox( Checkbox(
checked = shoppingListItem.checked, checked = itemState.item.checked,
onCheckedChange = onCheckedChange, onCheckedChange = onCheckedChange,
) )
@@ -154,8 +556,8 @@ fun ShoppingListItem(
?.let { DecimalFormat.getInstance().format(it) } ?.let { DecimalFormat.getInstance().format(it) }
val text = listOfNotNull( val text = listOfNotNull(
quantity, quantity,
shoppingListItem.unit.takeIf { isFood }, shoppingListItem.unit.takeIf { isFood }?.name,
shoppingListItem.food.takeIf { isFood }, shoppingListItem.food.takeIf { isFood }?.name,
shoppingListItem.note, shoppingListItem.note,
).filter { it.isNotBlank() }.joinToString(" ") ).filter { it.isNotBlank() }.joinToString(" ")
@@ -164,23 +566,60 @@ fun ShoppingListItem(
} }
}, },
modifier = modifier, modifier = modifier,
directions = setOf(DismissDirection.EndToStart),
) )
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@Preview @Preview
fun PreviewShoppingListItemChecked() { fun PreviewShoppingListItemChecked() {
AppTheme { AppTheme {
ShoppingListItem(shoppingListItem = PreviewData.milk, false) ShoppingListItem(
itemState = ShoppingListItemState.ExistingItem(PreviewData.milk),
showDivider = false
)
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@Preview @Preview
fun PreviewShoppingListItemUnchecked() { fun PreviewShoppingListItemUnchecked() {
AppTheme { 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, isFood = false,
note = "Black tea bags", note = "Black tea bags",
quantity = 30.0, quantity = 30.0,
unit = "", unit = null,
food = "", food = null,
recipeReferences = listOf( recipeReferences = listOf(
ShoppingListItemRecipeReferenceInfo( ShoppingListItemRecipeReferenceInfo(
shoppingListId = "1", shoppingListId = "1",
@@ -243,12 +682,12 @@ private object PreviewData {
id = "2", id = "2",
shoppingListId = "1", shoppingListId = "1",
checked = true, checked = true,
position = 1, position = 0,
isFood = true, isFood = true,
note = "Cold", note = "Cold",
quantity = 500.0, quantity = 500.0,
unit = "ml", unit = UnitInfo("ml", ""),
food = "Milk", food = FoodInfo("Milk", ""),
recipeReferences = listOf( recipeReferences = listOf(
ShoppingListItemRecipeReferenceInfo( ShoppingListItemRecipeReferenceInfo(
shoppingListId = "1", shoppingListId = "1",

View File

@@ -1,8 +1,45 @@
package gq.kirmanak.mealient.shopping_lists.ui 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.ShoppingListItemInfo
import gq.kirmanak.mealient.datasource.models.UnitInfo
import java.util.UUID
internal data class ShoppingListScreenState( internal data class ShoppingListScreenState(
val name: String, 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 androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.architecture.valueUpdatesOnly 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.models.ShoppingListItemInfo
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger 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.LoadingHelperFactory
import gq.kirmanak.mealient.shopping_lists.util.LoadingState import gq.kirmanak.mealient.shopping_lists.util.LoadingState
import gq.kirmanak.mealient.shopping_lists.util.LoadingStateNoData 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 gq.kirmanak.mealient.shopping_lists.util.map
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -39,18 +42,15 @@ internal class ShoppingListViewModel @Inject constructor(
private val args: ShoppingListNavArgs = ShoppingListScreenDestination.argsFrom(savedStateHandle) private val args: ShoppingListNavArgs = ShoppingListScreenDestination.argsFrom(savedStateHandle)
private val checkedOverride = MutableStateFlow<MutableMap<String, Boolean>>(mutableMapOf()) private val editingStateFlow = MutableStateFlow(ShoppingListEditingState())
private val deletedItemIds = MutableStateFlow<Set<String>>(mutableSetOf())
private val loadingHelper = loadingHelperFactory.create(viewModelScope) { private val loadingHelper = loadingHelperFactory.create(viewModelScope) {
shoppingListsRepo.getShoppingList(args.shoppingListId) loadShoppingListData()
} }
val loadingState: StateFlow<LoadingState<ShoppingListScreenState>> = combine( val loadingState: StateFlow<LoadingState<ShoppingListScreenState>> = combine(
loadingHelper.loadingState, loadingHelper.loadingState,
checkedOverride, editingStateFlow,
deletedItemIds,
::buildLoadingState, ::buildLoadingState,
).stateIn(viewModelScope, SharingStarted.Eagerly, LoadingStateNoData.InitialLoad) ).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() { private suspend fun doRefresh() {
_errorToShowInSnackbar = loadingHelper.refresh().exceptionOrNull() _errorToShowInSnackbar = loadingHelper.refresh().exceptionOrNull()
} }
private fun buildLoadingState( private fun buildLoadingState(
loadingState: LoadingState<FullShoppingListInfo>, loadingState: LoadingState<ShoppingListData>,
checkedOverrideMap: Map<String, Boolean>, editingState: ShoppingListEditingState,
deletedItemIds: Set<String>,
): LoadingState<ShoppingListScreenState> { ): LoadingState<ShoppingListScreenState> {
logger.v { "buildLoadingState() called with: loadingState = $loadingState, checkedOverrideMap = $checkedOverrideMap, deletedItemIds = $deletedItemIds" } logger.v { "buildLoadingState() called with: loadingState = $loadingState, editingState = $editingState" }
return loadingState.map { shoppingList -> return loadingState.map { data ->
val items = shoppingList.items val existingItems = data.shoppingList.items
.filter { it.id !in deletedItemIds } .filter { it.id !in editingState.deletedItemIds }
.map { it.copy(checked = checkedOverrideMap[it.id] ?: it.checked) } .map {
.sortedBy { it.checked } ShoppingListItemState.ExistingItem(
ShoppingListScreenState(name = shoppingList.name, items = items) 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) { fun onItemCheckedChange(state: ShoppingListItemState.ExistingItem, isChecked: Boolean) {
logger.v { "onItemCheckedChange() called with: item = $item, isChecked = $isChecked" } logger.v { "onItemCheckedChange() called with: state = $state, isChecked = $isChecked" }
viewModelScope.launch { val updatedItem = state.item.copy(checked = isChecked)
checkedOverride.update { originalMap -> updateItemInformation(updatedItem)
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 onSnackbarShown() { fun onSnackbarShown() {
@@ -126,10 +150,13 @@ internal class ShoppingListViewModel @Inject constructor(
_errorToShowInSnackbar = null _errorToShowInSnackbar = null
} }
fun deleteShoppingListItem(item: ShoppingListItemInfo) { fun deleteShoppingListItem(state: ShoppingListItemState.ExistingItem) {
logger.v { "deleteShoppingListItem() called with: item = $item" } logger.v { "deleteShoppingListItem() called with: state = $state" }
val item = state.item
viewModelScope.launch { viewModelScope.launch {
deletedItemIds.update { it + item.id } editingStateFlow.update {
it.copy(deletedItemIds = it.deletedItemIds + item.id)
}
val result = runCatchingExceptCancel { val result = runCatchingExceptCancel {
shoppingListsRepo.deleteShoppingListItem(item.id) shoppingListsRepo.deleteShoppingListItem(item.id)
}.onFailure { }.onFailure {
@@ -140,7 +167,121 @@ internal class ShoppingListViewModel @Inject constructor(
logger.v { "Item deleted" } logger.v { "Item deleted" }
doRefresh() 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.foundation.lazy.LazyListScope
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.FabPosition
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
@@ -32,6 +33,8 @@ fun <T> LazyColumnWithLoadingState(
errorToShowInSnackbar: Throwable? = null, errorToShowInSnackbar: Throwable? = null,
onSnackbarShown: () -> Unit = {}, onSnackbarShown: () -> Unit = {},
onRefresh: () -> Unit = {}, onRefresh: () -> Unit = {},
floatingActionButton: @Composable () -> Unit = {},
floatingActionButtonPosition: FabPosition = FabPosition.End,
lazyColumnContent: LazyListScope.(List<T>) -> Unit = {}, lazyColumnContent: LazyListScope.(List<T>) -> Unit = {},
) { ) {
val refreshState = rememberPullRefreshState( val refreshState = rememberPullRefreshState(
@@ -42,6 +45,8 @@ fun <T> LazyColumnWithLoadingState(
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
floatingActionButton = floatingActionButton,
floatingActionButtonPosition = floatingActionButtonPosition,
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
) { paddingValues -> ) { paddingValues ->
val innerModifier = Modifier val innerModifier = Modifier

View File

@@ -1,12 +1,25 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="shopping_lists_screen_cart_icon">Shopping cart</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_unknown_error">Unknown error</string>
<string name="shopping_list_screen_empty_list">%1$s is empty</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_delete_icon_content_description">Delete</string>
<string name="shopping_lists_screen_empty">No shopping lists found</string> <string name="shopping_list_screen_editor_quantity_label">Qty</string>
<string name="shopping_lists_screen_unauthorized_error">Authentication is required</string> <string name="shopping_list_screen_editor_note_label">Note</string>
<string name="shopping_lists_screen_no_connection">No server connection</string> <string name="shopping_list_screen_editor_food_label">Food</string>
<string name="shopping_lists_screen_unknown_error">Unknown error</string> <string name="shopping_list_screen_editor_unit_label">Unit</string>
<string name="shopping_lists_screen_empty_button_refresh">Try again</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> </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-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } 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-toolingPreview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-testJunit = { group = "androidx.compose.ui", name = "ui-test-junit4" } 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.AddRecipeIngredientInfo
import gq.kirmanak.mealient.datasource.models.AddRecipeInstructionInfo import gq.kirmanak.mealient.datasource.models.AddRecipeInstructionInfo
import gq.kirmanak.mealient.datasource.models.AddRecipeSettingsInfo 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.FullRecipeInfo
import gq.kirmanak.mealient.datasource.models.FullShoppingListInfo 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.ParseRecipeURLInfo
import gq.kirmanak.mealient.datasource.models.RecipeIngredientInfo import gq.kirmanak.mealient.datasource.models.RecipeIngredientInfo
import gq.kirmanak.mealient.datasource.models.RecipeInstructionInfo 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.ShoppingListItemInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListItemRecipeReferenceInfo import gq.kirmanak.mealient.datasource.models.ShoppingListItemRecipeReferenceInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListsInfo 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.models.VersionInfo
import gq.kirmanak.mealient.datasource.v0.models.AddRecipeIngredientV0 import gq.kirmanak.mealient.datasource.v0.models.AddRecipeIngredientV0
import gq.kirmanak.mealient.datasource.v0.models.AddRecipeInstructionV0 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.AddRecipeInstructionV1
import gq.kirmanak.mealient.datasource.v1.models.AddRecipeSettingsV1 import gq.kirmanak.mealient.datasource.v1.models.AddRecipeSettingsV1
import gq.kirmanak.mealient.datasource.v1.models.CreateRecipeRequestV1 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.GetRecipeIngredientResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeInstructionResponseV1 import gq.kirmanak.mealient.datasource.v1.models.GetRecipeInstructionResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeResponseV1 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.GetShoppingListResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetShoppingListsResponseV1 import gq.kirmanak.mealient.datasource.v1.models.GetShoppingListsResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetShoppingListsSummaryResponseV1 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.ParseRecipeURLRequestV1
import gq.kirmanak.mealient.datasource.v1.models.UpdateRecipeRequestV1 import gq.kirmanak.mealient.datasource.v1.models.UpdateRecipeRequestV1
import gq.kirmanak.mealient.datasource.v1.models.VersionResponseV1 import gq.kirmanak.mealient.datasource.v1.models.VersionResponseV1
@@ -127,4 +133,10 @@ interface ModelMapper {
fun toV0Instruction(addRecipeInstructionInfo: AddRecipeInstructionInfo): AddRecipeInstructionV0 fun toV0Instruction(addRecipeInstructionInfo: AddRecipeInstructionInfo): AddRecipeInstructionV0
fun toV0Request(parseRecipeURLInfo: ParseRecipeURLInfo): ParseRecipeURLRequestV0 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.AddRecipeIngredientInfo
import gq.kirmanak.mealient.datasource.models.AddRecipeInstructionInfo import gq.kirmanak.mealient.datasource.models.AddRecipeInstructionInfo
import gq.kirmanak.mealient.datasource.models.AddRecipeSettingsInfo 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.FullRecipeInfo
import gq.kirmanak.mealient.datasource.models.FullShoppingListInfo 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.ParseRecipeURLInfo
import gq.kirmanak.mealient.datasource.models.RecipeIngredientInfo import gq.kirmanak.mealient.datasource.models.RecipeIngredientInfo
import gq.kirmanak.mealient.datasource.models.RecipeInstructionInfo 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.ShoppingListItemInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListItemRecipeReferenceInfo import gq.kirmanak.mealient.datasource.models.ShoppingListItemRecipeReferenceInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListsInfo 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.models.VersionInfo
import gq.kirmanak.mealient.datasource.v0.models.AddRecipeIngredientV0 import gq.kirmanak.mealient.datasource.v0.models.AddRecipeIngredientV0
import gq.kirmanak.mealient.datasource.v0.models.AddRecipeInstructionV0 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.AddRecipeInstructionV1
import gq.kirmanak.mealient.datasource.v1.models.AddRecipeSettingsV1 import gq.kirmanak.mealient.datasource.v1.models.AddRecipeSettingsV1
import gq.kirmanak.mealient.datasource.v1.models.CreateRecipeRequestV1 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.GetRecipeIngredientResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeIngredientUnitResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeInstructionResponseV1 import gq.kirmanak.mealient.datasource.v1.models.GetRecipeInstructionResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeResponseV1 import gq.kirmanak.mealient.datasource.v1.models.GetRecipeResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeSettingsResponseV1 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.GetShoppingListResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetShoppingListsResponseV1 import gq.kirmanak.mealient.datasource.v1.models.GetShoppingListsResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetShoppingListsSummaryResponseV1 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.ParseRecipeURLRequestV1
import gq.kirmanak.mealient.datasource.v1.models.UpdateRecipeRequestV1 import gq.kirmanak.mealient.datasource.v1.models.UpdateRecipeRequestV1
import gq.kirmanak.mealient.datasource.v1.models.VersionResponseV1 import gq.kirmanak.mealient.datasource.v1.models.VersionResponseV1
@@ -198,12 +208,26 @@ class ModelMapperImpl @Inject constructor() : ModelMapper {
isFood = getShoppingListItemResponseV1.isFood, isFood = getShoppingListItemResponseV1.isFood,
note = getShoppingListItemResponseV1.note, note = getShoppingListItemResponseV1.note,
quantity = getShoppingListItemResponseV1.quantity, quantity = getShoppingListItemResponseV1.quantity,
unit = getShoppingListItemResponseV1.unit?.name.orEmpty(), unit = getShoppingListItemResponseV1.unit?.let { toUnitInfo(it) },
food = getShoppingListItemResponseV1.food?.name.orEmpty(), food = getShoppingListItemResponseV1.food?.let { toFoodInfo(it) },
recipeReferences = getShoppingListItemResponseV1.recipeReferences.map { it.recipeId } recipeReferences = getShoppingListItemResponseV1.recipeReferences.map { it.recipeId }
.mapNotNull { recipes[it] }.flatten().map { toShoppingListItemRecipeReferenceInfo(it) }, .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( override fun toShoppingListItemRecipeReferenceInfo(
getShoppingListItemRecipeReferenceFullResponseV1: GetShoppingListItemRecipeReferenceFullResponseV1 getShoppingListItemRecipeReferenceFullResponseV1: GetShoppingListItemRecipeReferenceFullResponseV1
) = ShoppingListItemRecipeReferenceInfo( ) = ShoppingListItemRecipeReferenceInfo(
@@ -305,4 +329,39 @@ class ModelMapperImpl @Inject constructor() : ModelMapper {
override fun toV0Request(parseRecipeURLInfo: ParseRecipeURLInfo) = ParseRecipeURLRequestV0( override fun toV0Request(parseRecipeURLInfo: ParseRecipeURLInfo) = ParseRecipeURLRequestV0(
url = parseRecipeURLInfo.url, 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,
)
}
} }