From c1bb51902394b76d9e167c3c4be5246f5eb73c4b Mon Sep 17 00:00:00 2001 From: Edigorin Date: Mon, 29 Dec 2025 11:59:50 +0100 Subject: [PATCH 1/6] feat: Add loyalty cards feature Add support for loyalty cards/membership cards that users can store and access from the shopping list page via a wallet FAB. Backend: - Add LoyaltyCard model with name, barcode data/type, description, color - Add CRUD API endpoints for loyalty cards - Add database migration for loyalty_cards table - Add household feature flag for loyalty cards - Add loyalty card import functionality - Add comprehensive API tests Frontend: - Add loyalty cards list page with search functionality - Add loyalty card detail page with fullscreen barcode display - Add loyalty card add/edit page with barcode scanning - Add barcode scanning from camera (mobile) and webcam (web) - Add barcode scanning from gallery/image upload - Add wallet FAB on shopping list page to access loyalty cards - Support multiple barcode types: CODE128, QR, EAN13, etc. - Add localization strings for loyalty cards feature - Add loyalty cards import settings option Closes #462 --- backend/app/api/register_controller.py | 4 + backend/app/config.py | 7 +- backend/app/controller/__init__.py | 1 + .../exportimport/import_controller.py | 5 + .../app/controller/exportimport/schemas.py | 11 + .../household/household_controller.py | 4 + backend/app/controller/household/schemas.py | 2 + .../app/controller/loyalty_card/__init__.py | 2 + .../loyalty_card/loyalty_card_controller.py | 88 +++ .../app/controller/loyalty_card/schemas.py | 20 + backend/app/models/__init__.py | 1 + backend/app/models/file.py | 14 +- backend/app/models/household.py | 12 + backend/app/models/loyalty_card.py | 65 ++ .../app/service/importServices/__init__.py | 1 + .../importServices/import_loyalty_card.py | 28 + .../versions/a1b2c3d4e5f6_loyalty_cards.py | 52 ++ backend/tests/api/test_api_loyalty_card.py | 276 +++++++++ .../android/app/src/main/AndroidManifest.xml | 1 + kitchenowl/ios/Runner/Info.plist | 4 +- kitchenowl/lib/cubits/loyalty_card_cubit.dart | 39 ++ .../lib/cubits/loyalty_card_list_cubit.dart | 87 +++ kitchenowl/lib/enums/views_enum.dart | 11 +- .../lib/helpers/web_barcode_scanner.dart | 19 + .../lib/helpers/web_barcode_scanner_stub.dart | 10 + .../lib/helpers/web_barcode_scanner_web.dart | 43 ++ kitchenowl/lib/l10n/app_en.arb | 92 ++- kitchenowl/lib/models/household.dart | 9 + kitchenowl/lib/models/import_settings.dart | 6 + kitchenowl/lib/models/loyalty_card.dart | 87 +++ .../lib/pages/barcode_scanner_page.dart | 192 ++++++ .../pages/loyalty_card_add_update_page.dart | 581 ++++++++++++++++++ .../lib/pages/loyalty_card_list_page.dart | 215 +++++++ kitchenowl/lib/pages/loyalty_card_page.dart | 456 ++++++++++++++ kitchenowl/lib/router.dart | 52 ++ kitchenowl/lib/services/api/api_service.dart | 1 + kitchenowl/lib/services/api/loyalty_card.dart | 56 ++ .../services/transactions/loyalty_card.dart | 135 ++++ .../lib/widgets/loyalty_card_create_fab.dart | 34 + kitchenowl/lib/widgets/loyalty_card_item.dart | 190 ++++++ .../import_settings_dialog.dart | 9 + kitchenowl/lib/widgets/shopping_list_fab.dart | 73 +++ kitchenowl/pubspec.lock | 32 + kitchenowl/pubspec.yaml | 2 + kitchenowl/web/barcode_scanner.js | 110 ++++ kitchenowl/web/index.html | 7 + 46 files changed, 3136 insertions(+), 10 deletions(-) create mode 100644 backend/app/controller/loyalty_card/__init__.py create mode 100644 backend/app/controller/loyalty_card/loyalty_card_controller.py create mode 100644 backend/app/controller/loyalty_card/schemas.py create mode 100644 backend/app/models/loyalty_card.py create mode 100644 backend/app/service/importServices/import_loyalty_card.py create mode 100644 backend/migrations/versions/a1b2c3d4e5f6_loyalty_cards.py create mode 100644 backend/tests/api/test_api_loyalty_card.py create mode 100644 kitchenowl/lib/cubits/loyalty_card_cubit.dart create mode 100644 kitchenowl/lib/cubits/loyalty_card_list_cubit.dart create mode 100644 kitchenowl/lib/helpers/web_barcode_scanner.dart create mode 100644 kitchenowl/lib/helpers/web_barcode_scanner_stub.dart create mode 100644 kitchenowl/lib/helpers/web_barcode_scanner_web.dart create mode 100644 kitchenowl/lib/models/loyalty_card.dart create mode 100644 kitchenowl/lib/pages/barcode_scanner_page.dart create mode 100644 kitchenowl/lib/pages/loyalty_card_add_update_page.dart create mode 100644 kitchenowl/lib/pages/loyalty_card_list_page.dart create mode 100644 kitchenowl/lib/pages/loyalty_card_page.dart create mode 100644 kitchenowl/lib/services/api/loyalty_card.dart create mode 100644 kitchenowl/lib/services/transactions/loyalty_card.dart create mode 100644 kitchenowl/lib/widgets/loyalty_card_create_fab.dart create mode 100644 kitchenowl/lib/widgets/loyalty_card_item.dart create mode 100644 kitchenowl/lib/widgets/shopping_list_fab.dart create mode 100644 kitchenowl/web/barcode_scanner.js diff --git a/backend/app/api/register_controller.py b/backend/app/api/register_controller.py index 1880b549d..242c53f70 100644 --- a/backend/app/api/register_controller.py +++ b/backend/app/api/register_controller.py @@ -16,6 +16,9 @@ api.household.register_blueprint( api.expenseHousehold, url_prefix="//expense" ) +api.household.register_blueprint( + api.loyaltyCardHousehold, url_prefix="//loyalty-card" +) api.household.register_blueprint( api.itemHousehold, url_prefix="//item" ) @@ -34,6 +37,7 @@ apiv1.register_blueprint(api.household, url_prefix="/household") apiv1.register_blueprint(api.category, url_prefix="/category") apiv1.register_blueprint(api.expense, url_prefix="/expense") +apiv1.register_blueprint(api.loyaltyCard, url_prefix="/loyalty-card") apiv1.register_blueprint(api.item, url_prefix="/item") apiv1.register_blueprint(api.onboarding, url_prefix="/onboarding") apiv1.register_blueprint(api.recipe, url_prefix="/recipe") diff --git a/backend/app/config.py b/backend/app/config.py index 7bbdec43f..90efc7b6b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -29,7 +29,10 @@ from flask_bcrypt import Bcrypt from flask_jwt_extended import JWTManager from flask_apscheduler import APScheduler -import sqlite_icu +try: + import sqlite_icu +except ImportError: + sqlite_icu = None import os @@ -285,7 +288,7 @@ def __call__(self, *args: object, **kwargs: object) -> object: # Load ICU extension for sqlite -if DB_URL.drivername == "sqlite": +if DB_URL.drivername == "sqlite" and sqlite_icu is not None: def load_extension(conn, unused): conn.enable_load_extension(True) diff --git a/backend/app/controller/__init__.py b/backend/app/controller/__init__.py index 89f0b6a78..e068f0e61 100644 --- a/backend/app/controller/__init__.py +++ b/backend/app/controller/__init__.py @@ -15,3 +15,4 @@ from .health_controller import health from .analytics import * from .report import * +from .loyalty_card import * diff --git a/backend/app/controller/exportimport/import_controller.py b/backend/app/controller/exportimport/import_controller.py index 8cdae92bf..cb2704c2c 100644 --- a/backend/app/controller/exportimport/import_controller.py +++ b/backend/app/controller/exportimport/import_controller.py @@ -6,6 +6,7 @@ importRecipe, importExpense, importShoppinglist, + importLoyaltyCard, ) from app.service.recalculate_balances import recalculateBalances from .schemas import ImportSchema @@ -49,5 +50,9 @@ def importData(args, household_id): for shoppinglist in args["shoppinglists"]: importShoppinglist(household, shoppinglist) + if "loyalty_cards" in args: + for card in args["loyalty_cards"]: + importLoyaltyCard(household, card) + app.logger.info(f"Import took: {(time.time() - t0):.3f}s") return jsonify({"msg": "DONE"}) diff --git a/backend/app/controller/exportimport/schemas.py b/backend/app/controller/exportimport/schemas.py index 47a077ed9..0e748c236 100644 --- a/backend/app/controller/exportimport/schemas.py +++ b/backend/app/controller/exportimport/schemas.py @@ -56,9 +56,20 @@ class Category(Schema): photo = fields.String(allow_none=True) category = fields.Nested(Category) + class LoyaltyCard(Schema): + class Meta: + unknown = EXCLUDE + + name = fields.String(required=True, validate=lambda a: a and not a.isspace()) + barcode_data = fields.String(allow_none=True) + barcode_type = fields.String(allow_none=True) + description = fields.String(allow_none=True) + color = fields.Integer(allow_none=True) + items = fields.List(fields.Nested(Item)) recipes = fields.List(fields.Nested(Recipe)) recipe_overwrite = fields.Boolean() expenses = fields.List(fields.Nested(Expense)) + loyalty_cards = fields.List(fields.Nested(LoyaltyCard)) member = fields.List(fields.String()) shoppinglists = fields.List(fields.String()) diff --git a/backend/app/controller/household/household_controller.py b/backend/app/controller/household/household_controller.py index ddcbfe7fd..88477325d 100644 --- a/backend/app/controller/household/household_controller.py +++ b/backend/app/controller/household/household_controller.py @@ -53,6 +53,8 @@ def addHousehold(args): household.planner_feature = args["planner_feature"] if "expenses_feature" in args: household.expenses_feature = args["expenses_feature"] + if "loyalty_cards_feature" in args: + household.loyalty_cards_feature = args["loyalty_cards_feature"] if "view_ordering" in args: household.view_ordering = args["view_ordering"] if "link" in args: @@ -111,6 +113,8 @@ def updateHousehold(args, household_id): household.planner_feature = args["planner_feature"] if "expenses_feature" in args: household.expenses_feature = args["expenses_feature"] + if "loyalty_cards_feature" in args: + household.loyalty_cards_feature = args["loyalty_cards_feature"] if "view_ordering" in args: household.view_ordering = args["view_ordering"] if "link" in args: diff --git a/backend/app/controller/household/schemas.py b/backend/app/controller/household/schemas.py index a6472ae2f..2d888d259 100644 --- a/backend/app/controller/household/schemas.py +++ b/backend/app/controller/household/schemas.py @@ -12,6 +12,7 @@ class Meta: language = fields.String() planner_feature = fields.Boolean() expenses_feature = fields.Boolean() + loyalty_cards_feature = fields.Boolean() view_ordering = fields.List(fields.String) member = fields.List(fields.Integer) @@ -27,6 +28,7 @@ class Meta: language = fields.String() planner_feature = fields.Boolean() expenses_feature = fields.Boolean() + loyalty_cards_feature = fields.Boolean() view_ordering = fields.List(fields.String) diff --git a/backend/app/controller/loyalty_card/__init__.py b/backend/app/controller/loyalty_card/__init__.py new file mode 100644 index 000000000..486d87a0e --- /dev/null +++ b/backend/app/controller/loyalty_card/__init__.py @@ -0,0 +1,2 @@ +from .loyalty_card_controller import loyaltyCard, loyaltyCardHousehold + diff --git a/backend/app/controller/loyalty_card/loyalty_card_controller.py b/backend/app/controller/loyalty_card/loyalty_card_controller.py new file mode 100644 index 000000000..aa424c6b8 --- /dev/null +++ b/backend/app/controller/loyalty_card/loyalty_card_controller.py @@ -0,0 +1,88 @@ +from app.errors import NotFoundRequest +from flask import jsonify, Blueprint +from flask_jwt_extended import jwt_required +from app.helpers import validate_args, authorize_household +from app.models import LoyaltyCard +from app.service.file_has_access_or_download import file_has_access_or_download +from .schemas import AddLoyaltyCard, UpdateLoyaltyCard + +loyaltyCard = Blueprint("loyalty_card", __name__) +loyaltyCardHousehold = Blueprint("loyalty_card", __name__) + + +@loyaltyCardHousehold.route("", methods=["GET"]) +@jwt_required() +@authorize_household() +def getAllLoyaltyCards(household_id): + return jsonify( + [e.obj_to_dict() for e in LoyaltyCard.find_by_household(household_id)] + ) + + +@loyaltyCard.route("/", methods=["GET"]) +@jwt_required() +def getLoyaltyCardById(id): + loyalty_card = LoyaltyCard.find_by_id(id) + if not loyalty_card: + raise NotFoundRequest() + loyalty_card.checkAuthorized() + return jsonify(loyalty_card.obj_to_full_dict()) + + +@loyaltyCardHousehold.route("", methods=["POST"]) +@jwt_required() +@authorize_household() +@validate_args(AddLoyaltyCard) +def addLoyaltyCard(args, household_id): + loyalty_card = LoyaltyCard() + loyalty_card.name = args["name"] + loyalty_card.barcode_type = args["barcode_type"] + loyalty_card.barcode_data = args["barcode_data"] + loyalty_card.household_id = household_id + if "description" in args: + loyalty_card.description = args["description"] + if "color" in args: + loyalty_card.color = args["color"] + if "photo" in args and args["photo"]: + loyalty_card.photo = file_has_access_or_download(args["photo"], None) + loyalty_card.save() + return jsonify(loyalty_card.obj_to_dict()) + + +@loyaltyCard.route("/", methods=["POST"]) +@jwt_required() +@validate_args(UpdateLoyaltyCard) +def updateLoyaltyCard(args, id): + loyalty_card = LoyaltyCard.find_by_id(id) + if not loyalty_card: + raise NotFoundRequest() + loyalty_card.checkAuthorized() + + if "name" in args: + loyalty_card.name = args["name"] + if "barcode_type" in args: + loyalty_card.barcode_type = args["barcode_type"] + if "barcode_data" in args: + loyalty_card.barcode_data = args["barcode_data"] + if "description" in args: + loyalty_card.description = args["description"] + if "color" in args: + loyalty_card.color = args["color"] + if "photo" in args and args["photo"] != loyalty_card.photo: + loyalty_card.photo = file_has_access_or_download(args["photo"], loyalty_card.photo) + + loyalty_card.save() + return jsonify(loyalty_card.obj_to_dict()) + + +@loyaltyCard.route("/", methods=["DELETE"]) +@jwt_required() +def deleteLoyaltyCardById(id): + loyalty_card = LoyaltyCard.find_by_id(id) + if not loyalty_card: + raise NotFoundRequest() + loyalty_card.checkAuthorized() + + loyalty_card.delete() + return jsonify({"msg": "DONE"}) + diff --git a/backend/app/controller/loyalty_card/schemas.py b/backend/app/controller/loyalty_card/schemas.py new file mode 100644 index 000000000..71baefd3d --- /dev/null +++ b/backend/app/controller/loyalty_card/schemas.py @@ -0,0 +1,20 @@ +from marshmallow import fields, Schema + + +class AddLoyaltyCard(Schema): + name = fields.String(required=True, validate=lambda a: a and not a.isspace()) + barcode_type = fields.String(required=True, validate=lambda a: a and not a.isspace()) + barcode_data = fields.String(required=True, validate=lambda a: a and not a.isspace()) + description = fields.String(allow_none=True) + color = fields.Integer(validate=lambda i: i >= 0, allow_none=True) + photo = fields.String(allow_none=True) + + +class UpdateLoyaltyCard(Schema): + name = fields.String(validate=lambda a: a and not a.isspace()) + barcode_type = fields.String(validate=lambda a: a and not a.isspace()) + barcode_data = fields.String(validate=lambda a: a and not a.isspace()) + description = fields.String(allow_none=True) + color = fields.Integer(validate=lambda i: i >= 0, allow_none=True) + photo = fields.String(allow_none=True) + diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 9af3ad90f..82188634b 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -18,3 +18,4 @@ from .challenge_password_reset import ChallengePasswordReset from .oidc import OIDCLink, OIDCRequest from .report import Report +from .loyalty_card import LoyaltyCard diff --git a/backend/app/models/file.py b/backend/app/models/file.py index 126a7fedb..9434bc4d0 100644 --- a/backend/app/models/file.py +++ b/backend/app/models/file.py @@ -13,7 +13,7 @@ Model = db.Model if TYPE_CHECKING: - from app.models import Household, Recipe, Expense + from app.models import Household, Recipe, Expense, LoyaltyCard from app.helpers.db_model_base import DbModelBase Model = DbModelBase @@ -66,6 +66,13 @@ class File(Model, DbModelAuthorizeMixin): uselist=False, ), ) + loyalty_card: Mapped["LoyaltyCard"] = cast( + Mapped["LoyaltyCard"], + db.relationship( + "LoyaltyCard", + uselist=False, + ), + ) def delete(self): """ @@ -81,6 +88,7 @@ def isUnused(self) -> bool: and not self.recipe and not self.expense and not self.profile_picture + and not self.loyalty_card ) def checkAuthorized( @@ -101,6 +109,10 @@ def checkAuthorized( super().checkAuthorized( household_id=self.expense.household_id, requires_admin=requires_admin ) + elif self.loyalty_card: + super().checkAuthorized( + household_id=self.loyalty_card.household_id, requires_admin=requires_admin + ) else: raise ForbiddenRequest() diff --git a/backend/app/models/household.py b/backend/app/models/household.py index 774871c1e..37f66ec6d 100644 --- a/backend/app/models/household.py +++ b/backend/app/models/household.py @@ -15,6 +15,7 @@ ExpenseCategory, User, File, + LoyaltyCard, ) from app.helpers.db_model_base import DbModelBase @@ -34,6 +35,9 @@ class Household(Model): expenses_feature: Mapped[bool] = db.Column( db.Boolean(), nullable=False, default=True ) + loyalty_cards_feature: Mapped[bool] = db.Column( + db.Boolean(), nullable=False, default=True + ) description: Mapped[str | None] = db.Column(db.String()) link: Mapped[str | None] = db.Column(db.String(255)) # For households that have verified their authenticity @@ -97,6 +101,14 @@ class Household(Model): cascade="all, delete-orphan", ), ) + loyaltyCards: Mapped[List["LoyaltyCard"]] = cast( + Mapped[List["LoyaltyCard"]], + db.relationship( + "LoyaltyCard", + back_populates="household", + cascade="all, delete-orphan", + ), + ) member: Mapped[List["HouseholdMember"]] = cast( Mapped[List["HouseholdMember"]], db.relationship( diff --git a/backend/app/models/loyalty_card.py b/backend/app/models/loyalty_card.py new file mode 100644 index 000000000..b3d9e198c --- /dev/null +++ b/backend/app/models/loyalty_card.py @@ -0,0 +1,65 @@ +from typing import Any, Self, 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, File + from app.helpers.db_model_base import DbModelBase + + Model = DbModelBase + + +class LoyaltyCard(Model, DbModelAuthorizeMixin): + __tablename__ = "loyalty_card" + + id: Mapped[int] = db.Column(db.Integer, primary_key=True) + name: Mapped[str] = db.Column(db.String(128), nullable=False) + barcode_type: Mapped[str] = db.Column(db.String(32), nullable=False, default="CODE128") + barcode_data: Mapped[str] = db.Column(db.String(256), nullable=False) + description: Mapped[str | None] = db.Column(db.String(512)) + color: Mapped[int | None] = db.Column(db.Integer) + photo: Mapped[str | None] = db.Column(db.String(), db.ForeignKey("file.filename")) + 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", + back_populates="loyaltyCards", + uselist=False, + ), + ) + photo_file: Mapped["File"] = cast( + Mapped["File"], + db.relationship( + "File", + back_populates="loyalty_card", + uselist=False, + ), + ) + + def obj_to_dict( + self, + skip_columns: list[str] | None = None, + include_columns: list[str] | None = None, + ) -> dict[str, Any]: + res = super().obj_to_dict(skip_columns, include_columns) + if self.photo_file: + res["photo_hash"] = self.photo_file.blur_hash + return res + + def obj_to_full_dict(self) -> dict[str, Any]: + return self.obj_to_dict() + + @classmethod + def find_by_id(cls, id: int) -> Self | None: + return cls.query.filter(cls.id == id).first() + + @classmethod + def find_by_household(cls, household_id: int) -> list[Self]: + return cls.query.filter(cls.household_id == household_id).order_by(cls.name).all() + diff --git a/backend/app/service/importServices/__init__.py b/backend/app/service/importServices/__init__.py index d18c39f03..87571d7cc 100644 --- a/backend/app/service/importServices/__init__.py +++ b/backend/app/service/importServices/__init__.py @@ -2,3 +2,4 @@ from .import_expense import importExpense from .import_shoppinglist import importShoppinglist from .import_item import importItem +from .import_loyalty_card import importLoyaltyCard diff --git a/backend/app/service/importServices/import_loyalty_card.py b/backend/app/service/importServices/import_loyalty_card.py new file mode 100644 index 000000000..9f7c19155 --- /dev/null +++ b/backend/app/service/importServices/import_loyalty_card.py @@ -0,0 +1,28 @@ +from ...models import LoyaltyCard +from app.config import db + + +def importLoyaltyCard(household, card_data): + card = LoyaltyCard.query.filter_by( + household_id=household.id, name=card_data["name"] + ).first() + + if not card: + card = LoyaltyCard() + card.household_id = household.id + card.name = card_data["name"] + + if "barcode_data" in card_data: + card.barcode_data = card_data["barcode_data"] + + if "barcode_type" in card_data: + card.barcode_type = card_data["barcode_type"] + + if "description" in card_data: + card.description = card_data["description"] + + if "color" in card_data: + card.color = card_data["color"] + + db.session.add(card) + db.session.commit() diff --git a/backend/migrations/versions/a1b2c3d4e5f6_loyalty_cards.py b/backend/migrations/versions/a1b2c3d4e5f6_loyalty_cards.py new file mode 100644 index 000000000..ccdd61a4a --- /dev/null +++ b/backend/migrations/versions/a1b2c3d4e5f6_loyalty_cards.py @@ -0,0 +1,52 @@ +"""Add loyalty cards feature + +Revision ID: a1b2c3d4e5f6 +Revises: bd383e73ef4d +Create Date: 2025-12-26 12:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a1b2c3d4e5f6' +down_revision = 'bd383e73ef4d' +branch_labels = None +depends_on = None + + +def upgrade(): + # Create loyalty_card table + op.create_table( + 'loyalty_card', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('barcode_type', sa.String(length=32), nullable=False), + sa.Column('barcode_data', sa.String(length=256), nullable=False), + sa.Column('description', sa.String(length=512), nullable=True), + sa.Column('color', sa.Integer(), nullable=True), + sa.Column('photo', sa.String(), nullable=True), + sa.Column('household_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['household_id'], ['household.id'], ), + sa.ForeignKeyConstraint(['photo'], ['file.filename'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_loyalty_card_household_id'), 'loyalty_card', ['household_id'], unique=False) + + # Add loyalty_cards_feature column to household + with op.batch_alter_table('household', schema=None) as batch_op: + batch_op.add_column(sa.Column('loyalty_cards_feature', sa.Boolean(), nullable=False, server_default='1')) + + +def downgrade(): + # Remove loyalty_cards_feature column from household + with op.batch_alter_table('household', schema=None) as batch_op: + batch_op.drop_column('loyalty_cards_feature') + + # Drop loyalty_card table + op.drop_index(op.f('ix_loyalty_card_household_id'), table_name='loyalty_card') + op.drop_table('loyalty_card') + diff --git a/backend/tests/api/test_api_loyalty_card.py b/backend/tests/api/test_api_loyalty_card.py new file mode 100644 index 000000000..0ca05e549 --- /dev/null +++ b/backend/tests/api/test_api_loyalty_card.py @@ -0,0 +1,276 @@ +"""Tests for Loyalty Card API endpoints.""" +import pytest + + +@pytest.fixture +def loyalty_card_data(): + """Sample loyalty card data for testing.""" + return { + "name": "Test Store Card", + "barcode_type": "CODE128", + "barcode_data": "1234567890", + "description": "My test loyalty card", + "color": 4280391411, # Blue color as int + } + + +@pytest.fixture +def updated_loyalty_card_data(): + """Updated loyalty card data for testing updates.""" + return { + "name": "Updated Store Card", + "barcode_type": "QR", + "barcode_data": "9876543210", + "description": "Updated description", + } + + +class TestLoyaltyCardAPI: + """Test suite for Loyalty Card API endpoints.""" + + def test_get_all_loyalty_cards_empty( + self, user_client_with_household, household_id + ): + """Test getting loyalty cards for a household with no cards.""" + response = user_client_with_household.get( + f"/api/household/{household_id}/loyalty-card" + ) + assert response.status_code == 200 + data = response.get_json() + assert isinstance(data, list) + assert len(data) == 0 + + def test_create_loyalty_card( + self, user_client_with_household, household_id, loyalty_card_data + ): + """Test creating a new loyalty card.""" + response = user_client_with_household.post( + f"/api/household/{household_id}/loyalty-card", + json=loyalty_card_data, + ) + assert response.status_code == 200 + data = response.get_json() + assert data["name"] == loyalty_card_data["name"] + assert data["barcode_type"] == loyalty_card_data["barcode_type"] + assert data["barcode_data"] == loyalty_card_data["barcode_data"] + assert data["description"] == loyalty_card_data["description"] + assert data["color"] == loyalty_card_data["color"] + assert "id" in data + + def test_create_loyalty_card_minimal( + self, user_client_with_household, household_id + ): + """Test creating a loyalty card with minimal required fields.""" + minimal_data = { + "name": "Minimal Card", + "barcode_type": "EAN13", + "barcode_data": "5901234123457", + } + response = user_client_with_household.post( + f"/api/household/{household_id}/loyalty-card", + json=minimal_data, + ) + assert response.status_code == 200 + data = response.get_json() + assert data["name"] == minimal_data["name"] + assert data["barcode_type"] == minimal_data["barcode_type"] + assert data["barcode_data"] == minimal_data["barcode_data"] + + def test_get_all_loyalty_cards_after_create( + self, user_client_with_household, household_id, loyalty_card_data + ): + """Test getting all loyalty cards after creating one.""" + # Create a card first + user_client_with_household.post( + f"/api/household/{household_id}/loyalty-card", + json=loyalty_card_data, + ) + + # Get all cards + response = user_client_with_household.get( + f"/api/household/{household_id}/loyalty-card" + ) + assert response.status_code == 200 + data = response.get_json() + assert len(data) == 1 + assert data[0]["name"] == loyalty_card_data["name"] + + def test_get_loyalty_card_by_id( + self, user_client_with_household, household_id, loyalty_card_data + ): + """Test getting a single loyalty card by ID.""" + # Create a card first + create_response = user_client_with_household.post( + f"/api/household/{household_id}/loyalty-card", + json=loyalty_card_data, + ) + card_id = create_response.get_json()["id"] + + # Get the card by ID + response = user_client_with_household.get( + f"/api/loyalty-card/{card_id}" + ) + assert response.status_code == 200 + data = response.get_json() + assert data["id"] == card_id + assert data["name"] == loyalty_card_data["name"] + + def test_get_loyalty_card_not_found(self, user_client_with_household): + """Test getting a non-existent loyalty card returns 404.""" + response = user_client_with_household.get("/api/loyalty-card/99999") + assert response.status_code == 404 + + def test_update_loyalty_card( + self, + user_client_with_household, + household_id, + loyalty_card_data, + updated_loyalty_card_data, + ): + """Test updating a loyalty card.""" + # Create a card first + create_response = user_client_with_household.post( + f"/api/household/{household_id}/loyalty-card", + json=loyalty_card_data, + ) + card_id = create_response.get_json()["id"] + + # Update the card + response = user_client_with_household.post( + f"/api/loyalty-card/{card_id}", + json=updated_loyalty_card_data, + ) + assert response.status_code == 200 + data = response.get_json() + assert data["name"] == updated_loyalty_card_data["name"] + assert data["barcode_type"] == updated_loyalty_card_data["barcode_type"] + assert data["barcode_data"] == updated_loyalty_card_data["barcode_data"] + + def test_update_loyalty_card_partial( + self, user_client_with_household, household_id, loyalty_card_data + ): + """Test partial update of a loyalty card.""" + # Create a card first + create_response = user_client_with_household.post( + f"/api/household/{household_id}/loyalty-card", + json=loyalty_card_data, + ) + card_id = create_response.get_json()["id"] + + # Partial update - only change name + partial_update = {"name": "Partially Updated Card"} + response = user_client_with_household.post( + f"/api/loyalty-card/{card_id}", + json=partial_update, + ) + assert response.status_code == 200 + data = response.get_json() + assert data["name"] == partial_update["name"] + # Original values should be preserved + assert data["barcode_type"] == loyalty_card_data["barcode_type"] + assert data["barcode_data"] == loyalty_card_data["barcode_data"] + + def test_delete_loyalty_card( + self, user_client_with_household, household_id, loyalty_card_data + ): + """Test deleting a loyalty card.""" + # Create a card first + create_response = user_client_with_household.post( + f"/api/household/{household_id}/loyalty-card", + json=loyalty_card_data, + ) + card_id = create_response.get_json()["id"] + + # Delete the card + response = user_client_with_household.delete( + f"/api/loyalty-card/{card_id}" + ) + assert response.status_code == 200 + + # Verify it's deleted + get_response = user_client_with_household.get( + f"/api/loyalty-card/{card_id}" + ) + assert get_response.status_code == 404 + + def test_delete_loyalty_card_not_found(self, user_client_with_household): + """Test deleting a non-existent loyalty card returns 404.""" + response = user_client_with_household.delete("/api/loyalty-card/99999") + assert response.status_code == 404 + + def test_create_loyalty_card_missing_required_field( + self, user_client_with_household, household_id + ): + """Test creating a loyalty card with missing required fields fails.""" + # Missing barcode_data + incomplete_data = { + "name": "Incomplete Card", + "barcode_type": "CODE128", + } + response = user_client_with_household.post( + f"/api/household/{household_id}/loyalty-card", + json=incomplete_data, + ) + assert response.status_code == 400 + + def test_multiple_loyalty_cards_ordered_by_name( + self, user_client_with_household, household_id + ): + """Test that multiple loyalty cards are returned ordered by name.""" + cards = [ + {"name": "Zebra Store", "barcode_type": "CODE128", "barcode_data": "111"}, + {"name": "Alpha Market", "barcode_type": "CODE128", "barcode_data": "222"}, + {"name": "Beta Shop", "barcode_type": "CODE128", "barcode_data": "333"}, + ] + + for card in cards: + user_client_with_household.post( + f"/api/household/{household_id}/loyalty-card", + json=card, + ) + + response = user_client_with_household.get( + f"/api/household/{household_id}/loyalty-card" + ) + assert response.status_code == 200 + data = response.get_json() + assert len(data) == 3 + # Should be ordered alphabetically by name + assert data[0]["name"] == "Alpha Market" + assert data[1]["name"] == "Beta Shop" + assert data[2]["name"] == "Zebra Store" + + +class TestLoyaltyCardAuthorization: + """Test authorization for Loyalty Card API endpoints.""" + + def test_get_loyalty_cards_requires_auth(self, client): + """Test that getting loyalty cards requires authentication.""" + response = client.get("/api/household/1/loyalty-card") + assert response.status_code == 401 + + def test_create_loyalty_card_requires_auth(self, client): + """Test that creating loyalty cards requires authentication.""" + response = client.post( + "/api/household/1/loyalty-card", + json={"name": "Test", "barcode_type": "QR", "barcode_data": "123"}, + ) + assert response.status_code == 401 + + def test_get_loyalty_card_requires_auth(self, client): + """Test that getting a single loyalty card requires authentication.""" + response = client.get("/api/loyalty-card/1") + assert response.status_code == 401 + + def test_update_loyalty_card_requires_auth(self, client): + """Test that updating a loyalty card requires authentication.""" + response = client.post( + "/api/loyalty-card/1", + json={"name": "Updated"}, + ) + assert response.status_code == 401 + + def test_delete_loyalty_card_requires_auth(self, client): + """Test that deleting a loyalty card requires authentication.""" + response = client.delete("/api/loyalty-card/1") + assert response.status_code == 401 diff --git a/kitchenowl/android/app/src/main/AndroidManifest.xml b/kitchenowl/android/app/src/main/AndroidManifest.xml index 22ea71467..f64c563a6 100644 --- a/kitchenowl/android/app/src/main/AndroidManifest.xml +++ b/kitchenowl/android/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + diff --git a/kitchenowl/ios/Runner/Info.plist b/kitchenowl/ios/Runner/Info.plist index b3b34a1d1..84034d5b6 100644 --- a/kitchenowl/ios/Runner/Info.plist +++ b/kitchenowl/ios/Runner/Info.plist @@ -124,9 +124,9 @@ NSCameraUsageDescription - Take recipe images + Take recipe images and scan barcodes NSPhotoLibraryUsageDescription - Select recipe images + Select recipe images and scan barcodes from photos UIApplicationSupportsIndirectInputEvents UIBackgroundModes diff --git a/kitchenowl/lib/cubits/loyalty_card_cubit.dart b/kitchenowl/lib/cubits/loyalty_card_cubit.dart new file mode 100644 index 000000000..af4c96585 --- /dev/null +++ b/kitchenowl/lib/cubits/loyalty_card_cubit.dart @@ -0,0 +1,39 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:kitchenowl/models/loyalty_card.dart'; +import 'package:kitchenowl/services/api/api_service.dart'; + +class LoyaltyCardCubit extends Cubit { + LoyaltyCardCubit(LoyaltyCard loyaltyCard) + : super(LoyaltyCardState(loyaltyCard: loyaltyCard)) { + refresh(); + } + + Future refresh() async { + if (state.loyaltyCard.id == null) return; + + final card = await ApiService.getInstance().getLoyaltyCard(state.loyaltyCard); + if (card != null) { + emit(LoyaltyCardState(loyaltyCard: card)); + } + } + + void updateCard(LoyaltyCard loyaltyCard) { + emit(LoyaltyCardState(loyaltyCard: loyaltyCard)); + } + + Future deleteCard() async { + if (state.loyaltyCard.id == null) return false; + return await ApiService.getInstance().deleteLoyaltyCard(state.loyaltyCard); + } +} + +class LoyaltyCardState extends Equatable { + final LoyaltyCard loyaltyCard; + + const LoyaltyCardState({required this.loyaltyCard}); + + @override + List get props => [loyaltyCard]; +} + diff --git a/kitchenowl/lib/cubits/loyalty_card_list_cubit.dart b/kitchenowl/lib/cubits/loyalty_card_list_cubit.dart new file mode 100644 index 000000000..fa78c1295 --- /dev/null +++ b/kitchenowl/lib/cubits/loyalty_card_list_cubit.dart @@ -0,0 +1,87 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:kitchenowl/models/household.dart'; +import 'package:kitchenowl/models/loyalty_card.dart'; +import 'package:kitchenowl/services/transaction_handler.dart'; +import 'package:kitchenowl/services/transactions/loyalty_card.dart'; + +class LoyaltyCardListCubit extends Cubit { + final Household household; + Future? _refreshThread; + + LoyaltyCardListCubit(this.household) + : super(const LoadingLoyaltyCardListCubitState()) { + refresh(); + } + + Future remove(LoyaltyCard loyaltyCard) async { + await TransactionHandler.getInstance().runTransaction( + TransactionLoyaltyCardRemove(loyaltyCard: loyaltyCard), + ); + await refresh(); + } + + Future add(LoyaltyCard loyaltyCard) async { + final result = await TransactionHandler.getInstance().runTransaction( + TransactionLoyaltyCardAdd( + household: household, + loyaltyCard: loyaltyCard, + ), + ); + await refresh(); + return result; + } + + Future update(LoyaltyCard loyaltyCard) async { + await TransactionHandler.getInstance().runTransaction( + TransactionLoyaltyCardUpdate(loyaltyCard: loyaltyCard), + ); + await refresh(); + } + + Future refresh() { + _refreshThread ??= _refresh(); + + return _refreshThread!; + } + + Future _refresh() async { + final loyaltyCards = await TransactionHandler.getInstance().runTransaction( + TransactionLoyaltyCardGetAll(household: household), + ); + + emit(LoyaltyCardListCubitState( + loyaltyCards: loyaltyCards, + )); + _refreshThread = null; + } +} + +class LoyaltyCardListCubitState extends Equatable { + final List loyaltyCards; + + const LoyaltyCardListCubitState({ + this.loyaltyCards = const [], + }); + + LoyaltyCardListCubitState copyWith({ + List? loyaltyCards, + }) => + LoyaltyCardListCubitState( + loyaltyCards: loyaltyCards ?? this.loyaltyCards, + ); + + @override + List get props => loyaltyCards; +} + +class LoadingLoyaltyCardListCubitState extends LoyaltyCardListCubitState { + const LoadingLoyaltyCardListCubitState() : super(); + + @override + LoyaltyCardListCubitState copyWith({ + List? loyaltyCards, + }) => + LoadingLoyaltyCardListCubitState(); +} + diff --git a/kitchenowl/lib/enums/views_enum.dart b/kitchenowl/lib/enums/views_enum.dart index 9ab52d1f9..84b04a609 100644 --- a/kitchenowl/lib/enums/views_enum.dart +++ b/kitchenowl/lib/enums/views_enum.dart @@ -7,8 +7,9 @@ import 'package:kitchenowl/kitchenowl.dart'; import 'package:kitchenowl/models/household.dart'; import 'package:kitchenowl/widgets/expense_create_fab.dart'; import 'package:kitchenowl/widgets/recipe_create_fab.dart'; -import 'package:kitchenowl/widgets/shoppinglist_confirm_remove_fab.dart'; +import 'package:kitchenowl/widgets/shopping_list_fab.dart'; +// Note: loyaltyCards is not a navigation view - accessed via FAB on shopping list page enum ViewsEnum { items, recipes, @@ -100,9 +101,7 @@ enum ViewsEnum { ? const ExpenseCreateFab() : null; case ViewsEnum.items: - return App.settings.shoppingListTapToRemove - ? null - : const ShoppingListConfirmRememoveFab(); + return const ShoppingListFab(); default: return null; } @@ -145,6 +144,10 @@ enum ViewsEnum { } } + String toRouteName() { + return name; + } + @override String toString() { return name; diff --git a/kitchenowl/lib/helpers/web_barcode_scanner.dart b/kitchenowl/lib/helpers/web_barcode_scanner.dart new file mode 100644 index 000000000..0bc13e339 --- /dev/null +++ b/kitchenowl/lib/helpers/web_barcode_scanner.dart @@ -0,0 +1,19 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:kitchenowl/pages/barcode_scanner_page.dart'; + +// Conditional import for web-specific implementation +import 'web_barcode_scanner_stub.dart' + if (dart.library.js) 'web_barcode_scanner_web.dart' as impl; + +/// Scans a barcode from image bytes on web platform. +/// Returns null if no barcode is found or if not on web. +Future scanBarcodeFromImageBytes(Uint8List bytes) async { + if (!kIsWeb) { + return null; + } + return impl.scanBarcodeFromImageBytesImpl(bytes); +} + diff --git a/kitchenowl/lib/helpers/web_barcode_scanner_stub.dart b/kitchenowl/lib/helpers/web_barcode_scanner_stub.dart new file mode 100644 index 000000000..bee06379d --- /dev/null +++ b/kitchenowl/lib/helpers/web_barcode_scanner_stub.dart @@ -0,0 +1,10 @@ +import 'dart:typed_data'; + +import 'package:kitchenowl/pages/barcode_scanner_page.dart'; + +/// Stub implementation for non-web platforms. +/// Always returns null since this functionality is web-only. +Future scanBarcodeFromImageBytesImpl(Uint8List bytes) async { + return null; +} + diff --git a/kitchenowl/lib/helpers/web_barcode_scanner_web.dart b/kitchenowl/lib/helpers/web_barcode_scanner_web.dart new file mode 100644 index 000000000..97e085e52 --- /dev/null +++ b/kitchenowl/lib/helpers/web_barcode_scanner_web.dart @@ -0,0 +1,43 @@ +// ignore_for_file: avoid_web_libraries_in_flutter + +import 'dart:async'; +import 'dart:convert'; +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:kitchenowl/pages/barcode_scanner_page.dart'; + +@JS('decodeBarcode') +external JSPromise _decodeBarcode(JSString base64Data); + +/// Web implementation that calls the JavaScript barcode scanner. +Future scanBarcodeFromImageBytesImpl(Uint8List bytes) async { + try { + // Convert bytes to base64 + final base64Data = base64Encode(bytes); + + // Call the JavaScript function + final jsResult = await _decodeBarcode(base64Data.toJS).toDart; + + if (jsResult == null) { + return null; + } + + // Cast to JSObject and extract properties + final jsObject = jsResult as JSObject; + final dataProperty = (jsObject as dynamic).data; + final typeProperty = (jsObject as dynamic).type; + + final data = (dataProperty as JSString?)?.toDart; + final type = (typeProperty as JSString?)?.toDart; + + if (data != null && type != null) { + return BarcodeScanResult(data: data, type: type); + } + + return null; + } catch (e) { + print('Web barcode scanner error: $e'); + return null; + } +} diff --git a/kitchenowl/lib/l10n/app_en.arb b/kitchenowl/lib/l10n/app_en.arb index da7de80f5..82a668d02 100644 --- a/kitchenowl/lib/l10n/app_en.arb +++ b/kitchenowl/lib/l10n/app_en.arb @@ -59,6 +59,9 @@ "@automaticIngredientDetection": {}, "@back": {}, "@balances": {}, + "@barcodeData": {}, + "@barcodeDataHint": {}, + "@barcodeType": {}, "@budget": {}, "@budgetSet": {}, "@camera": {}, @@ -87,6 +90,7 @@ }, "@changeIcon": {}, "@clear": {}, + "@color": {}, "@colorSelect": {}, "@compact": {}, "@confirm": {}, @@ -103,6 +107,7 @@ "@discover": {}, "@done": {}, "@dynamicAccentColor": {}, + "@edit": {}, "@email": {}, "@emailInvalid": {}, "@emailNotVerified": {}, @@ -143,6 +148,7 @@ } } }, + "@fieldRequired": {}, "@forceOfflineMode": {}, "@gallery": {}, "@general": {}, @@ -255,6 +261,19 @@ }, "@longPressToReorder": {}, "@longerThanExpected": {}, + "@loyaltyCardAdd": {}, + "@loyaltyCardDeleteConfirmation": { + "placeholders": { + "name": { + "example": "Target", + "type": "String" + } + } + }, + "@loyaltyCardEdit": {}, + "@loyaltyCardNameHint": {}, + "@loyaltyCards": {}, + "@loyaltyCardsEmpty": {}, "@markAsPaid": {}, "@mealPlanner": {}, "@member": {}, @@ -389,6 +408,7 @@ "@save": {}, "@search": {}, "@searchHint": {}, + "@select": {}, "@send": {}, "@server": {}, "@serverChange": {}, @@ -539,6 +559,9 @@ "balances": "Balances", "budget": "Budget", "budgetSet": "Set budget", + "barcodeData": "Barcode number", + "barcodeDataHint": "Enter the number printed below the barcode", + "barcodeType": "Barcode type", "camera": "Camera", "cancel": "Cancel", "cards": "Cards", @@ -551,6 +574,7 @@ "categoryExpenseDeleteConfirmation": "Are you sure you want to delete {category}? This will remove the category from all expenses.", "changeIcon": "Change icon", "clear": "Clear", + "color": "Color", "colorSelect": "Select a color", "compact": "Compact", "confirm": "Confirm", @@ -567,6 +591,7 @@ "discover": "Discover", "done": "Done", "dynamicAccentColor": "Dynamic accent color", + "edit": "Edit", "email": "Email", "emailInvalid": "Invalid email", "emailNotVerified": "Not verified", @@ -593,6 +618,7 @@ "export": "Export", "features": "Features", "fieldCannotBeEmpty": "{field} cannot be empty.", + "fieldRequired": "This field is required", "forceOfflineMode": "Force offline mode", "gallery": "Gallery", "general": "General", @@ -648,6 +674,12 @@ "logoutName": "Logout {name}", "longPressToReorder": "Long press to reorder", "longerThanExpected": "This is taking longer than expected", + "loyaltyCardAdd": "Add loyalty card", + "loyaltyCardDeleteConfirmation": "Are you sure you want to delete {name}?", + "loyaltyCardEdit": "Edit loyalty card", + "loyaltyCardNameHint": "e.g., Target, Costco", + "loyaltyCards": "Loyalty Cards", + "loyaltyCardsEmpty": "No loyalty cards yet. Add one to get started!", "markAsPaid": "Mark as paid", "mealPlanner": "Meal planner", "member": "Member", @@ -732,6 +764,7 @@ "save": "Save", "search": "Search", "searchHint": "Looking for something?", + "select": "Select", "send": "Send", "server": "Server", "serverChange": "Switch server", @@ -808,5 +841,62 @@ "yes": "Yes", "yesterday": "Yesterday", "yields": "Yields", - "you": "you" + "you": "you", + "loyaltyCards": "Cards", + "@loyaltyCards": {}, + "loyaltyCardsEmpty": "No loyalty cards yet. Add one to get started!", + "@loyaltyCardsEmpty": {}, + "loyaltyCardAdd": "Add card", + "@loyaltyCardAdd": {}, + "loyaltyCardEdit": "Edit card", + "@loyaltyCardEdit": {}, + "loyaltyCardNameHint": "e.g., Target, Costco", + "@loyaltyCardNameHint": {}, + "loyaltyCardDeleteConfirmation": "Are you sure you want to delete {name}?", + "@loyaltyCardDeleteConfirmation": { + "placeholders": { + "name": { + "example": "Target", + "type": "String" + } + } + }, + "barcodeData": "Barcode data", + "@barcodeData": {}, + "barcodeDataHint": "Enter barcode number", + "@barcodeDataHint": {}, + "barcodeType": "Barcode type", + "@barcodeType": {}, + "copyBarcode": "Copy barcode", + "@copyBarcode": {}, + "scanBarcode": "Scan barcode", + "@scanBarcode": {}, + "scanFromCamera": "Scan with camera", + "@scanFromCamera": {}, + "scanFromGallery": "Import from image", + "@scanFromGallery": {}, + "pointCameraAtBarcode": "Point camera at barcode", + "@pointCameraAtBarcode": {}, + "barcodeDetected": "Barcode detected", + "@barcodeDetected": {}, + "autoDetected": "auto-detected", + "@autoDetected": {}, + "enterManually": "Enter manually", + "@enterManually": {}, + "flashOn": "Flash on", + "@flashOn": {}, + "flashOff": "Flash off", + "@flashOff": {}, + "switchCamera": "Switch camera", + "@switchCamera": {}, + "noBarcodesFound": "No barcodes found in image", + "@noBarcodesFound": {}, + "webcam": "Webcam", + "@webcam": {}, + "camera": "Camera", + "@camera": {}, + "gallery": "Gallery", + "@gallery": {}, + "keepScreenOn": "Screen stays on for scanning", + "@keepScreenOn": {} } diff --git a/kitchenowl/lib/models/household.dart b/kitchenowl/lib/models/household.dart index 48285121b..c4511a8a5 100644 --- a/kitchenowl/lib/models/household.dart +++ b/kitchenowl/lib/models/household.dart @@ -13,6 +13,7 @@ class Household extends Model { final String? language; final bool? featurePlanner; final bool? featureExpenses; + final bool? featureLoyaltyCards; final List? viewOrdering; final List? member; final ShoppingList? defaultShoppingList; @@ -28,6 +29,7 @@ class Household extends Model { this.language, this.featurePlanner, this.featureExpenses, + this.featureLoyaltyCards, this.viewOrdering, this.member, this.defaultShoppingList, @@ -57,6 +59,7 @@ class Household extends Model { language: map['language'], featurePlanner: map['planner_feature'] ?? false, featureExpenses: map['expenses_feature'] ?? false, + featureLoyaltyCards: map['loyalty_cards_feature'] ?? false, description: map['description'], link: map['link'], verified: map['verified'] ?? false, @@ -77,6 +80,7 @@ class Household extends Model { bool? verified, bool? featurePlanner, bool? featureExpenses, + bool? featureLoyaltyCards, List? viewOrdering, }) => Household( @@ -87,6 +91,7 @@ class Household extends Model { language: language ?? this.language, featurePlanner: featurePlanner ?? this.featurePlanner, featureExpenses: featureExpenses ?? this.featureExpenses, + featureLoyaltyCards: featureLoyaltyCards ?? this.featureLoyaltyCards, viewOrdering: viewOrdering ?? this.viewOrdering, description: description ?? this.description, link: link ?? this.link, @@ -102,6 +107,7 @@ class Household extends Model { language, featurePlanner, featureExpenses, + featureLoyaltyCards, viewOrdering, member, defaultShoppingList, @@ -128,6 +134,9 @@ class Household extends Model { if (featureExpenses != null) { data['expenses_feature'] = featureExpenses; } + if (featureLoyaltyCards != null) { + data['loyalty_cards_feature'] = featureLoyaltyCards; + } if (viewOrdering != null) { data['view_ordering'] = viewOrdering!.map((e) => e.toString()).toList() ..remove(ViewsEnum.more.toString()); diff --git a/kitchenowl/lib/models/import_settings.dart b/kitchenowl/lib/models/import_settings.dart index e7b6342c1..0d00605d3 100644 --- a/kitchenowl/lib/models/import_settings.dart +++ b/kitchenowl/lib/models/import_settings.dart @@ -6,6 +6,7 @@ class ImportSettings extends Model { final bool recipesOverwrite; final bool expenses; final bool shoppinglists; + final bool loyaltyCards; const ImportSettings({ this.items = false, @@ -13,6 +14,7 @@ class ImportSettings extends Model { this.recipesOverwrite = false, this.expenses = false, this.shoppinglists = false, + this.loyaltyCards = true, }); ImportSettings copyWith({ @@ -21,6 +23,7 @@ class ImportSettings extends Model { bool? recipesOverwrite, bool? expenses, bool? shoppinglists, + bool? loyaltyCards, }) => ImportSettings( items: items ?? this.items, @@ -28,6 +31,7 @@ class ImportSettings extends Model { recipesOverwrite: recipesOverwrite ?? this.recipesOverwrite, expenses: expenses ?? this.expenses, shoppinglists: shoppinglists ?? this.shoppinglists, + loyaltyCards: loyaltyCards ?? this.loyaltyCards, ); @override @@ -37,6 +41,7 @@ class ImportSettings extends Model { recipesOverwrite, expenses, shoppinglists, + loyaltyCards, ]; @override @@ -46,5 +51,6 @@ class ImportSettings extends Model { "recipes_overwrite": recipesOverwrite, "expenses": expenses, "shoppinglists": shoppinglists, + "loyalty_cards": loyaltyCards, }; } diff --git a/kitchenowl/lib/models/loyalty_card.dart b/kitchenowl/lib/models/loyalty_card.dart new file mode 100644 index 000000000..b26df9637 --- /dev/null +++ b/kitchenowl/lib/models/loyalty_card.dart @@ -0,0 +1,87 @@ +import 'package:kitchenowl/models/model.dart'; + +class LoyaltyCard extends Model { + final int? id; + final String name; + final String barcodeType; + final String barcodeData; + final String? description; + final String? image; + final String? imageHash; + final int? color; + + const LoyaltyCard({ + this.id, + this.name = '', + this.barcodeType = 'CODE128', + this.barcodeData = '', + this.description, + this.image, + this.imageHash, + this.color, + }); + + factory LoyaltyCard.fromJson(Map map) { + return LoyaltyCard( + id: map['id'], + name: map['name'] ?? '', + barcodeType: map['barcode_type'] ?? 'CODE128', + barcodeData: map['barcode_data'] ?? '', + description: map['description'], + image: map['photo'], + imageHash: map['photo_hash'], + color: map['color'], + ); + } + + LoyaltyCard copyWith({ + String? name, + String? barcodeType, + String? barcodeData, + String? description, + String? image, + int? color, + }) => + LoyaltyCard( + id: id, + name: name ?? this.name, + barcodeType: barcodeType ?? this.barcodeType, + barcodeData: barcodeData ?? this.barcodeData, + description: description ?? this.description, + image: image ?? this.image, + imageHash: imageHash, + color: color ?? this.color, + ); + + @override + List get props => [ + id, + name, + barcodeType, + barcodeData, + description, + image, + imageHash, + color, + ]; + + @override + Map toJson() { + return { + "name": name, + "barcode_type": barcodeType, + "barcode_data": barcodeData, + if (description != null) "description": description, + if (image != null) "photo": image, + if (color != null) "color": color, + }; + } + + @override + Map toJsonWithId() => toJson() + ..addAll({ + "id": id, + if (imageHash != null) "photo_hash": imageHash, + }); +} + diff --git a/kitchenowl/lib/pages/barcode_scanner_page.dart b/kitchenowl/lib/pages/barcode_scanner_page.dart new file mode 100644 index 000000000..4ae4abcb2 --- /dev/null +++ b/kitchenowl/lib/pages/barcode_scanner_page.dart @@ -0,0 +1,192 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:kitchenowl/kitchenowl.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +/// Result returned from the barcode scanner +class BarcodeScanResult { + final String data; + final String type; + + const BarcodeScanResult({ + required this.data, + required this.type, + }); +} + +/// Helper to convert BarcodeFormat to our string type +String barcodeFormatToString(BarcodeFormat format) { + switch (format) { + case BarcodeFormat.code128: + return 'CODE128'; + case BarcodeFormat.code39: + return 'CODE39'; + case BarcodeFormat.code93: + return 'CODE93'; + case BarcodeFormat.codabar: + return 'CODABAR'; + case BarcodeFormat.ean13: + return 'EAN13'; + case BarcodeFormat.ean8: + return 'EAN8'; + case BarcodeFormat.itf: + return 'ITF'; + case BarcodeFormat.upcA: + return 'UPCA'; + case BarcodeFormat.upcE: + return 'UPCE'; + case BarcodeFormat.qrCode: + return 'QR'; + case BarcodeFormat.pdf417: + return 'PDF417'; + case BarcodeFormat.aztec: + return 'AZTEC'; + case BarcodeFormat.dataMatrix: + return 'DATAMATRIX'; + default: + return 'CODE128'; + } +} + +class BarcodeScannerPage extends StatefulWidget { + const BarcodeScannerPage({super.key}); + + @override + State createState() => _BarcodeScannerPageState(); +} + +class _BarcodeScannerPageState extends State { + late MobileScannerController _controller; + bool _hasScanned = false; + bool _torchEnabled = false; + + @override + void initState() { + super.initState(); + _controller = MobileScannerController( + detectionSpeed: DetectionSpeed.normal, + facing: CameraFacing.back, + torchEnabled: false, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onDetect(BarcodeCapture capture) { + if (_hasScanned) return; + + final List barcodes = capture.barcodes; + if (barcodes.isEmpty) return; + + final barcode = barcodes.first; + if (barcode.rawValue == null) return; + + setState(() { + _hasScanned = true; + }); + + final result = BarcodeScanResult( + data: barcode.rawValue!, + type: barcodeFormatToString(barcode.format), + ); + + Navigator.of(context).pop(result); + } + + void _toggleTorch() { + _controller.toggleTorch(); + setState(() { + _torchEnabled = !_torchEnabled; + }); + } + + void _switchCamera() { + _controller.switchCamera(); + } + + @override + Widget build(BuildContext context) { + // Torch only available on mobile platforms + final bool canUseTorch = !kIsWeb; + + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + elevation: 0, + title: Text(AppLocalizations.of(context)!.scanBarcode), + actions: [ + if (canUseTorch) + IconButton( + icon: Icon( + _torchEnabled ? Icons.flash_on : Icons.flash_off, + ), + onPressed: _toggleTorch, + tooltip: _torchEnabled + ? AppLocalizations.of(context)!.flashOff + : AppLocalizations.of(context)!.flashOn, + ), + if (!kIsWeb) + IconButton( + icon: const Icon(Icons.cameraswitch_rounded), + onPressed: _switchCamera, + tooltip: AppLocalizations.of(context)!.switchCamera, + ), + ], + ), + body: Stack( + children: [ + MobileScanner( + controller: _controller, + onDetect: _onDetect, + ), + // Scan overlay + Center( + child: Container( + width: 280, + height: 180, + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.primary, + width: 3, + ), + borderRadius: BorderRadius.circular(16), + ), + ), + ), + // Instructions + Positioned( + left: 0, + right: 0, + bottom: 100, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(24), + ), + child: Text( + AppLocalizations.of(context)!.pointCameraAtBarcode, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + ), + ), + ), + ), + ), + ], + ), + ); + } +} + diff --git a/kitchenowl/lib/pages/loyalty_card_add_update_page.dart b/kitchenowl/lib/pages/loyalty_card_add_update_page.dart new file mode 100644 index 000000000..a59bf7693 --- /dev/null +++ b/kitchenowl/lib/pages/loyalty_card_add_update_page.dart @@ -0,0 +1,581 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_colorpicker/flutter_colorpicker.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:kitchenowl/kitchenowl.dart'; +import 'package:kitchenowl/models/household.dart'; +import 'package:kitchenowl/models/loyalty_card.dart'; +import 'package:kitchenowl/helpers/web_barcode_scanner.dart'; +import 'package:kitchenowl/pages/barcode_scanner_page.dart'; +import 'package:kitchenowl/services/api/api_service.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +class LoyaltyCardAddUpdatePage extends StatefulWidget { + final Household household; + final LoyaltyCard? loyaltyCard; + + const LoyaltyCardAddUpdatePage({ + super.key, + required this.household, + this.loyaltyCard, + }); + + @override + State createState() => + _LoyaltyCardAddUpdatePageState(); +} + +class _LoyaltyCardAddUpdatePageState extends State { + final _formKey = GlobalKey(); + late TextEditingController _nameController; + late TextEditingController _barcodeDataController; + late TextEditingController _descriptionController; + String? _detectedBarcodeType; + bool _isAutoDetected = false; + bool _showManualEntry = false; + Color? _selectedColor; + bool _isLoading = false; + bool _isScanning = false; + + static const List _barcodeTypes = [ + 'CODE128', + 'CODE39', + 'EAN13', + 'EAN8', + 'UPCA', + 'UPCE', + 'QR', + 'PDF417', + 'DATAMATRIX', + 'AZTEC', + 'CODABAR', + 'ITF', + ]; + + bool get isEditing => widget.loyaltyCard != null; + bool get hasBarcode => + _barcodeDataController.text.isNotEmpty && _detectedBarcodeType != null; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(text: widget.loyaltyCard?.name); + _barcodeDataController = + TextEditingController(text: widget.loyaltyCard?.barcodeData); + _descriptionController = + TextEditingController(text: widget.loyaltyCard?.description); + _detectedBarcodeType = widget.loyaltyCard?.barcodeType; + _isAutoDetected = false; + _selectedColor = widget.loyaltyCard?.color != null + ? Color(widget.loyaltyCard!.color!) + : null; + // Show manual entry if editing existing card + _showManualEntry = isEditing; + } + + @override + void dispose() { + _nameController.dispose(); + _barcodeDataController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } + + Future _scanFromCamera() async { + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const BarcodeScannerPage(), + ), + ); + + if (result != null && mounted) { + setState(() { + _barcodeDataController.text = result.data; + _detectedBarcodeType = result.type; + _isAutoDetected = true; + _showManualEntry = false; + }); + } + } + + Future _scanFromGallery() async { + setState(() { + _isScanning = true; + }); + + try { + if (kIsWeb) { + // Web platform: use file picker and web barcode scanner + await _scanFromGalleryWeb(); + } else { + // Mobile platform: use mobile_scanner + await _scanFromGalleryMobile(); + } + } catch (e) { + if (mounted) { + _showNoBarcodeError(); + } + } finally { + if (mounted) { + setState(() { + _isScanning = false; + }); + } + } + } + + Future _scanFromGalleryWeb() async { + final result = await FilePicker.platform.pickFiles( + type: FileType.image, + withData: true, + ); + + if (result == null || result.files.first.bytes == null) { + return; + } + + final bytes = result.files.first.bytes!; + final scanResult = await scanBarcodeFromImageBytes(bytes); + + if (scanResult != null && mounted) { + setState(() { + _barcodeDataController.text = scanResult.data; + _detectedBarcodeType = scanResult.type; + _isAutoDetected = true; + _showManualEntry = false; + }); + } else if (mounted) { + _showNoBarcodeError(); + } + } + + Future _scanFromGalleryMobile() async { + final picker = ImagePicker(); + final XFile? image = await picker.pickImage(source: ImageSource.gallery); + + if (image == null) { + return; + } + + final controller = MobileScannerController(); + final BarcodeCapture? capture = await controller.analyzeImage(image.path); + await controller.dispose(); + + if (capture != null && capture.barcodes.isNotEmpty && mounted) { + final barcode = capture.barcodes.first; + if (barcode.rawValue != null) { + setState(() { + _barcodeDataController.text = barcode.rawValue!; + _detectedBarcodeType = barcodeFormatToString(barcode.format); + _isAutoDetected = true; + _showManualEntry = false; + }); + } else { + _showNoBarcodeError(); + } + } else if (mounted) { + _showNoBarcodeError(); + } + } + + void _showNoBarcodeError() { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.noBarcodesFound), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + isEditing + ? AppLocalizations.of(context)!.loyaltyCardEdit + : AppLocalizations.of(context)!.loyaltyCardAdd, + ), + actions: [ + if (_isLoading) + const Center( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ) + else + IconButton( + icon: const Icon(Icons.save), + onPressed: _save, + tooltip: AppLocalizations.of(context)!.save, + ), + ], + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + // Scan buttons section + if (!hasBarcode || _showManualEntry) ...[ + _buildScanSection(), + const SizedBox(height: 24), + ], + + // Detected barcode display + if (hasBarcode && !_showManualEntry) ...[ + _buildDetectedBarcodeCard(), + const SizedBox(height: 16), + ], + + // Manual entry section (collapsible) + if (_showManualEntry) ...[ + _buildManualEntrySection(), + const SizedBox(height: 16), + ], + + // Toggle manual entry + if (hasBarcode && !_showManualEntry) + TextButton.icon( + onPressed: () { + setState(() { + _showManualEntry = true; + }); + }, + icon: const Icon(Icons.edit, size: 18), + label: Text(AppLocalizations.of(context)!.enterManually), + ), + + const SizedBox(height: 8), + + // Store Name + TextFormField( + controller: _nameController, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.name, + hintText: AppLocalizations.of(context)!.loyaltyCardNameHint, + prefixIcon: const Icon(Icons.store), + ), + textCapitalization: TextCapitalization.words, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return AppLocalizations.of(context)!.fieldRequired; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Description + TextFormField( + controller: _descriptionController, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.description, + hintText: AppLocalizations.of(context)!.optional, + prefixIcon: const Icon(Icons.notes), + ), + maxLines: 2, + ), + const SizedBox(height: 16), + + // Color Picker + ListTile( + leading: const Icon(Icons.color_lens), + title: Text(AppLocalizations.of(context)!.color), + trailing: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: _selectedColor ?? + Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context).colorScheme.outline, + ), + ), + ), + onTap: _pickColor, + ), + ], + ), + ), + ); + } + + Widget _buildScanSection() { + // Show camera + gallery scanning options (works on both web and mobile) + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + AppLocalizations.of(context)!.scanBarcode, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: _isScanning ? null : _scanFromCamera, + icon: const Icon(Icons.camera_alt), + label: Text(kIsWeb + ? AppLocalizations.of(context)!.webcam + : AppLocalizations.of(context)!.camera), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + onPressed: _isScanning ? null : _scanFromGallery, + icon: _isScanning + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.photo_library), + label: Text(AppLocalizations.of(context)!.gallery), + ), + ), + ], + ), + const SizedBox(height: 12), + Center( + child: TextButton( + onPressed: () { + setState(() { + _showManualEntry = true; + }); + }, + child: Text(AppLocalizations.of(context)!.enterManually), + ), + ), + ], + ), + ), + ); + } + + Widget _buildDetectedBarcodeCard() { + return Card( + color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.check_circle, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + AppLocalizations.of(context)!.barcodeDetected, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + _barcodeDataController.text, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontFamily: 'monospace', + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Chip( + label: Text(_detectedBarcodeType ?? ''), + avatar: const Icon(Icons.qr_code, size: 18), + ), + if (_isAutoDetected) ...[ + const SizedBox(width: 8), + Text( + '(${AppLocalizations.of(context)!.autoDetected})', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.secondary, + ), + ), + ], + ], + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + onPressed: _scanFromCamera, + icon: const Icon(Icons.refresh, size: 18), + label: Text(AppLocalizations.of(context)!.scanBarcode), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildManualEntrySection() { + return Column( + children: [ + // Barcode Data + TextFormField( + controller: _barcodeDataController, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.barcodeData, + hintText: AppLocalizations.of(context)!.barcodeDataHint, + prefixIcon: const Icon(Icons.numbers), + ), + keyboardType: TextInputType.text, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return AppLocalizations.of(context)!.fieldRequired; + } + return null; + }, + onChanged: (value) { + setState(() {}); + }, + ), + const SizedBox(height: 16), + + // Barcode Type Dropdown + DropdownButtonFormField( + value: _barcodeTypes.contains(_detectedBarcodeType) + ? _detectedBarcodeType + : 'CODE128', + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.barcodeType, + prefixIcon: const Icon(Icons.qr_code), + ), + items: _barcodeTypes.map((type) { + return DropdownMenuItem( + value: type, + child: Text(type), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + setState(() { + _detectedBarcodeType = value; + _isAutoDetected = false; + }); + } + }, + ), + ], + ); + } + + Future _pickColor() async { + Color pickerColor = + _selectedColor ?? Theme.of(context).colorScheme.primaryContainer; + + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.color), + content: SingleChildScrollView( + child: ColorPicker( + pickerColor: pickerColor, + onColorChanged: (color) { + pickerColor = color; + }, + pickerAreaHeightPercent: 0.8, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(AppLocalizations.of(context)!.cancel), + ), + TextButton( + onPressed: () { + setState(() { + _selectedColor = pickerColor; + }); + Navigator.of(context).pop(); + }, + child: Text(AppLocalizations.of(context)!.select), + ), + ], + ), + ); + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + + // Validate barcode data exists + if (_barcodeDataController.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.fieldRequired), + ), + ); + return; + } + + setState(() { + _isLoading = true; + }); + + try { + final loyaltyCard = LoyaltyCard( + id: widget.loyaltyCard?.id, + name: _nameController.text.trim(), + barcodeType: _detectedBarcodeType ?? 'CODE128', + barcodeData: _barcodeDataController.text.trim(), + description: _descriptionController.text.trim().isEmpty + ? null + : _descriptionController.text.trim(), + color: _selectedColor?.value, + ); + + bool success; + LoyaltyCard? result; + if (isEditing) { + success = + await ApiService.getInstance().updateLoyaltyCard(loyaltyCard); + result = loyaltyCard; + } else { + result = await ApiService.getInstance().addLoyaltyCard( + widget.household, + loyaltyCard, + ); + success = result != null; + } + + if (success && mounted) { + Navigator.of(context).pop(result); + } else if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.error), + ), + ); + } + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } +} diff --git a/kitchenowl/lib/pages/loyalty_card_list_page.dart b/kitchenowl/lib/pages/loyalty_card_list_page.dart new file mode 100644 index 000000000..52bd93809 --- /dev/null +++ b/kitchenowl/lib/pages/loyalty_card_list_page.dart @@ -0,0 +1,215 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:kitchenowl/app.dart'; +import 'package:kitchenowl/cubits/loyalty_card_list_cubit.dart'; +import 'package:kitchenowl/kitchenowl.dart'; +import 'package:kitchenowl/models/household.dart'; +import 'package:kitchenowl/models/loyalty_card.dart'; +import 'package:kitchenowl/pages/loyalty_card_add_update_page.dart'; +import 'package:kitchenowl/widgets/loyalty_card_item.dart'; + +class LoyaltyCardListPageStandalone extends StatelessWidget { + final Household household; + + const LoyaltyCardListPageStandalone({ + super.key, + required this.household, + }); + + @override + Widget build(BuildContext context) { + final cubit = BlocProvider.of(context); + + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.loyaltyCards), + leading: BackButton( + onPressed: () { + // Check if we can pop, otherwise go back to the household items page + if (context.canPop()) { + context.pop(); + } else { + context.go('/household/${household.id}/items'); + } + }, + ), + ), + floatingActionButton: App.isOffline + ? null + : FloatingActionButton.extended( + heroTag: 'addLoyaltyCardFab', + onPressed: () async { + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => LoyaltyCardAddUpdatePage( + household: household, + ), + ), + ); + if (result != null) { + cubit.refresh(); + } + }, + icon: const Icon(Icons.add), + label: Text(AppLocalizations.of(context)!.add), + ), + body: RefreshIndicator( + onRefresh: cubit.refresh, + child: BlocBuilder( + bloc: cubit, + builder: (context, state) { + if (state is LoadingLoyaltyCardListCubitState && + state.loyaltyCards.isEmpty) { + return _buildLoadingState(context); + } + + if (state.loyaltyCards.isEmpty && !App.isOffline) { + return _buildEmptyState(context, cubit); + } + + if (state.loyaltyCards.isEmpty && App.isOffline) { + return _buildOfflineState(context); + } + + return _buildCardGrid(context, state); + }, + ), + ), + ); + } + + Widget _buildLoadingState(BuildContext context) { + return GridView.builder( + padding: const EdgeInsets.all(16), + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 320, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 1.5, + ), + itemCount: 4, + itemBuilder: (context, index) => const ShimmerCard(), + ); + } + + Widget _buildEmptyState(BuildContext context, LoyaltyCardListCubit cubit) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + Icons.wallet_rounded, + size: 64, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(height: 24), + Text( + AppLocalizations.of(context)!.loyaltyCardsEmpty, + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Store all your loyalty cards in one place and access them quickly at checkout.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + FilledButton.icon( + onPressed: () async { + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => LoyaltyCardAddUpdatePage( + household: household, + ), + ), + ); + if (result != null) { + cubit.refresh(); + } + }, + icon: const Icon(Icons.add), + label: Text(AppLocalizations.of(context)!.loyaltyCardAdd), + ), + ], + ), + ), + ); + } + + Widget _buildOfflineState(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.cloud_off_rounded, + size: 64, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + AppLocalizations.of(context)!.offlineMessage, + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + Widget _buildCardGrid( + BuildContext context, + LoyaltyCardListCubitState state, + ) { + return CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 320, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 1.5, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + final card = state.loyaltyCards[index]; + return Hero( + tag: 'loyalty_card_${card.id}', + child: LoyaltyCardItem( + loyaltyCard: card, + onTap: () { + context.push( + "/household/${household.id}/loyalty-cards/${card.id}", + extra: card, + ); + }, + ), + ); + }, + childCount: state.loyaltyCards.length, + ), + ), + ), + ], + ); + } +} + diff --git a/kitchenowl/lib/pages/loyalty_card_page.dart b/kitchenowl/lib/pages/loyalty_card_page.dart new file mode 100644 index 000000000..a59a8c84c --- /dev/null +++ b/kitchenowl/lib/pages/loyalty_card_page.dart @@ -0,0 +1,456 @@ +import 'package:barcode_widget/barcode_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:kitchenowl/cubits/loyalty_card_cubit.dart'; +import 'package:kitchenowl/kitchenowl.dart'; +import 'package:kitchenowl/models/household.dart'; +import 'package:kitchenowl/models/loyalty_card.dart'; +import 'package:kitchenowl/pages/loyalty_card_add_update_page.dart'; +import 'package:barcode/barcode.dart' as bc; +import 'package:wakelock_plus/wakelock_plus.dart'; + +class LoyaltyCardPage extends StatefulWidget { + final LoyaltyCard loyaltyCard; + final Household household; + + const LoyaltyCardPage({ + super.key, + required this.loyaltyCard, + required this.household, + }); + + @override + State createState() => _LoyaltyCardPageState(); +} + +class _LoyaltyCardPageState extends State { + late LoyaltyCardCubit cubit; + + @override + void initState() { + super.initState(); + cubit = LoyaltyCardCubit(widget.loyaltyCard); + WakelockPlus.enable(); + } + + @override + void dispose() { + WakelockPlus.disable(); + cubit.close(); + super.dispose(); + } + + bool _isQrLike(String type) { + return ['QR', 'DATAMATRIX', 'AZTEC', 'PDF417'] + .contains(type.toUpperCase()); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: cubit, + child: BlocBuilder( + builder: (context, state) { + final loyaltyCard = state.loyaltyCard; + final cardColor = loyaltyCard.color != null + ? Color(loyaltyCard.color!) + : Theme.of(context).colorScheme.primaryContainer; + final contrastColor = _getContrastColor(cardColor); + final isQrLike = _isQrLike(loyaltyCard.barcodeType); + final isLongData = loyaltyCard.barcodeData.length > 20; + + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.surface, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + title: Text(loyaltyCard.name), + leading: BackButton( + onPressed: () { + if (context.canPop()) { + context.pop(); + } else { + context.go('/household/${widget.household.id}/loyalty-cards'); + } + }, + ), + actions: [ + IconButton( + icon: const Icon(Icons.edit_rounded), + onPressed: () => _editCard(context, loyaltyCard), + tooltip: AppLocalizations.of(context)!.edit, + ), + PopupMenuButton( + itemBuilder: (context) => [ + PopupMenuItem( + value: 'copy', + child: ListTile( + leading: const Icon(Icons.copy), + title: Text(AppLocalizations.of(context)!.copyBarcode), + contentPadding: EdgeInsets.zero, + ), + ), + PopupMenuItem( + value: 'delete', + child: ListTile( + leading: Icon( + Icons.delete, + color: Theme.of(context).colorScheme.error, + ), + title: Text( + AppLocalizations.of(context)!.delete, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + contentPadding: EdgeInsets.zero, + ), + ), + ], + onSelected: (value) { + if (value == 'copy') { + _copyBarcode(context, loyaltyCard); + } else if (value == 'delete') { + _deleteCard(context, loyaltyCard); + } + }, + ), + ], + ), + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Card with barcode + Hero( + tag: 'loyalty_card_${loyaltyCard.id}', + child: Material( + color: Colors.transparent, + child: Container( + width: double.infinity, + constraints: const BoxConstraints(maxWidth: 400), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + cardColor, + HSLColor.fromColor(cardColor) + .withLightness( + (HSLColor.fromColor(cardColor).lightness - 0.12) + .clamp(0.0, 1.0), + ) + .toColor(), + ], + ), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: cardColor.withOpacity(0.3), + blurRadius: 16, + offset: const Offset(0, 8), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Barcode container + Container( + padding: EdgeInsets.all(isQrLike ? 16 : 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: _buildBarcode(context, loyaltyCard), + ), + const SizedBox(height: 16), + + // Barcode data - tappable to copy + InkWell( + onTap: () => _copyBarcode(context, loyaltyCard), + borderRadius: BorderRadius.circular(8), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + decoration: BoxDecoration( + color: contrastColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Expanded( + child: Text( + isLongData + ? _truncateMiddle(loyaltyCard.barcodeData, 30) + : loyaltyCard.barcodeData, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: contrastColor, + fontFamily: 'monospace', + letterSpacing: 1, + ), + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Icon( + Icons.copy_rounded, + size: 18, + color: contrastColor.withOpacity(0.7), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + + // Description + if (loyaltyCard.description != null && + loyaltyCard.description!.isNotEmpty) ...[ + const SizedBox(height: 24), + Container( + width: double.infinity, + constraints: const BoxConstraints(maxWidth: 400), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline_rounded, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + loyaltyCard.description!, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ], + + // Screen on indicator + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.brightness_high_rounded, + size: 14, + color: Theme.of(context).colorScheme.outline, + ), + const SizedBox(width: 6), + Text( + AppLocalizations.of(context)!.keepScreenOn, + style: + Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.outline, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + }, + ), + ); + } + + String _truncateMiddle(String text, int maxLength) { + if (text.length <= maxLength) return text; + final half = (maxLength - 3) ~/ 2; + return '${text.substring(0, half)}...${text.substring(text.length - half)}'; + } + + Widget _buildBarcode(BuildContext context, LoyaltyCard loyaltyCard) { + try { + final barcodeType = _getBarcodeType(loyaltyCard.barcodeType); + final isQrLike = _isQrLike(loyaltyCard.barcodeType); + // Don't show text for QR codes or long data (like URLs) + final showText = !isQrLike && loyaltyCard.barcodeData.length <= 20; + + return ConstrainedBox( + constraints: BoxConstraints( + maxWidth: isQrLike ? 220 : 300, + ), + child: BarcodeWidget( + barcode: barcodeType, + data: loyaltyCard.barcodeData, + width: isQrLike ? 200 : double.infinity, + height: isQrLike ? 200 : 80, + drawText: showText, + color: Colors.black, + errorBuilder: (context, error) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline_rounded, + size: 40, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 8), + Text( + 'Invalid barcode', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ], + ), + ), + ); + } catch (e) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline_rounded, + size: 40, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 8), + Text( + 'Cannot display barcode', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ], + ); + } + } + + bc.Barcode _getBarcodeType(String type) { + switch (type.toUpperCase()) { + case 'CODE128': + return bc.Barcode.code128(); + case 'CODE39': + return bc.Barcode.code39(); + case 'EAN13': + return bc.Barcode.ean13(); + case 'EAN8': + return bc.Barcode.ean8(); + case 'UPCA': + return bc.Barcode.upcA(); + case 'UPCE': + return bc.Barcode.upcE(); + case 'QR': + return bc.Barcode.qrCode(); + case 'PDF417': + return bc.Barcode.pdf417(); + case 'DATAMATRIX': + return bc.Barcode.dataMatrix(); + case 'AZTEC': + return bc.Barcode.aztec(); + case 'CODABAR': + return bc.Barcode.codabar(); + case 'ITF': + return bc.Barcode.itf(); + default: + return bc.Barcode.code128(); + } + } + + Color _getContrastColor(Color backgroundColor) { + final luminance = backgroundColor.computeLuminance(); + return luminance > 0.5 ? Colors.black : Colors.white; + } + + void _copyBarcode(BuildContext context, LoyaltyCard loyaltyCard) { + Clipboard.setData(ClipboardData(text: loyaltyCard.barcodeData)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.copied), + behavior: SnackBarBehavior.floating, + ), + ); + } + + Future _editCard(BuildContext context, LoyaltyCard loyaltyCard) async { + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => LoyaltyCardAddUpdatePage( + household: widget.household, + loyaltyCard: loyaltyCard, + ), + ), + ); + if (result != null) { + cubit.updateCard(result); + } + } + + Future _deleteCard(BuildContext context, LoyaltyCard loyaltyCard) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.delete), + content: Text( + AppLocalizations.of(context)! + .loyaltyCardDeleteConfirmation(loyaltyCard.name), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(AppLocalizations.of(context)!.cancel), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + ), + child: Text(AppLocalizations.of(context)!.delete), + ), + ], + ), + ); + + if (confirmed == true && context.mounted) { + await cubit.deleteCard(); + if (context.mounted) { + context.pop(); + } + } + } +} diff --git a/kitchenowl/lib/router.dart b/kitchenowl/lib/router.dart index 9da53825b..b2f928daa 100644 --- a/kitchenowl/lib/router.dart +++ b/kitchenowl/lib/router.dart @@ -9,10 +9,14 @@ import 'package:kitchenowl/helpers/fade_through_transition_page.dart'; import 'package:kitchenowl/helpers/shared_axis_transition_page.dart'; import 'package:kitchenowl/models/expense.dart'; import 'package:kitchenowl/models/household.dart'; +import 'package:kitchenowl/models/loyalty_card.dart'; import 'package:kitchenowl/models/recipe.dart'; import 'package:kitchenowl/pages/email_confirm_page.dart'; import 'package:kitchenowl/pages/expense_overview_page.dart'; import 'package:kitchenowl/pages/expense_page.dart'; +import 'package:kitchenowl/cubits/loyalty_card_list_cubit.dart'; +import 'package:kitchenowl/pages/loyalty_card_page.dart'; +import 'package:kitchenowl/pages/loyalty_card_list_page.dart'; import 'package:kitchenowl/pages/household_page/_export.dart'; import 'package:kitchenowl/pages/household_list_page.dart'; import 'package:kitchenowl/pages/household_about_page.dart'; @@ -419,6 +423,54 @@ final router = GoRouter( ), ], ), + GoRoute( + path: '/household/:id/loyalty-cards', + parentNavigatorKey: _rootNavigatorKey, + builder: (context, state) { + final household = (state.extra as Household?) ?? + Household( + id: int.tryParse(state.pathParameters['id'] ?? '') ?? -1, + ); + return BlocProvider( + create: (context) => LoyaltyCardListCubit(household), + child: LoyaltyCardListPageStandalone(household: household), + ); + }, + routes: [ + GoRoute( + path: ':loyaltyCardId', + parentNavigatorKey: _rootNavigatorKey, + builder: (context, state) { + final extra = state.extra; + final Household household; + final LoyaltyCard loyaltyCard; + + if (extra is Tuple2) { + household = extra.item1; + loyaltyCard = extra.item2; + } else if (extra is LoyaltyCard) { + household = Household( + id: int.tryParse(state.pathParameters['id'] ?? '') ?? -1, + ); + loyaltyCard = extra; + } else { + household = Household( + id: int.tryParse(state.pathParameters['id'] ?? '') ?? -1, + ); + loyaltyCard = LoyaltyCard( + id: int.tryParse(state.pathParameters['loyaltyCardId'] ?? ''), + ); + } + + return LoyaltyCardPage( + key: ValueKey(state.pathParameters['loyaltyCardId']), + household: household, + loyaltyCard: loyaltyCard, + ); + }, + ), + ], + ), GoRoute( path: '/confirm-email', parentNavigatorKey: _rootNavigatorKey, diff --git a/kitchenowl/lib/services/api/api_service.dart b/kitchenowl/lib/services/api/api_service.dart index 577848617..be75d341c 100644 --- a/kitchenowl/lib/services/api/api_service.dart +++ b/kitchenowl/lib/services/api/api_service.dart @@ -21,6 +21,7 @@ export 'tag.dart'; export 'upload.dart'; export 'category.dart'; export 'household.dart'; +export 'loyalty_card.dart'; enum Connection { disconnected, diff --git a/kitchenowl/lib/services/api/loyalty_card.dart b/kitchenowl/lib/services/api/loyalty_card.dart new file mode 100644 index 000000000..e707c9b29 --- /dev/null +++ b/kitchenowl/lib/services/api/loyalty_card.dart @@ -0,0 +1,56 @@ +import 'dart:convert'; + +import 'package:kitchenowl/models/household.dart'; +import 'package:kitchenowl/models/loyalty_card.dart'; +import 'package:kitchenowl/services/api/api_service.dart'; + +extension LoyaltyCardApi on ApiService { + static const baseRoute = '/loyalty-card'; + + Future?> getAllLoyaltyCards({ + required 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) => LoyaltyCard.fromJson(e)).toList(); + } + + Future getLoyaltyCard(LoyaltyCard loyaltyCard) async { + final res = await get('$baseRoute/${loyaltyCard.id}'); + if (res.statusCode != 200) return null; + + return LoyaltyCard.fromJson(jsonDecode(res.body)); + } + + Future addLoyaltyCard( + Household household, + LoyaltyCard loyaltyCard, + ) async { + final body = loyaltyCard.toJson(); + final res = await post( + "${householdPath(household)}$baseRoute", + jsonEncode(body), + ); + + if (res.statusCode != 200) return null; + + return LoyaltyCard.fromJson(jsonDecode(res.body)); + } + + Future deleteLoyaltyCard(LoyaltyCard loyaltyCard) async { + final res = await delete('$baseRoute/${loyaltyCard.id}'); + + return res.statusCode == 200; + } + + Future updateLoyaltyCard(LoyaltyCard loyaltyCard) async { + final body = loyaltyCard.toJson(); + final res = await post('$baseRoute/${loyaltyCard.id}', jsonEncode(body)); + + return res.statusCode == 200; + } +} + diff --git a/kitchenowl/lib/services/transactions/loyalty_card.dart b/kitchenowl/lib/services/transactions/loyalty_card.dart new file mode 100644 index 000000000..04a308e92 --- /dev/null +++ b/kitchenowl/lib/services/transactions/loyalty_card.dart @@ -0,0 +1,135 @@ +import 'package:kitchenowl/models/household.dart'; +import 'package:kitchenowl/models/loyalty_card.dart'; +import 'package:kitchenowl/services/api/api_service.dart'; +import 'package:kitchenowl/services/transaction.dart'; + +class TransactionLoyaltyCardGetAll extends Transaction> { + final Household household; + + TransactionLoyaltyCardGetAll({ + DateTime? timestamp, + required this.household, + }) : super.internal( + timestamp ?? DateTime.now(), "TransactionLoyaltyCardGetAll"); + + @override + Future> runLocal() async { + return []; + } + + @override + Future?> runOnline() async { + return await ApiService.getInstance().getAllLoyaltyCards( + household: household, + ); + } +} + +class TransactionLoyaltyCardGet extends Transaction { + final LoyaltyCard loyaltyCard; + + TransactionLoyaltyCardGet({required this.loyaltyCard, DateTime? timestamp}) + : super.internal( + timestamp ?? DateTime.now(), "TransactionLoyaltyCardGet"); + + @override + Future runLocal() async { + return loyaltyCard; + } + + @override + Future runOnline() async { + return await ApiService.getInstance().getLoyaltyCard(loyaltyCard); + } +} + +class TransactionLoyaltyCardAdd extends Transaction { + final LoyaltyCard loyaltyCard; + final Household household; + + TransactionLoyaltyCardAdd({ + required this.household, + required this.loyaltyCard, + DateTime? timestamp, + }) : super.internal( + timestamp ?? DateTime.now(), "TransactionLoyaltyCardAdd"); + + @override + Future runLocal() async { + return loyaltyCard; + } + + @override + Future runOnline() { + return ApiService.getInstance().addLoyaltyCard(household, loyaltyCard); + } +} + +class TransactionLoyaltyCardRemove extends Transaction { + final LoyaltyCard loyaltyCard; + + TransactionLoyaltyCardRemove({required this.loyaltyCard, DateTime? timestamp}) + : super.internal( + timestamp ?? DateTime.now(), + "TransactionLoyaltyCardRemove", + ); + + factory TransactionLoyaltyCardRemove.fromJson( + Map map, + DateTime timestamp, + ) => + TransactionLoyaltyCardRemove( + loyaltyCard: LoyaltyCard.fromJson(map['loyalty_card']), + timestamp: timestamp, + ); + + @override + Map toJson() => super.toJson() + ..addAll({ + "loyalty_card": loyaltyCard.toJsonWithId(), + }); + + @override + Future runLocal() async { + return true; + } + + @override + Future runOnline() { + return ApiService.getInstance().deleteLoyaltyCard(loyaltyCard); + } +} + +class TransactionLoyaltyCardUpdate extends Transaction { + final LoyaltyCard loyaltyCard; + + TransactionLoyaltyCardUpdate({required this.loyaltyCard, DateTime? timestamp}) + : super.internal( + timestamp ?? DateTime.now(), "TransactionLoyaltyCardUpdate"); + + factory TransactionLoyaltyCardUpdate.fromJson( + Map map, + DateTime timestamp, + ) => + TransactionLoyaltyCardUpdate( + loyaltyCard: LoyaltyCard.fromJson(map['loyalty_card']), + timestamp: timestamp, + ); + + @override + Map toJson() => super.toJson() + ..addAll({ + "loyalty_card": loyaltyCard.toJsonWithId(), + }); + + @override + Future runLocal() async { + return true; + } + + @override + Future runOnline() { + return ApiService.getInstance().updateLoyaltyCard(loyaltyCard); + } +} + diff --git a/kitchenowl/lib/widgets/loyalty_card_create_fab.dart b/kitchenowl/lib/widgets/loyalty_card_create_fab.dart new file mode 100644 index 000000000..f38267d8d --- /dev/null +++ b/kitchenowl/lib/widgets/loyalty_card_create_fab.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:kitchenowl/cubits/household_cubit.dart'; +import 'package:kitchenowl/cubits/loyalty_card_list_cubit.dart'; +import 'package:kitchenowl/kitchenowl.dart'; +import 'package:kitchenowl/pages/loyalty_card_add_update_page.dart'; + +class LoyaltyCardCreateFab extends StatelessWidget { + const LoyaltyCardCreateFab({super.key}); + + @override + Widget build(BuildContext context) { + return FloatingActionButton.extended( + heroTag: 'loyaltyCardCreateFab', + onPressed: () async { + final household = + BlocProvider.of(context).state.household; + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => LoyaltyCardAddUpdatePage( + household: household, + ), + ), + ); + if (result != null && context.mounted) { + BlocProvider.of(context).refresh(); + } + }, + icon: const Icon(Icons.add), + label: Text(AppLocalizations.of(context)!.loyaltyCardAdd), + ); + } +} + diff --git a/kitchenowl/lib/widgets/loyalty_card_item.dart b/kitchenowl/lib/widgets/loyalty_card_item.dart new file mode 100644 index 000000000..a943b3278 --- /dev/null +++ b/kitchenowl/lib/widgets/loyalty_card_item.dart @@ -0,0 +1,190 @@ +import 'package:barcode_widget/barcode_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:kitchenowl/models/loyalty_card.dart'; +import 'package:barcode/barcode.dart' as bc; + +class LoyaltyCardItem extends StatelessWidget { + final LoyaltyCard loyaltyCard; + final VoidCallback? onTap; + + const LoyaltyCardItem({ + super.key, + required this.loyaltyCard, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final cardColor = loyaltyCard.color != null + ? Color(loyaltyCard.color!) + : Theme.of(context).colorScheme.primaryContainer; + final contrastColor = _getContrastColor(cardColor); + + return Material( + color: Colors.transparent, + child: Card( + clipBehavior: Clip.antiAlias, + elevation: 2, + shadowColor: cardColor.withOpacity(0.3), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Ink( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + cardColor, + HSLColor.fromColor(cardColor) + .withLightness( + (HSLColor.fromColor(cardColor).lightness - 0.1) + .clamp(0.0, 1.0), + ) + .toColor(), + ], + ), + ), + child: InkWell( + onTap: onTap, + splashColor: contrastColor.withOpacity(0.1), + highlightColor: contrastColor.withOpacity(0.05), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Title row + Row( + children: [ + Expanded( + child: Text( + loyaltyCard.name, + style: + Theme.of(context).textTheme.titleMedium?.copyWith( + color: contrastColor, + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Icon( + Icons.arrow_forward_ios_rounded, + size: 14, + color: contrastColor.withOpacity(0.5), + ), + ], + ), + const SizedBox(height: 8), + // Barcode container - use Flexible instead of Expanded + Flexible( + child: Center( + child: Container( + constraints: const BoxConstraints( + maxHeight: 60, + ), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(6), + ), + child: _buildBarcode(context), + ), + ), + ), + const SizedBox(height: 6), + // Barcode data text + Text( + _truncateMiddle(loyaltyCard.barcodeData, 24), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: contrastColor.withOpacity(0.7), + fontFamily: 'monospace', + fontSize: 11, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + ), + ), + ); + } + + String _truncateMiddle(String text, int maxLength) { + if (text.length <= maxLength) return text; + final half = (maxLength - 3) ~/ 2; + return '${text.substring(0, half)}...${text.substring(text.length - half)}'; + } + + Widget _buildBarcode(BuildContext context) { + try { + final barcodeType = _getBarcodeType(loyaltyCard.barcodeType); + final isQrLike = ['QR', 'DATAMATRIX', 'AZTEC', 'PDF417'] + .contains(loyaltyCard.barcodeType.toUpperCase()); + + return BarcodeWidget( + barcode: barcodeType, + data: loyaltyCard.barcodeData, + width: isQrLike ? 45 : 150, + height: isQrLike ? 45 : 35, + drawText: false, + color: Colors.black, + errorBuilder: (context, error) => Icon( + Icons.qr_code_rounded, + size: 32, + color: Colors.grey.shade400, + ), + ); + } catch (e) { + return Icon( + Icons.qr_code_rounded, + size: 32, + color: Colors.grey.shade400, + ); + } + } + + bc.Barcode _getBarcodeType(String type) { + switch (type.toUpperCase()) { + case 'CODE128': + return bc.Barcode.code128(); + case 'CODE39': + return bc.Barcode.code39(); + case 'EAN13': + return bc.Barcode.ean13(); + case 'EAN8': + return bc.Barcode.ean8(); + case 'UPCA': + return bc.Barcode.upcA(); + case 'UPCE': + return bc.Barcode.upcE(); + case 'QR': + return bc.Barcode.qrCode(); + case 'PDF417': + return bc.Barcode.pdf417(); + case 'DATAMATRIX': + return bc.Barcode.dataMatrix(); + case 'AZTEC': + return bc.Barcode.aztec(); + case 'CODABAR': + return bc.Barcode.codabar(); + case 'ITF': + return bc.Barcode.itf(); + default: + return bc.Barcode.code128(); + } + } + + Color _getContrastColor(Color backgroundColor) { + final luminance = backgroundColor.computeLuminance(); + return luminance > 0.5 ? Colors.black : Colors.white; + } +} diff --git a/kitchenowl/lib/widgets/settings_household/import_settings_dialog.dart b/kitchenowl/lib/widgets/settings_household/import_settings_dialog.dart index 8b5abb83e..5cad5853f 100644 --- a/kitchenowl/lib/widgets/settings_household/import_settings_dialog.dart +++ b/kitchenowl/lib/widgets/settings_household/import_settings_dialog.dart @@ -79,6 +79,15 @@ class _ImportSettingsDialogState extends State<_ImportSettingsDialog> { }); }, ), + CheckboxListTile( + title: Text(AppLocalizations.of(context)!.loyaltyCards), + value: settings.loyaltyCards, + onChanged: (value) { + setState(() { + settings = settings.copyWith(loyaltyCards: value); + }); + }, + ), ], ), shape: RoundedRectangleBorder( diff --git a/kitchenowl/lib/widgets/shopping_list_fab.dart b/kitchenowl/lib/widgets/shopping_list_fab.dart new file mode 100644 index 000000000..0694b560d --- /dev/null +++ b/kitchenowl/lib/widgets/shopping_list_fab.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:kitchenowl/app.dart'; +import 'package:kitchenowl/cubits/household_cubit.dart'; +import 'package:kitchenowl/cubits/shoppinglist_cubit.dart'; +import 'package:kitchenowl/kitchenowl.dart'; + +class ShoppingListFab extends StatelessWidget { + const ShoppingListFab({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final hasSelectedItems = state.selectedListItems.isNotEmpty && + state is! SearchShoppinglistCubitState; + final showConfirmFab = + !App.settings.shoppingListTapToRemove && hasSelectedItems; + + // Check if loyalty cards feature is enabled + final household = context.read().state.household; + final showLoyaltyCardsFab = household.featureLoyaltyCards ?? true; + + if (!showConfirmFab && !showLoyaltyCardsFab) { + return const SizedBox(); + } + + if (showConfirmFab && showLoyaltyCardsFab) { + // Show both FABs in a column + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + FloatingActionButton.small( + heroTag: 'loyaltyCardsFab', + onPressed: () => _openLoyaltyCards(context, household), + tooltip: AppLocalizations.of(context)!.loyaltyCards, + child: const Icon(Icons.wallet_rounded), + ), + const SizedBox(height: 16), + FloatingActionButton( + heroTag: 'confirmRemoveFab', + onPressed: context.read().confirmRemove, + child: const Icon(Icons.done_all_rounded), + ), + ], + ); + } + + if (showConfirmFab) { + return FloatingActionButton( + heroTag: 'confirmRemoveFab', + onPressed: context.read().confirmRemove, + child: const Icon(Icons.done_all_rounded), + ); + } + + // Only show loyalty cards FAB + return FloatingActionButton( + heroTag: 'loyaltyCardsFab', + onPressed: () => _openLoyaltyCards(context, household), + tooltip: AppLocalizations.of(context)!.loyaltyCards, + child: const Icon(Icons.wallet_rounded), + ); + }, + ); + } + + void _openLoyaltyCards(BuildContext context, dynamic household) { + context.push('/household/${household.id}/loyalty-cards'); + } +} + diff --git a/kitchenowl/pubspec.lock b/kitchenowl/pubspec.lock index 3ea2d8f19..9c286bcbc 100755 --- a/kitchenowl/pubspec.lock +++ b/kitchenowl/pubspec.lock @@ -49,6 +49,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + barcode: + dependency: transitive + description: + name: barcode + sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" + url: "https://pub.dev" + source: hosted + version: "2.2.9" + barcode_widget: + dependency: "direct main" + description: + name: barcode_widget + sha256: "6f2c5b08659b1a5f4d88d183e6007133ea2f96e50e7b8bb628f03266c3931427" + url: "https://pub.dev" + source: hosted + version: "2.0.4" bloc: dependency: transitive description: @@ -724,6 +740,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: d234581c090526676fd8fab4ada92f35c6746e3fb4f05a399665d75a399fb760 + url: "https://pub.dev" + source: hosted + version: "5.2.3" native_toolchain_c: dependency: transitive description: @@ -900,6 +924,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" reorderables: dependency: "direct main" description: diff --git a/kitchenowl/pubspec.yaml b/kitchenowl/pubspec.yaml index 73a4de17d..e78514b19 100644 --- a/kitchenowl/pubspec.yaml +++ b/kitchenowl/pubspec.yaml @@ -77,6 +77,8 @@ dependencies: sign_in_with_apple: ^7.0.1 wakelock_plus: ^1.2.10 fuzzywuzzy: ^1.2.0 + barcode_widget: ^2.0.4 + mobile_scanner: ^5.2.3 dev_dependencies: flutter_test: diff --git a/kitchenowl/web/barcode_scanner.js b/kitchenowl/web/barcode_scanner.js new file mode 100644 index 000000000..0418e2b12 --- /dev/null +++ b/kitchenowl/web/barcode_scanner.js @@ -0,0 +1,110 @@ +// Barcode scanner helper for web platform +// Uses Html5-QRCode library to decode barcodes from images + +// Map Html5QrcodeScanner format to our string format +function formatToString(format) { + if (!format) return 'CODE128'; + + const formatMap = { + 'QR_CODE': 'QR', + 'AZTEC': 'AZTEC', + 'CODABAR': 'CODABAR', + 'CODE_39': 'CODE39', + 'CODE_93': 'CODE93', + 'CODE_128': 'CODE128', + 'DATA_MATRIX': 'DATAMATRIX', + 'MAXICODE': 'CODE128', + 'ITF': 'ITF', + 'EAN_13': 'EAN13', + 'EAN_8': 'EAN8', + 'PDF_417': 'PDF417', + 'RSS_14': 'CODE128', + 'RSS_EXPANDED': 'CODE128', + 'UPC_A': 'UPCA', + 'UPC_E': 'UPCE', + 'UPC_EAN_EXTENSION': 'EAN13', + }; + + return formatMap[format] || 'CODE128'; +} + +// Decode barcode from base64 image data +// Returns: { data: string, type: string } or null if not found +async function decodeBarcode(base64Data) { + console.log('decodeBarcode called, data length:', base64Data ? base64Data.length : 0); + + // Check if Html5Qrcode is available + if (typeof Html5Qrcode === 'undefined') { + console.error('Html5Qrcode library not found'); + return null; + } + + try { + // Add data URL prefix if not present + let imageDataUrl; + if (base64Data.startsWith('data:')) { + imageDataUrl = base64Data; + } else { + imageDataUrl = 'data:image/png;base64,' + base64Data; + } + + console.log('Attempting to decode barcode from image...'); + + // Convert data URL to File object + const response = await fetch(imageDataUrl); + const blob = await response.blob(); + const file = new File([blob], 'barcode.png', { type: 'image/png' }); + + console.log('File created:', file.size, 'bytes'); + + // Configure to scan ALL barcode formats + const config = { + verbose: false, + formatsToSupport: [ + Html5QrcodeSupportedFormats.QR_CODE, + Html5QrcodeSupportedFormats.AZTEC, + Html5QrcodeSupportedFormats.CODABAR, + Html5QrcodeSupportedFormats.CODE_39, + Html5QrcodeSupportedFormats.CODE_93, + Html5QrcodeSupportedFormats.CODE_128, + Html5QrcodeSupportedFormats.DATA_MATRIX, + Html5QrcodeSupportedFormats.ITF, + Html5QrcodeSupportedFormats.EAN_13, + Html5QrcodeSupportedFormats.EAN_8, + Html5QrcodeSupportedFormats.PDF_417, + Html5QrcodeSupportedFormats.UPC_A, + Html5QrcodeSupportedFormats.UPC_E, + ] + }; + + // Use Html5Qrcode to scan from file + // The temp-reader div is defined in index.html + const html5QrCode = new Html5Qrcode("temp-reader", config); + + // scanFileV2 with showImage=true to help with detection + const result = await html5QrCode.scanFileV2(file, true); + + console.log('Barcode found:', result.decodedText, 'Format:', result.result.format.formatName); + + return { + data: result.decodedText, + type: formatToString(result.result.format.formatName) + }; + } catch (error) { + // Handle "No barcode or QR code detected" error + const errorStr = error ? error.toString() : ''; + if (errorStr.includes('No barcode') || errorStr.includes('No QR code') || + errorStr.includes('No MultiFormat') || errorStr.includes('NotFoundException')) { + console.log('No barcode found in image'); + return null; + } + console.error('Barcode decode error:', error); + return null; + } +} + +// Expose functions to Dart via window object +window.decodeBarcode = decodeBarcode; + +// Log when script is loaded +console.log('Barcode scanner script loaded (using Html5Qrcode with all formats)'); diff --git a/kitchenowl/web/index.html b/kitchenowl/web/index.html index ead190e33..9adc67b61 100644 --- a/kitchenowl/web/index.html +++ b/kitchenowl/web/index.html @@ -106,6 +106,13 @@ + + + + + + + \ No newline at end of file From 65b0097292552e343e7e37cdc663362f5ced8cff Mon Sep 17 00:00:00 2001 From: Edigorin Date: Wed, 31 Dec 2025 09:27:45 +0100 Subject: [PATCH 2/6] fix: Remove unnecessary import and fix household serialization - Remove redundant dart:typed_data import (Uint8List already provided by flutter/foundation.dart) - Fix featureLoyaltyCards deserialization to preserve null values (fixes failing test) --- kitchenowl/lib/helpers/web_barcode_scanner.dart | 1 - kitchenowl/lib/models/household.dart | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/kitchenowl/lib/helpers/web_barcode_scanner.dart b/kitchenowl/lib/helpers/web_barcode_scanner.dart index 0bc13e339..7fa6e6b73 100644 --- a/kitchenowl/lib/helpers/web_barcode_scanner.dart +++ b/kitchenowl/lib/helpers/web_barcode_scanner.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:kitchenowl/pages/barcode_scanner_page.dart'; diff --git a/kitchenowl/lib/models/household.dart b/kitchenowl/lib/models/household.dart index c4511a8a5..04aa97153 100644 --- a/kitchenowl/lib/models/household.dart +++ b/kitchenowl/lib/models/household.dart @@ -59,7 +59,7 @@ class Household extends Model { language: map['language'], featurePlanner: map['planner_feature'] ?? false, featureExpenses: map['expenses_feature'] ?? false, - featureLoyaltyCards: map['loyalty_cards_feature'] ?? false, + featureLoyaltyCards: map['loyalty_cards_feature'], description: map['description'], link: map['link'], verified: map['verified'] ?? false, From 33b25474ee88338ce1491c38c9b5fbc393df9f2d Mon Sep 17 00:00:00 2001 From: Edigorin Date: Tue, 10 Feb 2026 15:50:12 +0100 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20Address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?remove=20unused=20photo=20field,=20make=20barcode=20optional,?= =?UTF-8?q?=20remove=20custom=20web=20scanner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove photo/photo_file column from loyalty card model, migration, schemas, and controller - Remove photo-related relationships from File model - Make barcode_type and barcode_data nullable in model, migration, and schemas - Allow creating loyalty cards without a barcode (frontend validation removed) - Conditionally render barcode UI only when barcode data is present - Delete custom web barcode scanner (web_barcode_scanner.dart, barcode_scanner.js) - Remove html5-qrcode CDN script from index.html - Hide gallery scan button on web (analyzeImage not supported) - Use mobile_scanner's built-in web support for camera scanning - Add test for creating loyalty card without barcode --- .../loyalty_card/loyalty_card_controller.py | 11 +- .../app/controller/loyalty_card/schemas.py | 11 +- backend/app/models/file.py | 15 +- backend/app/models/loyalty_card.py | 25 +--- .../versions/a1b2c3d4e5f6_loyalty_cards.py | 6 +- backend/tests/api/test_api_loyalty_card.py | 21 ++- .../lib/helpers/web_barcode_scanner.dart | 18 --- .../lib/helpers/web_barcode_scanner_stub.dart | 10 -- .../lib/helpers/web_barcode_scanner_web.dart | 43 ------ kitchenowl/lib/models/loyalty_card.dart | 29 +--- .../pages/loyalty_card_add_update_page.dart | 84 +++-------- kitchenowl/lib/pages/loyalty_card_page.dart | 141 ++++++++++-------- kitchenowl/lib/widgets/loyalty_card_item.dart | 72 +++++---- kitchenowl/web/barcode_scanner.js | 110 -------------- kitchenowl/web/index.html | 7 - 15 files changed, 181 insertions(+), 422 deletions(-) delete mode 100644 kitchenowl/lib/helpers/web_barcode_scanner.dart delete mode 100644 kitchenowl/lib/helpers/web_barcode_scanner_stub.dart delete mode 100644 kitchenowl/lib/helpers/web_barcode_scanner_web.dart delete mode 100644 kitchenowl/web/barcode_scanner.js diff --git a/backend/app/controller/loyalty_card/loyalty_card_controller.py b/backend/app/controller/loyalty_card/loyalty_card_controller.py index aa424c6b8..5e3a664eb 100644 --- a/backend/app/controller/loyalty_card/loyalty_card_controller.py +++ b/backend/app/controller/loyalty_card/loyalty_card_controller.py @@ -3,7 +3,6 @@ from flask_jwt_extended import jwt_required from app.helpers import validate_args, authorize_household from app.models import LoyaltyCard -from app.service.file_has_access_or_download import file_has_access_or_download from .schemas import AddLoyaltyCard, UpdateLoyaltyCard loyaltyCard = Blueprint("loyalty_card", __name__) @@ -36,15 +35,15 @@ def getLoyaltyCardById(id): def addLoyaltyCard(args, household_id): loyalty_card = LoyaltyCard() loyalty_card.name = args["name"] - loyalty_card.barcode_type = args["barcode_type"] - loyalty_card.barcode_data = args["barcode_data"] loyalty_card.household_id = household_id + if "barcode_type" in args: + loyalty_card.barcode_type = args["barcode_type"] + if "barcode_data" in args: + loyalty_card.barcode_data = args["barcode_data"] if "description" in args: loyalty_card.description = args["description"] if "color" in args: loyalty_card.color = args["color"] - if "photo" in args and args["photo"]: - loyalty_card.photo = file_has_access_or_download(args["photo"], None) loyalty_card.save() return jsonify(loyalty_card.obj_to_dict()) @@ -68,8 +67,6 @@ def updateLoyaltyCard(args, id): loyalty_card.description = args["description"] if "color" in args: loyalty_card.color = args["color"] - if "photo" in args and args["photo"] != loyalty_card.photo: - loyalty_card.photo = file_has_access_or_download(args["photo"], loyalty_card.photo) loyalty_card.save() return jsonify(loyalty_card.obj_to_dict()) diff --git a/backend/app/controller/loyalty_card/schemas.py b/backend/app/controller/loyalty_card/schemas.py index 71baefd3d..4873f7420 100644 --- a/backend/app/controller/loyalty_card/schemas.py +++ b/backend/app/controller/loyalty_card/schemas.py @@ -3,18 +3,17 @@ class AddLoyaltyCard(Schema): name = fields.String(required=True, validate=lambda a: a and not a.isspace()) - barcode_type = fields.String(required=True, validate=lambda a: a and not a.isspace()) - barcode_data = fields.String(required=True, validate=lambda a: a and not a.isspace()) + barcode_type = fields.String(allow_none=True) + barcode_data = fields.String(allow_none=True) description = fields.String(allow_none=True) color = fields.Integer(validate=lambda i: i >= 0, allow_none=True) - photo = fields.String(allow_none=True) class UpdateLoyaltyCard(Schema): name = fields.String(validate=lambda a: a and not a.isspace()) - barcode_type = fields.String(validate=lambda a: a and not a.isspace()) - barcode_data = fields.String(validate=lambda a: a and not a.isspace()) + barcode_type = fields.String(allow_none=True) + barcode_data = fields.String(allow_none=True) description = fields.String(allow_none=True) color = fields.Integer(validate=lambda i: i >= 0, allow_none=True) - photo = fields.String(allow_none=True) + diff --git a/backend/app/models/file.py b/backend/app/models/file.py index 9434bc4d0..c2ca8bddb 100644 --- a/backend/app/models/file.py +++ b/backend/app/models/file.py @@ -13,7 +13,7 @@ Model = db.Model if TYPE_CHECKING: - from app.models import Household, Recipe, Expense, LoyaltyCard + from app.models import Household, Recipe, Expense from app.helpers.db_model_base import DbModelBase Model = DbModelBase @@ -66,14 +66,6 @@ class File(Model, DbModelAuthorizeMixin): uselist=False, ), ) - loyalty_card: Mapped["LoyaltyCard"] = cast( - Mapped["LoyaltyCard"], - db.relationship( - "LoyaltyCard", - uselist=False, - ), - ) - def delete(self): """ Delete this instance of model from db @@ -88,7 +80,6 @@ def isUnused(self) -> bool: and not self.recipe and not self.expense and not self.profile_picture - and not self.loyalty_card ) def checkAuthorized( @@ -109,10 +100,6 @@ def checkAuthorized( super().checkAuthorized( household_id=self.expense.household_id, requires_admin=requires_admin ) - elif self.loyalty_card: - super().checkAuthorized( - household_id=self.loyalty_card.household_id, requires_admin=requires_admin - ) else: raise ForbiddenRequest() diff --git a/backend/app/models/loyalty_card.py b/backend/app/models/loyalty_card.py index b3d9e198c..7fe19250e 100644 --- a/backend/app/models/loyalty_card.py +++ b/backend/app/models/loyalty_card.py @@ -5,7 +5,7 @@ Model = db.Model if TYPE_CHECKING: - from app.models import Household, File + from app.models import Household from app.helpers.db_model_base import DbModelBase Model = DbModelBase @@ -16,11 +16,10 @@ class LoyaltyCard(Model, DbModelAuthorizeMixin): id: Mapped[int] = db.Column(db.Integer, primary_key=True) name: Mapped[str] = db.Column(db.String(128), nullable=False) - barcode_type: Mapped[str] = db.Column(db.String(32), nullable=False, default="CODE128") - barcode_data: Mapped[str] = db.Column(db.String(256), nullable=False) + barcode_type: Mapped[str | None] = db.Column(db.String(32)) + barcode_data: Mapped[str | None] = db.Column(db.String(256)) description: Mapped[str | None] = db.Column(db.String(512)) color: Mapped[int | None] = db.Column(db.Integer) - photo: Mapped[str | None] = db.Column(db.String(), db.ForeignKey("file.filename")) household_id: Mapped[int] = db.Column( db.Integer, db.ForeignKey("household.id"), nullable=False, index=True ) @@ -33,24 +32,6 @@ class LoyaltyCard(Model, DbModelAuthorizeMixin): uselist=False, ), ) - photo_file: Mapped["File"] = cast( - Mapped["File"], - db.relationship( - "File", - back_populates="loyalty_card", - uselist=False, - ), - ) - - def obj_to_dict( - self, - skip_columns: list[str] | None = None, - include_columns: list[str] | None = None, - ) -> dict[str, Any]: - res = super().obj_to_dict(skip_columns, include_columns) - if self.photo_file: - res["photo_hash"] = self.photo_file.blur_hash - return res def obj_to_full_dict(self) -> dict[str, Any]: return self.obj_to_dict() diff --git a/backend/migrations/versions/a1b2c3d4e5f6_loyalty_cards.py b/backend/migrations/versions/a1b2c3d4e5f6_loyalty_cards.py index ccdd61a4a..573dc10ca 100644 --- a/backend/migrations/versions/a1b2c3d4e5f6_loyalty_cards.py +++ b/backend/migrations/versions/a1b2c3d4e5f6_loyalty_cards.py @@ -22,16 +22,14 @@ def upgrade(): 'loyalty_card', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=128), nullable=False), - sa.Column('barcode_type', sa.String(length=32), nullable=False), - sa.Column('barcode_data', sa.String(length=256), nullable=False), + sa.Column('barcode_type', sa.String(length=32), nullable=True), + sa.Column('barcode_data', sa.String(length=256), nullable=True), sa.Column('description', sa.String(length=512), nullable=True), sa.Column('color', sa.Integer(), nullable=True), - sa.Column('photo', sa.String(), nullable=True), sa.Column('household_id', sa.Integer(), nullable=False), sa.Column('created_at', sa.DateTime(), nullable=True), sa.Column('updated_at', sa.DateTime(), nullable=True), sa.ForeignKeyConstraint(['household_id'], ['household.id'], ), - sa.ForeignKeyConstraint(['photo'], ['file.filename'], ), sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_loyalty_card_household_id'), 'loyalty_card', ['household_id'], unique=False) diff --git a/backend/tests/api/test_api_loyalty_card.py b/backend/tests/api/test_api_loyalty_card.py index 0ca05e549..7f70c4761 100644 --- a/backend/tests/api/test_api_loyalty_card.py +++ b/backend/tests/api/test_api_loyalty_card.py @@ -76,6 +76,23 @@ def test_create_loyalty_card_minimal( assert data["barcode_type"] == minimal_data["barcode_type"] assert data["barcode_data"] == minimal_data["barcode_data"] + def test_create_loyalty_card_without_barcode( + self, user_client_with_household, household_id + ): + """Test creating a loyalty card without barcode data.""" + no_barcode_data = { + "name": "No Barcode Card", + } + response = user_client_with_household.post( + f"/api/household/{household_id}/loyalty-card", + json=no_barcode_data, + ) + assert response.status_code == 200 + data = response.get_json() + assert data["name"] == no_barcode_data["name"] + assert data["barcode_type"] is None + assert data["barcode_data"] is None + def test_get_all_loyalty_cards_after_create( self, user_client_with_household, household_id, loyalty_card_data ): @@ -202,10 +219,10 @@ def test_create_loyalty_card_missing_required_field( self, user_client_with_household, household_id ): """Test creating a loyalty card with missing required fields fails.""" - # Missing barcode_data + # Missing name (required field) incomplete_data = { - "name": "Incomplete Card", "barcode_type": "CODE128", + "barcode_data": "1234567890", } response = user_client_with_household.post( f"/api/household/{household_id}/loyalty-card", diff --git a/kitchenowl/lib/helpers/web_barcode_scanner.dart b/kitchenowl/lib/helpers/web_barcode_scanner.dart deleted file mode 100644 index 7fa6e6b73..000000000 --- a/kitchenowl/lib/helpers/web_barcode_scanner.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; -import 'package:kitchenowl/pages/barcode_scanner_page.dart'; - -// Conditional import for web-specific implementation -import 'web_barcode_scanner_stub.dart' - if (dart.library.js) 'web_barcode_scanner_web.dart' as impl; - -/// Scans a barcode from image bytes on web platform. -/// Returns null if no barcode is found or if not on web. -Future scanBarcodeFromImageBytes(Uint8List bytes) async { - if (!kIsWeb) { - return null; - } - return impl.scanBarcodeFromImageBytesImpl(bytes); -} - diff --git a/kitchenowl/lib/helpers/web_barcode_scanner_stub.dart b/kitchenowl/lib/helpers/web_barcode_scanner_stub.dart deleted file mode 100644 index bee06379d..000000000 --- a/kitchenowl/lib/helpers/web_barcode_scanner_stub.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'dart:typed_data'; - -import 'package:kitchenowl/pages/barcode_scanner_page.dart'; - -/// Stub implementation for non-web platforms. -/// Always returns null since this functionality is web-only. -Future scanBarcodeFromImageBytesImpl(Uint8List bytes) async { - return null; -} - diff --git a/kitchenowl/lib/helpers/web_barcode_scanner_web.dart b/kitchenowl/lib/helpers/web_barcode_scanner_web.dart deleted file mode 100644 index 97e085e52..000000000 --- a/kitchenowl/lib/helpers/web_barcode_scanner_web.dart +++ /dev/null @@ -1,43 +0,0 @@ -// ignore_for_file: avoid_web_libraries_in_flutter - -import 'dart:async'; -import 'dart:convert'; -import 'dart:js_interop'; -import 'dart:typed_data'; - -import 'package:kitchenowl/pages/barcode_scanner_page.dart'; - -@JS('decodeBarcode') -external JSPromise _decodeBarcode(JSString base64Data); - -/// Web implementation that calls the JavaScript barcode scanner. -Future scanBarcodeFromImageBytesImpl(Uint8List bytes) async { - try { - // Convert bytes to base64 - final base64Data = base64Encode(bytes); - - // Call the JavaScript function - final jsResult = await _decodeBarcode(base64Data.toJS).toDart; - - if (jsResult == null) { - return null; - } - - // Cast to JSObject and extract properties - final jsObject = jsResult as JSObject; - final dataProperty = (jsObject as dynamic).data; - final typeProperty = (jsObject as dynamic).type; - - final data = (dataProperty as JSString?)?.toDart; - final type = (typeProperty as JSString?)?.toDart; - - if (data != null && type != null) { - return BarcodeScanResult(data: data, type: type); - } - - return null; - } catch (e) { - print('Web barcode scanner error: $e'); - return null; - } -} diff --git a/kitchenowl/lib/models/loyalty_card.dart b/kitchenowl/lib/models/loyalty_card.dart index b26df9637..9b7d891d3 100644 --- a/kitchenowl/lib/models/loyalty_card.dart +++ b/kitchenowl/lib/models/loyalty_card.dart @@ -3,21 +3,17 @@ import 'package:kitchenowl/models/model.dart'; class LoyaltyCard extends Model { final int? id; final String name; - final String barcodeType; - final String barcodeData; + final String? barcodeType; + final String? barcodeData; final String? description; - final String? image; - final String? imageHash; final int? color; const LoyaltyCard({ this.id, this.name = '', - this.barcodeType = 'CODE128', - this.barcodeData = '', + this.barcodeType, + this.barcodeData, this.description, - this.image, - this.imageHash, this.color, }); @@ -25,11 +21,9 @@ class LoyaltyCard extends Model { return LoyaltyCard( id: map['id'], name: map['name'] ?? '', - barcodeType: map['barcode_type'] ?? 'CODE128', - barcodeData: map['barcode_data'] ?? '', + barcodeType: map['barcode_type'], + barcodeData: map['barcode_data'], description: map['description'], - image: map['photo'], - imageHash: map['photo_hash'], color: map['color'], ); } @@ -39,7 +33,6 @@ class LoyaltyCard extends Model { String? barcodeType, String? barcodeData, String? description, - String? image, int? color, }) => LoyaltyCard( @@ -48,8 +41,6 @@ class LoyaltyCard extends Model { barcodeType: barcodeType ?? this.barcodeType, barcodeData: barcodeData ?? this.barcodeData, description: description ?? this.description, - image: image ?? this.image, - imageHash: imageHash, color: color ?? this.color, ); @@ -60,8 +51,6 @@ class LoyaltyCard extends Model { barcodeType, barcodeData, description, - image, - imageHash, color, ]; @@ -69,10 +58,9 @@ class LoyaltyCard extends Model { Map toJson() { return { "name": name, - "barcode_type": barcodeType, - "barcode_data": barcodeData, + if (barcodeType != null) "barcode_type": barcodeType, + if (barcodeData != null) "barcode_data": barcodeData, if (description != null) "description": description, - if (image != null) "photo": image, if (color != null) "color": color, }; } @@ -81,7 +69,6 @@ class LoyaltyCard extends Model { Map toJsonWithId() => toJson() ..addAll({ "id": id, - if (imageHash != null) "photo_hash": imageHash, }); } diff --git a/kitchenowl/lib/pages/loyalty_card_add_update_page.dart b/kitchenowl/lib/pages/loyalty_card_add_update_page.dart index a59bf7693..9036c8de7 100644 --- a/kitchenowl/lib/pages/loyalty_card_add_update_page.dart +++ b/kitchenowl/lib/pages/loyalty_card_add_update_page.dart @@ -1,4 +1,3 @@ -import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_colorpicker/flutter_colorpicker.dart'; @@ -6,7 +5,6 @@ import 'package:image_picker/image_picker.dart'; import 'package:kitchenowl/kitchenowl.dart'; import 'package:kitchenowl/models/household.dart'; import 'package:kitchenowl/models/loyalty_card.dart'; -import 'package:kitchenowl/helpers/web_barcode_scanner.dart'; import 'package:kitchenowl/pages/barcode_scanner_page.dart'; import 'package:kitchenowl/services/api/api_service.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; @@ -105,13 +103,7 @@ class _LoyaltyCardAddUpdatePageState extends State { }); try { - if (kIsWeb) { - // Web platform: use file picker and web barcode scanner - await _scanFromGalleryWeb(); - } else { - // Mobile platform: use mobile_scanner - await _scanFromGalleryMobile(); - } + await _scanFromGalleryMobile(); } catch (e) { if (mounted) { _showNoBarcodeError(); @@ -125,31 +117,6 @@ class _LoyaltyCardAddUpdatePageState extends State { } } - Future _scanFromGalleryWeb() async { - final result = await FilePicker.platform.pickFiles( - type: FileType.image, - withData: true, - ); - - if (result == null || result.files.first.bytes == null) { - return; - } - - final bytes = result.files.first.bytes!; - final scanResult = await scanBarcodeFromImageBytes(bytes); - - if (scanResult != null && mounted) { - setState(() { - _barcodeDataController.text = scanResult.data; - _detectedBarcodeType = scanResult.type; - _isAutoDetected = true; - _showManualEntry = false; - }); - } else if (mounted) { - _showNoBarcodeError(); - } - } - Future _scanFromGalleryMobile() async { final picker = ImagePicker(); final XFile? image = await picker.pickImage(source: ImageSource.gallery); @@ -332,19 +299,20 @@ class _LoyaltyCardAddUpdatePageState extends State { ), ), const SizedBox(width: 12), - Expanded( - child: OutlinedButton.icon( - onPressed: _isScanning ? null : _scanFromGallery, - icon: _isScanning - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.photo_library), - label: Text(AppLocalizations.of(context)!.gallery), + if (!kIsWeb) + Expanded( + child: OutlinedButton.icon( + onPressed: _isScanning ? null : _scanFromGallery, + icon: _isScanning + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.photo_library), + label: Text(AppLocalizations.of(context)!.gallery), + ), ), - ), ], ), const SizedBox(height: 12), @@ -442,12 +410,6 @@ class _LoyaltyCardAddUpdatePageState extends State { prefixIcon: const Icon(Icons.numbers), ), keyboardType: TextInputType.text, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return AppLocalizations.of(context)!.fieldRequired; - } - return null; - }, onChanged: (value) { setState(() {}); }, @@ -521,16 +483,6 @@ class _LoyaltyCardAddUpdatePageState extends State { Future _save() async { if (!_formKey.currentState!.validate()) return; - // Validate barcode data exists - if (_barcodeDataController.text.trim().isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(context)!.fieldRequired), - ), - ); - return; - } - setState(() { _isLoading = true; }); @@ -539,8 +491,12 @@ class _LoyaltyCardAddUpdatePageState extends State { final loyaltyCard = LoyaltyCard( id: widget.loyaltyCard?.id, name: _nameController.text.trim(), - barcodeType: _detectedBarcodeType ?? 'CODE128', - barcodeData: _barcodeDataController.text.trim(), + barcodeType: _barcodeDataController.text.trim().isNotEmpty + ? (_detectedBarcodeType ?? 'CODE128') + : null, + barcodeData: _barcodeDataController.text.trim().isNotEmpty + ? _barcodeDataController.text.trim() + : null, description: _descriptionController.text.trim().isEmpty ? null : _descriptionController.text.trim(), diff --git a/kitchenowl/lib/pages/loyalty_card_page.dart b/kitchenowl/lib/pages/loyalty_card_page.dart index a59a8c84c..0c7f7db8b 100644 --- a/kitchenowl/lib/pages/loyalty_card_page.dart +++ b/kitchenowl/lib/pages/loyalty_card_page.dart @@ -58,8 +58,9 @@ class _LoyaltyCardPageState extends State { ? Color(loyaltyCard.color!) : Theme.of(context).colorScheme.primaryContainer; final contrastColor = _getContrastColor(cardColor); - final isQrLike = _isQrLike(loyaltyCard.barcodeType); - final isLongData = loyaltyCard.barcodeData.length > 20; + final hasBarcode = loyaltyCard.barcodeData != null && loyaltyCard.barcodeData!.isNotEmpty; + final isQrLike = hasBarcode ? _isQrLike(loyaltyCard.barcodeType!) : false; + final isLongData = hasBarcode ? loyaltyCard.barcodeData!.length > 20 : false; return Scaffold( backgroundColor: Theme.of(context).colorScheme.surface, @@ -84,14 +85,15 @@ class _LoyaltyCardPageState extends State { ), PopupMenuButton( itemBuilder: (context) => [ - PopupMenuItem( - value: 'copy', - child: ListTile( - leading: const Icon(Icons.copy), - title: Text(AppLocalizations.of(context)!.copyBarcode), - contentPadding: EdgeInsets.zero, + if (hasBarcode) + PopupMenuItem( + value: 'copy', + child: ListTile( + leading: const Icon(Icons.copy), + title: Text(AppLocalizations.of(context)!.copyBarcode), + contentPadding: EdgeInsets.zero, + ), ), - ), PopupMenuItem( value: 'delete', child: ListTile( @@ -162,60 +164,69 @@ class _LoyaltyCardPageState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - // Barcode container - Container( - padding: EdgeInsets.all(isQrLike ? 16 : 12), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - ), - child: _buildBarcode(context, loyaltyCard), - ), - const SizedBox(height: 16), - - // Barcode data - tappable to copy - InkWell( - onTap: () => _copyBarcode(context, loyaltyCard), - borderRadius: BorderRadius.circular(8), - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), + if (hasBarcode) ...[ + // Barcode container + Container( + padding: EdgeInsets.all(isQrLike ? 16 : 12), decoration: BoxDecoration( - color: contrastColor.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), + color: Colors.white, + borderRadius: BorderRadius.circular(12), ), - child: Row( - children: [ - Expanded( - child: Text( - isLongData - ? _truncateMiddle(loyaltyCard.barcodeData, 30) - : loyaltyCard.barcodeData, - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith( - color: contrastColor, - fontFamily: 'monospace', - letterSpacing: 1, - ), - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, + child: _buildBarcode(context, loyaltyCard), + ), + const SizedBox(height: 16), + + // Barcode data - tappable to copy + InkWell( + onTap: () => _copyBarcode(context, loyaltyCard), + borderRadius: BorderRadius.circular(8), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + decoration: BoxDecoration( + color: contrastColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Expanded( + child: Text( + isLongData + ? _truncateMiddle(loyaltyCard.barcodeData!, 30) + : loyaltyCard.barcodeData!, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: contrastColor, + fontFamily: 'monospace', + letterSpacing: 1, + ), + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Icon( + Icons.copy_rounded, + size: 18, + color: contrastColor.withOpacity(0.7), ), - ), - const SizedBox(width: 8), - Icon( - Icons.copy_rounded, - size: 18, - color: contrastColor.withOpacity(0.7), - ), - ], + ], + ), ), ), - ), + ] else ...[ + // No barcode - show store icon + Icon( + Icons.store_rounded, + size: 64, + color: contrastColor.withOpacity(0.5), + ), + ], ], ), ), @@ -304,11 +315,14 @@ class _LoyaltyCardPageState extends State { } Widget _buildBarcode(BuildContext context, LoyaltyCard loyaltyCard) { + if (loyaltyCard.barcodeType == null || loyaltyCard.barcodeData == null) { + return const SizedBox.shrink(); + } try { - final barcodeType = _getBarcodeType(loyaltyCard.barcodeType); - final isQrLike = _isQrLike(loyaltyCard.barcodeType); + final barcodeType = _getBarcodeType(loyaltyCard.barcodeType!); + final isQrLike = _isQrLike(loyaltyCard.barcodeType!); // Don't show text for QR codes or long data (like URLs) - final showText = !isQrLike && loyaltyCard.barcodeData.length <= 20; + final showText = !isQrLike && loyaltyCard.barcodeData!.length <= 20; return ConstrainedBox( constraints: BoxConstraints( @@ -316,7 +330,7 @@ class _LoyaltyCardPageState extends State { ), child: BarcodeWidget( barcode: barcodeType, - data: loyaltyCard.barcodeData, + data: loyaltyCard.barcodeData!, width: isQrLike ? 200 : double.infinity, height: isQrLike ? 200 : 80, drawText: showText, @@ -398,7 +412,8 @@ class _LoyaltyCardPageState extends State { } void _copyBarcode(BuildContext context, LoyaltyCard loyaltyCard) { - Clipboard.setData(ClipboardData(text: loyaltyCard.barcodeData)); + if (loyaltyCard.barcodeData == null) return; + Clipboard.setData(ClipboardData(text: loyaltyCard.barcodeData!)); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context)!.copied), diff --git a/kitchenowl/lib/widgets/loyalty_card_item.dart b/kitchenowl/lib/widgets/loyalty_card_item.dart index a943b3278..dd48170b2 100644 --- a/kitchenowl/lib/widgets/loyalty_card_item.dart +++ b/kitchenowl/lib/widgets/loyalty_card_item.dart @@ -19,6 +19,7 @@ class LoyaltyCardItem extends StatelessWidget { ? Color(loyaltyCard.color!) : Theme.of(context).colorScheme.primaryContainer; final contrastColor = _getContrastColor(cardColor); + final hasBarcode = loyaltyCard.barcodeData != null && loyaltyCard.barcodeData!.isNotEmpty; return Material( color: Colors.transparent, @@ -78,37 +79,46 @@ class LoyaltyCardItem extends StatelessWidget { ], ), const SizedBox(height: 8), - // Barcode container - use Flexible instead of Expanded - Flexible( - child: Center( - child: Container( - constraints: const BoxConstraints( - maxHeight: 60, + if (hasBarcode) ...[ + // Barcode container - use Flexible instead of Expanded + Flexible( + child: Center( + child: Container( + constraints: const BoxConstraints( + maxHeight: 60, + ), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(6), + ), + child: _buildBarcode(context), ), - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(6), - ), - child: _buildBarcode(context), ), ), - ), - const SizedBox(height: 6), - // Barcode data text - Text( - _truncateMiddle(loyaltyCard.barcodeData, 24), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: contrastColor.withOpacity(0.7), - fontFamily: 'monospace', - fontSize: 11, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + const SizedBox(height: 6), + // Barcode data text + Text( + _truncateMiddle(loyaltyCard.barcodeData!, 24), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: contrastColor.withOpacity(0.7), + fontFamily: 'monospace', + fontSize: 11, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ] else ...[ + const SizedBox(height: 8), + Icon( + Icons.credit_card_rounded, + size: 32, + color: contrastColor.withOpacity(0.5), + ), + ], ], ), ), @@ -126,13 +136,13 @@ class LoyaltyCardItem extends StatelessWidget { Widget _buildBarcode(BuildContext context) { try { - final barcodeType = _getBarcodeType(loyaltyCard.barcodeType); + final barcodeType = _getBarcodeType(loyaltyCard.barcodeType ?? 'CODE128'); final isQrLike = ['QR', 'DATAMATRIX', 'AZTEC', 'PDF417'] - .contains(loyaltyCard.barcodeType.toUpperCase()); + .contains((loyaltyCard.barcodeType ?? '').toUpperCase()); return BarcodeWidget( barcode: barcodeType, - data: loyaltyCard.barcodeData, + data: loyaltyCard.barcodeData ?? '', width: isQrLike ? 45 : 150, height: isQrLike ? 45 : 35, drawText: false, diff --git a/kitchenowl/web/barcode_scanner.js b/kitchenowl/web/barcode_scanner.js deleted file mode 100644 index 0418e2b12..000000000 --- a/kitchenowl/web/barcode_scanner.js +++ /dev/null @@ -1,110 +0,0 @@ -// Barcode scanner helper for web platform -// Uses Html5-QRCode library to decode barcodes from images - -// Map Html5QrcodeScanner format to our string format -function formatToString(format) { - if (!format) return 'CODE128'; - - const formatMap = { - 'QR_CODE': 'QR', - 'AZTEC': 'AZTEC', - 'CODABAR': 'CODABAR', - 'CODE_39': 'CODE39', - 'CODE_93': 'CODE93', - 'CODE_128': 'CODE128', - 'DATA_MATRIX': 'DATAMATRIX', - 'MAXICODE': 'CODE128', - 'ITF': 'ITF', - 'EAN_13': 'EAN13', - 'EAN_8': 'EAN8', - 'PDF_417': 'PDF417', - 'RSS_14': 'CODE128', - 'RSS_EXPANDED': 'CODE128', - 'UPC_A': 'UPCA', - 'UPC_E': 'UPCE', - 'UPC_EAN_EXTENSION': 'EAN13', - }; - - return formatMap[format] || 'CODE128'; -} - -// Decode barcode from base64 image data -// Returns: { data: string, type: string } or null if not found -async function decodeBarcode(base64Data) { - console.log('decodeBarcode called, data length:', base64Data ? base64Data.length : 0); - - // Check if Html5Qrcode is available - if (typeof Html5Qrcode === 'undefined') { - console.error('Html5Qrcode library not found'); - return null; - } - - try { - // Add data URL prefix if not present - let imageDataUrl; - if (base64Data.startsWith('data:')) { - imageDataUrl = base64Data; - } else { - imageDataUrl = 'data:image/png;base64,' + base64Data; - } - - console.log('Attempting to decode barcode from image...'); - - // Convert data URL to File object - const response = await fetch(imageDataUrl); - const blob = await response.blob(); - const file = new File([blob], 'barcode.png', { type: 'image/png' }); - - console.log('File created:', file.size, 'bytes'); - - // Configure to scan ALL barcode formats - const config = { - verbose: false, - formatsToSupport: [ - Html5QrcodeSupportedFormats.QR_CODE, - Html5QrcodeSupportedFormats.AZTEC, - Html5QrcodeSupportedFormats.CODABAR, - Html5QrcodeSupportedFormats.CODE_39, - Html5QrcodeSupportedFormats.CODE_93, - Html5QrcodeSupportedFormats.CODE_128, - Html5QrcodeSupportedFormats.DATA_MATRIX, - Html5QrcodeSupportedFormats.ITF, - Html5QrcodeSupportedFormats.EAN_13, - Html5QrcodeSupportedFormats.EAN_8, - Html5QrcodeSupportedFormats.PDF_417, - Html5QrcodeSupportedFormats.UPC_A, - Html5QrcodeSupportedFormats.UPC_E, - ] - }; - - // Use Html5Qrcode to scan from file - // The temp-reader div is defined in index.html - const html5QrCode = new Html5Qrcode("temp-reader", config); - - // scanFileV2 with showImage=true to help with detection - const result = await html5QrCode.scanFileV2(file, true); - - console.log('Barcode found:', result.decodedText, 'Format:', result.result.format.formatName); - - return { - data: result.decodedText, - type: formatToString(result.result.format.formatName) - }; - } catch (error) { - // Handle "No barcode or QR code detected" error - const errorStr = error ? error.toString() : ''; - if (errorStr.includes('No barcode') || errorStr.includes('No QR code') || - errorStr.includes('No MultiFormat') || errorStr.includes('NotFoundException')) { - console.log('No barcode found in image'); - return null; - } - console.error('Barcode decode error:', error); - return null; - } -} - -// Expose functions to Dart via window object -window.decodeBarcode = decodeBarcode; - -// Log when script is loaded -console.log('Barcode scanner script loaded (using Html5Qrcode with all formats)'); diff --git a/kitchenowl/web/index.html b/kitchenowl/web/index.html index 9adc67b61..ead190e33 100644 --- a/kitchenowl/web/index.html +++ b/kitchenowl/web/index.html @@ -106,13 +106,6 @@ - - - - - - - \ No newline at end of file From 3e1c88853bd65fe56e7f49d16c1df801811a7ce8 Mon Sep 17 00:00:00 2001 From: Edigorin Date: Thu, 12 Feb 2026 15:08:46 +0100 Subject: [PATCH 4/6] fix(l10n): deduplicate ARB keys and add missing localization entries Remove 57-line duplicate block in app_en.arb, add 14 new loyalty card value keys with metadata, and localize 3 hardcoded strings in loyalty_card_page.dart and loyalty_card_list_page.dart. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- kitchenowl/lib/l10n/app_en.arb | 93 +++++++------------ .../lib/pages/loyalty_card_list_page.dart | 2 +- kitchenowl/lib/pages/loyalty_card_page.dart | 4 +- 3 files changed, 38 insertions(+), 61 deletions(-) diff --git a/kitchenowl/lib/l10n/app_en.arb b/kitchenowl/lib/l10n/app_en.arb index 82a668d02..32b4d1a2d 100644 --- a/kitchenowl/lib/l10n/app_en.arb +++ b/kitchenowl/lib/l10n/app_en.arb @@ -57,10 +57,14 @@ "description": "The title of the app" }, "@automaticIngredientDetection": {}, + "@autoDetected": {}, "@back": {}, "@balances": {}, "@barcodeData": {}, + "@barcodeCannotDisplay": {}, "@barcodeDataHint": {}, + "@barcodeDetected": {}, + "@barcodeInvalid": {}, "@barcodeType": {}, "@budget": {}, "@budgetSet": {}, @@ -96,6 +100,7 @@ "@confirm": {}, "@cookingTime": {}, "@copied": {}, + "@copyBarcode": {}, "@daily": {}, "@dangerZone": {}, "@darkmode": {}, @@ -115,6 +120,7 @@ "@emailSuccessfullyVerified": {}, "@emailUpdate": {}, "@emailUsed": {}, + "@enterManually": {}, "@error": {}, "@excludeFromStatistics": {}, "@expense": {}, @@ -149,6 +155,8 @@ } }, "@fieldRequired": {}, + "@flashOff": {}, + "@flashOn": {}, "@forceOfflineMode": {}, "@gallery": {}, "@general": {}, @@ -221,6 +229,7 @@ }, "@itemsOptional": {}, "@itemsRecent": {}, + "@keepScreenOn": {}, "@language": {}, "@languageSelect": {}, "@larger": {}, @@ -273,6 +282,7 @@ "@loyaltyCardEdit": {}, "@loyaltyCardNameHint": {}, "@loyaltyCards": {}, + "@loyaltyCardsDescription": {}, "@loyaltyCardsEmpty": {}, "@markAsPaid": {}, "@mealPlanner": {}, @@ -287,6 +297,7 @@ "@more": {}, "@name": {}, "@next": {}, + "@noBarcodesFound": {}, "@noTags": {}, "@none": {}, "@now": {}, @@ -333,6 +344,7 @@ "@planner": {}, "@plannerEmpty": {}, "@plannerTitle": {}, + "@pointCameraAtBarcode": {}, "@preparationTime": {}, "@privacyPolicy": {}, "@privacyPolicyAgree": { @@ -406,6 +418,9 @@ "@reportUser": {}, "@reset": {}, "@save": {}, + "@scanBarcode": {}, + "@scanFromCamera": {}, + "@scanFromGallery": {}, "@search": {}, "@searchHint": {}, "@select": {}, @@ -456,6 +471,7 @@ "@swipeToDelete": {}, "@swipeToDeleteAndLongPressToReorder": {}, "@swipeToRemove": {}, + "@switchCamera": {}, "@tagDelete": {}, "@tagDeleteConfirmation": { "placeholders": { @@ -519,6 +535,7 @@ "@usernameInvalid": {}, "@usernameUnavailable": {}, "@users": {}, + "@webcam": {}, "@website": {}, "@weekly": {}, "@welcomeBack": {}, @@ -555,12 +572,16 @@ "appDescription": "KitchenOwl helps you organize your grocery life.", "appTitle": "KitchenOwl", "automaticIngredientDetection": "Automatically detect ingredients", + "autoDetected": "auto-detected", "back": "Back", "balances": "Balances", "budget": "Budget", "budgetSet": "Set budget", + "barcodeCannotDisplay": "Cannot display barcode", "barcodeData": "Barcode number", "barcodeDataHint": "Enter the number printed below the barcode", + "barcodeDetected": "Barcode detected", + "barcodeInvalid": "Invalid barcode", "barcodeType": "Barcode type", "camera": "Camera", "cancel": "Cancel", @@ -580,6 +601,7 @@ "confirm": "Confirm", "cookingTime": "Cooking time", "copied": "Copied", + "copyBarcode": "Copy barcode", "daily": "Daily", "dangerZone": "Danger Zone", "darkmode": "Dark mode", @@ -599,6 +621,7 @@ "emailSuccessfullyVerified": "Email successfully verified!", "emailUpdate": "Update email", "emailUsed": "The email is already associated with an account", + "enterManually": "Enter manually", "error": "An error occurred", "excludeFromStatistics": "Exclude from statistics", "expense": "Expense", @@ -619,6 +642,8 @@ "features": "Features", "fieldCannotBeEmpty": "{field} cannot be empty.", "fieldRequired": "This field is required", + "flashOff": "Flash off", + "flashOn": "Flash on", "forceOfflineMode": "Force offline mode", "gallery": "Gallery", "general": "General", @@ -652,6 +677,7 @@ "itemsMergeConfirmation": "Are you sure you want to merge {item} and {other}? This cannot be undone.", "itemsOptional": "Optional items", "itemsRecent": "Recent items", + "keepScreenOn": "Screen stays on for scanning", "language": "Language", "languageSelect": "Select a language", "larger": "Larger", @@ -679,6 +705,7 @@ "loyaltyCardEdit": "Edit loyalty card", "loyaltyCardNameHint": "e.g., Target, Costco", "loyaltyCards": "Loyalty Cards", + "loyaltyCardsDescription": "Store all your loyalty cards in one place and access them quickly at checkout.", "loyaltyCardsEmpty": "No loyalty cards yet. Add one to get started!", "markAsPaid": "Mark as paid", "mealPlanner": "Meal planner", @@ -693,6 +720,7 @@ "more": "More", "name": "Name", "next": "Next", + "noBarcodesFound": "No barcodes found in image", "noTags": "No tags", "none": "None", "now": "Now", @@ -721,6 +749,7 @@ "planner": "Planner", "plannerEmpty": "No meals planned, start by selecting one of your recipes!", "plannerTitle": "Your planned meals", + "pointCameraAtBarcode": "Point camera at barcode", "preparationTime": "Preparation time", "privacyPolicy": "Privacy policy", "privacyPolicyAgree": "By creating an account you agree to our {privacyPolicy} and {terms}", @@ -762,6 +791,9 @@ "reportUser": "Report user", "reset": "Reset", "save": "Save", + "scanBarcode": "Scan barcode", + "scanFromCamera": "Scan with camera", + "scanFromGallery": "Import from image", "search": "Search", "searchHint": "Looking for something?", "select": "Select", @@ -792,6 +824,7 @@ "swipeToDelete": "Swipe to delete", "swipeToDeleteAndLongPressToReorder": "Swipe to delete and long press to reorder", "swipeToRemove": "Swipe to remove", + "switchCamera": "Switch camera", "tagDelete": "Delete tag", "tagDeleteConfirmation": "Are you sure you want to delete {tag}? This will remove the tag from any recipe containing it.", "tagEdit": "Edit tag", @@ -832,6 +865,7 @@ "usernameInvalid": "Username cannot contain '@'", "usernameUnavailable": "The username is unavailable", "users": "Users", + "webcam": "Webcam", "website": "Website", "weekly": "Weekly", "welcomeBack": "Welcome back", @@ -841,62 +875,5 @@ "yes": "Yes", "yesterday": "Yesterday", "yields": "Yields", - "you": "you", - "loyaltyCards": "Cards", - "@loyaltyCards": {}, - "loyaltyCardsEmpty": "No loyalty cards yet. Add one to get started!", - "@loyaltyCardsEmpty": {}, - "loyaltyCardAdd": "Add card", - "@loyaltyCardAdd": {}, - "loyaltyCardEdit": "Edit card", - "@loyaltyCardEdit": {}, - "loyaltyCardNameHint": "e.g., Target, Costco", - "@loyaltyCardNameHint": {}, - "loyaltyCardDeleteConfirmation": "Are you sure you want to delete {name}?", - "@loyaltyCardDeleteConfirmation": { - "placeholders": { - "name": { - "example": "Target", - "type": "String" - } - } - }, - "barcodeData": "Barcode data", - "@barcodeData": {}, - "barcodeDataHint": "Enter barcode number", - "@barcodeDataHint": {}, - "barcodeType": "Barcode type", - "@barcodeType": {}, - "copyBarcode": "Copy barcode", - "@copyBarcode": {}, - "scanBarcode": "Scan barcode", - "@scanBarcode": {}, - "scanFromCamera": "Scan with camera", - "@scanFromCamera": {}, - "scanFromGallery": "Import from image", - "@scanFromGallery": {}, - "pointCameraAtBarcode": "Point camera at barcode", - "@pointCameraAtBarcode": {}, - "barcodeDetected": "Barcode detected", - "@barcodeDetected": {}, - "autoDetected": "auto-detected", - "@autoDetected": {}, - "enterManually": "Enter manually", - "@enterManually": {}, - "flashOn": "Flash on", - "@flashOn": {}, - "flashOff": "Flash off", - "@flashOff": {}, - "switchCamera": "Switch camera", - "@switchCamera": {}, - "noBarcodesFound": "No barcodes found in image", - "@noBarcodesFound": {}, - "webcam": "Webcam", - "@webcam": {}, - "camera": "Camera", - "@camera": {}, - "gallery": "Gallery", - "@gallery": {}, - "keepScreenOn": "Screen stays on for scanning", - "@keepScreenOn": {} + "you": "you" } diff --git a/kitchenowl/lib/pages/loyalty_card_list_page.dart b/kitchenowl/lib/pages/loyalty_card_list_page.dart index 52bd93809..88790abc1 100644 --- a/kitchenowl/lib/pages/loyalty_card_list_page.dart +++ b/kitchenowl/lib/pages/loyalty_card_list_page.dart @@ -120,7 +120,7 @@ class LoyaltyCardListPageStandalone extends StatelessWidget { ), const SizedBox(height: 8), Text( - 'Store all your loyalty cards in one place and access them quickly at checkout.', + AppLocalizations.of(context)!.loyaltyCardsDescription, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), diff --git a/kitchenowl/lib/pages/loyalty_card_page.dart b/kitchenowl/lib/pages/loyalty_card_page.dart index 0c7f7db8b..21106fa9f 100644 --- a/kitchenowl/lib/pages/loyalty_card_page.dart +++ b/kitchenowl/lib/pages/loyalty_card_page.dart @@ -345,7 +345,7 @@ class _LoyaltyCardPageState extends State { ), const SizedBox(height: 8), Text( - 'Invalid barcode', + AppLocalizations.of(context)!.barcodeInvalid, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.error, ), @@ -365,7 +365,7 @@ class _LoyaltyCardPageState extends State { ), const SizedBox(height: 8), Text( - 'Cannot display barcode', + AppLocalizations.of(context)!.barcodeCannotDisplay, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.error, ), From 4887e6c2c6f868dcb714affd76f6d5d0709e76eb Mon Sep 17 00:00:00 2001 From: Edigorin Date: Thu, 12 Feb 2026 15:08:52 +0100 Subject: [PATCH 5/6] fix: improve type safety and backward compat in shopping list FAB Change featureLoyaltyCards default from true to false for backward compatibility when backend omits the field, and replace dynamic with Household type annotation. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- kitchenowl/lib/widgets/shopping_list_fab.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/kitchenowl/lib/widgets/shopping_list_fab.dart b/kitchenowl/lib/widgets/shopping_list_fab.dart index 0694b560d..98368a33f 100644 --- a/kitchenowl/lib/widgets/shopping_list_fab.dart +++ b/kitchenowl/lib/widgets/shopping_list_fab.dart @@ -5,6 +5,7 @@ import 'package:kitchenowl/app.dart'; import 'package:kitchenowl/cubits/household_cubit.dart'; import 'package:kitchenowl/cubits/shoppinglist_cubit.dart'; import 'package:kitchenowl/kitchenowl.dart'; +import 'package:kitchenowl/models/household.dart'; class ShoppingListFab extends StatelessWidget { const ShoppingListFab({super.key}); @@ -20,7 +21,7 @@ class ShoppingListFab extends StatelessWidget { // Check if loyalty cards feature is enabled final household = context.read().state.household; - final showLoyaltyCardsFab = household.featureLoyaltyCards ?? true; + final showLoyaltyCardsFab = household.featureLoyaltyCards ?? false; if (!showConfirmFab && !showLoyaltyCardsFab) { return const SizedBox(); @@ -66,7 +67,7 @@ class ShoppingListFab extends StatelessWidget { ); } - void _openLoyaltyCards(BuildContext context, dynamic household) { + void _openLoyaltyCards(BuildContext context, Household household) { context.push('/household/${household.id}/loyalty-cards'); } } From c0cd5788bc1531c7b50868c0621059d945645ff3 Mon Sep 17 00:00:00 2001 From: Edigorin Date: Thu, 12 Feb 2026 15:08:59 +0100 Subject: [PATCH 6/6] fix(backend): use save() pattern and explicit imports in loyalty card Replace db.session.add/commit with card.save() to match existing import services, switch to absolute import for LoyaltyCard model, and replace wildcard import with explicit named imports in controller init. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- backend/app/controller/__init__.py | 2 +- backend/app/service/importServices/import_loyalty_card.py | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/backend/app/controller/__init__.py b/backend/app/controller/__init__.py index e068f0e61..7169b5006 100644 --- a/backend/app/controller/__init__.py +++ b/backend/app/controller/__init__.py @@ -15,4 +15,4 @@ from .health_controller import health from .analytics import * from .report import * -from .loyalty_card import * +from .loyalty_card import loyaltyCard, loyaltyCardHousehold diff --git a/backend/app/service/importServices/import_loyalty_card.py b/backend/app/service/importServices/import_loyalty_card.py index 9f7c19155..f6d257527 100644 --- a/backend/app/service/importServices/import_loyalty_card.py +++ b/backend/app/service/importServices/import_loyalty_card.py @@ -1,5 +1,4 @@ -from ...models import LoyaltyCard -from app.config import db +from app.models import LoyaltyCard def importLoyaltyCard(household, card_data): @@ -24,5 +23,4 @@ def importLoyaltyCard(household, card_data): if "color" in card_data: card.color = card_data["color"] - db.session.add(card) - db.session.commit() + card.save()