feat: Shopping list / Swipe to check off (#7118)

Co-authored-by: Michael Genson <genson.michael@gmail.com>
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
This commit is contained in:
miah
2026-05-06 10:31:33 -05:00
committed by GitHub
parent f2b087730e
commit 09c2a0b2ad
5 changed files with 232 additions and 162 deletions

View File

@@ -1,9 +1,31 @@
<template> <template>
<div style="overflow-x: hidden;">
<v-container <v-container
v-if="!edit" v-if="!edit"
class="pa-0" class="pa-0"
:style="{
transform: `translateX(${isRtl ? -swiping : swiping}px)`,
transition: swiping === 0 ? 'transform 0.2s ease' : 'none',
opacity: swiping >= SWIPE_THRESHOLD ? 0.5 : 1,
}"
> >
<v-row <v-row
v-touch="{
move: ({ originalEvent: { touches: [{ screenX }] } }) => {
swipeInfo.touchendX = screenX;
},
start: ({ originalEvent: { touches: [{ screenX }] } }) => {
swipeInfo.touchstartX = screenX;
},
end: () => {
if (swiping < SWIPE_THRESHOLD) {
swipeInfo = {};
return;
}
swipeInfo = {};
toggleChecked();
},
}"
no-gutters no-gutters
class="flex-nowrap align-center" class="flex-nowrap align-center"
> >
@@ -151,16 +173,17 @@
@delete="$emit('delete')" @delete="$emit('delete')"
/> />
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useOnline } from "@vueuse/core"; import { useOnline } from "@vueuse/core";
import RecipeIngredientListItem from "../Recipe/RecipeIngredientListItem.vue"; import RecipeIngredientListItem from "../Recipe/RecipeIngredientListItem.vue";
import ShoppingListItemEditor from "./ShoppingListItemEditor.vue"; import ShoppingListItemEditor from "./ShoppingListItemEditor.vue";
import RecipeList from "~/components/Domain/Recipe/RecipeList.vue";
import type { ShoppingListItemOut } from "~/lib/api/types/household"; import type { ShoppingListItemOut } from "~/lib/api/types/household";
import type { MultiPurposeLabelOut } from "~/lib/api/types/labels"; import type { MultiPurposeLabelOut } from "~/lib/api/types/labels";
import type { IngredientFood, IngredientUnit, RecipeSummary } from "~/lib/api/types/recipe"; import type { IngredientUnit, IngredientFood, RecipeSummary } from "~/lib/api/types/recipe";
import RecipeList from "~/components/Domain/Recipe/RecipeList.vue";
const model = defineModel<ShoppingListItemOut>({ type: Object as () => ShoppingListItemOut, required: true }); const model = defineModel<ShoppingListItemOut>({ type: Object as () => ShoppingListItemOut, required: true });
@@ -188,6 +211,9 @@ const emit = defineEmits<{
(e: "delete"): void; (e: "delete"): void;
}>(); }>();
const SWIPE_THRESHOLD = 50;
const { isRtl } = useRtl();
const i18n = useI18n(); const i18n = useI18n();
const displayRecipeRefs = ref(false); const displayRecipeRefs = ref(false);
const itemLabelCols = computed<string>(() => (model.value?.checked ? "auto" : "6")); const itemLabelCols = computed<string>(() => (model.value?.checked ? "auto" : "6"));
@@ -238,6 +264,16 @@ function save() {
edit.value = false; 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<RecipeSummary[]>(() => { const recipeList = computed<RecipeSummary[]>(() => {
const ret: RecipeSummary[] = []; const ret: RecipeSummary[] = [];
if (!listItem.value.recipeReferences) return ret; if (!listItem.value.recipeReferences) return ret;

View File

@@ -4,7 +4,7 @@
v-model="toastAlert.open" v-model="toastAlert.open"
location="top" location="top"
:color="toastAlert.color" :color="toastAlert.color"
timeout="2000" :timeout="toastAlert.timeout ?? 2000"
> >
<v-icon <v-icon
v-if="icon" v-if="icon"
@@ -19,9 +19,12 @@
<template #actions> <template #actions>
<v-btn <v-btn
variant="text" variant="text"
@click="toastAlert.open = false" @click="() => {
toastAlert.action?.onClick();
toastAlert.open = false
}"
> >
{{ $t('general.close') }} {{ toastAlert.action?.message ?? $t('general.close') }}
</v-btn> </v-btn>
</template> </template>
</v-snackbar> </v-snackbar>

View File

@@ -3,6 +3,11 @@ interface Toast {
text: string; text: string;
title: string | null; title: string | null;
color: string; color: string;
timeout?: number;
action?: {
onClick: VoidFunction;
message?: string;
};
} }
export const toastAlert = reactive<Toast>({ export const toastAlert = reactive<Toast>({
@@ -19,11 +24,13 @@ export const toastLoading = reactive<Toast>({
color: "success", 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>) {
toast.open = true; toast.open = true;
toast.text = text; toast.text = text;
toast.title = title; toast.title = title;
toast.color = color; toast.color = color;
toast.timeout = options?.timeout;
toast.action = options?.action;
} }
export const loader = { export const loader = {
@@ -45,17 +52,17 @@ export const loader = {
}; };
export const alert = { export const alert = {
info(text: string, title: string | null = null) { info(text: string, title: string | null = null, options?: Partial<Toast>) {
setToast(toastAlert, text, title, "info"); setToast(toastAlert, text, title, "info", options);
}, },
success(text: string, title: string | null = null) { success(text: string, title: string | null = null, options?: Partial<Toast>) {
setToast(toastAlert, text, title, "success"); setToast(toastAlert, text, title, "success", options);
}, },
error(text: string, title: string | null = null) { error(text: string, title: string | null = null, options?: Partial<Toast>) {
setToast(toastAlert, text, title, "error"); setToast(toastAlert, text, title, "error", options);
}, },
warning(text: string, title: string | null = null) { warning(text: string, title: string | null = null, options?: Partial<Toast>) {
setToast(toastAlert, text, title, "warning"); setToast(toastAlert, text, title, "warning", options);
}, },
close() { close() {
toastAlert.open = false; toastAlert.open = false;

View File

@@ -169,6 +169,7 @@
"token": "Token", "token": "Token",
"tuesday": "Tuesday", "tuesday": "Tuesday",
"type": "Type", "type": "Type",
"undo": "Undo",
"update": "Update", "update": "Update",
"updated": "Updated", "updated": "Updated",
"upload": "Upload", "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-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-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?", "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": { "sidebar": {
"all-recipes": "All Recipes", "all-recipes": "All Recipes",

View File

@@ -223,7 +223,10 @@
:units="allUnits || []" :units="allUnits || []"
:foods="allFoods || []" :foods="allFoods || []"
:recipes="recipeMap" :recipes="recipeMap"
@checked="saveListItem" @checked="(item) => {
saveListItem(item);
itemCheckedToast(item);
}"
@save="saveListItem" @save="saveListItem"
@delete="deleteListItem(item)" @delete="deleteListItem(item)"
/> />
@@ -354,7 +357,9 @@ import ShoppingListAddItemForm from "~/components/Domain/ShoppingList/ShoppingLi
import ShoppingListItem from "~/components/Domain/ShoppingList/ShoppingListItem.vue"; import ShoppingListItem from "~/components/Domain/ShoppingList/ShoppingListItem.vue";
import ShoppingListItemEditor from "~/components/Domain/ShoppingList/ShoppingListItemEditor.vue"; import ShoppingListItemEditor from "~/components/Domain/ShoppingList/ShoppingListItemEditor.vue";
import { useShoppingListPage } from "~/composables/shopping-list-page/use-shopping-list-page"; 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 { mdAndUp } = useDisplay();
const i18n = useI18n(); const i18n = useI18n();
@@ -371,6 +376,23 @@ const { store: allLabels } = useLabelStore();
const { store: allUnits } = useUnitStore(); const { store: allUnits } = useUnitStore();
const { store: allFoods } = useFoodStore(); 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 { const {
shoppingList, shoppingList,
state, state,