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,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;

View File

@@ -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>