feat: Add loyalty cards feature#945
Conversation
|
Love this, hope to see it in! |
TomBursch
left a comment
There was a problem hiding this comment.
Hey, thanks for the PR. Because this is such a huge change, I didn't have time until now to review it. The code looks really good, and I'm happy to merge it.
I just have some minor questions:
Why does the loyalty model store a photo but it's never set to anything and not used? I also can't upload a loyalty card without a barcode.
And why do you implement the barcode scanning on the web yourself? Why not use the web functionality of the mobile scanner? https://pub.dev/packages/mobile_scanner#web
There was a problem hiding this comment.
Pull request overview
Adds a full “loyalty cards” feature across backend + Flutter client, including storage, CRUD APIs, import support, and UI flows for listing, viewing, creating/editing, and scanning barcodes from camera/gallery.
Changes:
- Backend: introduce
LoyaltyCardmodel, migration, CRUD endpoints, import integration, and API tests. - Frontend: add loyalty card pages/widgets, routing, FAB entrypoint from shopping list, and barcode rendering/scanning dependencies.
- Localization + platform permissions updates to support barcode scanning UX on Android/iOS.
Reviewed changes
Copilot reviewed 40 out of 41 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| kitchenowl/pubspec.yaml | Adds barcode rendering/scanning dependencies. |
| kitchenowl/lib/widgets/shopping_list_fab.dart | Replaces shopping-list FAB behavior and adds wallet FAB entrypoint. |
| kitchenowl/lib/router.dart | Adds routes for loyalty card list + detail pages. |
| kitchenowl/lib/pages/loyalty_card_list_page.dart | Implements loyalty cards list UI and navigation into details. |
| kitchenowl/lib/pages/loyalty_card_page.dart | Implements loyalty card detail view with fullscreen barcode display and actions. |
| kitchenowl/lib/pages/loyalty_card_add_update_page.dart | Implements add/edit flow including scanning from camera/gallery. |
| kitchenowl/lib/pages/barcode_scanner_page.dart | New camera barcode scanner page using mobile_scanner. |
| kitchenowl/lib/models/loyalty_card.dart | Frontend model for loyalty cards + JSON serialization. |
| kitchenowl/lib/models/household.dart | Adds household feature flag field for loyalty cards. |
| kitchenowl/lib/l10n/app_en.arb | Adds localization strings for loyalty card/scanning UI. |
| backend/app/models/loyalty_card.py | New backend DB model and household relationship. |
| backend/app/controller/loyalty_card/loyalty_card_controller.py | New loyalty card CRUD endpoints. |
| backend/migrations/versions/a1b2c3d4e5f6_loyalty_cards.py | Adds loyalty_card table and loyalty_cards_feature column. |
| backend/app/service/importServices/import_loyalty_card.py | Adds loyalty card import helper. |
| backend/tests/api/test_api_loyalty_card.py | Adds API test coverage for loyalty cards endpoints. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| "you": "you", | ||
| "loyaltyCards": "Cards", | ||
| "@loyaltyCards": {}, | ||
| "loyaltyCardsEmpty": "No loyalty cards yet. Add one to get started!", | ||
| "@loyaltyCardsEmpty": {}, | ||
| "loyaltyCardAdd": "Add card", | ||
| "@loyaltyCardAdd": {}, | ||
| "loyaltyCardEdit": "Edit card", | ||
| "@loyaltyCardEdit": {}, | ||
| "loyaltyCardNameHint": "e.g., Target, Costco", | ||
| "@loyaltyCardNameHint": {}, | ||
| "loyaltyCardDeleteConfirmation": "Are you sure you want to delete {name}?", | ||
| "@loyaltyCardDeleteConfirmation": { | ||
| "placeholders": { | ||
| "name": { | ||
| "example": "Target", | ||
| "type": "String" | ||
| } | ||
| } | ||
| }, | ||
| "barcodeData": "Barcode data", | ||
| "@barcodeData": {}, | ||
| "barcodeDataHint": "Enter barcode number", | ||
| "@barcodeDataHint": {}, | ||
| "barcodeType": "Barcode type", | ||
| "@barcodeType": {}, | ||
| "copyBarcode": "Copy barcode", | ||
| "@copyBarcode": {}, | ||
| "scanBarcode": "Scan barcode", | ||
| "@scanBarcode": {}, | ||
| "scanFromCamera": "Scan with camera", | ||
| "@scanFromCamera": {}, | ||
| "scanFromGallery": "Import from image", | ||
| "@scanFromGallery": {}, | ||
| "pointCameraAtBarcode": "Point camera at barcode", | ||
| "@pointCameraAtBarcode": {}, | ||
| "barcodeDetected": "Barcode detected", | ||
| "@barcodeDetected": {}, | ||
| "autoDetected": "auto-detected", | ||
| "@autoDetected": {}, | ||
| "enterManually": "Enter manually", | ||
| "@enterManually": {}, | ||
| "flashOn": "Flash on", | ||
| "@flashOn": {}, | ||
| "flashOff": "Flash off", | ||
| "@flashOff": {}, | ||
| "switchCamera": "Switch camera", | ||
| "@switchCamera": {}, | ||
| "noBarcodesFound": "No barcodes found in image", | ||
| "@noBarcodesFound": {}, | ||
| "webcam": "Webcam", | ||
| "@webcam": {}, | ||
| "camera": "Camera", | ||
| "@camera": {}, | ||
| "gallery": "Gallery", | ||
| "@gallery": {}, | ||
| "keepScreenOn": "Screen stays on for scanning", | ||
| "@keepScreenOn": {} |
There was a problem hiding this comment.
app_en.arb currently defines several localization keys twice (e.g., loyaltyCards, loyaltyCardsEmpty, loyaltyCardAdd, barcodeData, camera, gallery, etc.). ARB/JSON with duplicate keys is invalid and whichever key appears last will override earlier translations (here it also changes values like loyaltyCards from "Loyalty Cards" to "Cards"). Remove the duplicated block appended near the end of the file and keep a single canonical definition per key (with its @... metadata).
| "you": "you", | |
| "loyaltyCards": "Cards", | |
| "@loyaltyCards": {}, | |
| "loyaltyCardsEmpty": "No loyalty cards yet. Add one to get started!", | |
| "@loyaltyCardsEmpty": {}, | |
| "loyaltyCardAdd": "Add card", | |
| "@loyaltyCardAdd": {}, | |
| "loyaltyCardEdit": "Edit card", | |
| "@loyaltyCardEdit": {}, | |
| "loyaltyCardNameHint": "e.g., Target, Costco", | |
| "@loyaltyCardNameHint": {}, | |
| "loyaltyCardDeleteConfirmation": "Are you sure you want to delete {name}?", | |
| "@loyaltyCardDeleteConfirmation": { | |
| "placeholders": { | |
| "name": { | |
| "example": "Target", | |
| "type": "String" | |
| } | |
| } | |
| }, | |
| "barcodeData": "Barcode data", | |
| "@barcodeData": {}, | |
| "barcodeDataHint": "Enter barcode number", | |
| "@barcodeDataHint": {}, | |
| "barcodeType": "Barcode type", | |
| "@barcodeType": {}, | |
| "copyBarcode": "Copy barcode", | |
| "@copyBarcode": {}, | |
| "scanBarcode": "Scan barcode", | |
| "@scanBarcode": {}, | |
| "scanFromCamera": "Scan with camera", | |
| "@scanFromCamera": {}, | |
| "scanFromGallery": "Import from image", | |
| "@scanFromGallery": {}, | |
| "pointCameraAtBarcode": "Point camera at barcode", | |
| "@pointCameraAtBarcode": {}, | |
| "barcodeDetected": "Barcode detected", | |
| "@barcodeDetected": {}, | |
| "autoDetected": "auto-detected", | |
| "@autoDetected": {}, | |
| "enterManually": "Enter manually", | |
| "@enterManually": {}, | |
| "flashOn": "Flash on", | |
| "@flashOn": {}, | |
| "flashOff": "Flash off", | |
| "@flashOff": {}, | |
| "switchCamera": "Switch camera", | |
| "@switchCamera": {}, | |
| "noBarcodesFound": "No barcodes found in image", | |
| "@noBarcodesFound": {}, | |
| "webcam": "Webcam", | |
| "@webcam": {}, | |
| "camera": "Camera", | |
| "@camera": {}, | |
| "gallery": "Gallery", | |
| "@gallery": {}, | |
| "keepScreenOn": "Screen stays on for scanning", | |
| "@keepScreenOn": {} | |
| "you": "you" |
| // Check if loyalty cards feature is enabled | ||
| final household = context.read<HouseholdCubit>().state.household; | ||
| final showLoyaltyCardsFab = household.featureLoyaltyCards ?? true; | ||
|
|
There was a problem hiding this comment.
showLoyaltyCardsFab defaults to true when featureLoyaltyCards is null. Since Household.fromJson leaves featureLoyaltyCards null when the backend doesn’t send the flag, this can surface the loyalty-cards UI against older backends that don’t support the endpoints. Consider defaulting to false when the flag is missing (or explicitly gate on server capability) to preserve backward compatibility.
| void _openLoyaltyCards(BuildContext context, dynamic household) { | ||
| context.push('/household/${household.id}/loyalty-cards'); | ||
| } |
There was a problem hiding this comment.
_openLoyaltyCards takes dynamic household, even though callers pass a Household. Using dynamic here removes type-safety and can hide runtime errors (e.g., missing id). Prefer Household household as the parameter type.
| Text( | ||
| 'Store all your loyalty cards in one place and access them quickly at checkout.', | ||
| style: Theme.of(context).textTheme.bodyMedium?.copyWith( | ||
| color: Theme.of(context).colorScheme.onSurfaceVariant, | ||
| ), | ||
| textAlign: TextAlign.center, | ||
| ), |
There was a problem hiding this comment.
This empty-state description string is hardcoded in English and bypasses localization. Please move it into the ARB files and use AppLocalizations so it’s translated consistently with the rest of the app.
| Text( | ||
| 'Invalid barcode', | ||
| style: Theme.of(context).textTheme.bodySmall?.copyWith( | ||
| color: Theme.of(context).colorScheme.error, | ||
| ), | ||
| ), |
There was a problem hiding this comment.
These error messages are hardcoded ('Invalid barcode') instead of being localized via AppLocalizations. Please add localization keys and use them here so the barcode error UI is translated.
| Text( | ||
| 'Cannot display barcode', | ||
| style: Theme.of(context).textTheme.bodySmall?.copyWith( | ||
| color: Theme.of(context).colorScheme.error, | ||
| ), | ||
| ), |
There was a problem hiding this comment.
This error message ('Cannot display barcode') is hardcoded instead of being localized via AppLocalizations. Please add a localization key and use it here.
| from ...models import LoyaltyCard | ||
| from app.config import db | ||
|
|
||
|
|
||
| def importLoyaltyCard(household, card_data): | ||
| card = LoyaltyCard.query.filter_by( | ||
| household_id=household.id, name=card_data["name"] | ||
| ).first() | ||
|
|
||
| if not card: | ||
| card = LoyaltyCard() | ||
| card.household_id = household.id | ||
| card.name = card_data["name"] | ||
|
|
||
| if "barcode_data" in card_data: | ||
| card.barcode_data = card_data["barcode_data"] | ||
|
|
||
| if "barcode_type" in card_data: | ||
| card.barcode_type = card_data["barcode_type"] | ||
|
|
||
| if "description" in card_data: | ||
| card.description = card_data["description"] | ||
|
|
||
| if "color" in card_data: | ||
| card.color = card_data["color"] | ||
|
|
||
| db.session.add(card) | ||
| db.session.commit() |
There was a problem hiding this comment.
This import service commits directly via db.session.commit() for each card and bypasses the model’s save() pattern used by other import services (e.g., importItem/importExpense). Prefer using card.save() (and avoid committing per-record inside the helper) for consistency and to reduce transaction overhead when importing many cards.
| from .health_controller import health | ||
| from .analytics import * | ||
| from .report import * | ||
| from .loyalty_card import * |
There was a problem hiding this comment.
Import pollutes the enclosing namespace, as the imported module app.controller.loyalty_card does not define 'all'.
| from .loyalty_card import * | |
| from . import loyalty_card |
Add support for loyalty cards/membership cards that users can store and access from the shopping list page via a wallet FAB. Backend: - Add LoyaltyCard model with name, barcode data/type, description, color - Add CRUD API endpoints for loyalty cards - Add database migration for loyalty_cards table - Add household feature flag for loyalty cards - Add loyalty card import functionality - Add comprehensive API tests Frontend: - Add loyalty cards list page with search functionality - Add loyalty card detail page with fullscreen barcode display - Add loyalty card add/edit page with barcode scanning - Add barcode scanning from camera (mobile) and webcam (web) - Add barcode scanning from gallery/image upload - Add wallet FAB on shopping list page to access loyalty cards - Support multiple barcode types: CODE128, QR, EAN13, etc. - Add localization strings for loyalty cards feature - Add loyalty cards import settings option Closes TomBursch#462
- Remove redundant dart:typed_data import (Uint8List already provided by flutter/foundation.dart) - Fix featureLoyaltyCards deserialization to preserve null values (fixes failing test)
…onal, remove custom web scanner - Remove photo/photo_file column from loyalty card model, migration, schemas, and controller - Remove photo-related relationships from File model - Make barcode_type and barcode_data nullable in model, migration, and schemas - Allow creating loyalty cards without a barcode (frontend validation removed) - Conditionally render barcode UI only when barcode data is present - Delete custom web barcode scanner (web_barcode_scanner.dart, barcode_scanner.js) - Remove html5-qrcode CDN script from index.html - Hide gallery scan button on web (analyzeImage not supported) - Use mobile_scanner's built-in web support for camera scanning - Add test for creating loyalty card without barcode
Remove 57-line duplicate block in app_en.arb, add 14 new loyalty card value keys with metadata, and localize 3 hardcoded strings in loyalty_card_page.dart and loyalty_card_list_page.dart. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Change featureLoyaltyCards default from true to false for backward compatibility when backend omits the field, and replace dynamic with Household type annotation. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Replace db.session.add/commit with card.save() to match existing import services, switch to absolute import for LoyaltyCard model, and replace wildcard import with explicit named imports in controller init. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
ffc8cf9 to
c0cd578
Compare
|
I would really like this feature, so I would like to ask if there is a chance that this PR will be merged in the near future? |
Summary
Add support for loyalty cards/membership cards that users can store and quickly access from the shopping list page via a wallet FAB.
Closes #462
Changes
Backend
LoyaltyCardmodel with name, barcode data/type, description, color/api/household/{id}/loyalty-card)loyalty_cardstableFrontend
Testing
backend/tests/api/test_api_loyalty_card.pywith comprehensive API tests