diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/FoodInfo.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/FoodInfo.kt new file mode 100644 index 0000000..0b8b05b --- /dev/null +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/FoodInfo.kt @@ -0,0 +1,6 @@ +package gq.kirmanak.mealient.datasource.models + +data class FoodInfo( + val name: String, + val id: String +) diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/FullShoppingListInfo.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/FullShoppingListInfo.kt index b49bdc4..b7417f7 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/FullShoppingListInfo.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/FullShoppingListInfo.kt @@ -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, ) diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/NewShoppingListItemInfo.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/NewShoppingListItemInfo.kt new file mode 100644 index 0000000..b1901cf --- /dev/null +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/NewShoppingListItemInfo.kt @@ -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, +) diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/UnitInfo.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/UnitInfo.kt new file mode 100644 index 0000000..39e1947 --- /dev/null +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/UnitInfo.kt @@ -0,0 +1,6 @@ +package gq.kirmanak.mealient.datasource.models + +data class UnitInfo( + val name: String, + val id: String +) diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1.kt index c24461c..f9c17b1 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1.kt @@ -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) } \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1Impl.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1Impl.kt index 6150458..6443c10 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1Impl.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1Impl.kt @@ -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" } + ) } diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieServiceV1.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieServiceV1.kt index 6a2ce27..78c7f1b 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieServiceV1.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieServiceV1.kt @@ -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, + ) } \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/CreateShoppingListItemRequestV1.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/CreateShoppingListItemRequestV1.kt new file mode 100644 index 0000000..c52272d --- /dev/null +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/CreateShoppingListItemRequestV1.kt @@ -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?, +) diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/GetFoodsResponseV1.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/GetFoodsResponseV1.kt new file mode 100644 index 0000000..851eaab --- /dev/null +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/GetFoodsResponseV1.kt @@ -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, +) + +@Serializable +data class GetFoodResponseV1( + @SerialName("name") val name: String, + @SerialName("id") val id: String, +) diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/GetRecipeIngredientFoodResponseV1.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/GetRecipeIngredientFoodResponseV1.kt index 94a1e55..3ec2d90 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/GetRecipeIngredientFoodResponseV1.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/GetRecipeIngredientFoodResponseV1.kt @@ -6,4 +6,5 @@ import kotlinx.serialization.Serializable @Serializable data class GetRecipeIngredientFoodResponseV1( @SerialName("name") val name: String = "", + @SerialName("id") val id: String = "", ) \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/GetRecipeIngredientUnitResponseV1.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/GetRecipeIngredientUnitResponseV1.kt index 626d5c0..189a028 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/GetRecipeIngredientUnitResponseV1.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/GetRecipeIngredientUnitResponseV1.kt @@ -6,4 +6,5 @@ import kotlinx.serialization.Serializable @Serializable data class GetRecipeIngredientUnitResponseV1( @SerialName("name") val name: String = "", + @SerialName("id") val id: String = "", ) \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/GetUnitsResponseV1.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/GetUnitsResponseV1.kt new file mode 100644 index 0000000..921d046 --- /dev/null +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/GetUnitsResponseV1.kt @@ -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 +) + +@Serializable +data class GetUnitResponseV1( + @SerialName("name") val name: String, + @SerialName("id") val id: String +) diff --git a/features/shopping_lists/build.gradle.kts b/features/shopping_lists/build.gradle.kts index 2cf0885..b4f9065 100644 --- a/features/shopping_lists/build.gradle.kts +++ b/features/shopping_lists/build.gradle.kts @@ -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) diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/network/ShoppingListsDataSource.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/network/ShoppingListsDataSource.kt index 0cca61d..5da685b 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/network/ShoppingListsDataSource.kt +++ b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/network/ShoppingListsDataSource.kt @@ -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 + + suspend fun getUnits(): List + + suspend fun addShoppingListItem(item: NewShoppingListItemInfo) } \ No newline at end of file diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/network/ShoppingListsDataSourceImpl.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/network/ShoppingListsDataSourceImpl.kt index 6e59c55..403b27b 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/network/ShoppingListsDataSourceImpl.kt +++ b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/network/ShoppingListsDataSourceImpl.kt @@ -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 = modelMapper.toFoodInfo(v1Source.getFoods()) + + override suspend fun getUnits(): List = modelMapper.toUnitInfo(v1Source.getUnits()) + + override suspend fun addShoppingListItem( + item: NewShoppingListItemInfo + ) = v1Source.addShoppingListItem(modelMapper.toV1CreateRequest(item)) } diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/repo/ShoppingListsRepo.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/repo/ShoppingListsRepo.kt index c74f8cd..7f64255 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/repo/ShoppingListsRepo.kt +++ b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/repo/ShoppingListsRepo.kt @@ -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 suspend fun getShoppingList(id: String): FullShoppingListInfo suspend fun deleteShoppingListItem(id: String) + + suspend fun updateShoppingListItem(item: ShoppingListItemInfo) + + suspend fun getFoods(): List + + suspend fun getUnits(): List + + suspend fun addShoppingListItem(item: NewShoppingListItemInfo) } \ No newline at end of file diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/repo/ShoppingListsRepoImpl.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/repo/ShoppingListsRepoImpl.kt index 89c23d0..ddd38fc 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/repo/ShoppingListsRepoImpl.kt +++ b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/repo/ShoppingListsRepoImpl.kt @@ -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 { 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 { + logger.v { "getFoods() called" } + return dataSource.getFoods() + } + + override suspend fun getUnits(): List { + logger.v { "getUnits() called" } + return dataSource.getUnits() + } + + override suspend fun addShoppingListItem(item: NewShoppingListItemInfo) { + logger.v { "addShoppingListItem() called with: item = $item" } + dataSource.addShoppingListItem(item) + } } \ No newline at end of file diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListData.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListData.kt new file mode 100644 index 0000000..5710215 --- /dev/null +++ b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListData.kt @@ -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, + val units: List, + val shoppingList: FullShoppingListInfo, +) diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListEditingState.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListEditingState.kt new file mode 100644 index 0000000..a29921e --- /dev/null +++ b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListEditingState.kt @@ -0,0 +1,10 @@ +package gq.kirmanak.mealient.shopping_lists.ui + +import gq.kirmanak.mealient.datasource.models.ShoppingListItemInfo + +data class ShoppingListEditingState( + val deletedItemIds: Set = emptySet(), + val editingItemIds: Set = emptySet(), + val modifiedItems: Map = emptyMap(), + val newItems: List = emptyList(), +) diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListScreen.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListScreen.kt index 9e4ddf4..8f79f10 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListScreen.kt +++ b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListScreen.kt @@ -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, + val units: List, + 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, + units: List, + ) : 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", diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListScreenState.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListScreenState.kt index 2735bf5..a39e97f 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListScreenState.kt +++ b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListScreenState.kt @@ -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, + val listId: String, + val items: List, + val foods: List, + val units: List, ) + +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 + } diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListViewModel.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListViewModel.kt index b01d107..7e29466 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListViewModel.kt +++ b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListViewModel.kt @@ -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>(mutableMapOf()) - - private val deletedItemIds = MutableStateFlow>(mutableSetOf()) + private val editingStateFlow = MutableStateFlow(ShoppingListEditingState()) private val loadingHelper = loadingHelperFactory.create(viewModelScope) { - shoppingListsRepo.getShoppingList(args.shoppingListId) + loadShoppingListData() } val loadingState: StateFlow> = 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, - checkedOverrideMap: Map, - deletedItemIds: Set, + loadingState: LoadingState, + editingState: ShoppingListEditingState, ): LoadingState { - 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) + } } } } \ No newline at end of file diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/LazyColumnWithLoadingState.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/LazyColumnWithLoadingState.kt index f7efa29..4545727 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/LazyColumnWithLoadingState.kt +++ b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/LazyColumnWithLoadingState.kt @@ -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 LazyColumnWithLoadingState( errorToShowInSnackbar: Throwable? = null, onSnackbarShown: () -> Unit = {}, onRefresh: () -> Unit = {}, + floatingActionButton: @Composable () -> Unit = {}, + floatingActionButtonPosition: FabPosition = FabPosition.End, lazyColumnContent: LazyListScope.(List) -> Unit = {}, ) { val refreshState = rememberPullRefreshState( @@ -42,6 +45,8 @@ fun LazyColumnWithLoadingState( Scaffold( modifier = modifier, + floatingActionButton = floatingActionButton, + floatingActionButtonPosition = floatingActionButtonPosition, snackbarHost = { SnackbarHost(snackbarHostState) }, ) { paddingValues -> val innerModifier = Modifier diff --git a/features/shopping_lists/src/main/res/values/strings.xml b/features/shopping_lists/src/main/res/values/strings.xml index 08fc4f0..21608ee 100644 --- a/features/shopping_lists/src/main/res/values/strings.xml +++ b/features/shopping_lists/src/main/res/values/strings.xml @@ -1,12 +1,25 @@ - Shopping cart - Unknown error - %1$s is empty - Delete - No shopping lists found - Authentication is required - No server connection - Unknown error - Try again + Shopping cart + Unknown error + %1$s is empty + Delete + Qty + Note + Food + Unit + Save + Not food + Food + Cancel + Edit + Selected unit + Selected food + Add item + + No shopping lists found + Authentication is required + No server connection + Unknown error + Try again \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ea7c68e..d2bce51 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/model_mapper/src/main/kotlin/gq/kirmanak/mealient/model_mapper/ModelMapper.kt b/model_mapper/src/main/kotlin/gq/kirmanak/mealient/model_mapper/ModelMapper.kt index caf9f75..203b508 100644 --- a/model_mapper/src/main/kotlin/gq/kirmanak/mealient/model_mapper/ModelMapper.kt +++ b/model_mapper/src/main/kotlin/gq/kirmanak/mealient/model_mapper/ModelMapper.kt @@ -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 + + fun toUnitInfo(getUnitsResponseV1: GetUnitsResponseV1): List + + fun toV1CreateRequest(addRecipeInfo: NewShoppingListItemInfo): CreateShoppingListItemRequestV1 } \ No newline at end of file diff --git a/model_mapper/src/main/kotlin/gq/kirmanak/mealient/model_mapper/ModelMapperImpl.kt b/model_mapper/src/main/kotlin/gq/kirmanak/mealient/model_mapper/ModelMapperImpl.kt index f270c77..c55c358 100644 --- a/model_mapper/src/main/kotlin/gq/kirmanak/mealient/model_mapper/ModelMapperImpl.kt +++ b/model_mapper/src/main/kotlin/gq/kirmanak/mealient/model_mapper/ModelMapperImpl.kt @@ -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 { + 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 { + 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, + ) + } } \ No newline at end of file