mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-05-08 11:03: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>
|
||||
<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;
|
||||
|
||||
Reference in New Issue
Block a user