From 09c2a0b2ad023aab7b84528781f5100c77ebfc25 Mon Sep 17 00:00:00 2001 From: miah <144297490+miah120@users.noreply.github.com> Date: Wed, 6 May 2026 10:31:33 -0500 Subject: [PATCH] feat: Shopping list / Swipe to check off (#7118) Co-authored-by: Michael Genson Co-authored-by: Copilot Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com> --- .../Domain/ShoppingList/ShoppingListItem.vue | 330 ++++++++++-------- .../Layout/LayoutParts/TheSnackbar.vue | 9 +- frontend/app/composables/use-toast.ts | 25 +- frontend/app/lang/messages/en-US.json | 4 +- frontend/app/pages/shopping-lists/[id].vue | 26 +- 5 files changed, 232 insertions(+), 162 deletions(-) diff --git a/frontend/app/components/Domain/ShoppingList/ShoppingListItem.vue b/frontend/app/components/Domain/ShoppingList/ShoppingListItem.vue index 2664e07eb..b197c6b65 100644 --- a/frontend/app/components/Domain/ShoppingList/ShoppingListItem.vue +++ b/frontend/app/components/Domain/ShoppingList/ShoppingListItem.vue @@ -1,155 +1,178 @@ @@ -157,10 +180,10 @@ import { useOnline } from "@vueuse/core"; import RecipeIngredientListItem from "../Recipe/RecipeIngredientListItem.vue"; import ShoppingListItemEditor from "./ShoppingListItemEditor.vue"; +import RecipeList from "~/components/Domain/Recipe/RecipeList.vue"; import type { ShoppingListItemOut } from "~/lib/api/types/household"; import type { MultiPurposeLabelOut } from "~/lib/api/types/labels"; -import type { IngredientFood, IngredientUnit, RecipeSummary } from "~/lib/api/types/recipe"; -import RecipeList from "~/components/Domain/Recipe/RecipeList.vue"; +import type { IngredientUnit, IngredientFood, RecipeSummary } from "~/lib/api/types/recipe"; const model = defineModel({ type: Object as () => ShoppingListItemOut, required: true }); @@ -188,6 +211,9 @@ const emit = defineEmits<{ (e: "delete"): void; }>(); +const SWIPE_THRESHOLD = 50; + +const { isRtl } = useRtl(); const i18n = useI18n(); const displayRecipeRefs = ref(false); const itemLabelCols = computed(() => (model.value?.checked ? "auto" : "6")); @@ -238,6 +264,16 @@ function save() { edit.value = false; } +const swipeInfo: Ref<{ touchstartX?: number; touchendX?: number }> = ref({ touchstartX: undefined, touchendX: undefined }); +const swiping = computed(() => { + const { touchstartX, touchendX } = swipeInfo.value ?? {}; + if (touchstartX === undefined || touchendX === undefined) { + return 0; + } + const delta = isRtl.value ? touchstartX - touchendX : touchendX - touchstartX; + return Math.min(Math.max(0, delta), 100); +}); + const recipeList = computed(() => { const ret: RecipeSummary[] = []; if (!listItem.value.recipeReferences) return ret; diff --git a/frontend/app/components/Layout/LayoutParts/TheSnackbar.vue b/frontend/app/components/Layout/LayoutParts/TheSnackbar.vue index 8cc1bac41..0c17f4fb4 100644 --- a/frontend/app/components/Layout/LayoutParts/TheSnackbar.vue +++ b/frontend/app/components/Layout/LayoutParts/TheSnackbar.vue @@ -4,7 +4,7 @@ v-model="toastAlert.open" location="top" :color="toastAlert.color" - timeout="2000" + :timeout="toastAlert.timeout ?? 2000" > - {{ $t('general.close') }} + {{ toastAlert.action?.message ?? $t('general.close') }} diff --git a/frontend/app/composables/use-toast.ts b/frontend/app/composables/use-toast.ts index efd79cc41..68dd9503a 100644 --- a/frontend/app/composables/use-toast.ts +++ b/frontend/app/composables/use-toast.ts @@ -3,6 +3,11 @@ interface Toast { text: string; title: string | null; color: string; + timeout?: number; + action?: { + onClick: VoidFunction; + message?: string; + }; } export const toastAlert = reactive({ @@ -19,11 +24,13 @@ export const toastLoading = reactive({ color: "success", }); -function setToast(toast: Toast, text: string, title: string | null, color: string) { +function setToast(toast: Toast, text: string, title: string | null, color: string, options?: Partial) { toast.open = true; toast.text = text; toast.title = title; toast.color = color; + toast.timeout = options?.timeout; + toast.action = options?.action; } export const loader = { @@ -45,17 +52,17 @@ export const loader = { }; export const alert = { - info(text: string, title: string | null = null) { - setToast(toastAlert, text, title, "info"); + info(text: string, title: string | null = null, options?: Partial) { + setToast(toastAlert, text, title, "info", options); }, - success(text: string, title: string | null = null) { - setToast(toastAlert, text, title, "success"); + success(text: string, title: string | null = null, options?: Partial) { + setToast(toastAlert, text, title, "success", options); }, - error(text: string, title: string | null = null) { - setToast(toastAlert, text, title, "error"); + error(text: string, title: string | null = null, options?: Partial) { + setToast(toastAlert, text, title, "error", options); }, - warning(text: string, title: string | null = null) { - setToast(toastAlert, text, title, "warning"); + warning(text: string, title: string | null = null, options?: Partial) { + setToast(toastAlert, text, title, "warning", options); }, close() { toastAlert.open = false; diff --git a/frontend/app/lang/messages/en-US.json b/frontend/app/lang/messages/en-US.json index c57abd8c4..01ed8f24c 100644 --- a/frontend/app/lang/messages/en-US.json +++ b/frontend/app/lang/messages/en-US.json @@ -169,6 +169,7 @@ "token": "Token", "tuesday": "Tuesday", "type": "Type", + "undo": "Undo", "update": "Update", "updated": "Updated", "upload": "Upload", @@ -941,7 +942,8 @@ "are-you-sure-you-want-to-check-all-items": "Are you sure you want to check all items?", "are-you-sure-you-want-to-uncheck-all-items": "Are you sure you want to uncheck all items?", "are-you-sure-you-want-to-delete-checked-items": "Are you sure you want to delete all checked items?", - "no-shopping-lists-found": "No Shopping Lists Found" + "no-shopping-lists-found": "No Shopping Lists Found", + "item-checked-off": "{item} was checked off" }, "sidebar": { "all-recipes": "All Recipes", diff --git a/frontend/app/pages/shopping-lists/[id].vue b/frontend/app/pages/shopping-lists/[id].vue index a7928206b..7138c7378 100644 --- a/frontend/app/pages/shopping-lists/[id].vue +++ b/frontend/app/pages/shopping-lists/[id].vue @@ -223,7 +223,10 @@ :units="allUnits || []" :foods="allFoods || []" :recipes="recipeMap" - @checked="saveListItem" + @checked="(item) => { + saveListItem(item); + itemCheckedToast(item); + }" @save="saveListItem" @delete="deleteListItem(item)" /> @@ -354,7 +357,9 @@ import ShoppingListAddItemForm from "~/components/Domain/ShoppingList/ShoppingLi import ShoppingListItem from "~/components/Domain/ShoppingList/ShoppingListItem.vue"; import ShoppingListItemEditor from "~/components/Domain/ShoppingList/ShoppingListItemEditor.vue"; import { useShoppingListPage } from "~/composables/shopping-list-page/use-shopping-list-page"; -import { useFoodStore, useLabelStore, useUnitStore } from "~/composables/store"; +import { useLabelStore, useUnitStore, useFoodStore } from "~/composables/store"; +import { alert } from "~/composables/use-toast"; +import type { ShoppingListItemOut } from "~/lib/api/types/household"; const { mdAndUp } = useDisplay(); const i18n = useI18n(); @@ -371,6 +376,23 @@ const { store: allLabels } = useLabelStore(); const { store: allUnits } = useUnitStore(); const { store: allFoods } = useFoodStore(); +function itemCheckedToast(item: ShoppingListItemOut) { + alert.info( + i18n.t("shopping-list.item-checked-off", { item: item.food?.name || item.note || i18n.t("recipe.ingredient") }), + undefined, + { + timeout: 4000, + action: { + message: i18n.t("general.undo"), + onClick: () => { + item.checked = false; + shoppingListPage.saveListItem(item); + }, + }, + }, + ); +} + const { shoppingList, state,