mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-05-07 18:43:34 -04:00
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:
@@ -1,155 +1,178 @@
|
||||
<template>
|
||||
<v-container
|
||||
v-if="!edit"
|
||||
class="pa-0"
|
||||
>
|
||||
<v-row
|
||||
no-gutters
|
||||
class="flex-nowrap align-center"
|
||||
<div style="overflow-x: hidden;">
|
||||
<v-container
|
||||
v-if="!edit"
|
||||
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-col :cols="itemLabelCols">
|
||||
<div class="d-flex align-center flex-nowrap">
|
||||
<v-checkbox
|
||||
:model-value="listItem.checked"
|
||||
hide-details
|
||||
density="compact"
|
||||
class="mt-0 flex-shrink-0"
|
||||
color="null"
|
||||
@click="toggleChecked"
|
||||
/>
|
||||
<div
|
||||
class="ml-2 text-truncate"
|
||||
:class="listItem.checked ? 'strike-through' : ''"
|
||||
style="min-width: 0;"
|
||||
>
|
||||
<RecipeIngredientListItem :ingredient="listItem" />
|
||||
<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
|
||||
class="flex-nowrap align-center"
|
||||
>
|
||||
<v-col :cols="itemLabelCols">
|
||||
<div class="d-flex align-center flex-nowrap">
|
||||
<v-checkbox
|
||||
:model-value="listItem.checked"
|
||||
hide-details
|
||||
density="compact"
|
||||
class="mt-0 flex-shrink-0"
|
||||
color="null"
|
||||
@click="toggleChecked"
|
||||
/>
|
||||
<div
|
||||
class="ml-2 text-truncate"
|
||||
:class="listItem.checked ? 'strike-through' : ''"
|
||||
style="min-width: 0;"
|
||||
>
|
||||
<RecipeIngredientListItem :ingredient="listItem" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-spacer />
|
||||
<v-col
|
||||
cols="auto"
|
||||
class="text-right"
|
||||
>
|
||||
<div
|
||||
v-if="!listItem.checked"
|
||||
style="min-width: 72px"
|
||||
</v-col>
|
||||
<v-spacer />
|
||||
<v-col
|
||||
cols="auto"
|
||||
class="text-right"
|
||||
>
|
||||
<v-menu
|
||||
offset-x
|
||||
start
|
||||
min-width="125px"
|
||||
<div
|
||||
v-if="!listItem.checked"
|
||||
style="min-width: 72px"
|
||||
>
|
||||
<template #activator="{ props: hoverProps }">
|
||||
<v-tooltip
|
||||
v-if="recipeList && recipeList.length"
|
||||
open-delay="200"
|
||||
transition="slide-x-reverse-transition"
|
||||
density="compact"
|
||||
location="end"
|
||||
content-class="text-caption"
|
||||
>
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
class="ml-2"
|
||||
icon
|
||||
v-bind="tooltipProps"
|
||||
@click="displayRecipeRefs = !displayRecipeRefs"
|
||||
>
|
||||
<v-icon>
|
||||
{{ $globals.icons.potSteam }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<span>Toggle Recipes</span>
|
||||
</v-tooltip>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
class="ml-2"
|
||||
icon
|
||||
@click="toggleEdit(true)"
|
||||
>
|
||||
<v-icon>
|
||||
{{ $globals.icons.edit }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
class="handle"
|
||||
icon
|
||||
v-bind="hoverProps"
|
||||
>
|
||||
<v-icon>
|
||||
{{ $globals.icons.arrowUpDown }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="action in contextMenu"
|
||||
:key="action.event"
|
||||
density="compact"
|
||||
@click="contextHandler(action.event)"
|
||||
>
|
||||
<v-list-item-title>
|
||||
{{ action.text }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row
|
||||
v-if="!listItem.checked && recipeList && recipeList.length && displayRecipeRefs"
|
||||
no-gutters
|
||||
class="mb-2"
|
||||
>
|
||||
<v-col
|
||||
cols="auto"
|
||||
style="width: 100%;"
|
||||
<v-menu
|
||||
offset-x
|
||||
start
|
||||
min-width="125px"
|
||||
>
|
||||
<template #activator="{ props: hoverProps }">
|
||||
<v-tooltip
|
||||
v-if="recipeList && recipeList.length"
|
||||
open-delay="200"
|
||||
transition="slide-x-reverse-transition"
|
||||
density="compact"
|
||||
location="end"
|
||||
content-class="text-caption"
|
||||
>
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
class="ml-2"
|
||||
icon
|
||||
v-bind="tooltipProps"
|
||||
@click="displayRecipeRefs = !displayRecipeRefs"
|
||||
>
|
||||
<v-icon>
|
||||
{{ $globals.icons.potSteam }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<span>Toggle Recipes</span>
|
||||
</v-tooltip>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
class="ml-2"
|
||||
icon
|
||||
@click="toggleEdit(true)"
|
||||
>
|
||||
<v-icon>
|
||||
{{ $globals.icons.edit }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
class="handle"
|
||||
icon
|
||||
v-bind="hoverProps"
|
||||
>
|
||||
<v-icon>
|
||||
{{ $globals.icons.arrowUpDown }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="action in contextMenu"
|
||||
:key="action.event"
|
||||
density="compact"
|
||||
@click="contextHandler(action.event)"
|
||||
>
|
||||
<v-list-item-title>
|
||||
{{ action.text }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row
|
||||
v-if="!listItem.checked && recipeList && recipeList.length && displayRecipeRefs"
|
||||
no-gutters
|
||||
class="mb-2"
|
||||
>
|
||||
<RecipeList
|
||||
:recipes="recipeList"
|
||||
:list-item="listItem"
|
||||
:disabled="isOffline"
|
||||
size="small"
|
||||
tile
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row
|
||||
v-if="listItem.checked"
|
||||
no-gutters
|
||||
class="mb-2"
|
||||
<v-col
|
||||
cols="auto"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<RecipeList
|
||||
:recipes="recipeList"
|
||||
:list-item="listItem"
|
||||
:disabled="isOffline"
|
||||
size="small"
|
||||
tile
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row
|
||||
v-if="listItem.checked"
|
||||
no-gutters
|
||||
class="mb-2"
|
||||
>
|
||||
<v-col cols="auto">
|
||||
<div class="text-caption font-weight-light font-italic">
|
||||
{{ $t("shopping-list.completed-on", {
|
||||
date: listItem.updatedAt ? $d(new Date(listItem.updatedAt)) : '',
|
||||
}) }}
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
<div
|
||||
v-else
|
||||
class="mb-1 mt-6"
|
||||
>
|
||||
<v-col cols="auto">
|
||||
<div class="text-caption font-weight-light font-italic">
|
||||
{{ $t("shopping-list.completed-on", {
|
||||
date: listItem.updatedAt ? $d(new Date(listItem.updatedAt)) : '',
|
||||
}) }}
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
<div
|
||||
v-else
|
||||
class="mb-1 mt-6"
|
||||
>
|
||||
<ShoppingListItemEditor
|
||||
v-model="localListItem"
|
||||
:labels="labels"
|
||||
:units="units"
|
||||
:foods="foods"
|
||||
class="ma-2"
|
||||
@save="save"
|
||||
@cancel="toggleEdit(false)"
|
||||
@delete="$emit('delete')"
|
||||
/>
|
||||
<ShoppingListItemEditor
|
||||
v-model="localListItem"
|
||||
:labels="labels"
|
||||
:units="units"
|
||||
:foods="foods"
|
||||
class="ma-2"
|
||||
@save="save"
|
||||
@cancel="toggleEdit(false)"
|
||||
@delete="$emit('delete')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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<ShoppingListItemOut>({ 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<string>(() => (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<RecipeSummary[]>(() => {
|
||||
const ret: RecipeSummary[] = [];
|
||||
if (!listItem.value.recipeReferences) return ret;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
v-model="toastAlert.open"
|
||||
location="top"
|
||||
:color="toastAlert.color"
|
||||
timeout="2000"
|
||||
:timeout="toastAlert.timeout ?? 2000"
|
||||
>
|
||||
<v-icon
|
||||
v-if="icon"
|
||||
@@ -19,9 +19,12 @@
|
||||
<template #actions>
|
||||
<v-btn
|
||||
variant="text"
|
||||
@click="toastAlert.open = false"
|
||||
@click="() => {
|
||||
toastAlert.action?.onClick();
|
||||
toastAlert.open = false
|
||||
}"
|
||||
>
|
||||
{{ $t('general.close') }}
|
||||
{{ toastAlert.action?.message ?? $t('general.close') }}
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
|
||||
@@ -3,6 +3,11 @@ interface Toast {
|
||||
text: string;
|
||||
title: string | null;
|
||||
color: string;
|
||||
timeout?: number;
|
||||
action?: {
|
||||
onClick: VoidFunction;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const toastAlert = reactive<Toast>({
|
||||
@@ -19,11 +24,13 @@ export const toastLoading = reactive<Toast>({
|
||||
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.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<Toast>) {
|
||||
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<Toast>) {
|
||||
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<Toast>) {
|
||||
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<Toast>) {
|
||||
setToast(toastAlert, text, title, "warning", options);
|
||||
},
|
||||
close() {
|
||||
toastAlert.open = false;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user