Why Scadoo exists
The project was born from a daily problem: food expiring unseen, forgotten shopping lists, and no simple way to track how many boxes of pasta are left in the pantry. Existing apps either charge a subscription or have cluttered interfaces.
Scadoo's goal is to do a few things, do them well, and do them fast: scan a product, the app fetches name and brand from Open Food Facts, add the expiry date and you're done. Two taps. Fully offline.
The shopping list is designed for family use: one button shares the list via WhatsApp or iMessage, formatted cleanly and ready to use at the supermarket.
What the app does
Code structure
The code is organized in layers with clear responsibilities. Each layer depends only on the one below.
Dependencies & Rationale
Expiry logic in the model
Expiry logic is encapsulated directly in the model, with no Flutter dependencies. Easy to test and reuse.
class Product { final DateTime? expiryDate; int get daysToExpiry { if (expiryDate == null) return 9999; final today = DateTime(DateTime.now().year, DateTime.now().month, DateTime.now().day); final expiry = DateTime(expiryDate!.year, expiryDate!.month, expiryDate!.day); return expiry.difference(today).inDays; } bool get isExpired => expiryDate != null && daysToExpiry < 0; bool get isExpiringSoon => expiryDate != null && daysToExpiry <= 3; bool get isExpiringThisWeek => expiryDate != null && daysToExpiry <= 7; }
Open Food Facts Integration
The service tries the Italian endpoint first (more complete local products), then falls back to worldwide. The response is normalized into an OpenFoodFactsResult object independent of the API JSON.
Future<OpenFoodFactsResult> fetchProduct(String barcode) async { for (final base in [ 'https://it.openfoodfacts.org', 'https://world.openfoodfacts.org', ]) { final result = await _fetch('$base/api/v0/product/$barcode.json'); if (result.found) return result; } return const OpenFoodFactsResult(found: false); }
Problems encountered
Click each problem to see the solution adopted.
onDetect callback is called multiple times for the same barcode in milliseconds. Solution: boolean flag _processing that blocks subsequent callbacks until navigation completes.DateTime.now().difference(expiryDate) in hours can give wrong results near midnight. Solution: normalize both dates to DateTime(year, month, day) before calculating the difference in whole days.product_name in English or empty. Solution: fallback cascade — product_name_it → product_name → generic_name_it → generic_name. If all empty, the barcode is shown and the user enters the name manually.cancelAll() + reschedule inside loadProducts() of the provider, so the cycle is automatic.getDatabasesPath() combined with path.join() to build the correct path on both platforms.