From c14574503a78df3aa1c286108a75b46c58e098bf Mon Sep 17 00:00:00 2001 From: Marius Gravdal Date: Tue, 7 Jul 2026 19:21:23 +0200 Subject: [PATCH 1/2] feat: adds support for specifying stores on items (#838) --- backend/app/api/register_controller.py | 4 + backend/app/controller/__init__.py | 1 + .../app/controller/exportimport/schemas.py | 1 + .../app/controller/item/item_controller.py | 21 +- backend/app/controller/item/schemas.py | 8 + backend/app/controller/store/__init__.py | 1 + backend/app/controller/store/schemas.py | 15 ++ .../app/controller/store/store_controller.py | 73 +++++ backend/app/models/__init__.py | 3 +- backend/app/models/item.py | 68 ++++- backend/app/models/store.py | 80 ++++++ .../app/service/importServices/import_item.py | 8 +- backend/migrations/versions/7464b5211b10_.py | 52 ++++ backend/tests/api/test_api_store.py | 210 +++++++++++++++ .../household_settings_items_cubit.dart | 11 +- .../household_update_cubit.dart | 39 +++ kitchenowl/lib/cubits/item_edit_cubit.dart | 22 +- kitchenowl/lib/cubits/shoppinglist_cubit.dart | 28 ++ kitchenowl/lib/l10n/app_ar.arb | 19 ++ kitchenowl/lib/l10n/app_be.arb | 19 ++ kitchenowl/lib/l10n/app_bg.arb | 21 +- kitchenowl/lib/l10n/app_ca.arb | 19 ++ kitchenowl/lib/l10n/app_ca_valencia.arb | 21 +- kitchenowl/lib/l10n/app_cs.arb | 19 ++ kitchenowl/lib/l10n/app_da.arb | 14 + kitchenowl/lib/l10n/app_de.arb | 14 + kitchenowl/lib/l10n/app_de_CH.arb | 14 + kitchenowl/lib/l10n/app_el.arb | 19 ++ kitchenowl/lib/l10n/app_en.arb | 19 ++ kitchenowl/lib/l10n/app_en_AU.arb | 19 ++ kitchenowl/lib/l10n/app_es.arb | 14 + kitchenowl/lib/l10n/app_fa.arb | 19 ++ kitchenowl/lib/l10n/app_fi.arb | 14 + kitchenowl/lib/l10n/app_fr.arb | 14 + kitchenowl/lib/l10n/app_he.arb | 19 ++ kitchenowl/lib/l10n/app_hi.arb | 9 +- kitchenowl/lib/l10n/app_hr.arb | 14 + kitchenowl/lib/l10n/app_hu.arb | 19 ++ kitchenowl/lib/l10n/app_id.arb | 14 + kitchenowl/lib/l10n/app_it.arb | 14 + kitchenowl/lib/l10n/app_ja.arb | 16 +- kitchenowl/lib/l10n/app_ko.arb | 19 ++ kitchenowl/lib/l10n/app_lt.arb | 19 ++ kitchenowl/lib/l10n/app_nb.arb | 18 ++ kitchenowl/lib/l10n/app_nl.arb | 14 + kitchenowl/lib/l10n/app_pl.arb | 14 + kitchenowl/lib/l10n/app_pt.arb | 14 + kitchenowl/lib/l10n/app_pt_BR.arb | 14 + kitchenowl/lib/l10n/app_ro.arb | 19 ++ kitchenowl/lib/l10n/app_ru.arb | 14 + kitchenowl/lib/l10n/app_sk.arb | 19 ++ kitchenowl/lib/l10n/app_sl.arb | 19 ++ kitchenowl/lib/l10n/app_sv.arb | 14 + kitchenowl/lib/l10n/app_tr.arb | 14 + kitchenowl/lib/l10n/app_uk.arb | 19 ++ kitchenowl/lib/l10n/app_vi.arb | 19 ++ kitchenowl/lib/l10n/app_zh.arb | 19 ++ kitchenowl/lib/l10n/app_zh_Hant.arb | 16 +- kitchenowl/lib/models/item.dart | 38 ++- kitchenowl/lib/models/store.dart | 45 ++++ .../pages/household_page/shoppinglist.dart | 2 + .../lib/pages/household_update_page.dart | 16 ++ kitchenowl/lib/pages/item_page.dart | 43 +++ .../household_settings_items_page.dart | 2 + .../household_settings_store_page.dart | 255 ++++++++++++++++++ kitchenowl/lib/services/api/api_service.dart | 1 + kitchenowl/lib/services/api/store.dart | 59 ++++ .../lib/services/storage/mem_storage.dart | 24 ++ .../lib/services/storage/temp_storage.dart | 44 +++ .../lib/services/transactions/store.dart | 27 ++ .../sliver_category_item_grid_list.dart | 5 + .../lib/widgets/selectable_button_card.dart | 18 ++ .../widgets/selectable_button_list_tile.dart | 60 +++-- kitchenowl/lib/widgets/shopping_item.dart | 7 + .../sliver_shopinglist_item_view.dart | 6 + .../lib/widgets/sliver_item_grid_list.dart | 4 + 76 files changed, 1932 insertions(+), 36 deletions(-) create mode 100644 backend/app/controller/store/__init__.py create mode 100644 backend/app/controller/store/schemas.py create mode 100644 backend/app/controller/store/store_controller.py create mode 100644 backend/app/models/store.py create mode 100644 backend/migrations/versions/7464b5211b10_.py create mode 100644 backend/tests/api/test_api_store.py create mode 100644 kitchenowl/lib/models/store.dart create mode 100644 kitchenowl/lib/pages/settings_household/household_settings_store_page.dart create mode 100644 kitchenowl/lib/services/api/store.dart create mode 100644 kitchenowl/lib/services/transactions/store.dart diff --git a/backend/app/api/register_controller.py b/backend/app/api/register_controller.py index 15e6b453a..546741951 100644 --- a/backend/app/api/register_controller.py +++ b/backend/app/api/register_controller.py @@ -27,6 +27,9 @@ api.shoppinglistHousehold, url_prefix="//shoppinglist" ) api.household.register_blueprint(api.tagHousehold, url_prefix="//tag") +api.household.register_blueprint( + api.storeHousehold, url_prefix="//store" +) apiv1.register_blueprint( api.health, url_prefix="/health/8M4F88S8ooi4sMbLBfkkV7ctWwgibW6V" @@ -41,6 +44,7 @@ apiv1.register_blueprint(api.settings, url_prefix="/settings") apiv1.register_blueprint(api.shoppinglist, url_prefix="/shoppinglist") apiv1.register_blueprint(api.tag, url_prefix="/tag") +apiv1.register_blueprint(api.store, url_prefix="/store") apiv1.register_blueprint(api.user, url_prefix="/user") apiv1.register_blueprint(api.upload, url_prefix="/upload") apiv1.register_blueprint(api.analytics, url_prefix="/analytics") diff --git a/backend/app/controller/__init__.py b/backend/app/controller/__init__.py index 61daeab9b..d20723e8f 100644 --- a/backend/app/controller/__init__.py +++ b/backend/app/controller/__init__.py @@ -12,6 +12,7 @@ from .upload import * from .household import * from .category import * +from .store import * from .health_controller import health from .analytics import * from .report import * diff --git a/backend/app/controller/exportimport/schemas.py b/backend/app/controller/exportimport/schemas.py index 47a077ed9..af88ce458 100644 --- a/backend/app/controller/exportimport/schemas.py +++ b/backend/app/controller/exportimport/schemas.py @@ -9,6 +9,7 @@ class Item(Schema): name = fields.String(required=True, validate=lambda a: a and not a.isspace()) category = fields.String(validate=lambda a: a and not a.isspace()) icon = fields.String() + stores = fields.List(fields.String()) class Recipe(Schema): class Meta: diff --git a/backend/app/controller/item/item_controller.py b/backend/app/controller/item/item_controller.py index 3613f31c8..b12b428a3 100644 --- a/backend/app/controller/item/item_controller.py +++ b/backend/app/controller/item/item_controller.py @@ -1,15 +1,28 @@ +from app import db from app.helpers import validate_args, authorize_household from flask import jsonify, Blueprint from app.errors import InvalidUsage, NotFoundRequest import app.util.description_splitter as description_splitter from flask_jwt_extended import jwt_required -from app.models import Item, RecipeItems, Recipe, Category +from app.models import Item, RecipeItems, Recipe, Category, Store, ItemStores from .schemas import SearchByNameRequest, UpdateItem, AddItem item = Blueprint("item", __name__) itemHousehold = Blueprint("item", __name__) +def _setItemStores(item: Item, store_ids: list[int] | None) -> None: + new_ids = set(store_ids or []) + existing_ids = {s.store_id for s in item.stores} + for store_id in existing_ids - new_ids: + ItemStores.query.filter_by(item_id=item.id, store_id=store_id).delete() + for store_id in new_ids - existing_ids: + store = Store.find_by_id(store_id) + if store and store.household_id == item.household_id: + db.session.add(ItemStores(item_id=item.id, store_id=store_id)) + db.session.commit() + + @itemHousehold.route("", methods=["GET"]) @jwt_required() @authorize_household() @@ -91,6 +104,9 @@ def addItem(args, household_id): item.icon = args["icon"] item.save() + if "store_ids" in args: + _setItemStores(item, args["store_ids"]) + return jsonify(item.obj_to_dict()) @@ -118,6 +134,9 @@ def updateItem(args, id): item.name = newName item.save() + if "store_ids" in args: + _setItemStores(item, args["store_ids"]) + if "merge_item_id" in args and args["merge_item_id"] != id: mergeItem = Item.find_by_id(args["merge_item_id"]) if mergeItem: diff --git a/backend/app/controller/item/schemas.py b/backend/app/controller/item/schemas.py index 7dfb13ca8..dadff4966 100644 --- a/backend/app/controller/item/schemas.py +++ b/backend/app/controller/item/schemas.py @@ -25,6 +25,10 @@ class Meta: validate=lambda a: not a or not a.isspace(), allow_none=True, ) + store_ids = fields.List( + fields.Integer(validate=lambda a: a > 0), + allow_none=True, + ) # if set this merges the specified item into this item thus combining them to one merge_item_id = fields.Integer( @@ -54,3 +58,7 @@ class Meta: allow_none=False, required=True, ) + store_ids = fields.List( + fields.Integer(validate=lambda a: a > 0), + allow_none=True, + ) diff --git a/backend/app/controller/store/__init__.py b/backend/app/controller/store/__init__.py new file mode 100644 index 000000000..5e719ddc0 --- /dev/null +++ b/backend/app/controller/store/__init__.py @@ -0,0 +1 @@ +from .store_controller import store, storeHousehold diff --git a/backend/app/controller/store/schemas.py b/backend/app/controller/store/schemas.py new file mode 100644 index 000000000..88a213fbc --- /dev/null +++ b/backend/app/controller/store/schemas.py @@ -0,0 +1,15 @@ +from marshmallow import fields, Schema + + +class AddStore(Schema): + name = fields.String(required=True, validate=lambda a: a and not a.isspace()) + + +class UpdateStore(Schema): + name = fields.String(validate=lambda a: a and not a.isspace()) + + # if set this merges the specified store into this store thus combining them to one + merge_store_id = fields.Integer( + validate=lambda a: a > 0, + allow_none=True, + ) diff --git a/backend/app/controller/store/store_controller.py b/backend/app/controller/store/store_controller.py new file mode 100644 index 000000000..ee6c164bd --- /dev/null +++ b/backend/app/controller/store/store_controller.py @@ -0,0 +1,73 @@ +from app.helpers import validate_args, authorize_household +from flask import jsonify, Blueprint +from app.errors import NotFoundRequest +from flask_jwt_extended import jwt_required +from app.models import Store +from .schemas import AddStore, UpdateStore + +store = Blueprint("store", __name__) +storeHousehold = Blueprint("store", __name__) + + +@storeHousehold.route("", methods=["GET"]) +@jwt_required() +@authorize_household() +def getAllStores(household_id): + return jsonify( + [e.obj_to_dict() for e in Store.all_from_household_by_name(household_id)] + ) + + +@store.route("/", methods=["GET"]) +@jwt_required() +def getStore(id): + store = Store.find_by_id(id) + if not store: + raise NotFoundRequest() + store.checkAuthorized() + return jsonify(store.obj_to_dict()) + + +@storeHousehold.route("", methods=["POST"]) +@jwt_required() +@authorize_household() +@validate_args(AddStore) +def addStore(args, household_id): + store = Store() + store.name = args["name"] + store.household_id = household_id + store.save() + return jsonify(store.obj_to_dict()) + + +@store.route("/", methods=["POST"]) +@jwt_required() +@validate_args(UpdateStore) +def updateStore(args, id): + store = Store.find_by_id(id) + if not store: + raise NotFoundRequest() + store.checkAuthorized() + + if "name" in args: + store.name = args["name"] + + store.save() + + if "merge_store_id" in args and args["merge_store_id"] != id: + mergeStore = Store.find_by_id(args["merge_store_id"]) + if mergeStore: + store.merge(mergeStore) + + return jsonify(store.obj_to_dict()) + + +@store.route("/", methods=["DELETE"]) +@jwt_required() +def deleteStoreById(id): + store = Store.find_by_id(id) + if not store: + raise NotFoundRequest() + store.checkAuthorized() + store.delete() + return jsonify({"msg": "DONE"}) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 9af3ad90f..d89485767 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,5 +1,5 @@ from .user import User -from .item import Item +from .item import Item, ItemStores from .association import Association from .expense import Expense, ExpensePaidFor from .settings import Settings @@ -11,6 +11,7 @@ from .recipe_history import RecipeHistory from .expense_category import ExpenseCategory from .category import Category +from .store import Store from .token import Token from .household import Household, HouseholdMember from .file import File diff --git a/backend/app/models/item.py b/backend/app/models/item.py index 179d94b38..1f9c41d33 100644 --- a/backend/app/models/item.py +++ b/backend/app/models/item.py @@ -10,7 +10,7 @@ Model = db.Model if TYPE_CHECKING: - from app.models import Household, RecipeItems, ShoppinglistItems + from app.models import Household, RecipeItems, ShoppinglistItems, Store from app.helpers.db_model_base import DbModelBase Model = DbModelBase @@ -61,6 +61,14 @@ class Item(Model, DbModelAuthorizeMixin): cascade="all, delete-orphan", ), ) + stores: Mapped[List["ItemStores"]] = cast( + Mapped[List["ItemStores"]], + db.relationship( + "ItemStores", + back_populates="item", + cascade="all, delete-orphan", + ), + ) # determines order of items in the shoppinglist ordering = db.Column(db.Integer, server_default="0") @@ -92,6 +100,8 @@ def obj_to_dict( if self.category_id: category = cast(Category, Category.find_by_id(self.category_id)) res["category"] = category.obj_to_dict() + if self.stores: + res["stores"] = [s.store.obj_to_dict() for s in self.stores] return res def obj_to_export_dict(self) -> dict[str, Any]: @@ -102,6 +112,8 @@ def obj_to_export_dict(self) -> dict[str, Any]: res["icon"] = self.icon if self.category: res["category"] = self.category.name + if self.stores: + res["stores"] = [s.store.name for s in self.stores] return res def save(self, keepDefault: bool = False) -> Self: @@ -116,6 +128,7 @@ def merge(self, other: Self) -> None: from app.models import RecipeItems from app.models import History from app.models import ShoppinglistItems + from app.models import ItemStores if not self.default_key and other.default_key: self.default_key = other.default_key @@ -154,6 +167,14 @@ def merge(self, other: Self) -> None: db.session.delete(si) db.session.add(existingSi) + for istore in ItemStores.query.filter(ItemStores.item_id == other.id).all(): + istore: ItemStores + if not ItemStores.find_by_ids(self.id, istore.store_id): + istore.item_id = self.id + db.session.add(istore) + else: + db.session.delete(istore) + for history in History.query.filter(History.item_id == other.id).all(): history.item_id = self.id db.session.add(history) @@ -192,12 +213,14 @@ def find_by_default_key(cls, household_id: int, default_key: str) -> Self | None @classmethod def find_name_starts_with(cls, household_id: int, starts_with: str) -> Self | None: starts_with = starts_with.strip() - return (cls.query.filter( - cls.household_id == household_id, - func.lower(cls.name).like(func.lower(starts_with) + "%"), + return ( + cls.query.filter( + cls.household_id == household_id, + func.lower(cls.name).like(func.lower(starts_with) + "%"), + ) + .order_by(cls.name) + .first() ) - .order_by(cls.name) - .first()) @classmethod def search_name(cls, name: str, household_id: int) -> list[Self]: @@ -261,3 +284,36 @@ def search_name(cls, name: str, household_id: int) -> list[Self]: if item_count <= 0: return found return found + + +class ItemStores(Model): + __tablename__ = "item_stores" + + item_id: Mapped[int] = db.Column( + db.Integer, db.ForeignKey("item.id"), primary_key=True + ) + store_id: Mapped[int] = db.Column( + db.Integer, db.ForeignKey("store.id"), primary_key=True + ) + + item: Mapped["Item"] = cast( + Mapped["Item"], + db.relationship( + "Item", + back_populates="stores", + ), + ) + store: Mapped["Store"] = cast( + Mapped["Store"], + db.relationship( + "Store", + back_populates="items", + lazy="joined", + ), + ) + + @classmethod + def find_by_ids(cls, item_id: int, store_id: int) -> Self | None: + return cls.query.filter( + cls.item_id == item_id, cls.store_id == store_id + ).first() diff --git a/backend/app/models/store.py b/backend/app/models/store.py new file mode 100644 index 000000000..ae4691ed6 --- /dev/null +++ b/backend/app/models/store.py @@ -0,0 +1,80 @@ +from typing import Any, Self, List, TYPE_CHECKING, cast +from app import db +from app.helpers import DbModelAuthorizeMixin +from sqlalchemy.orm import Mapped + +Model = db.Model +if TYPE_CHECKING: + from app.models import Household, ItemStores + from app.helpers.db_model_base import DbModelBase + + Model = DbModelBase + + +class Store(Model, DbModelAuthorizeMixin): + __tablename__ = "store" + + id: Mapped[int] = db.Column(db.Integer, primary_key=True) + name: Mapped[str] = db.Column(db.String(128)) + + household_id: Mapped[int] = db.Column( + db.Integer, db.ForeignKey("household.id"), nullable=False, index=True + ) + + household: Mapped["Household"] = cast( + Mapped["Household"], + db.relationship( + "Household", + uselist=False, + ), + ) + items: Mapped[List["ItemStores"]] = cast( + Mapped[List["ItemStores"]], + db.relationship( + "ItemStores", + back_populates="store", + cascade="all, delete-orphan", + ), + ) + + def obj_to_full_dict(self) -> dict[str, Any]: + res = self.obj_to_dict() + return res + + def merge(self, other: Self) -> None: + if self.household_id != other.household_id: + return + + from app.models import ItemStores + + for istore in ItemStores.query.filter( + ItemStores.store_id == other.id, + ItemStores.item_id.notin_( + db.session.query(ItemStores.item_id) + .filter(ItemStores.store_id == self.id) + .subquery() + .select() + ), + ).all(): + istore.store_id = self.id + db.session.add(istore) + + try: + db.session.commit() + other.delete() + except Exception as e: + db.session.rollback() + raise e + + @classmethod + def create_by_name(cls, household_id: int, name: str) -> Self: + return cls( + name=name, + household_id=household_id, + ).save() + + @classmethod + def find_by_name(cls, household_id: int, name: str) -> Self | None: + return cls.query.filter( + cls.household_id == household_id, cls.name == name + ).first() diff --git a/backend/app/service/importServices/import_item.py b/backend/app/service/importServices/import_item.py index dbc491426..571897fb3 100644 --- a/backend/app/service/importServices/import_item.py +++ b/backend/app/service/importServices/import_item.py @@ -1,4 +1,4 @@ -from app.models import Household, Item, Category +from app.models import Household, Item, Category, Store, ItemStores def importItem(household: Household, args: dict): @@ -14,4 +14,10 @@ def importItem(household: Household, args: dict): if not category: category = Category.create_by_name(household.id, args["category"]) item.category = category + if "stores" in args and not item.stores: + for name in args["stores"]: + store = Store.find_by_name(household.id, name) + if not store: + store = Store.create_by_name(household.id, name) + item.stores.append(ItemStores(store=store)) item.save() diff --git a/backend/migrations/versions/7464b5211b10_.py b/backend/migrations/versions/7464b5211b10_.py new file mode 100644 index 000000000..efd2f8d86 --- /dev/null +++ b/backend/migrations/versions/7464b5211b10_.py @@ -0,0 +1,52 @@ +"""empty message + +Revision ID: 7464b5211b10 +Revises: 0b10d67750be +Create Date: 2026-07-06 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '7464b5211b10' +down_revision = '0b10d67750be' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('store', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=True), + sa.Column('household_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['household_id'], ['household.id'], name=op.f('fk_store_household_id_household')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_store')) + ) + with op.batch_alter_table('store', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_store_household_id'), ['household_id'], unique=False) + + op.create_table('item_stores', + sa.Column('item_id', sa.Integer(), nullable=False), + sa.Column('store_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['item_id'], ['item.id'], name=op.f('fk_item_stores_item_id_item')), + sa.ForeignKeyConstraint(['store_id'], ['store.id'], name=op.f('fk_item_stores_store_id_store')), + sa.PrimaryKeyConstraint('item_id', 'store_id', name=op.f('pk_item_stores')) + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('item_stores') + with op.batch_alter_table('store', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_store_household_id')) + + op.drop_table('store') + # ### end Alembic commands ### diff --git a/backend/tests/api/test_api_store.py b/backend/tests/api/test_api_store.py new file mode 100644 index 000000000..6655f3cbb --- /dev/null +++ b/backend/tests/api/test_api_store.py @@ -0,0 +1,210 @@ +import pytest + + +def create_store(client, household_id, name): + response = client.post( + f"/api/household/{household_id}/store", json={"name": name} + ) + assert response.status_code == 200 + data = response.get_json() + assert "id" in data + assert data["name"] == name + return data["id"] + + +def create_item(client, household_id, name, store_ids=None): + body = {"name": name} + if store_ids is not None: + body["store_ids"] = store_ids + response = client.post(f"/api/household/{household_id}/item", json=body) + assert response.status_code == 200 + return response.get_json() + + +def test_add_and_list_stores(user_client_with_household, household_id): + create_store(user_client_with_household, household_id, "Store A") + create_store(user_client_with_household, household_id, "Store B") + + response = user_client_with_household.get( + f"/api/household/{household_id}/store" + ) + assert response.status_code == 200 + data = response.get_json() + assert len(data) == 2 + assert {e["name"] for e in data} == {"Store A", "Store B"} + + +def test_rename_store(user_client_with_household, household_id): + store_id = create_store(user_client_with_household, household_id, "Store A") + + response = user_client_with_household.post( + f"/api/store/{store_id}", json={"name": "Renamed Store"} + ) + assert response.status_code == 200 + assert response.get_json()["name"] == "Renamed Store" + + +def test_delete_store(user_client_with_household, household_id): + store_id = create_store(user_client_with_household, household_id, "Store A") + + response = user_client_with_household.delete(f"/api/store/{store_id}") + assert response.status_code == 200 + + response = user_client_with_household.get( + f"/api/household/{household_id}/store" + ) + assert response.get_json() == [] + + +def test_item_with_store_ids(user_client_with_household, household_id): + store_a = create_store(user_client_with_household, household_id, "Store A") + store_b = create_store(user_client_with_household, household_id, "Store B") + + item = create_item( + user_client_with_household, + household_id, + "Cheese", + store_ids=[store_a, store_b], + ) + assert "stores" in item + assert {s["id"] for s in item["stores"]} == {store_a, store_b} + + response = user_client_with_household.get(f"/api/item/{item['id']}") + assert response.status_code == 200 + data = response.get_json() + assert {s["id"] for s in data["stores"]} == {store_a, store_b} + + +def test_update_item_store_ids_replaces_full_set( + user_client_with_household, household_id +): + store_a = create_store(user_client_with_household, household_id, "Store A") + store_b = create_store(user_client_with_household, household_id, "Store B") + + item = create_item( + user_client_with_household, household_id, "Cheese", store_ids=[store_a] + ) + + response = user_client_with_household.post( + f"/api/item/{item['id']}", json={"store_ids": [store_b]} + ) + assert response.status_code == 200 + data = response.get_json() + assert {s["id"] for s in data["stores"]} == {store_b} + + response = user_client_with_household.post( + f"/api/item/{item['id']}", json={"store_ids": []} + ) + assert response.status_code == 200 + assert "stores" not in response.get_json() + + +def test_merge_stores_dedups_item_assignments( + user_client_with_household, household_id +): + store_a = create_store(user_client_with_household, household_id, "Store A") + store_b = create_store(user_client_with_household, household_id, "Store B") + + item1 = create_item( + user_client_with_household, household_id, "Cheese", store_ids=[store_a] + ) + item2 = create_item( + user_client_with_household, + household_id, + "Milk", + store_ids=[store_a, store_b], + ) + + # merge store_b into store_a + response = user_client_with_household.post( + f"/api/store/{store_a}", json={"merge_store_id": store_b} + ) + assert response.status_code == 200 + + response = user_client_with_household.get( + f"/api/household/{household_id}/store" + ) + data = response.get_json() + assert len(data) == 1 + assert data[0]["id"] == store_a + + response = user_client_with_household.get(f"/api/item/{item1['id']}") + assert {s["id"] for s in response.get_json()["stores"]} == {store_a} + + response = user_client_with_household.get(f"/api/item/{item2['id']}") + assert {s["id"] for s in response.get_json()["stores"]} == {store_a} + + +def test_merge_items_dedups_stores(user_client_with_household, household_id): + store_a = create_store(user_client_with_household, household_id, "Store A") + store_b = create_store(user_client_with_household, household_id, "Store B") + + item1 = create_item( + user_client_with_household, household_id, "Cheese", store_ids=[store_a] + ) + item2 = create_item( + user_client_with_household, + household_id, + "Fromage", + store_ids=[store_a, store_b], + ) + + response = user_client_with_household.post( + f"/api/item/{item1['id']}", json={"merge_item_id": item2["id"]} + ) + assert response.status_code == 200 + data = response.get_json() + assert {s["id"] for s in data["stores"]} == {store_a, store_b} + + +def test_export_import_round_trips_stores(user_client_with_household, household_id): + store_a = create_store(user_client_with_household, household_id, "Store A") + create_item( + user_client_with_household, household_id, "Cheese", store_ids=[store_a] + ) + + response = user_client_with_household.get( + f"/api/household/{household_id}/export/items" + ) + assert response.status_code == 200 + exported = response.get_json()["items"] + cheese = next(e for e in exported if e["name"] == "Cheese") + assert cheese["stores"] == ["Store A"] + + response = user_client_with_household.post( + f"/api/household/{household_id}/import", json={"items": exported} + ) + assert response.status_code == 200 + + response = user_client_with_household.get( + f"/api/household/{household_id}/store" + ) + assert len(response.get_json()) == 1 + + response = user_client_with_household.get( + f"/api/household/{household_id}/item" + ) + cheese_after = next(e for e in response.get_json() if e["name"] == "Cheese") + assert {s["name"] for s in cheese_after["stores"]} == {"Store A"} + + +def test_import_does_not_override_existing_stores( + user_client_with_household, household_id +): + store_a = create_store(user_client_with_household, household_id, "Store A") + create_store(user_client_with_household, household_id, "Store B") + create_item( + user_client_with_household, household_id, "Cheese", store_ids=[store_a] + ) + + response = user_client_with_household.post( + f"/api/household/{household_id}/import", + json={"items": [{"name": "Cheese", "stores": ["Store B"]}]}, + ) + assert response.status_code == 200 + + response = user_client_with_household.get( + f"/api/household/{household_id}/item" + ) + cheese = next(e for e in response.get_json() if e["name"] == "Cheese") + assert {s["name"] for s in cheese["stores"]} == {"Store A"} diff --git a/kitchenowl/lib/cubits/household_add_update/household_settings_items_cubit.dart b/kitchenowl/lib/cubits/household_add_update/household_settings_items_cubit.dart index d88fbeaa9..8e57615e7 100644 --- a/kitchenowl/lib/cubits/household_add_update/household_settings_items_cubit.dart +++ b/kitchenowl/lib/cubits/household_add_update/household_settings_items_cubit.dart @@ -5,9 +5,11 @@ import 'package:kitchenowl/kitchenowl.dart'; import 'package:kitchenowl/models/category.dart'; import 'package:kitchenowl/models/household.dart'; import 'package:kitchenowl/models/item.dart'; +import 'package:kitchenowl/models/store.dart'; import 'package:kitchenowl/services/api/api_service.dart'; import 'package:kitchenowl/services/transaction_handler.dart'; import 'package:kitchenowl/services/transactions/category.dart'; +import 'package:kitchenowl/services/transactions/store.dart'; class HouseholdSettingsItemsCubit extends Cubit { final Household household; @@ -22,11 +24,15 @@ class HouseholdSettingsItemsCubit extends Cubit { final categories = TransactionHandler.getInstance().runTransaction( TransactionCategoriesGet(household: household), ); + final stores = TransactionHandler.getInstance().runTransaction( + TransactionStoresGet(household: household), + ); if (await items != null) { ShoppinglistSorting.sortShoppinglistItems(state.items, state.sorting); emit(HouseholdSettingsItemsState( items: (await items)!, categories: await categories, + stores: await stores, sorting: state.sorting, )); } @@ -75,11 +81,13 @@ class HouseholdSettingsItemsCubit extends Cubit { class HouseholdSettingsItemsState extends Equatable { final List items; final List categories; + final List stores; final ShoppinglistSorting sorting; const HouseholdSettingsItemsState({ this.items = const [], this.categories = const [], + this.stores = const [], this.sorting = ShoppinglistSorting.alphabetical, }); @@ -89,11 +97,12 @@ class HouseholdSettingsItemsState extends Equatable { HouseholdSettingsItemsState( items: this.items, categories: this.categories, + stores: this.stores, sorting: sorting ?? this.sorting, ); @override - List get props => [items, categories, sorting]; + List get props => [items, categories, stores, sorting]; } class LoadingHouseholdSettingsItemsState extends HouseholdSettingsItemsState { diff --git a/kitchenowl/lib/cubits/household_add_update/household_update_cubit.dart b/kitchenowl/lib/cubits/household_add_update/household_update_cubit.dart index 1785b0528..39b543538 100644 --- a/kitchenowl/lib/cubits/household_add_update/household_update_cubit.dart +++ b/kitchenowl/lib/cubits/household_add_update/household_update_cubit.dart @@ -6,6 +6,7 @@ import 'package:kitchenowl/models/household.dart'; import 'package:kitchenowl/models/import_settings.dart'; import 'package:kitchenowl/models/member.dart'; import 'package:kitchenowl/models/shoppinglist.dart'; +import 'package:kitchenowl/models/store.dart'; import 'package:kitchenowl/models/tag.dart'; import 'package:kitchenowl/services/api/api_service.dart'; @@ -62,6 +63,8 @@ class HouseholdUpdateCubit ApiService.getInstance().getCategories(this.household); Future?> expenseCategories = ApiService.getInstance().getExpenseCategories(this.household); + Future?> stores = + ApiService.getInstance().getAllStores(this.household); Household household = await fHousehold ?? this.household; @@ -76,6 +79,7 @@ class HouseholdUpdateCubit tags: await tags ?? {}, categories: await categories ?? const [], expenseCategories: await expenseCategories ?? const [], + stores: await stores ?? {}, supportedLanguages: state.supportedLanguages, link: household.link ?? "", description: household.description ?? "", @@ -264,6 +268,35 @@ class HouseholdUpdateCubit return res; } + Future deleteStore(Store store) async { + final res = await ApiService.getInstance().deleteStore(store); + refresh(); + + return res; + } + + Future addStore(String name) async { + final res = await ApiService.getInstance() + .addStore(household, Store(name: name)); + refresh(); + + return res; + } + + Future updateStore(Store store) async { + final res = await ApiService.getInstance().updateStore(store); + refresh(); + + return res; + } + + Future mergeStore(Store store, Store other) async { + final res = await ApiService.getInstance().mergeStore(store, other); + refresh(); + + return res; + } + Future mergeExpenseCategory( ExpenseCategory category, ExpenseCategory other, @@ -313,6 +346,7 @@ class HouseholdUpdateState extends HouseholdAddUpdateState { final Set tags; final List categories; final List expenseCategories; + final Set stores; final String link; final String description; @@ -330,6 +364,7 @@ class HouseholdUpdateState extends HouseholdAddUpdateState { this.tags = const {}, this.categories = const [], this.expenseCategories = const [], + this.stores = const {}, }); HouseholdUpdateState copyWith({ @@ -345,6 +380,7 @@ class HouseholdUpdateState extends HouseholdAddUpdateState { Set? tags, List? categories, List? expenseCategories, + Set? stores, String? link, String? description, }) => @@ -360,6 +396,7 @@ class HouseholdUpdateState extends HouseholdAddUpdateState { tags: tags ?? this.tags, categories: categories ?? this.categories, expenseCategories: expenseCategories ?? this.expenseCategories, + stores: stores ?? this.stores, link: link ?? this.link, description: description ?? this.description, ); @@ -373,6 +410,7 @@ class HouseholdUpdateState extends HouseholdAddUpdateState { tags, categories, expenseCategories, + stores, link, description, ]; @@ -405,6 +443,7 @@ class LoadingHouseholdUpdateState extends HouseholdUpdateState { Set? tags, List? categories, List? expenseCategories, + Set? stores, String? link, String? description, }) => diff --git a/kitchenowl/lib/cubits/item_edit_cubit.dart b/kitchenowl/lib/cubits/item_edit_cubit.dart index d52448a5e..ac4cc0798 100644 --- a/kitchenowl/lib/cubits/item_edit_cubit.dart +++ b/kitchenowl/lib/cubits/item_edit_cubit.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart' show setEquals; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:kitchenowl/kitchenowl.dart'; import 'package:kitchenowl/models/category.dart'; @@ -6,6 +7,7 @@ import 'package:kitchenowl/models/household.dart'; import 'package:kitchenowl/models/item.dart'; import 'package:kitchenowl/models/recipe.dart'; import 'package:kitchenowl/models/shoppinglist.dart'; +import 'package:kitchenowl/models/store.dart'; import 'package:kitchenowl/services/api/api_service.dart'; import 'package:kitchenowl/services/transaction_handler.dart'; import 'package:kitchenowl/services/transactions/item.dart'; @@ -23,6 +25,7 @@ class ItemEditCubit extends Cubit { category: Nullable(state.category), icon: Nullable(state.icon), name: state.name, + stores: state.stores, )) as T); } @@ -30,6 +33,7 @@ class ItemEditCubit extends Cubit { category: Nullable(state.category), icon: Nullable(state.icon), name: state.name, + stores: state.stores, ) as T; } @@ -40,6 +44,7 @@ class ItemEditCubit extends Cubit { icon: item.icon, name: item.name, category: item.category, + stores: item.stores, )) { refresh(); } @@ -128,6 +133,16 @@ class ItemEditCubit extends Cubit { icon: Nullable(icon), )); } + + void toggleStore(Store store, bool selected) { + final stores = List.of(state.stores); + if (selected) { + if (!stores.contains(store)) stores.add(store); + } else { + stores.remove(store); + } + emit(state.copyWith(stores: stores)); + } } class ItemEditState extends Equatable { @@ -136,6 +151,7 @@ class ItemEditState extends Equatable { final String? icon; final List recipes; final Category? category; + final List stores; final bool hasMerged; const ItemEditState({ @@ -144,6 +160,7 @@ class ItemEditState extends Equatable { this.icon, this.recipes = const [], this.category, + this.stores = const [], this.hasMerged = false, }); @@ -153,6 +170,7 @@ class ItemEditState extends Equatable { Nullable? icon, List? recipes, Nullable? category, + List? stores, bool? hasMerged, }) => ItemEditState( @@ -161,12 +179,13 @@ class ItemEditState extends Equatable { icon: (icon ?? Nullable(this.icon)).value, recipes: recipes ?? this.recipes, category: (category ?? Nullable(this.category)).value, + stores: stores ?? this.stores, hasMerged: hasMerged ?? this.hasMerged, ); @override List get props => - [name, description, icon, recipes, category, hasMerged]; + [name, description, icon, recipes, category, stores, hasMerged]; bool hasChanged(Item comparedTo) => hasChangedItem(comparedTo) || hasChangedDescription(comparedTo); @@ -175,6 +194,7 @@ class ItemEditState extends Equatable { comparedTo.category != category || comparedTo.icon != icon || comparedTo.name != name || + !setEquals(Set.of(comparedTo.stores), Set.of(stores)) || hasMerged; bool hasChangedDescription(Item comparedTo) { diff --git a/kitchenowl/lib/cubits/shoppinglist_cubit.dart b/kitchenowl/lib/cubits/shoppinglist_cubit.dart index 0a1621598..937d430e6 100644 --- a/kitchenowl/lib/cubits/shoppinglist_cubit.dart +++ b/kitchenowl/lib/cubits/shoppinglist_cubit.dart @@ -8,9 +8,11 @@ import 'package:kitchenowl/models/category.dart'; import 'package:kitchenowl/models/household.dart'; import 'package:kitchenowl/models/item.dart'; import 'package:kitchenowl/models/shoppinglist.dart'; +import 'package:kitchenowl/models/store.dart'; import 'package:kitchenowl/services/api/api_service.dart'; import 'package:kitchenowl/services/storage/storage.dart'; import 'package:kitchenowl/services/transactions/category.dart'; +import 'package:kitchenowl/services/transactions/store.dart'; import 'package:kitchenowl/services/transactions/shoppinglist.dart'; import 'package:kitchenowl/services/transaction_handler.dart'; @@ -359,6 +361,13 @@ class ShoppinglistCubit extends Cubit { ); } + Future> fetchStores([bool forceOffline = false]) { + return TransactionHandler.getInstance().runTransaction( + TransactionStoresGet(household: household), + forceOffline: forceOffline, + ); + } + Future _initialLoad() async { final shoppingLists = await fetchShoppingLists(true); @@ -378,11 +387,13 @@ class ShoppinglistCubit extends Cubit { if (shoppingList == null) return; Future> categories = fetchCategories(true); + Future> stores = fetchStores(true); final resState = LoadingShoppinglistCubitState( shoppinglists: shoppingLists, selectedShoppinglistId: shoppingList.id, categories: await categories, + stores: await stores, sorting: state.sorting, selectedListItems: state.selectedListItems .map((e) => @@ -408,6 +419,7 @@ class ShoppinglistCubit extends Cubit { shoppinglists: state.shoppinglists, sorting: state.sorting, categories: state.categories, + stores: state.stores, selectedListItems: state.selectedListItems, )); } @@ -426,6 +438,7 @@ class ShoppinglistCubit extends Cubit { final shoppinglist = shoppingLists[selectedShoppinglistId]; Future> categories = fetchCategories(); + Future> stores = fetchStores(); if (query != null && query.isNotEmpty) { // Split query into name and description @@ -468,6 +481,7 @@ class ShoppinglistCubit extends Cubit { result: loadedItems, query: query, categories: await categories, + stores: await stores, sorting: state.sorting, selectedListItems: state.selectedListItems .map((e) => @@ -480,6 +494,7 @@ class ShoppinglistCubit extends Cubit { shoppinglists: shoppingLists, selectedShoppinglistId: selectedShoppinglistId, categories: await categories, + stores: await stores, sorting: state.sorting, selectedListItems: state.selectedListItems .map((e) => @@ -523,6 +538,7 @@ class ShoppinglistCubitState extends Equatable { final Map shoppinglists; final int? selectedShoppinglistId; final List categories; + final List stores; final ShoppinglistSorting sorting; final List selectedListItems; final ShoppingList? _selectedShoppinglist; @@ -530,6 +546,7 @@ class ShoppinglistCubitState extends Equatable { const ShoppinglistCubitState._({ this.shoppinglists = const {}, this.categories = const [], + this.stores = const [], this.sorting = ShoppinglistSorting.alphabetical, this.selectedListItems = const [], this.selectedShoppinglistId = null, @@ -539,6 +556,7 @@ class ShoppinglistCubitState extends Equatable { this.shoppinglists = const {}, required this.selectedShoppinglistId, this.categories = const [], + this.stores = const [], this.sorting = ShoppinglistSorting.alphabetical, this.selectedListItems = const [], }) : _selectedShoppinglist = shoppinglists[selectedShoppinglistId]; @@ -549,6 +567,7 @@ class ShoppinglistCubitState extends Equatable { Map? shoppinglists, int? selectedShoppinglistId, List? categories, + List? stores, ShoppinglistSorting? sorting, List? selectedListItems, }) => @@ -557,6 +576,7 @@ class ShoppinglistCubitState extends Equatable { selectedShoppinglistId: selectedShoppinglistId ?? this.selectedShoppinglistId, categories: categories ?? this.categories, + stores: stores ?? this.stores, sorting: sorting ?? this.sorting, selectedListItems: selectedListItems ?? this.selectedListItems, ); @@ -566,6 +586,7 @@ class ShoppinglistCubitState extends Equatable { shoppinglists, selectedShoppinglistId, categories, + stores, sorting, selectedListItems, ]; @@ -577,6 +598,7 @@ class LoadingShoppinglistCubitState extends ShoppinglistCubitState { super.selectedShoppinglistId, super.shoppinglists, super.categories, + super.stores, super.selectedListItems, }) : super._(); @@ -587,6 +609,7 @@ class LoadingShoppinglistCubitState extends ShoppinglistCubitState { List? listItems, List? recentItems, List? categories, + List? stores, ShoppinglistSorting? sorting, List? selectedListItems, }) => @@ -596,6 +619,7 @@ class LoadingShoppinglistCubitState extends ShoppinglistCubitState { selectedShoppinglistId: selectedShoppinglistId ?? this.selectedShoppinglistId, categories: categories ?? this.categories, + stores: stores ?? this.stores, selectedListItems: selectedListItems ?? this.selectedListItems, ); } @@ -608,6 +632,7 @@ class SearchShoppinglistCubitState extends ShoppinglistCubitState { super.shoppinglists = const {}, required super.selectedShoppinglistId, super.categories = const [], + super.stores = const [], super.sorting = ShoppinglistSorting.alphabetical, this.query = "", this.result = const [], @@ -619,6 +644,7 @@ class SearchShoppinglistCubitState extends ShoppinglistCubitState { Map? shoppinglists, int? selectedShoppinglistId, List? categories, + List? stores, ShoppinglistSorting? sorting, List? result, List? selectedListItems, @@ -629,6 +655,7 @@ class SearchShoppinglistCubitState extends ShoppinglistCubitState { selectedShoppinglistId ?? this.selectedShoppinglistId, sorting: sorting ?? this.sorting, categories: categories ?? this.categories, + stores: stores ?? this.stores, query: query, result: result ?? this.result, selectedListItems: selectedListItems ?? this.selectedListItems, @@ -640,6 +667,7 @@ class SearchShoppinglistCubitState extends ShoppinglistCubitState { selectedShoppinglistId ?? this.selectedShoppinglistId, sorting: sorting, categories: categories, + stores: stores, selectedListItems: selectedListItems, ); diff --git a/kitchenowl/lib/l10n/app_ar.arb b/kitchenowl/lib/l10n/app_ar.arb index b53c24340..c415c3c1e 100644 --- a/kitchenowl/lib/l10n/app_ar.arb +++ b/kitchenowl/lib/l10n/app_ar.arb @@ -28,6 +28,7 @@ } } }, + "@addStore": {}, "@addedBy": { "placeholders": { "name": { @@ -232,6 +233,18 @@ "@signup": {}, "@sortingAlphabetical": {}, "@start": {}, + "@store": {}, + "@storeDelete": {}, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, + "@storeEdit": {}, + "@stores": {}, "@supportDevelopment": {}, "@themeDark": {}, "@themeLight": {}, @@ -279,6 +292,7 @@ "addLanguage": "إضافة لغة", "addLanguageConfirm": "هل أنت متأكد من أنك تريد تعيين اللغة إلى '{lang}'؟ لا يمكن التراجع عن هذا الإجراء وسيؤدي إلى إضافة عناصر وفئات إلى منزلك.", "addNumberIngredients": "أضف {number} المكونات", + "addStore": "إضافة متجر", "addedBy": "أضيفت من قبل {name}", "address": "العنوان", "appTitle": "كيتشن آول", @@ -405,6 +419,11 @@ "signup": "التسجيل", "sortingAlphabetical": "أبجدي", "start": "إبدأ", + "store": "متجر", + "storeDelete": "حذف متجر", + "storeDeleteConfirmation": "هل أنت متأكد من حذف {store}؟ سيؤدي هذا إلى إزالة المتجر من جميع العناصر.", + "storeEdit": "تعديل متجر", + "stores": "متاجر", "supportDevelopment": "إدعم التطوير", "themeDark": "مظلم", "themeLight": "مضيء", diff --git a/kitchenowl/lib/l10n/app_be.arb b/kitchenowl/lib/l10n/app_be.arb index aea3c9a05..9d390d825 100644 --- a/kitchenowl/lib/l10n/app_be.arb +++ b/kitchenowl/lib/l10n/app_be.arb @@ -37,6 +37,25 @@ "@addDescriptionFromSource": {}, "addTag": "Дадаць тэг", "@addTag": {}, + "addStore": "Дадаць магазін", + "@addStore": {}, + "store": "Магазін", + "@store": {}, + "storeDelete": "Выдаліць магазін", + "@storeDelete": {}, + "storeDeleteConfirmation": "Вы ўпэўнены, што хочаце выдаліць {store}? Гэта дзеянне выдаліць магазін з усіх прадметаў.", + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "type": "String", + "example": "Supermarket" + } + } + }, + "storeEdit": "Рэдагаваць магазін", + "@storeEdit": {}, + "stores": "Магазіны", + "@stores": {}, "addRecipeToPlanner": "Дадаць у план страў ({number})", "@addRecipeToPlanner": { "placeholders": { diff --git a/kitchenowl/lib/l10n/app_bg.arb b/kitchenowl/lib/l10n/app_bg.arb index 13290c667..8c64c977b 100644 --- a/kitchenowl/lib/l10n/app_bg.arb +++ b/kitchenowl/lib/l10n/app_bg.arb @@ -40,6 +40,7 @@ "description": "A shorter title/button text used wherever the space is limited. Excludes the number of yields." }, "@addShoppingList": {}, + "@addStore": {}, "@addTag": {}, "@addedBy": { "placeholders": { @@ -357,6 +358,18 @@ "@remove": {}, "@rename": {}, "@reportIssue": {}, + "@store": {}, + "@storeDelete": {}, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, + "@storeEdit": {}, + "@stores": {}, "about": "За нас", "accountCreate": "Създайте акаунт", "accountCreateHint": "Вашето потребителско име и име са публични, така че изберете нещо, което бихте споделили с останалите потребители.", @@ -375,6 +388,7 @@ "addRecipeToPlanner": "Добави към хранителния план ({number} порци)", "addRecipeToPlannerShort": "Добави към хранителния план", "addShoppingList": "Добави към списъка за пазаруване", + "addStore": "Добави магазин", "addTag": "Добави етикет", "addedBy": "Добавено от {name}", "address": "Адрес", @@ -560,5 +574,10 @@ "rememberLastShoppingList": "Запомни последно използвания списък за пазаруване", "remove": "Премахни", "rename": "Преименувай", - "reportIssue": "Докладвай проблем" + "reportIssue": "Докладвай проблем", + "store": "Магазин", + "storeDelete": "Изтрий магазин", + "storeDeleteConfirmation": "Сигурен ли си, че искаш да изтриеш {store}? Това ще изтрие магазина от всички артикули.", + "storeEdit": "Редактирай магазин", + "stores": "Магазини" } diff --git a/kitchenowl/lib/l10n/app_ca.arb b/kitchenowl/lib/l10n/app_ca.arb index 2ecbadf30..dac8495dd 100644 --- a/kitchenowl/lib/l10n/app_ca.arb +++ b/kitchenowl/lib/l10n/app_ca.arb @@ -40,6 +40,7 @@ "description": "A shorter title/button text used wherever the space is limited. Excludes the number of yields." }, "@addShoppingList": {}, + "@addStore": {}, "@addTag": {}, "@addedBy": { "placeholders": { @@ -415,6 +416,18 @@ "@sortingAlgorithmic": {}, "@sortingAlphabetical": {}, "@start": {}, + "@store": {}, + "@storeDelete": {}, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, + "@storeEdit": {}, + "@stores": {}, "@supportDevelopment": {}, "@swipeToDelete": {}, "@swipeToDeleteAndLongPressToReorder": {}, @@ -510,6 +523,7 @@ "addRecipeToPlanner": "Afegir a la planificació de menjades ({number} porcions)", "addRecipeToPlannerShort": "Afegir a la planificació de menjades", "addShoppingList": "Afegir llista d'anar a comprar", + "addStore": "Afegir botiga", "addTag": "Afegir etiqueta", "addedBy": "Afegit per {name}", "address": "Adreça", @@ -732,6 +746,11 @@ "sortingAlgorithmic": "Algorítmic", "sortingAlphabetical": "Alfabètic", "start": "Inici", + "store": "Botiga", + "storeDelete": "Eliminar Botiga", + "storeDeleteConfirmation": "Estàs segur que vols eliminar {store}? Aquesta acció eliminarà la botiga de tots els ítems.", + "storeEdit": "Editar Botiga", + "stores": "Botigues", "supportDevelopment": "Donar suport al desenvolupament", "swipeToDelete": "Llisca per eliminar", "swipeToDeleteAndLongPressToReorder": "Llisca per eliminar i mantén apretat per reordenar", diff --git a/kitchenowl/lib/l10n/app_ca_valencia.arb b/kitchenowl/lib/l10n/app_ca_valencia.arb index a90a8966e..ff540a4b1 100644 --- a/kitchenowl/lib/l10n/app_ca_valencia.arb +++ b/kitchenowl/lib/l10n/app_ca_valencia.arb @@ -37,6 +37,7 @@ "description": "A shorter title/button text used wherever the space is limited. Excludes the number of yields." }, "@addShoppingList": {}, + "@addStore": {}, "@addTag": {}, "@addedBy": { "placeholders": { @@ -278,6 +279,18 @@ } }, "@profile": {}, + "@store": {}, + "@storeDelete": {}, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, + "@storeEdit": {}, + "@stores": {}, "about": "Sobre", "accountCreate": "Crear Compte", "accountCreateTitle": "Crea el teu compte de KichenOwl", @@ -293,6 +306,7 @@ "addRecipeToPlanner": "Afegir a la planificació de menjades ({number} porcions)", "addRecipeToPlannerShort": "Afegir a la planificació de menjades", "addShoppingList": "Afegir llista de la compra", + "addStore": "Afegir botiga", "addTag": "Afegir etiqueta", "addedBy": "Afegit per {name}", "address": "Adreça", @@ -425,5 +439,10 @@ "preparationTime": "Temps de preparació", "privacyPolicy": "Política de privacitat", "privacyPolicyAgree": "Creant aquesta compta estàs d'acord amb la nostra {privacyPolicy}", - "profile": "Perfil" + "profile": "Perfil", + "store": "Botiga", + "storeDelete": "Eliminar Botiga", + "storeDeleteConfirmation": "Estàs segur que vols eliminar {store}? Aquesta acció eliminarà la botiga de tots els ítems.", + "storeEdit": "Editar Botiga", + "stores": "Botigues" } diff --git a/kitchenowl/lib/l10n/app_cs.arb b/kitchenowl/lib/l10n/app_cs.arb index 12b85f00c..ec373dddd 100644 --- a/kitchenowl/lib/l10n/app_cs.arb +++ b/kitchenowl/lib/l10n/app_cs.arb @@ -38,6 +38,7 @@ }, "@addRecipeToPlannerShort": {}, "@addShoppingList": {}, + "@addStore": {}, "@addTag": {}, "@addedBy": { "placeholders": { @@ -414,6 +415,18 @@ "@sortingAlgorithmic": {}, "@sortingAlphabetical": {}, "@start": {}, + "@store": {}, + "@storeDelete": {}, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, + "@storeEdit": {}, + "@stores": {}, "@supportDevelopment": {}, "@swipeToDelete": {}, "@swipeToDeleteAndLongPressToReorder": {}, @@ -507,6 +520,7 @@ "addRecipeToPlanner": "Přidat do jídelníčku ({number})", "addRecipeToPlannerShort": "Přidat jídlo do plánu", "addShoppingList": "Přidat nákupní seznam", + "addStore": "Přidat obchod", "addTag": "Přidat Tag", "addedBy": "Přidáno {name}", "address": "Adresa", @@ -730,6 +744,11 @@ "sortingAlgorithmic": "Algoritmické", "sortingAlphabetical": "Abecedně", "start": "Začít", + "store": "Obchod", + "storeDelete": "Smazat obchod", + "storeDeleteConfirmation": "Jste si jistí že chcete smazat {store}? Tento obchod bude odstraněn ze všech položek.", + "storeEdit": "Upravit obchod", + "stores": "Obchody", "supportDevelopment": "Podpořit vývoj", "swipeToDelete": "Přejetí prstem pro smazání", "swipeToDeleteAndLongPressToReorder": "Přejetí prstem pro smazání a dlouhé zmáčknutí pro změnu pořadí", diff --git a/kitchenowl/lib/l10n/app_da.arb b/kitchenowl/lib/l10n/app_da.arb index 927127f02..de86fc264 100644 --- a/kitchenowl/lib/l10n/app_da.arb +++ b/kitchenowl/lib/l10n/app_da.arb @@ -207,6 +207,14 @@ } } }, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, "@tagDeleteConfirmation": { "placeholders": { "tag": { @@ -252,6 +260,7 @@ "addRecipeToPlanner": "Føj til måltidsplan ({number} portioner)", "addRecipeToPlannerShort": "Føj til madplan", "addShoppingList": "Tilføj indkøbsliste", + "addStore": "Tilføj butik", "addTag": "Tilføj etiket", "addedBy": "Tilføjet af {name}", "address": "Adresse", @@ -483,6 +492,11 @@ "sortingAlgorithmic": "Algoritmisk", "sortingAlphabetical": "Alfabetisk", "start": "Start", + "store": "Butik", + "storeDelete": "Slet butik", + "storeDeleteConfirmation": "Er du sikker på, at du vil slette {store}? Dette fjerner butikken fra alle varer.", + "storeEdit": "Rediger butik", + "stores": "Butikker", "supportDevelopment": "Støt udviklingen", "swipeToDelete": "Stryg for at slette", "swipeToDeleteAndLongPressToReorder": "Stryg for at slette, og hold nede for at omarrangere", diff --git a/kitchenowl/lib/l10n/app_de.arb b/kitchenowl/lib/l10n/app_de.arb index c9507b6c1..2342355a1 100644 --- a/kitchenowl/lib/l10n/app_de.arb +++ b/kitchenowl/lib/l10n/app_de.arb @@ -199,6 +199,14 @@ } } }, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, "@tagDeleteConfirmation": { "placeholders": { "tag": { @@ -244,6 +252,7 @@ "addRecipeToPlanner": "Zum Essensplan hinzufügen ({number} Portionen)", "addRecipeToPlannerShort": "Zum Essensplan hinzufügen", "addShoppingList": "Einkaufsliste hinzufügen", + "addStore": "Geschäft hinzufügen", "addTag": "Markierung hinzufügen", "addedBy": "Hinzugefügt von {name}", "address": "Adresse", @@ -475,6 +484,11 @@ "sortingAlgorithmic": "Algorithmisch", "sortingAlphabetical": "Alphabetisch", "start": "Start", + "store": "Geschäft", + "storeDelete": "Geschäft löschen", + "storeDeleteConfirmation": "Bist du dir sicher, dass du {store} löschen möchtest? Diese Aktion entfernt das Geschäft von allen Artikeln.", + "storeEdit": "Geschäft bearbeiten", + "stores": "Geschäfte", "supportDevelopment": "Unterstütze die Entwicklung", "swipeToDelete": "Wische zum Löschen", "swipeToDeleteAndLongPressToReorder": "Wische zum Löschen und halte lang zum Umsortieren", diff --git a/kitchenowl/lib/l10n/app_de_CH.arb b/kitchenowl/lib/l10n/app_de_CH.arb index 17a6e206f..ea55aef25 100644 --- a/kitchenowl/lib/l10n/app_de_CH.arb +++ b/kitchenowl/lib/l10n/app_de_CH.arb @@ -210,6 +210,14 @@ } } }, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, "@tagDeleteConfirmation": { "placeholders": { "tag": { @@ -255,6 +263,7 @@ "addRecipeToPlanner": "Zum Menuplan hinzufügen ({number} Portionen)", "addRecipeToPlannerShort": "Zum Menuplan hinzufügen", "addShoppingList": "Einkaufsliste hinzufügen", + "addStore": "Geschäft hinzufügen", "addTag": "Markierung hinzufügen", "addedBy": "Hinzugefügt von {name}", "address": "Adresse", @@ -481,6 +490,11 @@ "sortingAlgorithmic": "Algorithmisch", "sortingAlphabetical": "Alphabetisch", "start": "Start", + "store": "Geschäft", + "storeDelete": "Geschäft löschen", + "storeDeleteConfirmation": "Bist du dir sicher, dass du {store} löschen möchtest? Diese Aktion entfernt das Geschäft von allen Artikeln.", + "storeEdit": "Geschäft bearbeiten", + "stores": "Geschäfte", "supportDevelopment": "Unterstütze die Entwicklung", "swipeToDelete": "Wischen zum Löschen", "swipeToDeleteAndLongPressToReorder": "Wischen zum Löschen und lange drücken zum Umsortieren", diff --git a/kitchenowl/lib/l10n/app_el.arb b/kitchenowl/lib/l10n/app_el.arb index cadf35b70..279ae7630 100644 --- a/kitchenowl/lib/l10n/app_el.arb +++ b/kitchenowl/lib/l10n/app_el.arb @@ -38,6 +38,7 @@ }, "@addRecipeToPlannerShort": {}, "@addShoppingList": {}, + "@addStore": {}, "@addTag": {}, "@addedBy": { "placeholders": { @@ -424,6 +425,18 @@ "@sortingAlgorithmic": {}, "@sortingAlphabetical": {}, "@start": {}, + "@store": {}, + "@storeDelete": {}, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, + "@storeEdit": {}, + "@stores": {}, "@supportDevelopment": {}, "@swipeToDelete": {}, "@swipeToDeleteAndLongPressToReorder": {}, @@ -519,6 +532,7 @@ "addRecipeToPlanner": "Προσθήκη στο πρόγραμμα γευμάτων ({number} Σερβιρίσματα)", "addRecipeToPlannerShort": "Προσθήκη στο πρόγραμμα γευμάτων", "addShoppingList": "Προσθήκη λίστας αγορών", + "addStore": "Προσθήκη καταστήματος", "addTag": "Προσθήκη ετικέτας", "addedBy": "Προστέθηκε από {name}", "address": "Διεύθυνση", @@ -745,6 +759,11 @@ "sortingAlgorithmic": "Αλγοριθμικά", "sortingAlphabetical": "Αλφαβητικά", "start": "Έναρξη", + "store": "Κατάστημα", + "storeDelete": "Διαγραφή καταστήματος", + "storeDeleteConfirmation": "Είστε σίγουροι πως θέλετε να διαγράψετε το κατάστημα {store}; Αυτό θα αφαιρέσει το κατάστημα από όλα τα είδη.", + "storeEdit": "Επεξεργασία Καταστήματος", + "stores": "Καταστήματα", "supportDevelopment": "Υποστηρίξτε την ανάπτυξη", "swipeToDelete": "Σύρετε για διαγραφή", "swipeToDeleteAndLongPressToReorder": "Σύρετε για διαγραφή και πατήστε παρατεταμένα για ανακατανομή", diff --git a/kitchenowl/lib/l10n/app_en.arb b/kitchenowl/lib/l10n/app_en.arb index c0ba23eb4..a302e8126 100644 --- a/kitchenowl/lib/l10n/app_en.arb +++ b/kitchenowl/lib/l10n/app_en.arb @@ -40,6 +40,7 @@ "description": "A shorter title/button text used wherever the space is limited. Excludes the number of yields." }, "@addShoppingList": {}, + "@addStore": {}, "@addTag": {}, "@addedBy": { "placeholders": { @@ -433,6 +434,18 @@ "@sortingAlgorithmic": {}, "@sortingAlphabetical": {}, "@start": {}, + "@store": {}, + "@storeDelete": {}, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, + "@storeEdit": {}, + "@stores": {}, "@supportDevelopment": {}, "@swipeToDelete": {}, "@swipeToDeleteAndLongPressToReorder": {}, @@ -528,6 +541,7 @@ "addRecipeToPlanner": "Add to meal plan ({number} serves)", "addRecipeToPlannerShort": "Add to meal plan", "addShoppingList": "Add shopping list", + "addStore": "Add store", "addTag": "Add tag", "addedBy": "Added by {name}", "address": "Address", @@ -759,6 +773,11 @@ "sortingAlgorithmic": "Algorithmic", "sortingAlphabetical": "Alphabetical", "start": "Start", + "store": "Store", + "storeDelete": "Delete store", + "storeDeleteConfirmation": "Are you sure you want to delete {store}? This will remove the store from all items.", + "storeEdit": "Edit store", + "stores": "Stores", "supportDevelopment": "Support the development", "swipeToDelete": "Swipe to delete", "swipeToDeleteAndLongPressToReorder": "Swipe to delete and long press to reorder", diff --git a/kitchenowl/lib/l10n/app_en_AU.arb b/kitchenowl/lib/l10n/app_en_AU.arb index a6bea44b6..e6aa28b94 100644 --- a/kitchenowl/lib/l10n/app_en_AU.arb +++ b/kitchenowl/lib/l10n/app_en_AU.arb @@ -39,6 +39,7 @@ "description": "A shorter title/button text used wherever the space is limited. Excludes the number of yields." }, "@addShoppingList": {}, + "@addStore": {}, "@addTag": {}, "@addedBy": { "placeholders": { @@ -356,6 +357,18 @@ "@sortingAlgorithmic": {}, "@sortingAlphabetical": {}, "@start": {}, + "@store": {}, + "@storeDelete": {}, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, + "@storeEdit": {}, + "@stores": {}, "@supportDevelopment": {}, "@swipeToDelete": {}, "@swipeToDeleteAndLongPressToReorder": {}, @@ -431,6 +444,7 @@ "addRecipeToPlanner": "Add to meal plan ({number} serves)", "addRecipeToPlannerShort": "Add to meal plan", "addShoppingList": "Add shopping list", + "addStore": "Add store", "addTag": "Add tag", "addedBy": "Added by {name}", "address": "Address", @@ -617,6 +631,11 @@ "sortingAlgorithmic": "Algorithmic", "sortingAlphabetical": "Alphabetical", "start": "Start", + "store": "Store", + "storeDelete": "Delete store", + "storeDeleteConfirmation": "Are you sure you want to delete {store}? This will remove the store from all items.", + "storeEdit": "Edit store", + "stores": "Stores", "supportDevelopment": "Support the development", "swipeToDelete": "Swipe to delete", "swipeToDeleteAndLongPressToReorder": "Swipe to delete and long press to reorder", diff --git a/kitchenowl/lib/l10n/app_es.arb b/kitchenowl/lib/l10n/app_es.arb index 325160272..39078cc63 100644 --- a/kitchenowl/lib/l10n/app_es.arb +++ b/kitchenowl/lib/l10n/app_es.arb @@ -207,6 +207,14 @@ } } }, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, "@tagDeleteConfirmation": { "placeholders": { "tag": { @@ -252,6 +260,7 @@ "addRecipeToPlanner": "Añadir al plan de comidas ({number} porciones)", "addRecipeToPlannerShort": "Agregar al plan de comidas", "addShoppingList": "Agregar a la lista de compras", + "addStore": "Añadir tienda", "addTag": "Añadir etiqueta", "addedBy": "Añadido por {name}", "address": "Dirección", @@ -480,6 +489,11 @@ "sortingAlgorithmic": "Algorítmico", "sortingAlphabetical": "Alfabetico", "start": "Comenzar", + "store": "Tienda", + "storeDelete": "Borrar tienda", + "storeDeleteConfirmation": "¿Está seguro de que desea eliminar {store}? Esto eliminará la tienda de todos los artículos.", + "storeEdit": "Editar tienda", + "stores": "Tiendas", "supportDevelopment": "Apoyar el desarrollo", "swipeToDelete": "Desliza para borrar", "swipeToDeleteAndLongPressToReorder": "Desliza para borrar y pulsación larga para reordenar", diff --git a/kitchenowl/lib/l10n/app_fa.arb b/kitchenowl/lib/l10n/app_fa.arb index aab9d310d..424e5cdd8 100644 --- a/kitchenowl/lib/l10n/app_fa.arb +++ b/kitchenowl/lib/l10n/app_fa.arb @@ -40,6 +40,7 @@ "description": "A shorter title/button text used wherever the space is limited. Excludes the number of yields." }, "@addShoppingList": {}, + "@addStore": {}, "@addTag": {}, "@addedBy": { "placeholders": { @@ -430,6 +431,18 @@ "@sortingAlgorithmic": {}, "@sortingAlphabetical": {}, "@start": {}, + "@store": {}, + "@storeDelete": {}, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, + "@storeEdit": {}, + "@stores": {}, "@supportDevelopment": {}, "@swipeToDelete": {}, "@swipeToDeleteAndLongPressToReorder": {}, @@ -525,6 +538,7 @@ "addRecipeToPlanner": "افزودن به برنامه غذایی ({number} پرس)", "addRecipeToPlannerShort": "افزودن به برنامه غذایی", "addShoppingList": "افزودن لیست خرید", + "addStore": "افزودن فروشگاه", "addTag": "افزودن برچسب", "addedBy": "اضافه شده توسط {name}", "address": "آدرس", @@ -751,6 +765,11 @@ "sortingAlgorithmic": "الگوریتمی", "sortingAlphabetical": "حروف الفبا", "start": "شروع", + "store": "فروشگاه", + "storeDelete": "حذف فروشگاه", + "storeDeleteConfirmation": "آیا مطمئن هستید که می‌خواهید {store} را حذف کنید؟ این کار فروشگاه را از همه آیتم‌ها حذف می‌کند.", + "storeEdit": "ویرایش فروشگاه", + "stores": "فروشگاه‌ها", "supportDevelopment": "حمایت از توسعه", "swipeToDelete": "برای حذف بکشید", "swipeToDeleteAndLongPressToReorder": "برای حذف بکشید و برای مرتب‌سازی مجدد طولانی فشار دهید", diff --git a/kitchenowl/lib/l10n/app_fi.arb b/kitchenowl/lib/l10n/app_fi.arb index c8e6ac3b9..838ba82c6 100644 --- a/kitchenowl/lib/l10n/app_fi.arb +++ b/kitchenowl/lib/l10n/app_fi.arb @@ -207,6 +207,14 @@ } } }, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, "@tagDeleteConfirmation": { "placeholders": { "tag": { @@ -252,6 +260,7 @@ "addRecipeToPlanner": "Lisää ateriasuunnitelmaan ({number})", "addRecipeToPlannerShort": "Lisää ateriasuunnitelmaan", "addShoppingList": "Lisää ostoslista", + "addStore": "Lisää kauppa", "addTag": "Lisää avainsana", "addedBy": "{name} lisäsi", "address": "Osoite", @@ -483,6 +492,11 @@ "sortingAlgorithmic": "Algoritmi", "sortingAlphabetical": "Aakkosjärjestys", "start": "Aloita", + "store": "Kauppa", + "storeDelete": "Poista kauppa", + "storeDeleteConfirmation": "Haluatko varmasti poistaa kaupan {store}? Kauppa poistetaan kaikilta kohteilta.", + "storeEdit": "Muokkaa kauppaa", + "stores": "Kaupat", "supportDevelopment": "Tue sovelluskehitystä", "swipeToDelete": "Pyyhkäise poistaaksesi", "swipeToDeleteAndLongPressToReorder": "Pyyhkäise poistaaksesi ja paina pitkään järjestelläksesi uudelleen", diff --git a/kitchenowl/lib/l10n/app_fr.arb b/kitchenowl/lib/l10n/app_fr.arb index 070fce216..6d80a01a8 100644 --- a/kitchenowl/lib/l10n/app_fr.arb +++ b/kitchenowl/lib/l10n/app_fr.arb @@ -207,6 +207,14 @@ } } }, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, "@tagDeleteConfirmation": { "placeholders": { "tag": { @@ -252,6 +260,7 @@ "addRecipeToPlanner": "Ajouter au planning de repas ({number} portions)", "addRecipeToPlannerShort": "Ajouter au plan de repas", "addShoppingList": "Ajouter une liste de courses", + "addStore": "Ajouter un magasin", "addTag": "Ajouter une étiquette", "addedBy": "Ajouté par {name}", "address": "Adresse", @@ -480,6 +489,11 @@ "sortingAlgorithmic": "Algorithmique", "sortingAlphabetical": "Alphabétique", "start": "Commencer", + "store": "Magasin", + "storeDelete": "Supprimer le magasin", + "storeDeleteConfirmation": "Êtes-vous sûr·e de vouloir supprimer {store} ? Ceci supprimera le magasin de tous les articles.", + "storeEdit": "Modifier le magasin", + "stores": "Magasins", "supportDevelopment": "Soutenir le développement", "swipeToDelete": "Balayez pour supprimer", "swipeToDeleteAndLongPressToReorder": "Balayez pour supprimer et appuyez longuement pour réorganiser", diff --git a/kitchenowl/lib/l10n/app_he.arb b/kitchenowl/lib/l10n/app_he.arb index 0d599c48d..baf885aec 100644 --- a/kitchenowl/lib/l10n/app_he.arb +++ b/kitchenowl/lib/l10n/app_he.arb @@ -40,6 +40,7 @@ "description": "A shorter title/button text used wherever the space is limited. Excludes the number of yields." }, "@addShoppingList": {}, + "@addStore": {}, "@addTag": {}, "@addedBy": { "placeholders": { @@ -426,6 +427,18 @@ "@sortingAlgorithmic": {}, "@sortingAlphabetical": {}, "@start": {}, + "@store": {}, + "@storeDelete": {}, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, + "@storeEdit": {}, + "@stores": {}, "@supportDevelopment": {}, "@swipeToDelete": {}, "@swipeToDeleteAndLongPressToReorder": {}, @@ -521,6 +534,7 @@ "addRecipeToPlanner": "הוספה לתוכנית תזונה ({number} הגשות)", "addRecipeToPlannerShort": "הוספה לתוכנית תזונה", "addShoppingList": "הוספה לרשימת קניות", + "addStore": "הוספת חנות", "addTag": "הוספת תגית", "addedBy": "הוסף על ידי {name}", "address": "כתובת", @@ -747,6 +761,11 @@ "sortingAlgorithmic": "אַלְגּוֹרִיתְמִי", "sortingAlphabetical": "אלפבתי", "start": "התחל", + "store": "חנות", + "storeDelete": "מחק חנות", + "storeDeleteConfirmation": "האם אתה בטוח שתרצה למחוק את {store}? חנות זו תוסר מכל המצרכים.", + "storeEdit": "ערוך חנות", + "stores": "חנויות", "supportDevelopment": "תמוך בפיתוח", "swipeToDelete": "החלק למחיקה", "swipeToDeleteAndLongPressToReorder": "החלק למחיקה והחלק לחיצה כדי להקליט", diff --git a/kitchenowl/lib/l10n/app_hi.arb b/kitchenowl/lib/l10n/app_hi.arb index 0967ef424..b226d2242 100644 --- a/kitchenowl/lib/l10n/app_hi.arb +++ b/kitchenowl/lib/l10n/app_hi.arb @@ -1 +1,8 @@ -{} +{ + "addStore": "दुकान जोड़ें", + "store": "दुकान", + "storeDelete": "दुकान हटाएं", + "storeDeleteConfirmation": "क्या आप वाकई {store} को हटाना चाहते हैं? इससे यह दुकान सभी वस्तुओं से हटा दी जाएगी।", + "storeEdit": "दुकान संपादित करें", + "stores": "दुकानें" +} diff --git a/kitchenowl/lib/l10n/app_hr.arb b/kitchenowl/lib/l10n/app_hr.arb index 3655508d7..8383de64a 100644 --- a/kitchenowl/lib/l10n/app_hr.arb +++ b/kitchenowl/lib/l10n/app_hr.arb @@ -214,6 +214,14 @@ } } }, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, "@tagDeleteConfirmation": { "placeholders": { "tag": { @@ -259,6 +267,7 @@ "addRecipeToPlanner": "Dodaj u prehrambeni plan ({number} porcija)", "addRecipeToPlannerShort": "Dodaj u prehrambeni plan", "addShoppingList": "Dodaj popis za kupnju", + "addStore": "Dodaj trgovinu", "addTag": "Dodaj oznaku", "addedBy": "Dodao {name}", "address": "Adresa", @@ -487,6 +496,11 @@ "sortingAlgorithmic": "Algoritmično", "sortingAlphabetical": "Abecedno", "start": "Kreni", + "store": "Trgovina", + "storeDelete": "Obriši trgovinu", + "storeDeleteConfirmation": "Jeste li sigurni da želite obrisati {store}? Trgovina će biti uklonjena iz svih stavki.", + "storeEdit": "Uredi trgovinu", + "stores": "Trgovine", "supportDevelopment": "Podržite razvoj", "swipeToDelete": "Povucite za brisanje", "swipeToDeleteAndLongPressToReorder": "Povucite za brisanje ili dugo držite za promjenu položaja", diff --git a/kitchenowl/lib/l10n/app_hu.arb b/kitchenowl/lib/l10n/app_hu.arb index 953cf0cfa..4c78f3ba3 100644 --- a/kitchenowl/lib/l10n/app_hu.arb +++ b/kitchenowl/lib/l10n/app_hu.arb @@ -38,6 +38,7 @@ }, "@addRecipeToPlannerShort": {}, "@addShoppingList": {}, + "@addStore": {}, "@addTag": {}, "@addedBy": { "placeholders": { @@ -424,6 +425,18 @@ "@sortingAlgorithmic": {}, "@sortingAlphabetical": {}, "@start": {}, + "@store": {}, + "@storeDelete": {}, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, + "@storeEdit": {}, + "@stores": {}, "@supportDevelopment": {}, "@swipeToDelete": {}, "@swipeToDeleteAndLongPressToReorder": {}, @@ -519,6 +532,7 @@ "addRecipeToPlanner": "Hozzáadás az étkezési tervhez ({number})", "addRecipeToPlannerShort": "Hozzáadás az étkezési tervhez", "addShoppingList": "Bevásárló listához adás", + "addStore": "Bolt hozzáadása", "addTag": "Címke hozzáadása", "addedBy": "Létrehozta: {name}", "address": "Cím", @@ -745,6 +759,11 @@ "sortingAlgorithmic": "Algoritmikus", "sortingAlphabetical": "ABC sorrend", "start": "Kezdés", + "store": "Bolt", + "storeDelete": "Bolt törlése", + "storeDeleteConfirmation": "Biztosan törli a következőt: {store}? Ezzel eltávolítja a boltot az összes elemből.", + "storeEdit": "Bolt szerkesztése", + "stores": "Boltok", "supportDevelopment": "Támogasd a fejlesztést", "swipeToDelete": "Suhintás a törléshez", "swipeToDeleteAndLongPressToReorder": "Suhintás a törléshez és hosszú lenyomás az átrendezéshez", diff --git a/kitchenowl/lib/l10n/app_id.arb b/kitchenowl/lib/l10n/app_id.arb index 19e682326..60ba4fe65 100644 --- a/kitchenowl/lib/l10n/app_id.arb +++ b/kitchenowl/lib/l10n/app_id.arb @@ -200,6 +200,14 @@ } } }, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, "@tagDeleteConfirmation": { "placeholders": { "tag": { @@ -245,6 +253,7 @@ "addRecipeToPlanner": "Tambah ke rencana makan ({number} saji)", "addRecipeToPlannerShort": "Tambah ke rencana makan", "addShoppingList": "Tambah Daftar belanja", + "addStore": "Tambah Toko", "addTag": "Tambah Tag", "addedBy": "Ditambahkan oleh {name}", "address": "Alamat", @@ -473,6 +482,11 @@ "sortingAlgorithmic": "Algoritma", "sortingAlphabetical": "Abjad", "start": "Mulai", + "store": "Toko", + "storeDelete": "Hapus Toko", + "storeDeleteConfirmation": "Apakah Anda yakin ingin menghapus {store}? Ini akan menghapus toko dari semua item.", + "storeEdit": "Atur Toko", + "stores": "Toko", "supportDevelopment": "Dukung pengembangannya", "swipeToDelete": "Geser untuk menghapus", "swipeToDeleteAndLongPressToReorder": "Geser untuk menghapus dan ketuk lama untuk mengatur urutan", diff --git a/kitchenowl/lib/l10n/app_it.arb b/kitchenowl/lib/l10n/app_it.arb index b2d4c7e88..91a4656b3 100644 --- a/kitchenowl/lib/l10n/app_it.arb +++ b/kitchenowl/lib/l10n/app_it.arb @@ -207,6 +207,14 @@ } } }, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, "@tagDeleteConfirmation": { "placeholders": { "tag": { @@ -252,6 +260,7 @@ "addRecipeToPlanner": "Aggiungi al piano pasti ({number} porzioni)", "addRecipeToPlannerShort": "Aggiungi al piano pasti", "addShoppingList": "Aggiungi una lista della spesa", + "addStore": "Aggiungi un negozio", "addTag": "Aggiungi un'etichetta", "addedBy": "Aggiunto da {name}", "address": "Indirizzo", @@ -480,6 +489,11 @@ "sortingAlgorithmic": "Algoritmico", "sortingAlphabetical": "Alfabetico", "start": "Inizia", + "store": "Negozio", + "storeDelete": "Elimina il negozio", + "storeDeleteConfirmation": "Sei sicuro/a di voler eliminare {store}? Questo rimuoverà il negozio da tutti gli elementi.", + "storeEdit": "Modifica il negozio", + "stores": "Negozi", "supportDevelopment": "Supporta lo sviluppo", "swipeToDelete": "Scorri per eliminare", "swipeToDeleteAndLongPressToReorder": "Scorri per eliminare e premi a lungo per riordinare", diff --git a/kitchenowl/lib/l10n/app_ja.arb b/kitchenowl/lib/l10n/app_ja.arb index e6b58c91b..d3e1eb2f8 100644 --- a/kitchenowl/lib/l10n/app_ja.arb +++ b/kitchenowl/lib/l10n/app_ja.arb @@ -101,6 +101,14 @@ } } }, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, "about": "このアプリについて", "accountCreate": "アカウントを作成", "accountCreateHint": "ユーザー名と氏名は公開されます。公開されて問題ないものを使ってください。", @@ -119,6 +127,7 @@ "addRecipeToPlanner": "食事プランに追加({number}食)", "addRecipeToPlannerShort": "献立に追加", "addShoppingList": "買い物リストに追加", + "addStore": "店舗を追加", "addTag": "タグを追加", "addedBy": "{name}が追加", "address": "住所", @@ -207,5 +216,10 @@ "ingredients": "材料", "ingredientsOptional": "任意の材料", "itemDelete": "アイテムを削除", - "itemDeleteConfirmation": "{item} を削除しますか? 削除すると、その材料を含むすべてのレシピから削除されます。" + "itemDeleteConfirmation": "{item} を削除しますか? 削除すると、その材料を含むすべてのレシピから削除されます。", + "store": "店舗", + "storeDelete": "店舗を削除", + "storeDeleteConfirmation": "{store}を削除してもよいですか? 削除すると、すべてのアイテムからこの店舗が削除されます。", + "storeEdit": "店舗を編集", + "stores": "店舗" } diff --git a/kitchenowl/lib/l10n/app_ko.arb b/kitchenowl/lib/l10n/app_ko.arb index 5f6c2f0a6..fdf20cd35 100644 --- a/kitchenowl/lib/l10n/app_ko.arb +++ b/kitchenowl/lib/l10n/app_ko.arb @@ -7,6 +7,7 @@ "description": "A shorter title/button text used wherever the space is limited. Excludes the number of yields." }, "@addShoppingList": {}, + "@addStore": {}, "@addTag": {}, "@addedBy": { "placeholders": { @@ -103,6 +104,18 @@ }, "@signup": {}, "@start": {}, + "@store": {}, + "@storeDelete": {}, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "type": "String", + "example": "Supermarket" + } + } + }, + "@storeEdit": {}, + "@stores": {}, "@tagDelete": {}, "@tagEdit": {}, "@themeMode": {}, @@ -119,6 +132,7 @@ "addLanguage": "언어 추가", "addRecipeToPlannerShort": "식단 추가", "addShoppingList": "쇼핑리스트 추가", + "addStore": "매장 추가", "addTag": "태그 추가", "addedBy": "{name}에서 추가됨", "address": "주소", @@ -187,6 +201,11 @@ "signInWith": "{provider}로 로그인", "signup": "가입", "start": "시작", + "store": "매장", + "storeDelete": "매장 삭제", + "storeDeleteConfirmation": "정말 {store}을(를) 삭제하겠습니까? 이 작업은 모든 항목에서 매장을 제거합니다.", + "storeEdit": "매장 편집", + "stores": "매장", "tagDelete": "태그 삭제", "tagEdit": "태그 편집", "themeMode": "테마", diff --git a/kitchenowl/lib/l10n/app_lt.arb b/kitchenowl/lib/l10n/app_lt.arb index 2341fcc20..f5a48a18f 100644 --- a/kitchenowl/lib/l10n/app_lt.arb +++ b/kitchenowl/lib/l10n/app_lt.arb @@ -39,6 +39,7 @@ "description": "A shorter title/button text used wherever the space is limited. Excludes the number of yields." }, "@addShoppingList": {}, + "@addStore": {}, "@addTag": {}, "@addedBy": { "placeholders": { @@ -354,6 +355,18 @@ "@sortingAlgorithmic": {}, "@sortingAlphabetical": {}, "@start": {}, + "@store": {}, + "@storeDelete": {}, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, + "@storeEdit": {}, + "@stores": {}, "@supportDevelopment": {}, "@swipeToDelete": {}, "@swipeToDeleteAndLongPressToReorder": {}, @@ -428,6 +441,7 @@ "addRecipeToPlanner": "Pridėti prie patiekalo planų ({number} patiekimų)", "addRecipeToPlannerShort": "Pridėti prie patiekalo planų", "addShoppingList": "Pridėti apsipirkimo sąrašą", + "addStore": "Pridėti parduotuvę", "addTag": "Pridėti žymą", "addedBy": "{name} pridėjo šią informaciją", "address": "Adresas", @@ -612,6 +626,11 @@ "sortingAlgorithmic": "Algoritmas", "sortingAlphabetical": "Pagal abėcėlę", "start": "Pradėti", + "store": "Parduotuvė", + "storeDelete": "Ištrinti parduotuvę", + "storeDeleteConfirmation": "Ar Jūs tikri, kad norite ištrinti {store}? Tai ištrins parduotuvę iš visų elementų.", + "storeEdit": "Koreguoti parduotuvę", + "stores": "Parduotuvės", "supportDevelopment": "Paremkit kurėjus", "swipeToDelete": "Brauk kad ištrintum", "swipeToDeleteAndLongPressToReorder": "Brauk kad ištrintum ir ilgai paspausk kad pertvarkytum", diff --git a/kitchenowl/lib/l10n/app_nb.arb b/kitchenowl/lib/l10n/app_nb.arb index fbb485f32..b06495913 100644 --- a/kitchenowl/lib/l10n/app_nb.arb +++ b/kitchenowl/lib/l10n/app_nb.arb @@ -424,6 +424,18 @@ "@sortingAlgorithmic": {}, "@sortingAlphabetical": {}, "@start": {}, + "@store": {}, + "@storeDelete": {}, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, + "@storeEdit": {}, + "@stores": {}, "@supportDevelopment": {}, "@swipeToDelete": {}, "@swipeToDeleteAndLongPressToReorder": {}, @@ -519,6 +531,7 @@ "addRecipeToPlanner": "Legg til i måltidsplan ({number} serveringer)", "addRecipeToPlannerShort": "Legg til i måltidsplan", "addShoppingList": "Legg til i handleliste", + "addStore": "Legg til butikk", "addTag": "Legg til etikett", "addedBy": "Lagt til av {name}", "address": "Adresse", @@ -745,6 +758,11 @@ "sortingAlgorithmic": "Algoritmisk", "sortingAlphabetical": "A-Å", "start": "Start", + "store": "Butikk", + "storeDelete": "Slett butikk", + "storeDeleteConfirmation": "Er du sikker på at du vil slette {store}? Dette vil fjerne butikken fra alle varer.", + "storeEdit": "Rediger butikk", + "stores": "Butikker", "supportDevelopment": "Støtt utviklingen", "swipeToDelete": "Dra for å slette", "swipeToDeleteAndLongPressToReorder": "Dra for å slette og lang-trykk for å endre rekkefølge", diff --git a/kitchenowl/lib/l10n/app_nl.arb b/kitchenowl/lib/l10n/app_nl.arb index 23bc8947f..a87fdad0d 100644 --- a/kitchenowl/lib/l10n/app_nl.arb +++ b/kitchenowl/lib/l10n/app_nl.arb @@ -207,6 +207,14 @@ } } }, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, "@tagDeleteConfirmation": { "placeholders": { "tag": { @@ -252,6 +260,7 @@ "addRecipeToPlanner": "Toevoegen aan maaltijdplan ({number} porties)", "addRecipeToPlannerShort": "Toevoegen aan maaltijdplan", "addShoppingList": "Boodschappenlijst toevoegen", + "addStore": "Winkel toevoegen", "addTag": "Tag toevoegen", "addedBy": "Toegevoegd door {name}", "address": "Adres", @@ -481,6 +490,11 @@ "sortingAlgorithmic": "Algoritmische", "sortingAlphabetical": "Alfabetisch", "start": "Begin", + "store": "Winkel", + "storeDelete": "Verwijder winkel", + "storeDeleteConfirmation": "Weet je zeker dat je {store} wilt verwijderen? Dit zal de winkel van alle items verwijderen.", + "storeEdit": "Bewerk winkel", + "stores": "Winkels", "supportDevelopment": "Ondersteun de ontwikkeling", "swipeToDelete": "Vegen om te verwijderen", "swipeToDeleteAndLongPressToReorder": "Vegen om te verwijderen en lang drukken om opnieuw te rangschikken", diff --git a/kitchenowl/lib/l10n/app_pl.arb b/kitchenowl/lib/l10n/app_pl.arb index bc90639dd..29e177155 100644 --- a/kitchenowl/lib/l10n/app_pl.arb +++ b/kitchenowl/lib/l10n/app_pl.arb @@ -207,6 +207,14 @@ } } }, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, "@tagDeleteConfirmation": { "placeholders": { "tag": { @@ -252,6 +260,7 @@ "addRecipeToPlanner": "Dodaj do jadłospisu ({number} porcji)", "addRecipeToPlannerShort": "Dodaj do jadłospisu", "addShoppingList": "Dodaj listę zakupów", + "addStore": "Dodaj sklep", "addTag": "Dodaj tagi", "addedBy": "Dodane przez {name}", "address": "Adres", @@ -479,6 +488,11 @@ "sortingAlgorithmic": "Kolejność algorytmiczna", "sortingAlphabetical": "Kolejność alfabetyczna", "start": "Start", + "store": "Sklep", + "storeDelete": "Usuń sklep", + "storeDeleteConfirmation": "Czy jesteś pewien, że chcesz usunąć {store}? To działanie usunie go ze wszystkich elementów.", + "storeEdit": "Edytuj sklep", + "stores": "Sklepy", "supportDevelopment": "Wesprzyj rozwój", "swipeToDelete": "Przesuń aby usunąć", "swipeToDeleteAndLongPressToReorder": "Przesuń aby usunąć i przytrzymaj by zmienić kolejność", diff --git a/kitchenowl/lib/l10n/app_pt.arb b/kitchenowl/lib/l10n/app_pt.arb index b9578ca74..4ec04a2cb 100644 --- a/kitchenowl/lib/l10n/app_pt.arb +++ b/kitchenowl/lib/l10n/app_pt.arb @@ -210,6 +210,14 @@ } } }, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, "@tagDeleteConfirmation": { "placeholders": { "tag": { @@ -255,6 +263,7 @@ "addRecipeToPlanner": "Adicionar ao plano de refeições ({number} de receitas)", "addRecipeToPlannerShort": "Adicionar ao plano de refeição", "addShoppingList": "Adicionar à lista de compras", + "addStore": "Adicionar loja", "addTag": "Adicionar etiqueta", "addedBy": "Inserido por {name}", "address": "Endereço", @@ -484,6 +493,11 @@ "sortingAlgorithmic": "Algorítmico", "sortingAlphabetical": "Alfabética", "start": "Iniciar", + "store": "Loja", + "storeDelete": "Apagar loja", + "storeDeleteConfirmation": "Tem a certeza que quer apagar {store}? Isto irá remover a loja de todos os itens.", + "storeEdit": "Editar loja", + "stores": "Lojas", "supportDevelopment": "Apoie o desenvolvimento", "swipeToDelete": "Deslize para apagar", "swipeToDeleteAndLongPressToReorder": "Deslize para apagar e mantenha pressionado para reordenar", diff --git a/kitchenowl/lib/l10n/app_pt_BR.arb b/kitchenowl/lib/l10n/app_pt_BR.arb index e79d11d65..5dfc1b8f4 100644 --- a/kitchenowl/lib/l10n/app_pt_BR.arb +++ b/kitchenowl/lib/l10n/app_pt_BR.arb @@ -210,6 +210,14 @@ } } }, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, "@tagDeleteConfirmation": { "placeholders": { "tag": { @@ -255,6 +263,7 @@ "addRecipeToPlanner": "Adicionar ao plano de refeições ({number} de receitas)", "addRecipeToPlannerShort": "Adicionar ao plano de refeição", "addShoppingList": "Adicionar à lista de compras", + "addStore": "Adicionar loja", "addTag": "Adicionar etiqueta", "addedBy": "Adicionado por {name}", "address": "Endereço", @@ -483,6 +492,11 @@ "sortingAlgorithmic": "Algorítmico", "sortingAlphabetical": "Alfabética", "start": "Iniciar", + "store": "Loja", + "storeDelete": "Excluir loja", + "storeDeleteConfirmation": "Tem certeza de que deseja excluir {store}? Isso removerá a loja de todos os itens.", + "storeEdit": "Editar loja", + "stores": "Lojas", "supportDevelopment": "Apoie o desenvolvimento", "swipeToDelete": "Deslize para excluir", "swipeToDeleteAndLongPressToReorder": "Deslize para excluir e mantenha pressionado para reordenar", diff --git a/kitchenowl/lib/l10n/app_ro.arb b/kitchenowl/lib/l10n/app_ro.arb index 4a6823a22..426f42fd1 100644 --- a/kitchenowl/lib/l10n/app_ro.arb +++ b/kitchenowl/lib/l10n/app_ro.arb @@ -40,6 +40,7 @@ "description": "A shorter title/button text used wherever the space is limited. Excludes the number of yields." }, "@addShoppingList": {}, + "@addStore": {}, "@addTag": {}, "@addedBy": { "placeholders": { @@ -400,6 +401,18 @@ "@sortingAlgorithmic": {}, "@sortingAlphabetical": {}, "@start": {}, + "@store": {}, + "@storeDelete": {}, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, + "@storeEdit": {}, + "@stores": {}, "@supportDevelopment": {}, "@swipeToDelete": {}, "@swipeToDeleteAndLongPressToReorder": {}, @@ -492,6 +505,7 @@ "addRecipeToPlanner": "Adăugați la planul de masă ({number}servers)", "addRecipeToPlannerShort": "Adaugă un plan de mese", "addShoppingList": "Adaugă listă de cumpărături", + "addStore": "Adaugă magazin", "addTag": "Adaugă etichetă", "addedBy": "Adăugat de {name}", "address": "Adresă", @@ -701,6 +715,11 @@ "sortingAlgorithmic": "Algoritmic", "sortingAlphabetical": "Alfabetic", "start": "Start", + "store": "Magazin", + "storeDelete": "Șterge magazin", + "storeDeleteConfirmation": "Ești sigur că vrei să ștergi {store}? Vei șterge magazinul de la toate articolele.", + "storeEdit": "Editează magazinul", + "stores": "Magazine", "supportDevelopment": "Susțineți dezvoltarea", "swipeToDelete": "Glisați pentru a șterge", "swipeToDeleteAndLongPressToReorder": "Glisați pentru a șterge și apăsați lung pentru rearanjare", diff --git a/kitchenowl/lib/l10n/app_ru.arb b/kitchenowl/lib/l10n/app_ru.arb index 24ebccce7..7e888ef5d 100644 --- a/kitchenowl/lib/l10n/app_ru.arb +++ b/kitchenowl/lib/l10n/app_ru.arb @@ -207,6 +207,14 @@ } } }, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, "@tagDeleteConfirmation": { "placeholders": { "tag": { @@ -252,6 +260,7 @@ "addRecipeToPlanner": "Добавить в план питания (порций: {number})", "addRecipeToPlannerShort": "Добавить в план питания", "addShoppingList": "Добавить список покупок", + "addStore": "Добавить магазин", "addTag": "Добавить тег", "addedBy": "Добавил {name}", "address": "Адрес", @@ -481,6 +490,11 @@ "sortingAlgorithmic": "Алгоритмически", "sortingAlphabetical": "По алфавиту", "start": "Начать", + "store": "Магазин", + "storeDelete": "Удалить магазин", + "storeDeleteConfirmation": "Вы уверены, что хотите удалить {store}? Это действие уберёт магазин из всех предметов.", + "storeEdit": "Редактировать магазин", + "stores": "Магазины", "supportDevelopment": "Поддержать разработчика", "swipeToDelete": "Смахните, чтобы удалить", "swipeToDeleteAndLongPressToReorder": "Смахните в сторону для удаления или удерживайте для изменения порядка", diff --git a/kitchenowl/lib/l10n/app_sk.arb b/kitchenowl/lib/l10n/app_sk.arb index 8cff00c3f..238bc3870 100644 --- a/kitchenowl/lib/l10n/app_sk.arb +++ b/kitchenowl/lib/l10n/app_sk.arb @@ -40,6 +40,7 @@ "description": "A shorter title/button text used wherever the space is limited. Excludes the number of yields." }, "@addShoppingList": {}, + "@addStore": {}, "@addTag": {}, "@addedBy": { "placeholders": { @@ -428,6 +429,18 @@ "@sortingAlgorithmic": {}, "@sortingAlphabetical": {}, "@start": {}, + "@store": {}, + "@storeDelete": {}, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, + "@storeEdit": {}, + "@stores": {}, "@supportDevelopment": {}, "@swipeToDelete": {}, "@swipeToDeleteAndLongPressToReorder": {}, @@ -523,6 +536,7 @@ "addRecipeToPlanner": "Pridať do jedálneho lístka ({number} porcií)", "addRecipeToPlannerShort": "Pridať do jedálneho lístka", "addShoppingList": "Pridať nákupný zoznam", + "addStore": "Pridať obchod", "addTag": "Pridať tag", "addedBy": "Pridané {name}", "address": "Adresa", @@ -751,6 +765,11 @@ "sortingAlgorithmic": "Algoritmicky", "sortingAlphabetical": "Abecedne", "start": "Začať", + "store": "Obchod", + "storeDelete": "Odstrániť obchod", + "storeDeleteConfirmation": "Ste si istí, že chcete zmazať {store}? Tento obchod bude odstránený zo všetkých položiek.", + "storeEdit": "Upraviť obchod", + "stores": "Obchody", "supportDevelopment": "Podporiť vývoj", "swipeToDelete": "Prejdite prstom pre zmazanie", "swipeToDeleteAndLongPressToReorder": "Prejdite prstom pre zmazanie a podržte pre zmenu poradia", diff --git a/kitchenowl/lib/l10n/app_sl.arb b/kitchenowl/lib/l10n/app_sl.arb index ba28d1133..7dd05e00b 100644 --- a/kitchenowl/lib/l10n/app_sl.arb +++ b/kitchenowl/lib/l10n/app_sl.arb @@ -40,6 +40,7 @@ "description": "A shorter title/button text used wherever the space is limited. Excludes the number of yields." }, "@addShoppingList": {}, + "@addStore": {}, "@addTag": {}, "@addedBy": { "placeholders": { @@ -426,6 +427,18 @@ "@sortingAlgorithmic": {}, "@sortingAlphabetical": {}, "@start": {}, + "@store": {}, + "@storeDelete": {}, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, + "@storeEdit": {}, + "@stores": {}, "@supportDevelopment": {}, "@swipeToDelete": {}, "@swipeToDeleteAndLongPressToReorder": {}, @@ -521,6 +534,7 @@ "addRecipeToPlanner": "Dodaj v plan obrokov ({number} porcij)", "addRecipeToPlannerShort": "Dodaj v plan obrokov", "addShoppingList": "Dodaj na nakupovalni list", + "addStore": "Dodaj trgovino", "addTag": "Dodaj oznako", "addedBy": "Dodano od {name}", "address": "Naslov", @@ -747,6 +761,11 @@ "sortingAlgorithmic": "Algoritemsko", "sortingAlphabetical": "Abecedno", "start": "Začni", + "store": "Trgovina", + "storeDelete": "Izbriši trgovino", + "storeDeleteConfirmation": "Ali si prepričan, da želiš izbrisati {store}? To bo odstranilo trgovino iz vseh izdelkov.", + "storeEdit": "Uredi trgovino", + "stores": "Trgovine", "supportDevelopment": "Podpri razvijalce", "swipeToDelete": "Podrsaj da izbrišeš", "swipeToDeleteAndLongPressToReorder": "Podrsaj, da izbrišeš in dolgo pritisni , da prerazporediš", diff --git a/kitchenowl/lib/l10n/app_sv.arb b/kitchenowl/lib/l10n/app_sv.arb index 1ece0392f..587dccf38 100644 --- a/kitchenowl/lib/l10n/app_sv.arb +++ b/kitchenowl/lib/l10n/app_sv.arb @@ -207,6 +207,14 @@ } } }, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, "@tagDeleteConfirmation": { "placeholders": { "tag": { @@ -252,6 +260,7 @@ "addRecipeToPlanner": "Lägg till i måltidsplanen ({number} serveringar)", "addRecipeToPlannerShort": "Lägg till måltidsplanen", "addShoppingList": "Lägg till inköpslista", + "addStore": "Lägg till butik", "addTag": "Lägg till tagg", "addedBy": "Tillagd av {name}", "address": "Adress", @@ -483,6 +492,11 @@ "sortingAlgorithmic": "Algoritmisk", "sortingAlphabetical": "Alfabetisk", "start": "Start", + "store": "Butik", + "storeDelete": "Ta bort butik", + "storeDeleteConfirmation": "Är du säker på att du vill ta bort {store}? Detta tar bort butiken från alla objekt.", + "storeEdit": "Ändra butik", + "stores": "Butiker", "supportDevelopment": "Supportera utvecklingen", "swipeToDelete": "Swipe för att ta bort", "swipeToDeleteAndLongPressToReorder": "Swipe för att ta bort och tryck länge för att beställa på nytt", diff --git a/kitchenowl/lib/l10n/app_tr.arb b/kitchenowl/lib/l10n/app_tr.arb index 91e0cfe1e..dd4b2f17b 100644 --- a/kitchenowl/lib/l10n/app_tr.arb +++ b/kitchenowl/lib/l10n/app_tr.arb @@ -192,6 +192,14 @@ } } }, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, "@tagDeleteConfirmation": { "placeholders": { "tag": { @@ -229,6 +237,7 @@ "addRecipeToPlanner": "Yemek planına ekle ({number})", "addRecipeToPlannerShort": "Yemek planına ekle", "addShoppingList": "Alışveriş listesi ekle", + "addStore": "Mağaza ekle", "addTag": "Etiket ekle", "addedBy": "{name} tarafından eklendi", "address": "Adres", @@ -436,6 +445,11 @@ "sortingAlgorithmic": "Algoritmik", "sortingAlphabetical": "Alfabetik", "start": "Başlat", + "store": "Mağaza", + "storeDelete": "Mağaza sil", + "storeDeleteConfirmation": "{store} mağazasını silmek istediğinizden emin misiniz? Bu, mağazayı tüm ögelerden kaldıracaktır.", + "storeEdit": "Mağaza düzenle", + "stores": "Mağazalar", "supportDevelopment": "Geliştirmeyi destekle", "swipeToDelete": "Silmek için kaydırın", "swipeToDeleteAndLongPressToReorder": "Silmek için kaydırın ve yeniden sıralamak için uzun basın", diff --git a/kitchenowl/lib/l10n/app_uk.arb b/kitchenowl/lib/l10n/app_uk.arb index 1af2b93ad..5553b546f 100644 --- a/kitchenowl/lib/l10n/app_uk.arb +++ b/kitchenowl/lib/l10n/app_uk.arb @@ -40,6 +40,7 @@ "description": "A shorter title/button text used wherever the space is limited. Excludes the number of yields." }, "@addShoppingList": {}, + "@addStore": {}, "@addTag": {}, "@addedBy": { "placeholders": { @@ -426,6 +427,18 @@ "@sortingAlgorithmic": {}, "@sortingAlphabetical": {}, "@start": {}, + "@store": {}, + "@storeDelete": {}, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, + "@storeEdit": {}, + "@stores": {}, "@supportDevelopment": {}, "@swipeToDelete": {}, "@swipeToDeleteAndLongPressToReorder": {}, @@ -521,6 +534,7 @@ "addRecipeToPlanner": "Додати до плану харчування ({number} подач)", "addRecipeToPlannerShort": "Додати до плану харчування", "addShoppingList": "Додати список покупок", + "addStore": "Додати магазин", "addTag": "Додати тег", "addedBy": "Додано {name}", "address": "Адреса", @@ -747,6 +761,11 @@ "sortingAlgorithmic": "Алгоритмічно", "sortingAlphabetical": "Алфавітно", "start": "Початок", + "store": "Магазин", + "storeDelete": "Видалити магазин", + "storeDeleteConfirmation": "Ви впевнені, що хочете видалити {store}? Це видалить магазин з усіх елементів.", + "storeEdit": "Змінити магазин", + "stores": "Магазини", "supportDevelopment": "Підтримати розробку", "swipeToDelete": "Прогорнути щоб видалити", "swipeToDeleteAndLongPressToReorder": "Прогорнути, щоб видалити та довгий дотик для перетягування", diff --git a/kitchenowl/lib/l10n/app_vi.arb b/kitchenowl/lib/l10n/app_vi.arb index 1e565f733..b574caa65 100644 --- a/kitchenowl/lib/l10n/app_vi.arb +++ b/kitchenowl/lib/l10n/app_vi.arb @@ -39,6 +39,7 @@ "description": "A shorter title/button text used wherever the space is limited. Excludes the number of yields." }, "@addShoppingList": {}, + "@addStore": {}, "@addTag": {}, "@addedBy": { "placeholders": { @@ -356,6 +357,18 @@ "@sortingAlgorithmic": {}, "@sortingAlphabetical": {}, "@start": {}, + "@store": {}, + "@storeDelete": {}, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, + "@storeEdit": {}, + "@stores": {}, "@supportDevelopment": {}, "@swipeToDelete": {}, "@swipeToDeleteAndLongPressToReorder": {}, @@ -431,6 +444,7 @@ "addRecipeToPlanner": "Thêm vào kế hoạch bữa ăn ({number} suất)", "addRecipeToPlannerShort": "Thêm vào kế hoạch bữa ăn", "addShoppingList": "Thêm danh sách mua", + "addStore": "Thêm cửa hàng", "addTag": "Thêm thẻ", "addedBy": "Được thêm bởi {name}", "address": "Địa chỉ", @@ -617,6 +631,11 @@ "sortingAlgorithmic": "Thuật toán", "sortingAlphabetical": "Theo bảng chữ cái", "start": "Bắt đầu", + "store": "Cửa hàng", + "storeDelete": "Xóa cửa hàng", + "storeDeleteConfirmation": "Bạn có chắc muốn xóa {store}? Thao tác này sẽ xóa cửa hàng từ tất cả phần tử.", + "storeEdit": "Chỉnh sửa cửa hàng", + "stores": "Cửa hàng", "supportDevelopment": "Hỗ trợ việc phát triển", "swipeToDelete": "Vuốt để xóa", "swipeToDeleteAndLongPressToReorder": "Vuốt để xóa và nhấn giữ để sắp xếp", diff --git a/kitchenowl/lib/l10n/app_zh.arb b/kitchenowl/lib/l10n/app_zh.arb index 052a5685e..bb528a7c6 100644 --- a/kitchenowl/lib/l10n/app_zh.arb +++ b/kitchenowl/lib/l10n/app_zh.arb @@ -40,6 +40,7 @@ "description": "A shorter title/button text used wherever the space is limited. Excludes the number of yields." }, "@addShoppingList": {}, + "@addStore": {}, "@addTag": {}, "@addedBy": { "placeholders": { @@ -398,6 +399,18 @@ "@sortingAlgorithmic": {}, "@sortingAlphabetical": {}, "@start": {}, + "@store": {}, + "@storeDelete": {}, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, + "@storeEdit": {}, + "@stores": {}, "@supportDevelopment": {}, "@swipeToDelete": {}, "@swipeToDeleteAndLongPressToReorder": {}, @@ -489,6 +502,7 @@ "addRecipeToPlanner": "添加到膳食计划({number}份)", "addRecipeToPlannerShort": "添加到膳食计划", "addShoppingList": "新增购物清单", + "addStore": "新增商店", "addTag": "新增标签", "addedBy": "由 {name} 添加", "address": "地址", @@ -696,6 +710,11 @@ "sortingAlgorithmic": "算法", "sortingAlphabetical": "按字母顺序", "start": "开始", + "store": "商店", + "storeDelete": "删除商店", + "storeDeleteConfirmation": "确定要删除 {store}?所有物品中的这个商店都会被移除。", + "storeEdit": "编辑商店", + "stores": "商店", "supportDevelopment": "支持开发", "swipeToDelete": "滑动以删除", "swipeToDeleteAndLongPressToReorder": "滑动删除,长按重新排序", diff --git a/kitchenowl/lib/l10n/app_zh_Hant.arb b/kitchenowl/lib/l10n/app_zh_Hant.arb index e01850c4c..2384d71d2 100644 --- a/kitchenowl/lib/l10n/app_zh_Hant.arb +++ b/kitchenowl/lib/l10n/app_zh_Hant.arb @@ -137,6 +137,14 @@ } } }, + "@storeDeleteConfirmation": { + "placeholders": { + "store": { + "example": "Supermarket", + "type": "String" + } + } + }, "about": "關於", "accountCreate": "建立帳號", "accountCreateHint": "您的使用者名稱和姓名是公開的,因此請選擇您願意與他人分享的內容。", @@ -155,6 +163,7 @@ "addRecipeToPlanner": "新增({number}份)膳食計劃", "addRecipeToPlannerShort": "新增到膳食計劃", "addShoppingList": "新增購物清單", + "addStore": "新增商店", "addTag": "新增標簽", "addedBy": "由{name}添加", "address": "地址", @@ -313,5 +322,10 @@ "shoppingList": "購物清單", "shoppingListDelete": "刪除購物清單", "shoppingListDeleteConfirmation": "是否確定要刪除 {shoppingList}?", - "shoppingListEdit": "修改購物清單" + "shoppingListEdit": "修改購物清單", + "store": "商店", + "storeDelete": "刪除商店", + "storeDeleteConfirmation": "您確定要刪除{store}嗎?此操作將從所有項目中移除此商店。", + "storeEdit": "修改商店", + "stores": "商店" } diff --git a/kitchenowl/lib/models/item.dart b/kitchenowl/lib/models/item.dart index 7085fb540..d675715dd 100644 --- a/kitchenowl/lib/models/item.dart +++ b/kitchenowl/lib/models/item.dart @@ -2,6 +2,7 @@ import 'package:fraction/fraction.dart'; import 'package:kitchenowl/helpers/string_scaler.dart'; import 'package:kitchenowl/models/category.dart'; import 'package:kitchenowl/models/model.dart'; +import 'package:kitchenowl/models/store.dart'; import 'nullable.dart'; @@ -13,6 +14,7 @@ class Item extends Model { final Category? category; final bool? isDefault; final String? defaultKey; + final List stores; const Item({ this.id, @@ -22,6 +24,7 @@ class Item extends Model { this.category, this.isDefault, this.defaultKey, + this.stores = const [], }); factory Item.fromItem({ @@ -35,6 +38,7 @@ class Item extends Model { ordering: item.ordering, isDefault: item.isDefault, defaultKey: item.defaultKey, + stores: item.stores, ); factory Item.fromJson(Map map) => Item( @@ -46,12 +50,16 @@ class Item extends Model { icon: map['icon'], category: map['category'] != null ? Category.fromJson(map['category']) : null, + stores: map['stores'] != null + ? List.from(map['stores'].map((e) => Store.fromJson(e))) + : const [], ); Item copyWith({ String? name, Nullable? category, Nullable? icon, + List? stores, }) => Item( id: id, @@ -59,11 +67,12 @@ class Item extends Model { category: (category ?? Nullable(this.category)).value, icon: (icon ?? Nullable(this.icon)).value, ordering: ordering, + stores: stores ?? this.stores, ); @override List get props => - [id, name, icon, ordering, isDefault, defaultKey, category]; + [id, name, icon, ordering, isDefault, defaultKey, category, stores]; @override Map toJson() => { @@ -79,6 +88,7 @@ class Item extends Model { "category": category?.toJsonWithId(), "default": isDefault, "default_key": defaultKey, + "store_ids": stores.map((e) => e.id).toList(), }); } @@ -93,6 +103,7 @@ class ItemWithDescription extends Item { super.isDefault, super.defaultKey, super.category, + super.stores, this.description = '', }); @@ -106,6 +117,9 @@ class ItemWithDescription extends Item { defaultKey: map['default_key'], category: map['category'] != null ? Category.fromJson(map['category']) : null, + stores: map['stores'] != null + ? List.from(map['stores'].map((e) => Store.fromJson(e))) + : const [], ); factory ItemWithDescription.fromItem({ @@ -120,6 +134,7 @@ class ItemWithDescription extends Item { ordering: item.ordering, isDefault: item.isDefault, defaultKey: item.defaultKey, + stores: item.stores, description: description ?? ((item is ItemWithDescription) ? item.description : ''), ); @@ -135,6 +150,7 @@ class ItemWithDescription extends Item { String? name, Nullable? category, Nullable? icon, + List? stores, String? description, }) => ItemWithDescription( @@ -142,6 +158,7 @@ class ItemWithDescription extends Item { name: name ?? this.name, category: (category ?? Nullable(this.category)).value, icon: (icon ?? Nullable(this.icon)).value, + stores: stores ?? this.stores, description: description ?? this.description, ordering: ordering, isDefault: isDefault, @@ -165,6 +182,7 @@ class ShoppinglistItem extends ItemWithDescription { super.ordering = 0, super.defaultKey, super.isDefault, + super.stores, this.createdById, this.createdAt, }); @@ -180,6 +198,9 @@ class ShoppinglistItem extends ItemWithDescription { icon: map['icon'], category: map['category'] != null ? Category.fromJson(map['category']) : null, + stores: map['stores'] != null + ? List.from(map['stores'].map((e) => Store.fromJson(e))) + : const [], createdAt: map['created_at'] != null ? DateTime.fromMillisecondsSinceEpoch(map['created_at'], isUtc: true) .toLocal() @@ -207,6 +228,7 @@ class ShoppinglistItem extends ItemWithDescription { ordering: item.ordering, isDefault: item.isDefault, defaultKey: item.defaultKey, + stores: item.stores, createdAt: createdAt ?? DateTime.now(), createdById: createdById, ); @@ -216,6 +238,7 @@ class ShoppinglistItem extends ItemWithDescription { String? name, Nullable? category, Nullable? icon, + List? stores, String? description, }) => ShoppinglistItem( @@ -223,10 +246,13 @@ class ShoppinglistItem extends ItemWithDescription { name: name ?? this.name, category: (category ?? Nullable(this.category)).value, icon: (icon ?? Nullable(this.icon)).value, + stores: stores ?? this.stores, description: description ?? this.description, ordering: ordering, isDefault: isDefault, defaultKey: defaultKey, + createdById: createdById, + createdAt: createdAt, ); @override @@ -252,6 +278,7 @@ class RecipeItem extends ItemWithDescription { super.isDefault, super.category, super.icon, + super.stores, this.optional = false, }); @@ -265,6 +292,9 @@ class RecipeItem extends ItemWithDescription { optional: map['optional'], category: map['category'] != null ? Category.fromJson(map['category']) : null, + stores: map['stores'] != null + ? List.from(map['stores'].map((e) => Store.fromJson(e))) + : const [], ); factory RecipeItem.fromItem({ @@ -280,6 +310,7 @@ class RecipeItem extends ItemWithDescription { ordering: item.ordering, isDefault: item.isDefault, defaultKey: item.defaultKey, + stores: item.stores, description: item is ItemWithDescription ? item.description : description, optional: optional, @@ -296,6 +327,7 @@ class RecipeItem extends ItemWithDescription { String? name, Nullable? category, Nullable? icon, + List? stores, String? description, bool? optional, }) => @@ -304,6 +336,7 @@ class RecipeItem extends ItemWithDescription { name: name ?? this.name, category: (category ?? Nullable(this.category)).value, icon: (icon ?? Nullable(this.icon)).value, + stores: stores ?? this.stores, description: description ?? this.description, optional: optional ?? this.optional, ordering: ordering, @@ -328,6 +361,7 @@ class RecipeItem extends ItemWithDescription { defaultKey: defaultKey, isDefault: isDefault, category: category, + stores: stores, ); ItemWithDescription toItemWithDescription() => ItemWithDescription( @@ -338,6 +372,7 @@ class RecipeItem extends ItemWithDescription { defaultKey: defaultKey, isDefault: isDefault, category: category, + stores: stores, description: description, ); @@ -349,6 +384,7 @@ class RecipeItem extends ItemWithDescription { defaultKey: defaultKey, isDefault: isDefault, category: category, + stores: stores, description: description, ); diff --git a/kitchenowl/lib/models/store.dart b/kitchenowl/lib/models/store.dart new file mode 100644 index 000000000..5c0b5f9d1 --- /dev/null +++ b/kitchenowl/lib/models/store.dart @@ -0,0 +1,45 @@ +import 'package:kitchenowl/models/model.dart'; + +class Store extends Model { + final int? id; + final String name; + + const Store({ + this.id, + this.name = '', + }); + + factory Store.fromJson(Map map) { + return Store( + id: map['id'], + name: map['name'], + ); + } + + Store copyWith({ + String? name, + }) => + Store( + id: id, + name: name ?? this.name, + ); + + @override + List get props => [id, name]; + + @override + String toString() { + return name; + } + + @override + Map toJson() => { + "name": name, + }; + + @override + Map toJsonWithId() => toJson() + ..addAll({ + "id": id, + }); +} diff --git a/kitchenowl/lib/pages/household_page/shoppinglist.dart b/kitchenowl/lib/pages/household_page/shoppinglist.dart index ce6c10ea1..59b814cdc 100644 --- a/kitchenowl/lib/pages/household_page/shoppinglist.dart +++ b/kitchenowl/lib/pages/household_page/shoppinglist.dart @@ -171,6 +171,7 @@ class _ShoppinglistPageState extends State { ), items: state.result, categories: state.categories, + stores: state.stores, shoppingList: state.selectedShoppinglist, onRefresh: () => cubit.refresh(), selected: (item) => @@ -230,6 +231,7 @@ class _ShoppinglistPageState extends State { gridSize: settingsState.gridSize, ), categories: state.categories, + stores: state.stores, isLoading: state is LoadingShoppinglistCubitState, selectedListItems: diff --git a/kitchenowl/lib/pages/household_update_page.dart b/kitchenowl/lib/pages/household_update_page.dart index ff971ab0c..5f1e34e0f 100644 --- a/kitchenowl/lib/pages/household_update_page.dart +++ b/kitchenowl/lib/pages/household_update_page.dart @@ -11,6 +11,7 @@ import 'package:kitchenowl/pages/settings_household/household_settings_expense_c import 'package:kitchenowl/widgets/settings_household/sliver_household_feature_settings.dart'; import 'package:kitchenowl/pages/settings_household/household_settings_shoppinglist_page.dart'; import 'package:kitchenowl/pages/settings_household/household_settings_tags_page.dart'; +import 'package:kitchenowl/pages/settings_household/household_settings_store_page.dart'; import 'package:sliver_tools/sliver_tools.dart'; class HouseholdUpdatePage extends StatefulWidget { @@ -226,6 +227,21 @@ class _HouseholdUpdatePageState extends State { ), ), ), + ListTile( + title: Text(AppLocalizations.of(context)!.stores), + leading: const Icon(Icons.store_rounded), + trailing: const Icon(Icons.arrow_forward_ios_rounded), + contentPadding: + const EdgeInsets.only(left: 16, right: 16), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => BlocProvider.value( + value: cubit, + child: HouseholdSettingsStorePage(), + ), + ), + ), + ), if (true) ListTile( title: Text( diff --git a/kitchenowl/lib/pages/item_page.dart b/kitchenowl/lib/pages/item_page.dart index 2ef03a4c6..abd358bc5 100644 --- a/kitchenowl/lib/pages/item_page.dart +++ b/kitchenowl/lib/pages/item_page.dart @@ -1,4 +1,5 @@ import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart' show setEquals; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; @@ -12,6 +13,7 @@ import 'package:kitchenowl/models/category.dart'; import 'package:kitchenowl/models/item.dart'; import 'package:kitchenowl/kitchenowl.dart'; import 'package:kitchenowl/models/shoppinglist.dart'; +import 'package:kitchenowl/models/store.dart'; import 'package:kitchenowl/models/update_value.dart'; import 'package:kitchenowl/widgets/item_popup_menu_button.dart'; import 'package:kitchenowl/widgets/item_wrap_menu.dart'; @@ -21,6 +23,7 @@ class ItemPage extends StatefulWidget { final T item; final ShoppingList? shoppingList; final List categories; + final List stores; final bool advancedView; const ItemPage({ @@ -28,6 +31,7 @@ class ItemPage extends StatefulWidget { required this.item, this.shoppingList, this.categories = const [], + this.stores = const [], this.advancedView = false, }); @@ -254,6 +258,45 @@ class _ItemPageState extends State> { ), ), ), + if (widget.item is! RecipeItem && widget.stores.isNotEmpty) + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + sliver: SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context)!.stores, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 8), + BlocBuilder( + bloc: cubit, + buildWhen: (prev, curr) => !setEquals( + Set.of(prev.stores), + Set.of(curr.stores), + ), + builder: (context, state) => Wrap( + spacing: 8, + runSpacing: 8, + children: widget.stores + .map( + (e) => FilterChip( + label: Text(e.name), + selected: state.stores.contains(e), + onSelected: !App.isOffline + ? (selected) => + cubit.toggleStore(e, selected) + : null, + ), + ) + .toList(), + ), + ), + ], + ), + ), + ), if (widget.advancedView) BlocBuilder( bloc: cubit, diff --git a/kitchenowl/lib/pages/settings_household/household_settings_items_page.dart b/kitchenowl/lib/pages/settings_household/household_settings_items_page.dart index 3c22ebd28..2022351ea 100644 --- a/kitchenowl/lib/pages/settings_household/household_settings_items_page.dart +++ b/kitchenowl/lib/pages/settings_household/household_settings_items_page.dart @@ -57,6 +57,7 @@ class _HouseholdSettingsItemsPageState isLoading: state is LoadingHouseholdSettingsItemsState, items: state.items, categories: state.categories, + stores: state.stores, onRefresh: cubit.refresh, extraOption: _itemPopmenuBuilder, shoppingListStyle: const ShoppingListStyle( @@ -79,6 +80,7 @@ class _HouseholdSettingsItemsPageState AppLocalizations.of(context)!.uncategorized, isLoading: state is LoadingHouseholdSettingsItemsState, categories: state.categories, + stores: state.stores, onRefresh: cubit.refresh, items: items, extraOption: _itemPopmenuBuilder, diff --git a/kitchenowl/lib/pages/settings_household/household_settings_store_page.dart b/kitchenowl/lib/pages/settings_household/household_settings_store_page.dart new file mode 100644 index 000000000..4ccf6b7ef --- /dev/null +++ b/kitchenowl/lib/pages/settings_household/household_settings_store_page.dart @@ -0,0 +1,255 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:kitchenowl/cubits/household_add_update/household_update_cubit.dart'; +import 'package:kitchenowl/kitchenowl.dart'; +import 'package:kitchenowl/models/store.dart'; +import 'package:kitchenowl/widgets/dismissible_card.dart'; +import 'package:sliver_tools/sliver_tools.dart'; + +enum _StoreAction { + rename, + merge, + delete; +} + +class HouseholdSettingsStorePage extends StatelessWidget { + const HouseholdSettingsStorePage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar( + title: Text(AppLocalizations.of(context)!.stores), + actions: [ + IconButton( + icon: const Icon(Icons.add), + tooltip: AppLocalizations.of(context)!.addStore, + onPressed: () async { + final res = await showDialog( + context: context, + builder: (BuildContext context) { + return TextDialog( + title: AppLocalizations.of(context)!.addStore, + doneText: AppLocalizations.of(context)!.add, + hintText: AppLocalizations.of(context)!.name, + isInputValid: (s) => s.isNotEmpty, + ); + }, + ); + if (res != null) { + BlocProvider.of(context) + .addStore(res); + } + }, + ), + ], + ), + SliverCrossAxisConstrained( + maxCrossAxisExtent: 600, + child: BlocBuilder( + buildWhen: (prev, curr) => + prev.stores != curr.stores || + prev is LoadingHouseholdUpdateState, + builder: (context, state) { + if (state is LoadingHouseholdUpdateState) { + return const SliverToBoxAdapter( + child: Center(child: CircularProgressIndicator()), + ); + } + + return SliverList( + delegate: SliverChildBuilderDelegate( + childCount: state.stores.length, + (context, i) => DismissibleCard( + key: ValueKey(state.stores.elementAt(i)), + confirmDismiss: (direction) async { + return (await askForConfirmation( + context: context, + title: Text( + AppLocalizations.of(context)!.storeDelete, + ), + content: Text( + AppLocalizations.of(context)! + .storeDeleteConfirmation( + state.stores.elementAt(i).name, + ), + ), + )); + }, + onDismissed: (direction) { + BlocProvider.of(context) + .deleteStore(state.stores.elementAt(i)); + }, + title: Text(state.stores.elementAt(i).name), + onTap: () async { + _handleAction( + context, + state.stores, + i, + await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) => SafeArea( + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: Text( + state.stores.elementAt(i).name, + style: Theme.of(context) + .textTheme + .titleLarge, + ), + ), + const Divider(), + Wrap( + alignment: WrapAlignment.start, + spacing: 8, + runSpacing: 8, + children: [ + ActionChip( + avatar: + const Icon(Icons.edit_rounded), + label: Text( + AppLocalizations.of(context)! + .rename, + ), + onPressed: () => Navigator.of(context) + .pop(_StoreAction.rename), + ), + ActionChip( + avatar: + const Icon(Icons.merge_rounded), + label: Text( + AppLocalizations.of(context)!.merge, + ), + onPressed: () => Navigator.of(context) + .pop(_StoreAction.merge), + ), + ActionChip( + avatar: + const Icon(Icons.delete_rounded), + label: Text( + AppLocalizations.of(context)! + .delete, + ), + onPressed: () => Navigator.of(context) + .pop(_StoreAction.delete), + ), + ], + ), + const SizedBox(height: 16), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ); + }, + ), + ), + ], + ), + ); + } + +//ignore: long-method + Future _handleAction( + BuildContext context, + Set stores, + int storeIndex, + _StoreAction? action, + ) async { + if (action == null) return; + switch (action) { + case _StoreAction.rename: + final res = await showDialog( + context: context, + builder: (BuildContext context) { + return TextDialog( + title: AppLocalizations.of(context)!.storeEdit, + doneText: AppLocalizations.of(context)!.rename, + hintText: AppLocalizations.of(context)!.name, + initialText: stores.elementAt(storeIndex).name, + isInputValid: (s) => + s.trim().isNotEmpty && s != stores.elementAt(storeIndex).name, + ); + }, + ); + + if (res != null) { + BlocProvider.of(context).updateStore( + stores.elementAt(storeIndex).copyWith(name: res), + ); + } + break; + case _StoreAction.merge: + Store? other = await showDialog( + context: context, + builder: (context) => SelectDialog( + title: AppLocalizations.of(context)!.merge, + cancelText: AppLocalizations.of(context)!.cancel, + options: stores + .whereIndexed((index, element) => index != storeIndex) + .map( + (e) => SelectDialogOption( + e, + e.name, + ), + ) + .toList(), + ), + ); + if (other != null) { + final confirmed = await askForConfirmation( + context: context, + title: Text( + AppLocalizations.of(context)!.merge, + ), + confirmText: AppLocalizations.of(context)!.merge, + content: Text( + AppLocalizations.of(context)!.itemsMergeConfirmation( + stores.elementAt(storeIndex).name, + other.name, + ), + ), + ); + if (confirmed) { + BlocProvider.of(context) + .mergeStore(stores.elementAt(storeIndex), other); + } + } + break; + case _StoreAction.delete: + if (await askForConfirmation( + context: context, + title: Text( + AppLocalizations.of(context)!.storeDelete, + ), + content: Text( + AppLocalizations.of(context)!.storeDeleteConfirmation( + stores.elementAt(storeIndex).name, + ), + ), + )) { + BlocProvider.of(context).deleteStore( + stores.elementAt(storeIndex), + ); + } + break; + } + } +} diff --git a/kitchenowl/lib/services/api/api_service.dart b/kitchenowl/lib/services/api/api_service.dart index 7e63f0241..218f44aad 100644 --- a/kitchenowl/lib/services/api/api_service.dart +++ b/kitchenowl/lib/services/api/api_service.dart @@ -20,6 +20,7 @@ export 'expense.dart'; export 'tag.dart'; export 'upload.dart'; export 'category.dart'; +export 'store.dart'; export 'household.dart'; enum Connection { diff --git a/kitchenowl/lib/services/api/store.dart b/kitchenowl/lib/services/api/store.dart new file mode 100644 index 000000000..d6741af66 --- /dev/null +++ b/kitchenowl/lib/services/api/store.dart @@ -0,0 +1,59 @@ +import 'dart:convert'; +import 'package:kitchenowl/models/household.dart'; +import 'package:kitchenowl/models/store.dart'; +import 'package:kitchenowl/services/api/api_service.dart'; + +extension StoreApi on ApiService { + static const baseRoute = '/store'; + + Future?> getAllStores(Household household) async { + final res = await get(householdPath(household) + baseRoute); + if (res.statusCode != 200) return null; + + final body = List.from(jsonDecode(res.body)); + + return body.map((e) => Store.fromJson(e)).toSet(); + } + + Future addStore(Household household, Store store) async { + final res = await post( + householdPath(household) + baseRoute, + json.encode(store.toJson()), + ); + + return res.statusCode == 200; + } + + Future updateStore(Store store) async { + final res = + await post('$baseRoute/${store.id}', json.encode(store.toJson())); + + return res.statusCode == 200; + } + + Future mergeStore(Store store, Store other) async { + final res = await post( + '$baseRoute/${store.id}', + jsonEncode({ + "merge_store_id": other.id, + }), + ); + + return res.statusCode == 200; + } + + Future getStore(Store store) async { + final res = await get('$baseRoute/${store.id}'); + if (res.statusCode != 200) return null; + + final body = jsonDecode(res.body); + + return Store.fromJson(body); + } + + Future deleteStore(Store store) async { + final res = await delete('$baseRoute/${store.id}'); + + return res.statusCode == 200; + } +} diff --git a/kitchenowl/lib/services/storage/mem_storage.dart b/kitchenowl/lib/services/storage/mem_storage.dart index 75fa4207a..b86cbb944 100644 --- a/kitchenowl/lib/services/storage/mem_storage.dart +++ b/kitchenowl/lib/services/storage/mem_storage.dart @@ -2,6 +2,7 @@ import 'package:kitchenowl/models/category.dart'; import 'package:kitchenowl/models/household.dart'; import 'package:kitchenowl/models/recipe.dart'; import 'package:kitchenowl/models/shoppinglist.dart'; +import 'package:kitchenowl/models/store.dart'; import 'package:kitchenowl/models/tag.dart'; import 'package:kitchenowl/models/user.dart'; import 'package:kitchenowl/services/storage/temp_storage.dart'; @@ -29,6 +30,7 @@ class MemStorage { clearRecipes(household), clearCategories(household), clearTags(household), + clearStores(household), ]); }).toList() ?? [], @@ -146,6 +148,28 @@ class MemStorage { _tags = {}; } + Map?> _stores = {}; + + Future?> readStores(Household household) async { + if (persistentStorage != null && _stores[household.id] == null) { + _stores[household.id] = await persistentStorage!.readStores(household); + } + if (_stores[household.id] == null) return null; + return List.of(_stores[household.id]!); + } + + Future writeStores( + Household household, + List stores, + ) async { + persistentStorage?.writeStores(household, stores); + _stores[household.id] = stores; + } + + Future clearStores(Household household) async { + _stores = {}; + } + List? _households; Future?> readHouseholds() async { diff --git a/kitchenowl/lib/services/storage/temp_storage.dart b/kitchenowl/lib/services/storage/temp_storage.dart index 6c2c3f5c8..5bd82dda1 100644 --- a/kitchenowl/lib/services/storage/temp_storage.dart +++ b/kitchenowl/lib/services/storage/temp_storage.dart @@ -6,6 +6,7 @@ import 'package:kitchenowl/models/category.dart'; import 'package:kitchenowl/models/household.dart'; import 'package:kitchenowl/models/recipe.dart'; import 'package:kitchenowl/models/shoppinglist.dart'; +import 'package:kitchenowl/models/store.dart'; import 'package:kitchenowl/models/tag.dart'; import 'package:kitchenowl/models/user.dart'; import 'package:path_provider/path_provider.dart' as pathProvider; @@ -60,6 +61,12 @@ class TempStorage { return File('$path/${household.id}-tags.json'); } + Future _localStoresFile(Household household) async { + final path = await _localPath; + + return File('$path/${household.id}-stores.json'); + } + Future clearAll() async { await readHouseholds().then( (value) => Future.wait( @@ -69,6 +76,7 @@ class TempStorage { clearRecipes(household), clearCategories(household), clearTags(household), + clearStores(household), ]); }).toList() ?? [], @@ -250,6 +258,42 @@ class TempStorage { } } + Future?> readStores(Household household) async { + if (!foundation.kIsWeb) { + try { + final file = await _localStoresFile(household); + final String content = await file.readAsString(); + + return List.from( + json.decode(content).map((e) => Store.fromJson(e)), + ); + } catch (_) {} + } + + return null; + } + + Future writeStores( + Household household, + List stores, + ) async { + if (!foundation.kIsWeb) { + final file = await _localStoresFile(household); + await file.writeAsString( + json.encode(stores.map((e) => e.toJsonWithId()).toList()), + ); + } + } + + Future clearStores(Household household) async { + if (!foundation.kIsWeb) { + try { + final file = await _localStoresFile(household); + if (await file.exists()) await file.delete(); + } catch (_) {} + } + } + Future?> readHouseholds() async { if (!foundation.kIsWeb) { try { diff --git a/kitchenowl/lib/services/transactions/store.dart b/kitchenowl/lib/services/transactions/store.dart new file mode 100644 index 000000000..5b54a2ee0 --- /dev/null +++ b/kitchenowl/lib/services/transactions/store.dart @@ -0,0 +1,27 @@ +import 'package:kitchenowl/models/store.dart'; +import 'package:kitchenowl/models/household.dart'; +import 'package:kitchenowl/services/api/api_service.dart'; +import 'package:kitchenowl/services/storage/mem_storage.dart'; +import 'package:kitchenowl/services/transaction.dart'; + +class TransactionStoresGet extends Transaction> { + final Household household; + + TransactionStoresGet({required this.household, DateTime? timestamp}) + : super.internal(timestamp ?? DateTime.now(), "TransactionStoresGet"); + + @override + Future> runLocal() async { + return await MemStorage.getInstance().readStores(household) ?? const []; + } + + @override + Future?> runOnline() async { + final stores = await ApiService.getInstance().getAllStores(household); + if (stores != null) { + MemStorage.getInstance().writeStores(household, stores.toList()); + } + + return stores?.toList(); + } +} diff --git a/kitchenowl/lib/widgets/home_page/sliver_category_item_grid_list.dart b/kitchenowl/lib/widgets/home_page/sliver_category_item_grid_list.dart index 4b2fcc470..c20e77810 100644 --- a/kitchenowl/lib/widgets/home_page/sliver_category_item_grid_list.dart +++ b/kitchenowl/lib/widgets/home_page/sliver_category_item_grid_list.dart @@ -3,6 +3,7 @@ import 'package:kitchenowl/kitchenowl.dart'; import 'package:kitchenowl/models/category.dart'; import 'package:kitchenowl/models/item.dart'; import 'package:kitchenowl/models/shoppinglist.dart'; +import 'package:kitchenowl/models/store.dart'; import 'package:kitchenowl/widgets/sliver_expansion_tile.dart'; import 'package:sliver_tools/sliver_tools.dart'; @@ -15,6 +16,7 @@ class SliverCategoryItemGridList extends StatefulWidget { final Nullable? onLongPressed; final List items; final List? categories; // forwarded to item page on long press + final List? stores; // forwarded to item page on long press final ShoppingList? shoppingList; // forwarded to item page on long press final bool Function(T)? selected; final bool isLoading; @@ -31,6 +33,7 @@ class SliverCategoryItemGridList extends StatefulWidget { this.onLongPressed, this.items = const [], this.categories, + this.stores, this.shoppingList, this.selected, this.isLoading = false, @@ -68,6 +71,7 @@ class _SliverCategoryItemGridListState name: category?.name ?? AppLocalizations.of(context)!.uncategorized, items: items, categories: widget.categories, + stores: widget.stores, shoppingList: widget.shoppingList, selected: widget.selected, isLoading: widget.isLoading, @@ -85,6 +89,7 @@ class _SliverCategoryItemGridListState onLongPressed: widget.onLongPressed, items: widget.items, categories: widget.categories, + stores: widget.stores, shoppingList: widget.shoppingList, selected: widget.selected, isLoading: widget.isLoading, diff --git a/kitchenowl/lib/widgets/selectable_button_card.dart b/kitchenowl/lib/widgets/selectable_button_card.dart index 20439d596..3f4d6007f 100644 --- a/kitchenowl/lib/widgets/selectable_button_card.dart +++ b/kitchenowl/lib/widgets/selectable_button_card.dart @@ -4,6 +4,7 @@ class SelectableButtonCard extends StatefulWidget { final String title; final IconData? icon; final String? description; + final String? storesLabel; final bool selected; final void Function()? onPressed; final void Function()? onLongPressed; @@ -14,6 +15,7 @@ class SelectableButtonCard extends StatefulWidget { this.icon, required this.title, this.description, + this.storesLabel, this.onPressed, this.onLongPressed, this.selected = false, @@ -114,6 +116,22 @@ class _SelectableButtonCardState extends State { softWrap: true, textAlign: TextAlign.center, ), + if (widget.storesLabel?.isNotEmpty ?? false) + Text( + widget.storesLabel!, + style: Theme.of(context).textTheme.bodySmall!.copyWith( + color: widget.selected + ? Theme.of(context) + .colorScheme + .onPrimary + .withAlpha(178) + : null, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + softWrap: true, + textAlign: TextAlign.center, + ), ], ), ], diff --git a/kitchenowl/lib/widgets/selectable_button_list_tile.dart b/kitchenowl/lib/widgets/selectable_button_list_tile.dart index 577a613f7..a36b7c1b2 100644 --- a/kitchenowl/lib/widgets/selectable_button_list_tile.dart +++ b/kitchenowl/lib/widgets/selectable_button_list_tile.dart @@ -5,6 +5,7 @@ class SelectableButtonListTile extends StatefulWidget { final String title; final IconData? icon; final String? description; + final String? storesLabel; final bool selected; final bool raised; final void Function()? onPressed; @@ -17,6 +18,7 @@ class SelectableButtonListTile extends StatefulWidget { required this.title, this.icon, this.description, + this.storesLabel, required this.selected, this.onPressed, this.onLongPressed, @@ -69,25 +71,7 @@ class _SelectableButtonListTileState extends State { .withAlpha(170)), ), selected: widget.selected, - subtitle: (widget.description?.isNotEmpty ?? false) - ? Text( - widget.description!, - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall!.copyWith( - color: !widget.raised - ? Theme.of(context) - .textTheme - .bodySmall! - .color! - .withAlpha(85) - : Theme.of(context) - .textTheme - .bodySmall! - .color! - .withAlpha(170)), - ) - : null, + subtitle: _buildSubtitle(context), onTap: widget.onPressed, onLongPress: widget.onLongPressed, contentPadding: const EdgeInsets.only(left: 16, right: 8), @@ -105,6 +89,44 @@ class _SelectableButtonListTileState extends State { ), ); + return _wrapInCard(listItem); + } + + Widget? _buildSubtitle(BuildContext context) { + final hasDescription = widget.description?.isNotEmpty ?? false; + final hasStoresLabel = widget.storesLabel?.isNotEmpty ?? false; + if (!hasDescription && !hasStoresLabel) return null; + + final color = !widget.raised + ? Theme.of(context).textTheme.bodySmall!.color!.withAlpha(85) + : Theme.of(context).textTheme.bodySmall!.color!.withAlpha(170); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (hasDescription) + Text( + widget.description!, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall!.copyWith( + color: color, + ), + ), + if (hasStoresLabel) + Text( + widget.storesLabel!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall!.copyWith( + color: color, + ), + ), + ], + ); + } + + Widget _wrapInCard(Widget listItem) { return (widget.listStyle == ListStyle.cards) ? Card( margin: const EdgeInsets.symmetric(vertical: 4), diff --git a/kitchenowl/lib/widgets/shopping_item.dart b/kitchenowl/lib/widgets/shopping_item.dart index 9fba6d4c0..b8e2bd520 100644 --- a/kitchenowl/lib/widgets/shopping_item.dart +++ b/kitchenowl/lib/widgets/shopping_item.dart @@ -32,6 +32,11 @@ class ShoppingItemWidget extends StatelessWidget { @override Widget build(BuildContext context) { + final storesLabel = (item is ShoppinglistItem && + (item as ShoppinglistItem).stores.isNotEmpty) + ? (item as ShoppinglistItem).stores.map((e) => e.name).join(', ') + : null; + return gridStyle ? SelectableButtonCard( title: item.name, @@ -40,6 +45,7 @@ class ShoppingItemWidget extends StatelessWidget { description: (item is ItemWithDescription) ? (item as ItemWithDescription).description : null, + storesLabel: storesLabel, onPressed: onPressed != null ? () => onPressed!(item) : null, onLongPressed: onLongPressed != null ? () => onLongPressed!(item) : null, @@ -55,6 +61,7 @@ class ShoppingItemWidget extends StatelessWidget { description: (item is ItemWithDescription) ? (item as ItemWithDescription).description : null, + storesLabel: storesLabel, onPressed: onPressed != null ? () => onPressed!(item) : null, onLongPressed: onLongPressed != null ? () => onLongPressed!(item) : null, diff --git a/kitchenowl/lib/widgets/shopping_list/sliver_shopinglist_item_view.dart b/kitchenowl/lib/widgets/shopping_list/sliver_shopinglist_item_view.dart index fada77f29..168170362 100644 --- a/kitchenowl/lib/widgets/shopping_list/sliver_shopinglist_item_view.dart +++ b/kitchenowl/lib/widgets/shopping_list/sliver_shopinglist_item_view.dart @@ -5,11 +5,13 @@ import 'package:kitchenowl/kitchenowl.dart'; import 'package:kitchenowl/models/category.dart'; import 'package:kitchenowl/models/item.dart'; import 'package:kitchenowl/models/shoppinglist.dart'; +import 'package:kitchenowl/models/store.dart'; import 'package:kitchenowl/widgets/home_page/sliver_category_item_grid_list.dart'; class SliverShopinglistItemView extends StatelessWidget { final ShoppingList? shoppingList; final List categories; + final List stores; final void Function()? onRefresh; final Nullable? onPressed; final Nullable? onRecentPressed; @@ -22,6 +24,7 @@ class SliverShopinglistItemView extends StatelessWidget { super.key, this.shoppingList, required this.categories, + this.stores = const [], this.onRefresh, this.onPressed, this.onRecentPressed, @@ -39,6 +42,7 @@ class SliverShopinglistItemView extends StatelessWidget { main = SliverItemGridList( items: shoppingList?.items ?? [], categories: categories, + stores: stores, shoppingList: shoppingList, selected: (item) => App.settings.shoppingListTapToRemove && @@ -65,6 +69,7 @@ class SliverShopinglistItemView extends StatelessWidget { name: category?.name ?? AppLocalizations.of(context)!.uncategorized, items: items, categories: categories, + stores: stores, shoppingList: shoppingList, selected: (item) => App.settings.shoppingListTapToRemove && @@ -94,6 +99,7 @@ class SliverShopinglistItemView extends StatelessWidget { [], onPressed: onRecentPressed, categories: categories, + stores: stores, shoppingList: shoppingList, onRefresh: onRefresh, isLoading: isLoading, diff --git a/kitchenowl/lib/widgets/sliver_item_grid_list.dart b/kitchenowl/lib/widgets/sliver_item_grid_list.dart index b8e58bd76..f812c2bc5 100644 --- a/kitchenowl/lib/widgets/sliver_item_grid_list.dart +++ b/kitchenowl/lib/widgets/sliver_item_grid_list.dart @@ -7,6 +7,7 @@ import 'package:kitchenowl/kitchenowl.dart'; import 'package:kitchenowl/models/category.dart'; import 'package:kitchenowl/models/item.dart'; import 'package:kitchenowl/models/shoppinglist.dart'; +import 'package:kitchenowl/models/store.dart'; import 'package:kitchenowl/models/update_value.dart'; import 'package:kitchenowl/pages/item_page.dart'; import 'package:kitchenowl/widgets/shopping_item.dart'; @@ -18,6 +19,7 @@ class SliverItemGridList extends StatelessWidget { final Nullable? onLongPressed; final List items; final List? categories; // forwarded to item page on long press + final List? stores; // forwarded to item page on long press final ShoppingList? shoppingList; // forwarded to item page on long press final bool Function(T)? selected; final bool isLoading; @@ -31,6 +33,7 @@ class SliverItemGridList extends StatelessWidget { this.onLongPressed, this.items = const [], this.categories, + this.stores, this.shoppingList, this.selected, this.isLoading = false, @@ -95,6 +98,7 @@ class SliverItemGridList extends StatelessWidget { item: item, shoppingList: shoppingList, categories: categories ?? const [], + stores: stores ?? const [], advancedView: shoppingListStyle.advancedItemView, ); final householdCubit = context.readOrNull(); From bcea0abcce064802d914ff97912356cddb5f6d45 Mon Sep 17 00:00:00 2001 From: Marius Gravdal Date: Tue, 7 Jul 2026 21:53:11 +0200 Subject: [PATCH 2/2] fix: deserializing and serializing stores for offline cache --- .../app/controller/item/item_controller.py | 12 +++++----- backend/app/controller/item/schemas.py | 22 +++++++++++++++---- backend/tests/api/test_api_store.py | 6 ++--- kitchenowl/lib/models/item.dart | 2 +- kitchenowl/test/models/item.dart | 14 ++++++++++++ 5 files changed, 42 insertions(+), 14 deletions(-) diff --git a/backend/app/controller/item/item_controller.py b/backend/app/controller/item/item_controller.py index b12b428a3..42d126fe1 100644 --- a/backend/app/controller/item/item_controller.py +++ b/backend/app/controller/item/item_controller.py @@ -11,8 +11,8 @@ itemHousehold = Blueprint("item", __name__) -def _setItemStores(item: Item, store_ids: list[int] | None) -> None: - new_ids = set(store_ids or []) +def _setItemStores(item: Item, stores: list[dict] | None) -> None: + new_ids = {s["id"] for s in stores or []} existing_ids = {s.store_id for s in item.stores} for store_id in existing_ids - new_ids: ItemStores.query.filter_by(item_id=item.id, store_id=store_id).delete() @@ -104,8 +104,8 @@ def addItem(args, household_id): item.icon = args["icon"] item.save() - if "store_ids" in args: - _setItemStores(item, args["store_ids"]) + if "stores" in args: + _setItemStores(item, args["stores"]) return jsonify(item.obj_to_dict()) @@ -134,8 +134,8 @@ def updateItem(args, id): item.name = newName item.save() - if "store_ids" in args: - _setItemStores(item, args["store_ids"]) + if "stores" in args: + _setItemStores(item, args["stores"]) if "merge_item_id" in args and args["merge_item_id"] != id: mergeItem = Item.find_by_id(args["merge_item_id"]) diff --git a/backend/app/controller/item/schemas.py b/backend/app/controller/item/schemas.py index dadff4966..d14551f09 100644 --- a/backend/app/controller/item/schemas.py +++ b/backend/app/controller/item/schemas.py @@ -16,6 +16,13 @@ class Meta: id = fields.Integer(required=True, validate=lambda a: a > 0) name = fields.String(validate=lambda a: not a or a and not a.isspace()) + class Store(Schema): + class Meta: + unknown = EXCLUDE + + id = fields.Integer(required=True, validate=lambda a: a > 0) + name = fields.String(validate=lambda a: not a or a and not a.isspace()) + category = fields.Nested(Category(), allow_none=True) icon = fields.String( validate=lambda a: not a or not a.isspace(), @@ -25,8 +32,8 @@ class Meta: validate=lambda a: not a or not a.isspace(), allow_none=True, ) - store_ids = fields.List( - fields.Integer(validate=lambda a: a > 0), + stores = fields.List( + fields.Nested(Store()), allow_none=True, ) @@ -48,6 +55,13 @@ class Meta: id = fields.Integer(required=True, validate=lambda a: a > 0) name = fields.String(validate=lambda a: not a or a and not a.isspace()) + class Store(Schema): + class Meta: + unknown = EXCLUDE + + id = fields.Integer(required=True, validate=lambda a: a > 0) + name = fields.String(validate=lambda a: not a or a and not a.isspace()) + category = fields.Nested(Category(), allow_none=True) icon = fields.String( validate=lambda a: not a or not a.isspace(), @@ -58,7 +72,7 @@ class Meta: allow_none=False, required=True, ) - store_ids = fields.List( - fields.Integer(validate=lambda a: a > 0), + stores = fields.List( + fields.Nested(Store()), allow_none=True, ) diff --git a/backend/tests/api/test_api_store.py b/backend/tests/api/test_api_store.py index 6655f3cbb..596f87c5f 100644 --- a/backend/tests/api/test_api_store.py +++ b/backend/tests/api/test_api_store.py @@ -15,7 +15,7 @@ def create_store(client, household_id, name): def create_item(client, household_id, name, store_ids=None): body = {"name": name} if store_ids is not None: - body["store_ids"] = store_ids + body["stores"] = [{"id": store_id} for store_id in store_ids] response = client.post(f"/api/household/{household_id}/item", json=body) assert response.status_code == 200 return response.get_json() @@ -86,14 +86,14 @@ def test_update_item_store_ids_replaces_full_set( ) response = user_client_with_household.post( - f"/api/item/{item['id']}", json={"store_ids": [store_b]} + f"/api/item/{item['id']}", json={"stores": [{"id": store_b}]} ) assert response.status_code == 200 data = response.get_json() assert {s["id"] for s in data["stores"]} == {store_b} response = user_client_with_household.post( - f"/api/item/{item['id']}", json={"store_ids": []} + f"/api/item/{item['id']}", json={"stores": []} ) assert response.status_code == 200 assert "stores" not in response.get_json() diff --git a/kitchenowl/lib/models/item.dart b/kitchenowl/lib/models/item.dart index d675715dd..760d50a2b 100644 --- a/kitchenowl/lib/models/item.dart +++ b/kitchenowl/lib/models/item.dart @@ -88,7 +88,7 @@ class Item extends Model { "category": category?.toJsonWithId(), "default": isDefault, "default_key": defaultKey, - "store_ids": stores.map((e) => e.id).toList(), + "stores": stores.map((e) => e.toJsonWithId()).toList(), }); } diff --git a/kitchenowl/test/models/item.dart b/kitchenowl/test/models/item.dart index 13c79c5dc..d3164fd3b 100644 --- a/kitchenowl/test/models/item.dart +++ b/kitchenowl/test/models/item.dart @@ -1,6 +1,7 @@ // ignore_for_file: prefer_const_constructors import 'package:flutter_test/flutter_test.dart'; import 'package:kitchenowl/models/item.dart'; +import 'package:kitchenowl/models/store.dart'; void main() { test("Item should be equal", () { @@ -25,4 +26,17 @@ void main() { // Then expect(actual, equals(c)); }); + test("Item should de-/serialize with stores", () { + // Given + final c = Item( + id: 1, + name: "Name", + ordering: 1, + stores: [Store(id: 1, name: "Store A"), Store(id: 2, name: "Store B")], + ); + // When + final actual = Item.fromJson(c.toJsonWithId()); + // Then + expect(actual, equals(c)); + }); }