personal project · 2025

Scadoo

Mobile app for intelligent pantry management: barcode scanning, expiry tracking, push notifications, and a shareable shopping list. Offline-first. Zero backend.

In Development Flutter · Dart SQLite Open Food Facts iOS · Android
zsh — scadoo
$flutter run --release
Building Scadoo (release)...
Connected to iPhone 15 Pro
Running Scadoo v1.0.0+1
_
BARCODE
8001505005592
Open Food Facts ✓
OFFLINE
SQLite local
0 server required
PREZZO
min €1.29
avg €1.74 · max €2.10
Hey! 👋
Wednesday, May 28
🌿
12
Total
3
Expiring
1
Expired
Expiring soon
Greek Yogurt
Chobani · Fridge · 2 pcs
Expired
Whole Milk
Parmalat · Fridge · 1 pc
2 days
Mozzarella
Galbani · Fridge · 3 pcs
3 days
🏠
📦
📷
🛒
⚙️
0
packages
0
screens
0
platforms
servers needed
Platform
iOS · Android
Framework
Flutter 3.x / Dart
Database
SQLite (sqflite)
State
Provider + ChangeNotifier
Product API
Open Food Facts
Notifications
flutter_local_notifications
// 01 — overview

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.

// 02 — features

What the app does

📷
Quick Scan
Scan barcode → API lookup → pre-filled form. Product in pantry in 2 taps.
🌐
Open Food Facts
Italian endpoint first, then worldwide fallback. Fetches name, brand, category, image.
⏱️
Expiry Tracking
Color coding: green (ok) → yellow (7 days) → orange (3 days) → red (expired).
📦
Separate Locations
Fridge, Pantry and Freezer — filterable from the inventory screen.
🔔
Smart Notifications
Daily digest at 9:00 AM with expiry summary. No server required.
🛒
Shopping List
Check, reorder, add manually. Share via Share Sheet in one tap.
Quantity Control
+/− directly on the card. Quantity hits 0 → product removed automatically.
📴
Offline-first
Everything on local SQLite. Works without connection. API is optional.
// 03 — architecture

Code structure

The code is organized in layers with clear responsibilities. Each layer depends only on the one below.

lib/ ├── main.dart ← init, Provider setup, bottom nav ├── theme/ │ └── app_theme.dart ← colors, typography, expiryColor() ├── models/ ← pure data, no Flutter dependency │ ├── product.dart ← daysToExpiry, isExpired, toMap/fromMap │ └── shopping_item.dart ├── services/ ← I/O, network, system — no UI │ ├── database_service.dart ← SQLite singleton, CRUD │ ├── notification_service.dart ← schedule, daily digest │ └── openfoodfacts_service.dart ├── providers/ ← global state, calls services, notifies UI │ ├── inventory_provider.dart │ └── shopping_list_provider.dart ├── screens/ ← one screen = one file │ ├── home_screen.dart │ ├── inventory_screen.dart │ ├── scanner_screen.dart │ ├── add_product_screen.dart │ └── shopping_list_screen.dart └── widgets/ └── product_card.dart ← swipe, quantity control, expiry badge
// 04 — tech stack

Dependencies & Rationale

📷
mobile_scanner
Best-performing barcode scanner for Flutter. Handles focus, torch, and custom overlay via CustomPainter.
🗄️
sqflite + path
Native SQLite, 100% offline. No cloud required. Two tables: products and shopping_items.
🔔
flutter_local_notifications
Locally scheduled notifications without server. Daily digest at 9:00 AM + immediate on open.
🌐
http
REST calls to Open Food Facts API. Italian endpoint first, then worldwide fallback.
🧩
provider
ChangeNotifier for InventoryProvider and ShoppingListProvider. Simple, readable, no codegen.
↔️
flutter_slidable
Swipe-to-delete and swipe-to-edit on product cards. Native mobile UX.
📤
share_plus
Share shopping list via OS share sheet (WhatsApp, iMessage, etc.) without backend.
🆔
uuid
Unique IDs for products and shopping items. Prevents collisions in SQLite.
// 05 — key code

Expiry logic in the model

Expiry logic is encapsulated directly in the model, with no Flutter dependencies. Easy to test and reuse.

Dart lib/models/product.dart
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.

Dart lib/services/openfoodfacts_service.dart
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);
}
// 06 — challenges

Problems encountered

Click each problem to see the solution adopted.

Accidental double scan from barcode scanner
The onDetect callback is called multiple times for the same barcode in milliseconds. Solution: boolean flag _processing that blocks subsequent callbacks until navigation completes.
Expiry day calculation: timezone and midnight
Using 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.
Open Food Facts: variable quality of Italian data
Many Italian products have 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.
Notifications: recalculate after every pantry change
Every add, edit or delete requires recalculating scheduled notifications. Solution: cancelAll() + reschedule inside loadProducts() of the provider, so the cycle is automatic.
SQLite on Flutter: database path between iOS and Android
Database path differs between iOS and Android. Solution: use sqflite's getDatabasesPath() combined with path.join() to build the correct path on both platforms.
// 07 — roadmap

Progress

Progress 11 / 15 completed
Architecture models / services / providers / screensDone
Barcode scanner with mobile_scannerDone
Open Food Facts integration (IT + World endpoint)Done
Local SQLite database with full CRUDDone
Local push notifications (immediate + daily digest)Done
Shopping list with Share Sheet sharingDone
Location filter (Fridge / Pantry / Freezer)Done
Duplicate barcode detection + manual entryDone
100+ Italian product categories (Open Food Facts)Done
Add to pantry directly from shopping listDone
Price history with min / max / average statisticsDone
iOS home screen widget with upcoming expiriesNext
Waste statistics: food expired per monthNext
Recipe suggestions based on expiring productsNext
App Store release (iOS only)Next
Back to projects