Implement the dialog to add a new shopping list (#280)

* Add endpoint to create new shopping lists

* Initialize editing of lists names

* Implement adding new lists

* Fix invalid password for demo

* Use StateFlow to avoid lost state updates

* Refactor the list update to support empty lists

* Hide add new list button if there's a new list

* Scroll to the newly added list or item

* Replace deprecated Divider

* Move new field name input to dialog

* Display a modal dialog instead of bottom sheet

* Reduce unnecessary recompositions

* Do not hide button since it is overlapped by dialog

* Extract Composable for editable items

* Remove unused imports

* Add UI for removing and editing shopping lists

* Implement editing list name and removing lists

* Fix initial cursor state when editing name

* Add capitalization of list names

* Fix color of divider in dark mode
This commit is contained in:
Kirill Kamakin
2024-07-27 20:12:00 +02:00
committed by GitHub
parent f0098af3f6
commit 86e70e03d0
22 changed files with 1066 additions and 171 deletions

View File

@@ -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(

View File

@@ -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,

View File

@@ -45,7 +45,7 @@
<string name="fragment_add_recipe_clear_button">Clear</string>
<string name="fragment_base_url_url_input_helper_text">Example: demo.mealie.io</string>
<string name="fragment_authentication_email_input_helper_text">Example: changeme@example.com</string>
<string name="fragment_authentication_password_input_helper_text">Example: demo</string>
<string name="fragment_authentication_password_input_helper_text">Example: MyPassword</string>
<string name="fragment_recipes_last_page_loaded_toast">Last page loaded</string>
<string name="fragment_recipes_load_failure_toast" comment="EXAMPLE: Load error: unauthorized.">Load error: %1$s.</string>
<string name="fragment_recipes_load_failure_toast_no_reason">Load failed.</string>

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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<GetRecipeSummaryResponse> = 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)
}
}

View File

@@ -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 = {},

View File

@@ -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,
)

View File

@@ -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<GetUnitResponse>
suspend fun addShoppingListItem(item: CreateShoppingListItemRequest)
suspend fun addShoppingList(request: CreateShoppingListRequest)
suspend fun deleteShoppingList(id: String)
suspend fun updateShoppingListName(id: String, name: String)
}

View File

@@ -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<GetFoodResponse> = dataSource.getFoods().items
@@ -35,7 +36,18 @@ class ShoppingListsDataSourceImpl @Inject constructor(
override suspend fun getUnits(): List<GetUnitResponse> = 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)
}

View File

@@ -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<GetUnitResponse>
suspend fun addShoppingListItem(item: CreateShoppingListItemRequest)
suspend fun addShoppingList(request: CreateShoppingListRequest)
suspend fun deleteShoppingList(id: String)
suspend fun updateShoppingListName(id: String, name: String)
}

View File

@@ -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)
}
}

View File

@@ -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
)
}
}

View File

@@ -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
)
}

View File

@@ -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<ShoppingListScreenState>,
@@ -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(

View File

@@ -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"
)
}
}

View File

@@ -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 = {},
)
}
}

View File

@@ -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 = {}
)
}
}

View File

@@ -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<LoadingState<List<GetShoppingListsSummaryResponse>>> =
loadingHelper.loadingState
private var _errorToShowInSnackbar by mutableStateOf<Throwable?>(null)
val errorToShowInSnackBar: Throwable? get() = _errorToShowInSnackbar
private var _shoppingListsState = MutableStateFlow(ShoppingListsState())
val shoppingListsState: StateFlow<ShoppingListsState> 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<List<DisplayList>> = 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<List<DisplayList>> = 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
}

View File

@@ -22,4 +22,18 @@
<string name="shopping_lists_screen_no_connection">No server connection</string>
<string name="shopping_lists_screen_unknown_error">Unknown error</string>
<string name="shopping_lists_screen_empty_button_refresh">Try again</string>
<string name="shopping_lists_screen_add_icon_content_description">Add list</string>
<string name="shopping_lists_screen_list_name_placeholder">List name</string>
<string name="shopping_lists_screen_list_name_done_content_description">Done</string>
<string name="shopping_lists_dialog_delete_icon_content_description">Delete %1$s</string>
<string name="shopping_lists_dialog_edit_icon_content_description">Rename %1$s</string>
<string name="shopping_lists_dialog_add_new_title">Add new shopping list</string>
<string name="shopping_lists_dialog_edit_name_title">Edit %1$s</string>
<string name="shopping_lists_dialog_confirm">Confirm</string>
<string name="shopping_lists_dialog_cancel">Cancel</string>
<string name="shopping_lists_dialog_name_clear_input">Clear input</string>
<string name="shopping_lists_dialog_delete_confirm_title">Removing %1$s</string>
<string name="shopping_lists_dialog_delete_confirm_text">Confirm that you want to remove the shopping list including all items.</string>
</resources>

View File

@@ -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
)

View File

@@ -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 <T> LazyColumnWithLoadingState(
onRefresh: () -> Unit = {},
floatingActionButton: @Composable () -> Unit = {},
floatingActionButtonPosition: FabPosition = FabPosition.End,
lazyListState: LazyListState = rememberLazyListState(),
lazyColumnContent: LazyListScope.(List<T>) -> Unit = {},
) {
val refreshState = rememberPullRefreshState(
@@ -76,6 +79,7 @@ fun <T> LazyColumnWithLoadingState(
contentPadding = contentPadding,
verticalArrangement = verticalArrangement,
lazyColumnContent = { lazyColumnContent(list) },
lazyListState = lazyListState,
modifier = innerModifier,
)