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..7169b5006 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 loyaltyCard, loyaltyCardHousehold 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..5e3a664eb --- /dev/null +++ b/backend/app/controller/loyalty_card/loyalty_card_controller.py @@ -0,0 +1,85 @@ +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 .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.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"] + 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"] + + 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..4873f7420 --- /dev/null +++ b/backend/app/controller/loyalty_card/schemas.py @@ -0,0 +1,19 @@ +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(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) + + +class UpdateLoyaltyCard(Schema): + name = 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) + + 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..c2ca8bddb 100644 --- a/backend/app/models/file.py +++ b/backend/app/models/file.py @@ -66,7 +66,6 @@ class File(Model, DbModelAuthorizeMixin): uselist=False, ), ) - def delete(self): """ Delete this instance of model from db 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..7fe19250e --- /dev/null +++ b/backend/app/models/loyalty_card.py @@ -0,0 +1,46 @@ +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 + 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 | 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) + 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, + ), + ) + + 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..f6d257527 --- /dev/null +++ b/backend/app/service/importServices/import_loyalty_card.py @@ -0,0 +1,26 @@ +from app.models import LoyaltyCard + + +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"] + + card.save() diff --git a/backend/migrations/versions/a1b2c3d4e5f6_loyalty_cards.py b/backend/migrations/versions/a1b2c3d4e5f6_loyalty_cards.py new file mode 100644 index 000000000..573dc10ca --- /dev/null +++ b/backend/migrations/versions/a1b2c3d4e5f6_loyalty_cards.py @@ -0,0 +1,50 @@ +"""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=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('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.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..7f70c4761 --- /dev/null +++ b/backend/tests/api/test_api_loyalty_card.py @@ -0,0 +1,293 @@ +"""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_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 + ): + """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 name (required field) + incomplete_data = { + "barcode_type": "CODE128", + "barcode_data": "1234567890", + } + 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/l10n/app_en.arb b/kitchenowl/lib/l10n/app_en.arb index da7de80f5..32b4d1a2d 100644 --- a/kitchenowl/lib/l10n/app_en.arb +++ b/kitchenowl/lib/l10n/app_en.arb @@ -57,8 +57,15 @@ "description": "The title of the app" }, "@automaticIngredientDetection": {}, + "@autoDetected": {}, "@back": {}, "@balances": {}, + "@barcodeData": {}, + "@barcodeCannotDisplay": {}, + "@barcodeDataHint": {}, + "@barcodeDetected": {}, + "@barcodeInvalid": {}, + "@barcodeType": {}, "@budget": {}, "@budgetSet": {}, "@camera": {}, @@ -87,11 +94,13 @@ }, "@changeIcon": {}, "@clear": {}, + "@color": {}, "@colorSelect": {}, "@compact": {}, "@confirm": {}, "@cookingTime": {}, "@copied": {}, + "@copyBarcode": {}, "@daily": {}, "@dangerZone": {}, "@darkmode": {}, @@ -103,6 +112,7 @@ "@discover": {}, "@done": {}, "@dynamicAccentColor": {}, + "@edit": {}, "@email": {}, "@emailInvalid": {}, "@emailNotVerified": {}, @@ -110,6 +120,7 @@ "@emailSuccessfullyVerified": {}, "@emailUpdate": {}, "@emailUsed": {}, + "@enterManually": {}, "@error": {}, "@excludeFromStatistics": {}, "@expense": {}, @@ -143,6 +154,9 @@ } } }, + "@fieldRequired": {}, + "@flashOff": {}, + "@flashOn": {}, "@forceOfflineMode": {}, "@gallery": {}, "@general": {}, @@ -215,6 +229,7 @@ }, "@itemsOptional": {}, "@itemsRecent": {}, + "@keepScreenOn": {}, "@language": {}, "@languageSelect": {}, "@larger": {}, @@ -255,6 +270,20 @@ }, "@longPressToReorder": {}, "@longerThanExpected": {}, + "@loyaltyCardAdd": {}, + "@loyaltyCardDeleteConfirmation": { + "placeholders": { + "name": { + "example": "Target", + "type": "String" + } + } + }, + "@loyaltyCardEdit": {}, + "@loyaltyCardNameHint": {}, + "@loyaltyCards": {}, + "@loyaltyCardsDescription": {}, + "@loyaltyCardsEmpty": {}, "@markAsPaid": {}, "@mealPlanner": {}, "@member": {}, @@ -268,6 +297,7 @@ "@more": {}, "@name": {}, "@next": {}, + "@noBarcodesFound": {}, "@noTags": {}, "@none": {}, "@now": {}, @@ -314,6 +344,7 @@ "@planner": {}, "@plannerEmpty": {}, "@plannerTitle": {}, + "@pointCameraAtBarcode": {}, "@preparationTime": {}, "@privacyPolicy": {}, "@privacyPolicyAgree": { @@ -387,8 +418,12 @@ "@reportUser": {}, "@reset": {}, "@save": {}, + "@scanBarcode": {}, + "@scanFromCamera": {}, + "@scanFromGallery": {}, "@search": {}, "@searchHint": {}, + "@select": {}, "@send": {}, "@server": {}, "@serverChange": {}, @@ -436,6 +471,7 @@ "@swipeToDelete": {}, "@swipeToDeleteAndLongPressToReorder": {}, "@swipeToRemove": {}, + "@switchCamera": {}, "@tagDelete": {}, "@tagDeleteConfirmation": { "placeholders": { @@ -499,6 +535,7 @@ "@usernameInvalid": {}, "@usernameUnavailable": {}, "@users": {}, + "@webcam": {}, "@website": {}, "@weekly": {}, "@welcomeBack": {}, @@ -535,10 +572,17 @@ "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", "cards": "Cards", @@ -551,11 +595,13 @@ "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", "cookingTime": "Cooking time", "copied": "Copied", + "copyBarcode": "Copy barcode", "daily": "Daily", "dangerZone": "Danger Zone", "darkmode": "Dark mode", @@ -567,6 +613,7 @@ "discover": "Discover", "done": "Done", "dynamicAccentColor": "Dynamic accent color", + "edit": "Edit", "email": "Email", "emailInvalid": "Invalid email", "emailNotVerified": "Not verified", @@ -574,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", @@ -593,6 +641,9 @@ "export": "Export", "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", @@ -626,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", @@ -648,6 +700,13 @@ "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", + "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", "member": "Member", @@ -661,6 +720,7 @@ "more": "More", "name": "Name", "next": "Next", + "noBarcodesFound": "No barcodes found in image", "noTags": "No tags", "none": "None", "now": "Now", @@ -689,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}", @@ -730,8 +791,12 @@ "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", "send": "Send", "server": "Server", "serverChange": "Switch server", @@ -759,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", @@ -799,6 +865,7 @@ "usernameInvalid": "Username cannot contain '@'", "usernameUnavailable": "The username is unavailable", "users": "Users", + "webcam": "Webcam", "website": "Website", "weekly": "Weekly", "welcomeBack": "Welcome back", diff --git a/kitchenowl/lib/models/household.dart b/kitchenowl/lib/models/household.dart index 48285121b..04aa97153 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'], 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..9b7d891d3 --- /dev/null +++ b/kitchenowl/lib/models/loyalty_card.dart @@ -0,0 +1,74 @@ +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 int? color; + + const LoyaltyCard({ + this.id, + this.name = '', + this.barcodeType, + this.barcodeData, + this.description, + this.color, + }); + + factory LoyaltyCard.fromJson(Map map) { + return LoyaltyCard( + id: map['id'], + name: map['name'] ?? '', + barcodeType: map['barcode_type'], + barcodeData: map['barcode_data'], + description: map['description'], + color: map['color'], + ); + } + + LoyaltyCard copyWith({ + String? name, + String? barcodeType, + String? barcodeData, + String? description, + int? color, + }) => + LoyaltyCard( + id: id, + name: name ?? this.name, + barcodeType: barcodeType ?? this.barcodeType, + barcodeData: barcodeData ?? this.barcodeData, + description: description ?? this.description, + color: color ?? this.color, + ); + + @override + List get props => [ + id, + name, + barcodeType, + barcodeData, + description, + color, + ]; + + @override + Map toJson() { + return { + "name": name, + if (barcodeType != null) "barcode_type": barcodeType, + if (barcodeData != null) "barcode_data": barcodeData, + if (description != null) "description": description, + if (color != null) "color": color, + }; + } + + @override + Map toJsonWithId() => toJson() + ..addAll({ + "id": id, + }); +} + 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..9036c8de7 --- /dev/null +++ b/kitchenowl/lib/pages/loyalty_card_add_update_page.dart @@ -0,0 +1,537 @@ +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/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 { + await _scanFromGalleryMobile(); + } catch (e) { + if (mounted) { + _showNoBarcodeError(); + } + } finally { + if (mounted) { + setState(() { + _isScanning = false; + }); + } + } + } + + 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), + 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), + 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, + 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; + + setState(() { + _isLoading = true; + }); + + try { + final loyaltyCard = LoyaltyCard( + id: widget.loyaltyCard?.id, + name: _nameController.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(), + 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..88790abc1 --- /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( + AppLocalizations.of(context)!.loyaltyCardsDescription, + 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..21106fa9f --- /dev/null +++ b/kitchenowl/lib/pages/loyalty_card_page.dart @@ -0,0 +1,471 @@ +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 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, + 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) => [ + 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( + 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: [ + if (hasBarcode) ...[ + // 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), + ), + ], + ), + ), + ), + ] else ...[ + // No barcode - show store icon + Icon( + Icons.store_rounded, + size: 64, + color: contrastColor.withOpacity(0.5), + ), + ], + ], + ), + ), + ), + ), + ), + + // 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) { + if (loyaltyCard.barcodeType == null || loyaltyCard.barcodeData == null) { + return const SizedBox.shrink(); + } + 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( + AppLocalizations.of(context)!.barcodeInvalid, + 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( + AppLocalizations.of(context)!.barcodeCannotDisplay, + 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) { + if (loyaltyCard.barcodeData == null) return; + 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..dd48170b2 --- /dev/null +++ b/kitchenowl/lib/widgets/loyalty_card_item.dart @@ -0,0 +1,200 @@ +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); + final hasBarcode = loyaltyCard.barcodeData != null && loyaltyCard.barcodeData!.isNotEmpty; + + 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), + 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), + ), + ), + ), + 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), + ), + ], + ], + ), + ), + ), + ), + ), + ); + } + + 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 ?? 'CODE128'); + 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..98368a33f --- /dev/null +++ b/kitchenowl/lib/widgets/shopping_list_fab.dart @@ -0,0 +1,74 @@ +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'; +import 'package:kitchenowl/models/household.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 ?? false; + + 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, Household 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: