mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-05-09 03:23:30 -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>
|
<template>
|
||||||
<v-container
|
<div style="overflow-x: hidden;">
|
||||||
v-if="!edit"
|
<v-container
|
||||||
class="pa-0"
|
v-if="!edit"
|
||||||
>
|
class="pa-0"
|
||||||
<v-row
|
:style="{
|
||||||
no-gutters
|
transform: `translateX(${isRtl ? -swiping : swiping}px)`,
|
||||||
class="flex-nowrap align-center"
|
transition: swiping === 0 ? 'transform 0.2s ease' : 'none',
|
||||||
|
opacity: swiping >= SWIPE_THRESHOLD ? 0.5 : 1,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<v-col :cols="itemLabelCols">
|
<v-row
|
||||||
<div class="d-flex align-center flex-nowrap">
|
v-touch="{
|
||||||
<v-checkbox
|
move: ({ originalEvent: { touches: [{ screenX }] } }) => {
|
||||||
:model-value="listItem.checked"
|
swipeInfo.touchendX = screenX;
|
||||||
hide-details
|
},
|
||||||
density="compact"
|
start: ({ originalEvent: { touches: [{ screenX }] } }) => {
|
||||||
class="mt-0 flex-shrink-0"
|
swipeInfo.touchstartX = screenX;
|
||||||
color="null"
|
},
|
||||||
@click="toggleChecked"
|
end: () => {
|
||||||
/>
|
if (swiping < SWIPE_THRESHOLD) {
|
||||||
<div
|
swipeInfo = {};
|
||||||
class="ml-2 text-truncate"
|
return;
|
||||||
:class="listItem.checked ? 'strike-through' : ''"
|
}
|
||||||
style="min-width: 0;"
|
swipeInfo = {};
|
||||||
>
|
toggleChecked();
|
||||||
<RecipeIngredientListItem :ingredient="listItem" />
|
},
|
||||||
|
}"
|
||||||
|
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>
|
||||||
</div>
|
</v-col>
|
||||||
</v-col>
|
<v-spacer />
|
||||||
<v-spacer />
|
<v-col
|
||||||
<v-col
|
cols="auto"
|
||||||
cols="auto"
|
class="text-right"
|
||||||
class="text-right"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="!listItem.checked"
|
|
||||||
style="min-width: 72px"
|
|
||||||
>
|
>
|
||||||
<v-menu
|
<div
|
||||||
offset-x
|
v-if="!listItem.checked"
|
||||||
start
|
style="min-width: 72px"
|
||||||
min-width="125px"
|
|
||||||
>
|
>
|
||||||
<template #activator="{ props: hoverProps }">
|
<v-menu
|
||||||
<v-tooltip
|
offset-x
|
||||||
v-if="recipeList && recipeList.length"
|
start
|
||||||
open-delay="200"
|
min-width="125px"
|
||||||
transition="slide-x-reverse-transition"
|
>
|
||||||
density="compact"
|
<template #activator="{ props: hoverProps }">
|
||||||
location="end"
|
<v-tooltip
|
||||||
content-class="text-caption"
|
v-if="recipeList && recipeList.length"
|
||||||
>
|
open-delay="200"
|
||||||
<template #activator="{ props: tooltipProps }">
|
transition="slide-x-reverse-transition"
|
||||||
<v-btn
|
density="compact"
|
||||||
size="small"
|
location="end"
|
||||||
variant="text"
|
content-class="text-caption"
|
||||||
class="ml-2"
|
>
|
||||||
icon
|
<template #activator="{ props: tooltipProps }">
|
||||||
v-bind="tooltipProps"
|
<v-btn
|
||||||
@click="displayRecipeRefs = !displayRecipeRefs"
|
size="small"
|
||||||
>
|
variant="text"
|
||||||
<v-icon>
|
class="ml-2"
|
||||||
{{ $globals.icons.potSteam }}
|
icon
|
||||||
</v-icon>
|
v-bind="tooltipProps"
|
||||||
</v-btn>
|
@click="displayRecipeRefs = !displayRecipeRefs"
|
||||||
</template>
|
>
|
||||||
<span>Toggle Recipes</span>
|
<v-icon>
|
||||||
</v-tooltip>
|
{{ $globals.icons.potSteam }}
|
||||||
<v-btn
|
</v-icon>
|
||||||
size="small"
|
</v-btn>
|
||||||
variant="text"
|
</template>
|
||||||
class="ml-2"
|
<span>Toggle Recipes</span>
|
||||||
icon
|
</v-tooltip>
|
||||||
@click="toggleEdit(true)"
|
<v-btn
|
||||||
>
|
size="small"
|
||||||
<v-icon>
|
variant="text"
|
||||||
{{ $globals.icons.edit }}
|
class="ml-2"
|
||||||
</v-icon>
|
icon
|
||||||
</v-btn>
|
@click="toggleEdit(true)"
|
||||||
<v-btn
|
>
|
||||||
size="small"
|
<v-icon>
|
||||||
variant="text"
|
{{ $globals.icons.edit }}
|
||||||
class="handle"
|
</v-icon>
|
||||||
icon
|
</v-btn>
|
||||||
v-bind="hoverProps"
|
<v-btn
|
||||||
>
|
size="small"
|
||||||
<v-icon>
|
variant="text"
|
||||||
{{ $globals.icons.arrowUpDown }}
|
class="handle"
|
||||||
</v-icon>
|
icon
|
||||||
</v-btn>
|
v-bind="hoverProps"
|
||||||
</template>
|
>
|
||||||
<v-list density="compact">
|
<v-icon>
|
||||||
<v-list-item
|
{{ $globals.icons.arrowUpDown }}
|
||||||
v-for="action in contextMenu"
|
</v-icon>
|
||||||
:key="action.event"
|
</v-btn>
|
||||||
density="compact"
|
</template>
|
||||||
@click="contextHandler(action.event)"
|
<v-list density="compact">
|
||||||
>
|
<v-list-item
|
||||||
<v-list-item-title>
|
v-for="action in contextMenu"
|
||||||
{{ action.text }}
|
:key="action.event"
|
||||||
</v-list-item-title>
|
density="compact"
|
||||||
</v-list-item>
|
@click="contextHandler(action.event)"
|
||||||
</v-list>
|
>
|
||||||
</v-menu>
|
<v-list-item-title>
|
||||||
</div>
|
{{ action.text }}
|
||||||
</v-col>
|
</v-list-item-title>
|
||||||
</v-row>
|
</v-list-item>
|
||||||
<v-row
|
</v-list>
|
||||||
v-if="!listItem.checked && recipeList && recipeList.length && displayRecipeRefs"
|
</v-menu>
|
||||||
no-gutters
|
</div>
|
||||||
class="mb-2"
|
</v-col>
|
||||||
>
|
</v-row>
|
||||||
<v-col
|
<v-row
|
||||||
cols="auto"
|
v-if="!listItem.checked && recipeList && recipeList.length && displayRecipeRefs"
|
||||||
style="width: 100%;"
|
no-gutters
|
||||||
|
class="mb-2"
|
||||||
>
|
>
|
||||||
<RecipeList
|
<v-col
|
||||||
:recipes="recipeList"
|
cols="auto"
|
||||||
:list-item="listItem"
|
style="width: 100%;"
|
||||||
:disabled="isOffline"
|
>
|
||||||
size="small"
|
<RecipeList
|
||||||
tile
|
:recipes="recipeList"
|
||||||
/>
|
:list-item="listItem"
|
||||||
</v-col>
|
:disabled="isOffline"
|
||||||
</v-row>
|
size="small"
|
||||||
<v-row
|
tile
|
||||||
v-if="listItem.checked"
|
/>
|
||||||
no-gutters
|
</v-col>
|
||||||
class="mb-2"
|
</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">
|
<ShoppingListItemEditor
|
||||||
<div class="text-caption font-weight-light font-italic">
|
v-model="localListItem"
|
||||||
{{ $t("shopping-list.completed-on", {
|
:labels="labels"
|
||||||
date: listItem.updatedAt ? $d(new Date(listItem.updatedAt)) : '',
|
:units="units"
|
||||||
}) }}
|
:foods="foods"
|
||||||
</div>
|
class="ma-2"
|
||||||
</v-col>
|
@save="save"
|
||||||
</v-row>
|
@cancel="toggleEdit(false)"
|
||||||
</v-container>
|
@delete="$emit('delete')"
|
||||||
<div
|
/>
|
||||||
v-else
|
</div>
|
||||||
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')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -157,10 +180,10 @@
|
|||||||
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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user