diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/IngredientsSection.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/IngredientsSection.kt index fc7aeb4..bdeb517 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/IngredientsSection.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/IngredientsSection.kt @@ -7,7 +7,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Card import androidx.compose.material3.Checkbox -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -73,7 +74,9 @@ private fun IngredientListItem( style = MaterialTheme.typography.titleMedium, ) - Divider() + HorizontalDivider( + color = LocalContentColor.current + ) } Row( diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/InstructionsSection.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/InstructionsSection.kt index 1fe146d..d3d1b2b 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/InstructionsSection.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/InstructionsSection.kt @@ -5,7 +5,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Card -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -86,7 +87,9 @@ private fun InstructionListItem( ) if (ingredients.isNotEmpty()) { - Divider() + HorizontalDivider( + color = LocalContentColor.current + ) ingredients.forEach { ingredient -> Text( text = ingredient.display, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b3d921f..7d17d6f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -45,7 +45,7 @@ Clear Example: demo.mealie.io Example: changeme@example.com - Example: demo + Example: MyPassword Last page loaded Load error: %1$s. Load failed. diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/MealieDataSource.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/MealieDataSource.kt index 807262e..6ac793e 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/MealieDataSource.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/MealieDataSource.kt @@ -4,6 +4,7 @@ import gq.kirmanak.mealient.datasource.models.CreateApiTokenRequest import gq.kirmanak.mealient.datasource.models.CreateApiTokenResponse import gq.kirmanak.mealient.datasource.models.CreateRecipeRequest import gq.kirmanak.mealient.datasource.models.CreateShoppingListItemRequest +import gq.kirmanak.mealient.datasource.models.CreateShoppingListRequest import gq.kirmanak.mealient.datasource.models.GetFoodsResponse import gq.kirmanak.mealient.datasource.models.GetRecipeResponse import gq.kirmanak.mealient.datasource.models.GetRecipeSummaryResponse @@ -75,4 +76,10 @@ interface MealieDataSource { suspend fun getUnits(): GetUnitsResponse suspend fun addShoppingListItem(request: CreateShoppingListItemRequest) + + suspend fun addShoppingList(request: CreateShoppingListRequest) + + suspend fun deleteShoppingList(id: String) + + suspend fun updateShoppingListName(id: String, name: String) } \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/MealieService.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/MealieService.kt index d4749aa..0717ec8 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/MealieService.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/MealieService.kt @@ -4,6 +4,7 @@ import gq.kirmanak.mealient.datasource.models.CreateApiTokenRequest import gq.kirmanak.mealient.datasource.models.CreateApiTokenResponse import gq.kirmanak.mealient.datasource.models.CreateRecipeRequest import gq.kirmanak.mealient.datasource.models.CreateShoppingListItemRequest +import gq.kirmanak.mealient.datasource.models.CreateShoppingListRequest import gq.kirmanak.mealient.datasource.models.GetFoodsResponse import gq.kirmanak.mealient.datasource.models.GetRecipeResponse import gq.kirmanak.mealient.datasource.models.GetRecipesResponse @@ -61,4 +62,12 @@ internal interface MealieService { suspend fun getUnits(perPage: Int): GetUnitsResponse suspend fun createShoppingListItem(request: CreateShoppingListItemRequest) + + suspend fun createShoppingList(request: CreateShoppingListRequest) + + suspend fun deleteShoppingList(id: String) + + suspend fun updateShoppingList(id: String, request: JsonElement) + + suspend fun getShoppingListJson(id: String) : JsonElement } \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/MealieDataSourceImpl.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/MealieDataSourceImpl.kt index 2bd6864..8f6149f 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/MealieDataSourceImpl.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/MealieDataSourceImpl.kt @@ -8,6 +8,7 @@ import gq.kirmanak.mealient.datasource.models.CreateApiTokenRequest import gq.kirmanak.mealient.datasource.models.CreateApiTokenResponse import gq.kirmanak.mealient.datasource.models.CreateRecipeRequest import gq.kirmanak.mealient.datasource.models.CreateShoppingListItemRequest +import gq.kirmanak.mealient.datasource.models.CreateShoppingListRequest import gq.kirmanak.mealient.datasource.models.ErrorDetail import gq.kirmanak.mealient.datasource.models.GetFoodsResponse import gq.kirmanak.mealient.datasource.models.GetRecipeResponse @@ -37,7 +38,7 @@ internal class MealieDataSourceImpl @Inject constructor( ) : MealieDataSource { override suspend fun createRecipe( - recipe: CreateRecipeRequest + recipe: CreateRecipeRequest, ): String = networkRequestWrapper.makeCallAndHandleUnauthorized( block = { service.createRecipe(recipe) }, logMethod = { "createRecipe" }, @@ -46,7 +47,7 @@ internal class MealieDataSourceImpl @Inject constructor( override suspend fun updateRecipe( slug: String, - recipe: UpdateRecipeRequest + recipe: UpdateRecipeRequest, ): GetRecipeResponse = networkRequestWrapper.makeCallAndHandleUnauthorized( block = { service.updateRecipe(recipe, slug) }, logMethod = { "updateRecipe" }, @@ -80,7 +81,7 @@ internal class MealieDataSourceImpl @Inject constructor( override suspend fun requestRecipes( page: Int, - perPage: Int + perPage: Int, ): List = networkRequestWrapper.makeCallAndHandleUnauthorized( block = { service.getRecipeSummary(page, perPage) }, logMethod = { "requestRecipes" }, @@ -88,7 +89,7 @@ internal class MealieDataSourceImpl @Inject constructor( ).items override suspend fun requestRecipeInfo( - slug: String + slug: String, ): GetRecipeResponse = networkRequestWrapper.makeCallAndHandleUnauthorized( block = { service.getRecipe(slug) }, logMethod = { "requestRecipeInfo" }, @@ -96,7 +97,7 @@ internal class MealieDataSourceImpl @Inject constructor( ) override suspend fun parseRecipeFromURL( - request: ParseRecipeURLRequest + request: ParseRecipeURLRequest, ): String = networkRequestWrapper.makeCallAndHandleUnauthorized( block = { service.createRecipeFromURL(request) }, logMethod = { "parseRecipeFromURL" }, @@ -104,7 +105,7 @@ internal class MealieDataSourceImpl @Inject constructor( ) override suspend fun createApiToken( - request: CreateApiTokenRequest + request: CreateApiTokenRequest, ): CreateApiTokenResponse = networkRequestWrapper.makeCallAndHandleUnauthorized( block = { service.createApiToken(request) }, logMethod = { "createApiToken" }, @@ -120,7 +121,7 @@ internal class MealieDataSourceImpl @Inject constructor( override suspend fun removeFavoriteRecipe( userId: String, - recipeSlug: String + recipeSlug: String, ): Unit = networkRequestWrapper.makeCallAndHandleUnauthorized( block = { service.removeFavoriteRecipe(userId, recipeSlug) }, logMethod = { "removeFavoriteRecipe" }, @@ -129,7 +130,7 @@ internal class MealieDataSourceImpl @Inject constructor( override suspend fun addFavoriteRecipe( userId: String, - recipeSlug: String + recipeSlug: String, ): Unit = networkRequestWrapper.makeCallAndHandleUnauthorized( block = { service.addFavoriteRecipe(userId, recipeSlug) }, logMethod = { "addFavoriteRecipe" }, @@ -137,7 +138,7 @@ internal class MealieDataSourceImpl @Inject constructor( ) override suspend fun deleteRecipe( - slug: String + slug: String, ): Unit = networkRequestWrapper.makeCallAndHandleUnauthorized( block = { service.deleteRecipe(slug) }, logMethod = { "deleteRecipe" }, @@ -154,7 +155,7 @@ internal class MealieDataSourceImpl @Inject constructor( ) override suspend fun getShoppingList( - id: String + id: String, ): GetShoppingListResponse = networkRequestWrapper.makeCallAndHandleUnauthorized( block = { service.getShoppingList(id) }, logMethod = { "getShoppingList" }, @@ -187,7 +188,7 @@ internal class MealieDataSourceImpl @Inject constructor( ) override suspend fun updateShoppingListItem( - item: GetShoppingListItemResponse + item: GetShoppingListItemResponse, ) { // Has to be done in two steps because we can't specify only the changed fields val remoteItem = getShoppingListItem(item.id) @@ -219,10 +220,55 @@ internal class MealieDataSourceImpl @Inject constructor( } override suspend fun addShoppingListItem( - request: CreateShoppingListItemRequest + request: CreateShoppingListItemRequest, ) = networkRequestWrapper.makeCallAndHandleUnauthorized( block = { service.createShoppingListItem(request) }, logMethod = { "addShoppingListItem" }, logParameters = { "request = $request" } ) + + override suspend fun addShoppingList( + request: CreateShoppingListRequest, + ) = networkRequestWrapper.makeCallAndHandleUnauthorized( + block = { service.createShoppingList(request) }, + logMethod = { "createShoppingList" }, + logParameters = { "request = $request" } + ) + + private suspend fun updateShoppingList( + id: String, + request: JsonElement, + ) = networkRequestWrapper.makeCallAndHandleUnauthorized( + block = { service.updateShoppingList(id, request) }, + logMethod = { "updateShoppingList" }, + logParameters = { "id = $id, request = $request" } + ) + + private suspend fun getShoppingListJson( + id: String, + ) = networkRequestWrapper.makeCallAndHandleUnauthorized( + block = { service.getShoppingListJson(id) }, + logMethod = { "getShoppingListJson" }, + logParameters = { "id = $id" } + ) + + override suspend fun deleteShoppingList( + id: String, + ) = networkRequestWrapper.makeCallAndHandleUnauthorized( + block = { service.deleteShoppingList(id) }, + logMethod = { "deleteShoppingList" }, + logParameters = { "id = $id" } + ) + + override suspend fun updateShoppingListName( + id: String, + name: String + ) { + // Has to be done in two steps because we can't specify only the changed fields + val remoteItem = getShoppingListJson(id) + val updatedItem = remoteItem.jsonObject.toMutableMap().apply { + put("name", JsonPrimitive(name)) + }.let(::JsonObject) + updateShoppingList(id, updatedItem) + } } diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/MealieServiceKtor.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/MealieServiceKtor.kt index 3af8013..32269ce 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/MealieServiceKtor.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/MealieServiceKtor.kt @@ -6,6 +6,7 @@ import gq.kirmanak.mealient.datasource.models.CreateApiTokenRequest import gq.kirmanak.mealient.datasource.models.CreateApiTokenResponse import gq.kirmanak.mealient.datasource.models.CreateRecipeRequest import gq.kirmanak.mealient.datasource.models.CreateShoppingListItemRequest +import gq.kirmanak.mealient.datasource.models.CreateShoppingListRequest import gq.kirmanak.mealient.datasource.models.GetFoodsResponse import gq.kirmanak.mealient.datasource.models.GetRecipeResponse import gq.kirmanak.mealient.datasource.models.GetRecipesResponse @@ -196,6 +197,34 @@ internal class MealieServiceKtor @Inject constructor( } } + override suspend fun createShoppingList(request: CreateShoppingListRequest) { + httpClient.post { + endpoint("/api/groups/shopping/lists") + contentType(ContentType.Application.Json) + setBody(request) + } + } + + override suspend fun deleteShoppingList(id: String) { + httpClient.delete { + endpoint("/api/groups/shopping/lists/$id") + } + } + + override suspend fun updateShoppingList(id: String, request: JsonElement) { + httpClient.put { + endpoint("/api/groups/shopping/lists/$id") + contentType(ContentType.Application.Json) + setBody(request) + } + } + + override suspend fun getShoppingListJson(id: String): JsonElement { + return httpClient.get { + endpoint("/api/groups/shopping/lists/$id") + }.body() + } + private suspend fun HttpRequestBuilder.endpoint( path: String, block: URLBuilder.() -> Unit = {}, diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/CreateShoppingListRequest.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/CreateShoppingListRequest.kt new file mode 100644 index 0000000..f114b98 --- /dev/null +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/CreateShoppingListRequest.kt @@ -0,0 +1,9 @@ +package gq.kirmanak.mealient.datasource.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CreateShoppingListRequest( + @SerialName("name") val name: String, +) 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 a11eb3e..87540c6 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,6 +1,7 @@ package gq.kirmanak.mealient.shopping_lists.network import gq.kirmanak.mealient.datasource.models.CreateShoppingListItemRequest +import gq.kirmanak.mealient.datasource.models.CreateShoppingListRequest import gq.kirmanak.mealient.datasource.models.GetFoodResponse import gq.kirmanak.mealient.datasource.models.GetShoppingListItemResponse import gq.kirmanak.mealient.datasource.models.GetShoppingListResponse @@ -22,4 +23,10 @@ interface ShoppingListsDataSource { suspend fun getUnits(): List suspend fun addShoppingListItem(item: CreateShoppingListItemRequest) + + suspend fun addShoppingList(request: CreateShoppingListRequest) + + suspend fun deleteShoppingList(id: String) + + suspend fun updateShoppingListName(id: String, name: String) } \ 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 693d7d9..5c914ef 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 @@ -2,6 +2,7 @@ package gq.kirmanak.mealient.shopping_lists.network import gq.kirmanak.mealient.datasource.MealieDataSource import gq.kirmanak.mealient.datasource.models.CreateShoppingListItemRequest +import gq.kirmanak.mealient.datasource.models.CreateShoppingListRequest import gq.kirmanak.mealient.datasource.models.GetFoodResponse import gq.kirmanak.mealient.datasource.models.GetShoppingListItemResponse import gq.kirmanak.mealient.datasource.models.GetShoppingListResponse @@ -27,7 +28,7 @@ class ShoppingListsDataSourceImpl @Inject constructor( ) = dataSource.deleteShoppingListItem(id) override suspend fun updateShoppingListItem( - item: GetShoppingListItemResponse + item: GetShoppingListItemResponse, ) = dataSource.updateShoppingListItem(item) override suspend fun getFoods(): List = dataSource.getFoods().items @@ -35,7 +36,18 @@ class ShoppingListsDataSourceImpl @Inject constructor( override suspend fun getUnits(): List = dataSource.getUnits().items override suspend fun addShoppingListItem( - item: CreateShoppingListItemRequest + item: CreateShoppingListItemRequest, ) = dataSource.addShoppingListItem(item) + + override suspend fun addShoppingList( + request: CreateShoppingListRequest, + ) = dataSource.addShoppingList(request) + + override suspend fun updateShoppingListName( + id: String, + name: String + ) = dataSource.updateShoppingListName(id, name) + + override suspend fun deleteShoppingList(id: String) = dataSource.deleteShoppingList(id) } 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 c82f03c..ae4f260 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,6 +1,7 @@ package gq.kirmanak.mealient.shopping_lists.repo import gq.kirmanak.mealient.datasource.models.CreateShoppingListItemRequest +import gq.kirmanak.mealient.datasource.models.CreateShoppingListRequest import gq.kirmanak.mealient.datasource.models.GetFoodResponse import gq.kirmanak.mealient.datasource.models.GetShoppingListItemResponse import gq.kirmanak.mealient.datasource.models.GetShoppingListResponse @@ -22,4 +23,10 @@ interface ShoppingListsRepo { suspend fun getUnits(): List suspend fun addShoppingListItem(item: CreateShoppingListItemRequest) + + suspend fun addShoppingList(request: CreateShoppingListRequest) + + suspend fun deleteShoppingList(id: String) + + suspend fun updateShoppingListName(id: String, name: String) } \ 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 dc4f23a..9565bf2 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,6 +1,7 @@ package gq.kirmanak.mealient.shopping_lists.repo import gq.kirmanak.mealient.datasource.models.CreateShoppingListItemRequest +import gq.kirmanak.mealient.datasource.models.CreateShoppingListRequest import gq.kirmanak.mealient.datasource.models.GetFoodResponse import gq.kirmanak.mealient.datasource.models.GetShoppingListItemResponse import gq.kirmanak.mealient.datasource.models.GetShoppingListResponse @@ -49,4 +50,20 @@ class ShoppingListsRepoImpl @Inject constructor( logger.v { "addShoppingListItem() called with: item = $item" } dataSource.addShoppingListItem(item) } + + override suspend fun addShoppingList(request: CreateShoppingListRequest) { + logger.v { "addShoppingList() called with: request = $request" } + dataSource.addShoppingList(request) + } + + override suspend fun updateShoppingListName(id: String, name: String) { + logger.v { "updateShoppingListName() called with: id = $id, name = $name" } + dataSource.updateShoppingListName(id, name) + + } + + override suspend fun deleteShoppingList(id: String) { + logger.v { "deleteShoppingList() called with: id = $id" } + dataSource.deleteShoppingList(id) + } } \ No newline at end of file diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/EditableItemBox.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/EditableItemBox.kt new file mode 100644 index 0000000..9c7072a --- /dev/null +++ b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/EditableItemBox.kt @@ -0,0 +1,105 @@ +package gq.kirmanak.mealient.shopping_lists.ui.composables + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.contentColorFor +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import gq.kirmanak.mealient.ui.Dimens + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun EditableItemBox( + onDelete: () -> Unit, + onEdit: () -> Unit, + deleteContentDescription: String, + editContentDescription: String, + content: @Composable (RowScope.() -> Unit), + modifier: Modifier = Modifier +) { + val dismissState = rememberSwipeToDismissBoxState() + + LaunchedEffect(dismissState.currentValue, onDelete, onEdit) { + when (dismissState.currentValue) { + SwipeToDismissBoxValue.EndToStart -> { + onDelete() + dismissState.reset() + } + + SwipeToDismissBoxValue.StartToEnd -> { + onEdit() + dismissState.reset() + } + + SwipeToDismissBoxValue.Settled -> Unit + } + } + + SwipeToDismissBox( + modifier = modifier, + state = dismissState, + content = content, + backgroundContent = { + if (dismissState.targetValue == SwipeToDismissBoxValue.EndToStart) { + EditableItemBoxBackground( + icon = Icons.Default.Delete, + backgroundColor = MaterialTheme.colorScheme.error, + iconAlignment = Alignment.CenterEnd, + contentDescription = deleteContentDescription + ) + } else if (dismissState.targetValue == SwipeToDismissBoxValue.StartToEnd) { + EditableItemBoxBackground( + icon = Icons.Default.Edit, + backgroundColor = MaterialTheme.colorScheme.primary, + iconAlignment = Alignment.CenterStart, + contentDescription = editContentDescription + ) + } + }, + ) +} + +@Composable +private fun EditableItemBoxBackground( + icon: ImageVector, + backgroundColor: Color, + iconAlignment: Alignment, + contentDescription: String, + modifier: Modifier = Modifier, + contentColor: Color = MaterialTheme.colorScheme.contentColorFor(backgroundColor) +) { + val color by animateColorAsState(backgroundColor, label = "background-color") + val iconColor by animateColorAsState(contentColor, label = "icon-color") + Box( + modifier = modifier + .fillMaxSize() + .background(color) + ) { + Icon( + modifier = Modifier + .align(iconAlignment) + .padding(horizontal = Dimens.Small), + imageVector = icon, + contentDescription = contentDescription, + tint = iconColor + ) + } +} \ No newline at end of file diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/MealientTextField.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/MealientTextField.kt new file mode 100644 index 0000000..84c9802 --- /dev/null +++ b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/MealientTextField.kt @@ -0,0 +1,64 @@ +package gq.kirmanak.mealient.shopping_lists.ui.composables + +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue + +/** + * Wraps Material [TextField] and sets initial selection ([TextRange]) to the end of the text. + */ +@Composable +internal fun MealientTextField( + value: String, + onValueChange: (newValue: String) -> Unit, + modifier: Modifier = Modifier, + keyboardActions: KeyboardActions = KeyboardActions.Default, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + singleLine: Boolean = true, + trailingIcon: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null +) { + var textFieldValueState by remember { + mutableStateOf(TextFieldValue(text = value, selection = TextRange(value.length))) + } + val textFieldValue = textFieldValueState.copy(text = value) + + SideEffect { + if (textFieldValue.selection != textFieldValueState.selection || + textFieldValue.composition != textFieldValueState.composition + ) { + textFieldValueState = textFieldValue + } + } + + var lastTextValue by remember(value) { mutableStateOf(value) } + + TextField( + modifier = modifier, + value = textFieldValueState, + onValueChange = { newTextFieldValueState -> + textFieldValueState = newTextFieldValueState + + val stringChangedSinceLastInvocation = lastTextValue != newTextFieldValueState.text + lastTextValue = newTextFieldValueState.text + + if (stringChangedSinceLastInvocation) { + onValueChange((newTextFieldValueState.text)) + } + }, + placeholder = placeholder, + singleLine = singleLine, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + trailingIcon = trailingIcon + ) +} \ No newline at end of file diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/details/ShoppingListScreen.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/details/ShoppingListScreen.kt index 55e6edb..b1230a5 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/details/ShoppingListScreen.kt +++ b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/details/ShoppingListScreen.kt @@ -1,23 +1,18 @@ package gq.kirmanak.mealient.shopping_lists.ui.details -import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -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.lazy.rememberLazyListState 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 @@ -31,15 +26,12 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.SwipeToDismissBox -import androidx.compose.material3.SwipeToDismissBoxState -import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text -import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -56,6 +48,7 @@ import gq.kirmanak.mealient.datasource.models.GetShoppingListItemRecipeReference import gq.kirmanak.mealient.datasource.models.GetShoppingListItemResponse import gq.kirmanak.mealient.datasource.models.GetUnitResponse import gq.kirmanak.mealient.shopping_list.R +import gq.kirmanak.mealient.shopping_lists.ui.composables.EditableItemBox import gq.kirmanak.mealient.shopping_lists.ui.composables.getErrorMessage import gq.kirmanak.mealient.ui.AppTheme import gq.kirmanak.mealient.ui.Dimens @@ -101,7 +94,6 @@ internal fun ShoppingListScreen( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ShoppingListScreen( loadingState: LoadingState, @@ -123,6 +115,12 @@ private fun ShoppingListScreen( loadingState.data?.name.orEmpty() ) + var lastAddedItemIndex by remember { mutableIntStateOf(-1) } + val lazyListState = rememberLazyListState() + LaunchedEffect(lastAddedItemIndex) { + if (lastAddedItemIndex >= 0) lazyListState.animateScrollToItem(lastAddedItemIndex) + } + LazyColumnWithLoadingState( modifier = modifier, loadingState = loadingState.map { it.items }, @@ -145,9 +143,12 @@ private fun ShoppingListScreen( contentDescription = stringResource(id = R.string.shopping_list_screen_add_icon_content_description), ) } - } + }, + lazyListState = lazyListState ) { items -> val firstCheckedItemIndex = items.indexOfFirst { it.checked } + lastAddedItemIndex = + items.indexOfLast { it is ShoppingListItemState.NewItem } itemsIndexed(items, { _, item -> item.id }) { index, itemState -> if (itemState is ShoppingListItemState.ExistingItem) { @@ -507,7 +508,6 @@ fun ShoppingListItemEditorNonFoodPreview() { } } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ShoppingListItem( itemState: ShoppingListItemState.ExistingItem, @@ -516,57 +516,13 @@ fun ShoppingListItem( onCheckedChange: (Boolean) -> Unit = {}, onDismissed: () -> Unit = {}, onEditStart: () -> Unit = {}, - dismissState: SwipeToDismissBoxState = rememberSwipeToDismissBoxState( - confirmValueChange = { - when (it) { - SwipeToDismissBoxValue.EndToStart -> onDismissed() - SwipeToDismissBoxValue.StartToEnd -> onEditStart() - SwipeToDismissBoxValue.Settled -> Unit - } - true - } - ), ) { - val shoppingListItem = itemState.item - SwipeToDismissBox( - state = dismissState, - backgroundContent = { - if (dismissState.targetValue == SwipeToDismissBoxValue.EndToStart) { - val color by animateColorAsState(MaterialTheme.colorScheme.error) - val iconColor by animateColorAsState(MaterialTheme.colorScheme.onError) - Box( - modifier = Modifier - .fillMaxSize() - .background(color) - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = stringResource(R.string.shopping_list_screen_delete_icon_content_description), - tint = iconColor, - modifier = Modifier - .align(Alignment.CenterEnd) - .padding(end = Dimens.Small) - ) - } - } else if (dismissState.targetValue == SwipeToDismissBoxValue.StartToEnd) { - 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) - ) - } - } - }, + EditableItemBox( + modifier = modifier, + onDelete = onDismissed, + onEdit = onEditStart, + deleteContentDescription = stringResource(R.string.shopping_list_screen_delete_icon_content_description), + editContentDescription = stringResource(R.string.shopping_list_screen_edit_icon_content_description), content = { Column( modifier = Modifier @@ -585,6 +541,7 @@ fun ShoppingListItem( onCheckedChange = onCheckedChange, ) + val shoppingListItem = itemState.item val isFood = shoppingListItem.isFood val quantity = shoppingListItem.quantity .takeUnless { it == 0.0 } @@ -601,11 +558,9 @@ fun ShoppingListItem( } } }, - modifier = modifier, ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable @ColorSchemePreview fun PreviewShoppingListItemChecked() { @@ -617,7 +572,6 @@ fun PreviewShoppingListItemChecked() { } } -@OptIn(ExperimentalMaterial3Api::class) @Composable @ColorSchemePreview fun PreviewShoppingListItemUnchecked() { @@ -629,36 +583,6 @@ fun PreviewShoppingListItemUnchecked() { } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -@ColorSchemePreview -fun PreviewShoppingListItemDismissed() { - AppTheme { - ShoppingListItem( - itemState = ShoppingListItemState.ExistingItem(PreviewData.blackTeaBags), - showDivider = false, - dismissState = rememberSwipeToDismissBoxState( - initialValue = SwipeToDismissBoxValue.EndToStart, - ), - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -@ColorSchemePreview -fun PreviewShoppingListItemEditing() { - AppTheme { - ShoppingListItem( - itemState = ShoppingListItemState.ExistingItem(PreviewData.blackTeaBags), - showDivider = false, - dismissState = rememberSwipeToDismissBoxState( - initialValue = SwipeToDismissBoxValue.StartToEnd, - ), - ) - } -} - private object PreviewData { val blackTeaBags = GetShoppingListItemResponse( diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/list/DeleteListConfirmDialog.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/list/DeleteListConfirmDialog.kt new file mode 100644 index 0000000..1ede374 --- /dev/null +++ b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/list/DeleteListConfirmDialog.kt @@ -0,0 +1,72 @@ +package gq.kirmanak.mealient.shopping_lists.ui.list + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import gq.kirmanak.mealient.shopping_list.R +import gq.kirmanak.mealient.ui.AppTheme + +@Composable +internal fun DeleteListConfirmDialog( + onEvent: (ShoppingListsEvent) -> Unit, + onConfirm: ShoppingListsEvent, + listName: String, +) { + AlertDialog( + onDismissRequest = { + onEvent(ShoppingListsEvent.DialogDismissed) + }, + dismissButton = { + IconButton( + onClick = { onEvent(ShoppingListsEvent.DialogDismissed) } + ) { + Icon( + imageVector = Icons.Default.Cancel, + contentDescription = stringResource(id = R.string.shopping_lists_dialog_cancel) + ) + } + }, + confirmButton = { + IconButton( + onClick = { onEvent(onConfirm) }, + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(id = R.string.shopping_lists_dialog_confirm) + ) + } + }, + title = { + Text( + text = stringResource( + id = R.string.shopping_lists_dialog_delete_confirm_title, + listName + ) + ) + }, + text = { + Text( + text = stringResource(id = R.string.shopping_lists_dialog_delete_confirm_text) + ) + } + ) +} + +@Preview +@Composable +private fun DeleteListConfirmDialogPreview() { + AppTheme { + DeleteListConfirmDialog( + onEvent = {}, + onConfirm = ShoppingListsEvent.DialogDismissed, + listName = "Test" + ) + } +} \ No newline at end of file diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/list/ShoppingListNameDialog.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/list/ShoppingListNameDialog.kt new file mode 100644 index 0000000..81a5cb4 --- /dev/null +++ b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/list/ShoppingListNameDialog.kt @@ -0,0 +1,225 @@ +package gq.kirmanak.mealient.shopping_lists.ui.list + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActionScope +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import gq.kirmanak.mealient.shopping_list.R +import gq.kirmanak.mealient.shopping_lists.ui.composables.MealientTextField +import gq.kirmanak.mealient.ui.AppTheme +import gq.kirmanak.mealient.ui.Dimens +import gq.kirmanak.mealient.ui.preview.ColorSchemePreview + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ShoppingListNameDialog( + onEvent: (ShoppingListsEvent) -> Unit, + listName: String, + onConfirm: ShoppingListsEvent, + modifier: Modifier = Modifier, + oldName: String? = null +) { + BasicAlertDialog( + modifier = modifier, + onDismissRequest = { + onEvent(ShoppingListsEvent.DialogDismissed) + } + ) { + ShoppingListNameDialogContent( + listName = listName, + onEvent = onEvent, + oldName = oldName, + onConfirm = onConfirm + ) + } +} + +@Composable +private fun ShoppingListNameDialogContent( + listName: String, + onEvent: (ShoppingListsEvent) -> Unit, + oldName: String?, + onConfirm: ShoppingListsEvent, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(Dimens.Small), + ) { + Column( + modifier = Modifier + .padding(Dimens.Medium), + verticalArrangement = Arrangement.spacedBy(Dimens.Medium) + ) { + Text( + text = if (oldName == null) { + stringResource(R.string.shopping_lists_dialog_add_new_title) + } else { + stringResource(id = R.string.shopping_lists_dialog_edit_name_title, oldName) + }, + style = MaterialTheme.typography.titleMedium + ) + + NameTextField( + listName = listName, + isEdit = oldName != null, + onEvent = onEvent + ) + + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Dimens.Small, Alignment.End) + ) { + IconButton( + onClick = { onEvent(ShoppingListsEvent.DialogDismissed) } + ) { + Icon( + imageVector = Icons.Default.Cancel, + contentDescription = stringResource(id = R.string.shopping_lists_dialog_cancel) + ) + } + + IconButton( + onClick = { + if (oldName == null) { + onEvent(onConfirm) + } else { + onEvent(onConfirm) + } + }, + enabled = listName.isNotBlank() + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(id = R.string.shopping_lists_dialog_confirm) + ) + } + } + } + } +} + +@Composable +private fun NameTextField( + listName: String, + isEdit: Boolean, + onEvent: (ShoppingListsEvent) -> Unit, + modifier: Modifier = Modifier, +) { + val onDone: KeyboardActionScope.() -> Unit = { + onEvent(ShoppingListsEvent.NewListSaved(listName)) + } + + val onEdit: (newValue: String) -> Unit = { newValue -> + if (isEdit) { + onEvent(ShoppingListsEvent.EditListInput(newValue)) + } else { + onEvent(ShoppingListsEvent.NewListInput(newValue)) + } + } + + val trailingIcon: @Composable () -> Unit = { + IconButton( + onClick = { onEdit("") } + ) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = stringResource(id = R.string.shopping_lists_dialog_name_clear_input) + ) + } + } + + val isInputEmpty = listName.isBlank() + + MealientTextField( + modifier = modifier + .showKeyboard() + .fillMaxWidth(), + value = listName, + onValueChange = { onEdit(it) }, + placeholder = { + Text( + text = stringResource(R.string.shopping_lists_screen_list_name_placeholder) + ) + }, + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = if (isInputEmpty) ImeAction.None else ImeAction.Done, + capitalization = KeyboardCapitalization.Sentences + ), + keyboardActions = KeyboardActions( + onDone = onDone.takeUnless { isInputEmpty }, + ), + trailingIcon = trailingIcon.takeUnless { isInputEmpty } + ) +} + +private fun Modifier.showKeyboard() = composed { + val windowInfo = LocalWindowInfo.current + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(windowInfo) { + snapshotFlow { windowInfo.isWindowFocused }.collect { isWindowFocused -> + if (isWindowFocused) { + focusRequester.requestFocus() + } + } + } + + focusRequester(focusRequester) +} + +@ColorSchemePreview +@Composable +private fun ShoppingListNameDialogEditPreview() { + AppTheme { + ShoppingListNameDialog( + listName = "Test", + onConfirm = ShoppingListsEvent.DialogDismissed, + oldName = "Old test", + onEvent = {} + ) + } +} + +@ColorSchemePreview +@Composable +private fun ShoppingListNameDialogNewPreview() { + AppTheme { + ShoppingListNameDialog( + listName = "Test", + onConfirm = ShoppingListsEvent.DialogDismissed, + onEvent = {}, + ) + } +} diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/list/ShoppingListsScreen.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/list/ShoppingListsScreen.kt index 9973cd5..4b3dde8 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/list/ShoppingListsScreen.kt +++ b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/list/ShoppingListsScreen.kt @@ -1,14 +1,17 @@ package gq.kirmanak.mealient.shopping_lists.ui.list import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ShoppingCart import androidx.compose.material3.Card +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -21,8 +24,8 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.navigate -import gq.kirmanak.mealient.datasource.models.GetShoppingListsSummaryResponse import gq.kirmanak.mealient.shopping_list.R +import gq.kirmanak.mealient.shopping_lists.ui.composables.EditableItemBox import gq.kirmanak.mealient.shopping_lists.ui.composables.getErrorMessage import gq.kirmanak.mealient.shopping_lists.ui.destinations.ShoppingListScreenDestination import gq.kirmanak.mealient.ui.AppTheme @@ -35,33 +38,57 @@ import gq.kirmanak.mealient.ui.util.error @Destination @Composable -fun ShoppingListsScreen( +internal fun ShoppingListsScreen( navController: NavController, baseScreenState: BaseScreenState, shoppingListsViewModel: ShoppingListsViewModel = hiltViewModel(), ) { - val loadingState by shoppingListsViewModel.loadingState.collectAsState() - val errorToShowInSnackbar = shoppingListsViewModel.errorToShowInSnackBar + val screenState by shoppingListsViewModel.shoppingListsState.collectAsState() + + ShoppingListsScreenDialog( + dialog = screenState.dialog, + onEvent = shoppingListsViewModel::onEvent + ) BaseScreenWithNavigation( baseScreenState = baseScreenState, ) { modifier -> LazyColumnWithLoadingState( modifier = modifier, - loadingState = loadingState, - emptyListError = loadingState.error?.let { getErrorMessage(it) } + loadingState = screenState.loadingState, + emptyListError = screenState.loadingState.error?.let { getErrorMessage(it) } ?: stringResource(R.string.shopping_lists_screen_empty), retryButtonText = stringResource(id = R.string.shopping_lists_screen_empty_button_refresh), - snackbarText = errorToShowInSnackbar?.let { getErrorMessage(error = it) }, - onSnackbarShown = shoppingListsViewModel::onSnackbarShown, - onRefresh = shoppingListsViewModel::refresh + snackbarText = screenState.errorToShow?.let { getErrorMessage(error = it) }, + onSnackbarShown = { shoppingListsViewModel.onEvent(ShoppingListsEvent.SnackbarShown) }, + onRefresh = { shoppingListsViewModel.onEvent(ShoppingListsEvent.RefreshRequested) }, + floatingActionButton = { + FloatingActionButton( + onClick = { shoppingListsViewModel.onEvent(ShoppingListsEvent.AddShoppingList) } + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(id = R.string.shopping_lists_screen_add_icon_content_description), + ) + } + }, ) { items -> - items(items) { shoppingList -> + items( + items = items, + key = { it.id }, + contentType = { "Existing list" } + ) { displayList -> ShoppingListCard( - shoppingList = shoppingList, - onItemClick = { clickedEntity -> - val shoppingListId = clickedEntity.id + listName = displayList.name, + onClick = { + val shoppingListId = displayList.id navController.navigate(ShoppingListScreenDestination(shoppingListId)) + }, + onDelete = { + shoppingListsViewModel.onEvent(ShoppingListsEvent.RemoveList(displayList)) + }, + onEdit = { + shoppingListsViewModel.onEvent(ShoppingListsEvent.EditList(displayList)) } ) } @@ -70,41 +97,122 @@ fun ShoppingListsScreen( } @Composable -@ColorSchemePreview -private fun PreviewShoppingListCard() { - AppTheme { - ShoppingListCard( - shoppingList = GetShoppingListsSummaryResponse("1", "Weekend shopping"), - ) +private fun ShoppingListsScreenDialog( + dialog: ShoppingListsDialog, + onEvent: (ShoppingListsEvent) -> Unit, +) { + when (dialog) { + is ShoppingListsDialog.EditListItem -> { + ShoppingListNameDialog( + onEvent = onEvent, + onConfirm = dialog.onConfirm, + listName = dialog.listName, + oldName = dialog.oldListName + ) + + } + + is ShoppingListsDialog.NewListItem -> { + ShoppingListNameDialog( + onEvent = onEvent, + onConfirm = ShoppingListsEvent.NewListSaved(dialog.listName), + listName = dialog.listName + ) + } + + + is ShoppingListsDialog.RemoveListItem -> { + DeleteListConfirmDialog( + onEvent = onEvent, + onConfirm = dialog.onConfirm, + listName = dialog.listName + ) + } + + is ShoppingListsDialog.None -> { + Unit + } } } @Composable private fun ShoppingListCard( - shoppingList: GetShoppingListsSummaryResponse?, + listName: String, + onClick: () -> Unit, + onDelete: () -> Unit, + onEdit: () -> Unit, modifier: Modifier = Modifier, - onItemClick: (GetShoppingListsSummaryResponse) -> Unit = {}, ) { - Card( - modifier = modifier - .padding(horizontal = Dimens.Medium, vertical = Dimens.Small) - .fillMaxWidth() - .clickable { shoppingList?.let { onItemClick(it) } }, - ) { - Row( - modifier = Modifier.padding(Dimens.Medium), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Default.ShoppingCart, - contentDescription = stringResource(id = R.string.shopping_lists_screen_cart_icon), - modifier = Modifier.height(Dimens.Large), - ) - Text( - text = shoppingList?.name.orEmpty(), - modifier = Modifier.padding(start = Dimens.Medium), - ) - } + EditableItemBox( + modifier = modifier, + onDelete = onDelete, + onEdit = onEdit, + deleteContentDescription = stringResource( + id = R.string.shopping_list_screen_delete_icon_content_description, + listName + ), + editContentDescription = stringResource( + id = R.string.shopping_list_screen_edit_icon_content_description, + listName + ), + content = { + Card( + modifier = Modifier + .padding( + horizontal = Dimens.Medium, + vertical = Dimens.Small + ) + .fillMaxWidth() + .clickable( + onClick = onClick + ) + ) { + Row( + modifier = Modifier + .padding(Dimens.Medium), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Dimens.Medium) + ) { + Icon( + modifier = Modifier + .height(Dimens.Large), + imageVector = Icons.Default.ShoppingCart, + contentDescription = stringResource(id = R.string.shopping_lists_screen_cart_icon), + ) + + + Text( + text = listName, + ) + } + } + }, + ) +} + +@Composable +@ColorSchemePreview +private fun PreviewShoppingListCard() { + AppTheme { + ShoppingListCard( + listName = "Weekend shopping", + onClick = {}, + onDelete = {}, + onEdit = {} + ) + } +} + +@Composable +@ColorSchemePreview +private fun PreviewEditingShoppingListCard() { + AppTheme { + ShoppingListCard( + listName = "Weekend shopping", + onClick = {}, + onDelete = {}, + onEdit = {} + ) } } diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/list/ShoppingListsViewModel.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/list/ShoppingListsViewModel.kt index 767933e..0f6cdf8 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/list/ShoppingListsViewModel.kt +++ b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/list/ShoppingListsViewModel.kt @@ -1,12 +1,10 @@ package gq.kirmanak.mealient.shopping_lists.ui.list -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue 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.CreateShoppingListRequest import gq.kirmanak.mealient.datasource.models.GetShoppingListsSummaryResponse import gq.kirmanak.mealient.datasource.runCatchingExceptCancel import gq.kirmanak.mealient.logging.Logger @@ -15,12 +13,19 @@ import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsRepo import gq.kirmanak.mealient.ui.util.LoadingHelper import gq.kirmanak.mealient.ui.util.LoadingHelperFactory import gq.kirmanak.mealient.ui.util.LoadingState +import gq.kirmanak.mealient.ui.util.LoadingStateNoData +import gq.kirmanak.mealient.ui.util.map +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class ShoppingListsViewModel @Inject constructor( +internal class ShoppingListsViewModel @Inject constructor( private val logger: Logger, private val shoppingListsRepo: ShoppingListsRepo, private val authRepo: ShoppingListsAuthRepo, @@ -32,15 +37,13 @@ class ShoppingListsViewModel @Inject constructor( runCatchingExceptCancel { shoppingListsRepo.getShoppingLists() } } - val loadingState: StateFlow>> = - loadingHelper.loadingState - - private var _errorToShowInSnackbar by mutableStateOf(null) - val errorToShowInSnackBar: Throwable? get() = _errorToShowInSnackbar + private var _shoppingListsState = MutableStateFlow(ShoppingListsState()) + val shoppingListsState: StateFlow get() = _shoppingListsState.asStateFlow() init { refresh() listenToAuthState() + observeScreenState() } private fun listenToAuthState() { @@ -53,15 +56,238 @@ class ShoppingListsViewModel @Inject constructor( } } - fun refresh() { - logger.v { "refresh() called" } - viewModelScope.launch { - _errorToShowInSnackbar = loadingHelper.refresh().exceptionOrNull() + private fun observeScreenState() { + logger.v { "observeScreenState() called" } + + loadingHelper.loadingState.onEach { loadingState -> + logger.d { "screenStateUpdate: loadingState: $loadingState" } + val existingLists: LoadingState> = loadingState.map { lists -> + lists.map { DisplayList(it) } + } + _shoppingListsState.update { it.copy(loadingState = existingLists) } + }.launchIn(viewModelScope) + } + + fun onEvent(event: ShoppingListsEvent) { + logger.v { "onEvent($event) called" } + when (event) { + is ShoppingListsEvent.AddShoppingList -> { + onAddShoppingListClicked() + } + + is ShoppingListsEvent.NewListInput -> { + onNewListInput(event) + } + + is ShoppingListsEvent.SnackbarShown -> { + onSnackbarShown() + } + + is ShoppingListsEvent.RefreshRequested -> { + refresh() + } + + is ShoppingListsEvent.NewListSaved -> { + onNewListSaved(event) + } + + is ShoppingListsEvent.DialogDismissed -> { + onDialogDismissed() + } + + is ShoppingListsEvent.EditList -> { + onEditList(event) + } + + is ShoppingListsEvent.RemoveList -> { + onRemoveList(event) + } + + is ShoppingListsEvent.RemoveListConfirmed -> { + onRemoveListConfirmed(event) + } + + is ShoppingListsEvent.EditListConfirmed -> { + onEditListConfirmed(event) + } + + is ShoppingListsEvent.EditListInput -> { + onEditListInput(event) + } } } - fun onSnackbarShown() { + private fun onEditListConfirmed(event: ShoppingListsEvent.EditListConfirmed) { + viewModelScope.launch { + runCatchingExceptCancel { + shoppingListsRepo.updateShoppingListName( + id = event.displayList.id, + name = event.listName + ) + }.onFailure { exception -> + logger.e(exception) { "Error while updating shopping list" } + _shoppingListsState.update { it.copy(errorToShow = exception) } + }.onSuccess { + refresh() + onDialogDismissed() + } + } + } + + private fun onRemoveListConfirmed(event: ShoppingListsEvent.RemoveListConfirmed) { + viewModelScope.launch { + runCatchingExceptCancel { + shoppingListsRepo.deleteShoppingList(event.displayList.id) + }.onFailure { exception -> + logger.e(exception) { "Error while deleting shopping list" } + _shoppingListsState.update { it.copy(errorToShow = exception) } + }.onSuccess { + refresh() + onDialogDismissed() + } + } + } + + private fun onEditListInput(event: ShoppingListsEvent.EditListInput) { + _shoppingListsState.update { + val old = it.dialog as? ShoppingListsDialog.EditListItem ?: return@update it + val onConfirm = old.onConfirm.copy(listName = event.newValue) + it.copy(dialog = old.copy(listName = event.newValue, onConfirm = onConfirm)) + } + } + + private fun onRemoveList(event: ShoppingListsEvent.RemoveList) { + val onConfirm = ShoppingListsEvent.RemoveListConfirmed( + displayList = event.list + ) + _shoppingListsState.update { + it.copy(dialog = ShoppingListsDialog.RemoveListItem(event.list.name, onConfirm)) + } + } + + private fun onEditList(event: ShoppingListsEvent.EditList) { + val name = event.list.name + val onConfirm = ShoppingListsEvent.EditListConfirmed( + listName = name, + displayList = event.list + ) + _shoppingListsState.update { + it.copy(dialog = ShoppingListsDialog.EditListItem(name, name, onConfirm)) + } + } + + private fun onDialogDismissed() { + _shoppingListsState.update { + it.copy(dialog = ShoppingListsDialog.None) + } + } + + private fun onNewListSaved(event: ShoppingListsEvent.NewListSaved) { + logger.v { "onNewListSaved($event) called" } + val request = CreateShoppingListRequest(event.name) + viewModelScope.launch { + runCatchingExceptCancel { + shoppingListsRepo.addShoppingList(request) + }.onFailure { exception -> + logger.e(exception) { "Error while creating shopping list" } + _shoppingListsState.update { it.copy(errorToShow = exception) } + }.onSuccess { + logger.d { "Shopping list \"${request.name}\" created" } + refresh() + onDialogDismissed() + } + } + } + + private fun refresh() { + logger.v { "refresh() called" } + viewModelScope.launch { + val errorToShow = loadingHelper.refresh().exceptionOrNull() + _shoppingListsState.update { + it.copy(errorToShow = errorToShow) + } + } + } + + private fun onSnackbarShown() { logger.v { "onSnackbarShown() called" } - _errorToShowInSnackbar = null + _shoppingListsState.update { + it.copy(errorToShow = null) + } + } + + private fun onNewListInput(event: ShoppingListsEvent.NewListInput) { + logger.v { "onNewListNameChanged($event) called" } + val filteredName = event.newName.replace(System.lineSeparator(), "") + _shoppingListsState.update { + it.copy(dialog = ShoppingListsDialog.NewListItem(filteredName)) + } + } + + private fun onAddShoppingListClicked() { + logger.v { "onAddShoppingListClicked() called" } + _shoppingListsState.update { + it.copy(dialog = ShoppingListsDialog.NewListItem("")) + } } } + +internal data class DisplayList( + private val list: GetShoppingListsSummaryResponse, + val name: String = list.name.orEmpty(), + val id: String = list.id +) + +internal data class ShoppingListsState( + val errorToShow: Throwable? = null, + val loadingState: LoadingState> = LoadingStateNoData.InitialLoad, + val dialog: ShoppingListsDialog = ShoppingListsDialog.None +) + +internal sealed interface ShoppingListsEvent { + + data object AddShoppingList : ShoppingListsEvent + + data class NewListInput(val newName: String) : ShoppingListsEvent + + data object SnackbarShown : ShoppingListsEvent + + data object RefreshRequested : ShoppingListsEvent + + data class NewListSaved(val name: String) : ShoppingListsEvent + + data object DialogDismissed : ShoppingListsEvent + + data class EditList(val list: DisplayList) : ShoppingListsEvent + + data class RemoveList(val list: DisplayList) : ShoppingListsEvent + + data class EditListInput(val newValue: String) : ShoppingListsEvent + + data class RemoveListConfirmed( + val displayList: DisplayList, + ) : ShoppingListsEvent + + data class EditListConfirmed( + val listName: String, + val displayList: DisplayList, + ) : ShoppingListsEvent +} + +internal sealed interface ShoppingListsDialog { + + data class NewListItem(val listName: String) : ShoppingListsDialog + + data class EditListItem( + val listName: String, + val oldListName: String, + val onConfirm: ShoppingListsEvent.EditListConfirmed + ) : ShoppingListsDialog + + data class RemoveListItem( + val listName: String, + val onConfirm: ShoppingListsEvent.RemoveListConfirmed + ) : ShoppingListsDialog + + data object None : ShoppingListsDialog +} \ No newline at end of file diff --git a/features/shopping_lists/src/main/res/values/strings.xml b/features/shopping_lists/src/main/res/values/strings.xml index 21608ee..c816921 100644 --- a/features/shopping_lists/src/main/res/values/strings.xml +++ b/features/shopping_lists/src/main/res/values/strings.xml @@ -22,4 +22,18 @@ No server connection Unknown error Try again + Add list + List name + Done + Delete %1$s + Rename %1$s + + Add new shopping list + Edit %1$s + Confirm + Cancel + Clear input + Removing %1$s + Confirm that you want to remove the shopping list including all items. + \ No newline at end of file diff --git a/ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/LazyColumnPullRefresh.kt b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/LazyColumnPullRefresh.kt index 8118e93..eeedabc 100644 --- a/ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/LazyColumnPullRefresh.kt +++ b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/LazyColumnPullRefresh.kt @@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.PullRefreshState @@ -22,6 +24,7 @@ fun LazyColumnPullRefresh( verticalArrangement: Arrangement.Vertical, lazyColumnContent: LazyListScope.() -> Unit, modifier: Modifier = Modifier, + lazyListState: LazyListState = rememberLazyListState(), ) { Box( modifier = modifier.pullRefresh(refreshState), @@ -29,6 +32,7 @@ fun LazyColumnPullRefresh( LazyColumn( contentPadding = contentPadding, verticalArrangement = verticalArrangement, + state = lazyListState, content = lazyColumnContent ) diff --git a/ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/LazyColumnWithLoadingState.kt b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/LazyColumnWithLoadingState.kt index 6f72117..4e67ceb 100644 --- a/ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/LazyColumnWithLoadingState.kt +++ b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/LazyColumnWithLoadingState.kt @@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material3.FabPosition @@ -35,6 +37,7 @@ fun LazyColumnWithLoadingState( onRefresh: () -> Unit = {}, floatingActionButton: @Composable () -> Unit = {}, floatingActionButtonPosition: FabPosition = FabPosition.End, + lazyListState: LazyListState = rememberLazyListState(), lazyColumnContent: LazyListScope.(List) -> Unit = {}, ) { val refreshState = rememberPullRefreshState( @@ -76,6 +79,7 @@ fun LazyColumnWithLoadingState( contentPadding = contentPadding, verticalArrangement = verticalArrangement, lazyColumnContent = { lazyColumnContent(list) }, + lazyListState = lazyListState, modifier = innerModifier, )