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

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,