Initial commit: Sage Kitchen Management App v1.0.0
✨ Features implemented: - Smart inventory tracking with Hive database - Barcode scanning with auto-populated product info - Multiple API fallbacks (Open Food Facts, UPCItemDB) - Smart expiration date predictions by category - Discord webhook notifications (persisted) - Custom sage leaf vector icon - Material Design 3 UI with sage green theme - Privacy Policy & Terms of Service - Local-first, privacy-focused architecture 🎨 UI/UX: - Home dashboard with inventory stats - Add Item screen with barcode integration - Inventory list with expiration indicators - Settings with persistent preferences - About section with legal docs 🔧 Technical: - Flutter 3.35.5 with Riverpod state management - Hive 2.2.3 for local database - Mobile scanner for barcode detection - Feature-first architecture 🤖 Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
185
lib/features/inventory/services/barcode_service.dart
Normal file
185
lib/features/inventory/services/barcode_service.dart
Normal file
@@ -0,0 +1,185 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class BarcodeService {
|
||||
/// Lookup product info from barcode using multiple APIs
|
||||
static Future<ProductInfo?> lookupBarcode(String barcode) async {
|
||||
// Try Open Food Facts first (best for food)
|
||||
final openFoodResult = await _tryOpenFoodFacts(barcode);
|
||||
if (openFoodResult != null) return openFoodResult;
|
||||
|
||||
// Try UPCItemDB (good for vitamins, supplements, general products)
|
||||
final upcItemDbResult = await _tryUPCItemDB(barcode);
|
||||
if (upcItemDbResult != null) return upcItemDbResult;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Try Open Food Facts API
|
||||
static Future<ProductInfo?> _tryOpenFoodFacts(String barcode) async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('https://world.openfoodfacts.org/api/v0/product/$barcode.json'),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
|
||||
if (data['status'] == 1) {
|
||||
final product = data['product'];
|
||||
|
||||
return ProductInfo(
|
||||
name: product['product_name'] ?? 'Unknown Product',
|
||||
category: _extractCategory(product),
|
||||
imageUrl: product['image_url'],
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('Open Food Facts error: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Try UPCItemDB API (no key needed for limited requests)
|
||||
static Future<ProductInfo?> _tryUPCItemDB(String barcode) async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('https://api.upcitemdb.com/prod/trial/lookup?upc=$barcode'),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
|
||||
if (data['items'] != null && data['items'].isNotEmpty) {
|
||||
final item = data['items'][0];
|
||||
|
||||
return ProductInfo(
|
||||
name: item['title'] ?? 'Unknown Product',
|
||||
category: item['category'] ?? _guessCategoryFromTitle(item['title']),
|
||||
imageUrl: item['images']?[0],
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('UPCItemDB error: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Guess category from product title
|
||||
static String? _guessCategoryFromTitle(String? title) {
|
||||
if (title == null) return null;
|
||||
|
||||
final titleLower = title.toLowerCase();
|
||||
|
||||
if (titleLower.contains('vitamin') || titleLower.contains('supplement')) return 'Supplements';
|
||||
if (titleLower.contains('protein') || titleLower.contains('powder')) return 'Supplements';
|
||||
if (titleLower.contains('milk') || titleLower.contains('cheese')) return 'Dairy';
|
||||
if (titleLower.contains('sauce') || titleLower.contains('dressing')) return 'Condiments';
|
||||
if (titleLower.contains('drink') || titleLower.contains('beverage')) return 'Beverages';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? _extractCategory(Map<String, dynamic> product) {
|
||||
// Try to get category from various fields
|
||||
if (product['categories'] != null && product['categories'].toString().isNotEmpty) {
|
||||
final categories = product['categories'].toString().split(',');
|
||||
if (categories.isNotEmpty) {
|
||||
return categories.first.trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (product['food_groups'] != null) {
|
||||
return product['food_groups'].toString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get smart expiration days based on category
|
||||
static int getSmartExpirationDays(String? category) {
|
||||
if (category == null) return 7; // Default 1 week
|
||||
|
||||
final categoryLower = category.toLowerCase();
|
||||
|
||||
// Dairy products
|
||||
if (categoryLower.contains('milk') ||
|
||||
categoryLower.contains('dairy') ||
|
||||
categoryLower.contains('yogurt') ||
|
||||
categoryLower.contains('cheese')) {
|
||||
return 7; // 1 week
|
||||
}
|
||||
|
||||
// Meat and seafood
|
||||
if (categoryLower.contains('meat') ||
|
||||
categoryLower.contains('chicken') ||
|
||||
categoryLower.contains('beef') ||
|
||||
categoryLower.contains('pork') ||
|
||||
categoryLower.contains('fish') ||
|
||||
categoryLower.contains('seafood')) {
|
||||
return 3; // 3 days
|
||||
}
|
||||
|
||||
// Produce
|
||||
if (categoryLower.contains('fruit') ||
|
||||
categoryLower.contains('vegetable') ||
|
||||
categoryLower.contains('produce')) {
|
||||
return 5; // 5 days
|
||||
}
|
||||
|
||||
// Beverages
|
||||
if (categoryLower.contains('beverage') ||
|
||||
categoryLower.contains('drink') ||
|
||||
categoryLower.contains('juice') ||
|
||||
categoryLower.contains('soda')) {
|
||||
return 30; // 30 days
|
||||
}
|
||||
|
||||
// Condiments and sauces
|
||||
if (categoryLower.contains('sauce') ||
|
||||
categoryLower.contains('condiment') ||
|
||||
categoryLower.contains('dressing') ||
|
||||
categoryLower.contains('ketchup') ||
|
||||
categoryLower.contains('mustard')) {
|
||||
return 90; // 3 months
|
||||
}
|
||||
|
||||
// Canned/packaged goods
|
||||
if (categoryLower.contains('canned') ||
|
||||
categoryLower.contains('packaged') ||
|
||||
categoryLower.contains('snack')) {
|
||||
return 180; // 6 months
|
||||
}
|
||||
|
||||
// Bread and bakery
|
||||
if (categoryLower.contains('bread') ||
|
||||
categoryLower.contains('bakery') ||
|
||||
categoryLower.contains('pastry')) {
|
||||
return 5; // 5 days
|
||||
}
|
||||
|
||||
// Supplements and vitamins
|
||||
if (categoryLower.contains('supplement') ||
|
||||
categoryLower.contains('vitamin') ||
|
||||
categoryLower.contains('protein') ||
|
||||
categoryLower.contains('pill')) {
|
||||
return 365; // 1 year
|
||||
}
|
||||
|
||||
return 7; // Default 1 week
|
||||
}
|
||||
}
|
||||
|
||||
class ProductInfo {
|
||||
final String name;
|
||||
final String? category;
|
||||
final String? imageUrl;
|
||||
|
||||
ProductInfo({
|
||||
required this.name,
|
||||
this.category,
|
||||
this.imageUrl,
|
||||
});
|
||||
}
|
Reference in New Issue
Block a user