import 'dart:convert'; import 'package:flutter/services.dart'; import 'package:flutter/foundation.dart'; import '../models/bean.dart'; import '../models/machine.dart'; import '../models/recipe.dart'; import '../models/drink.dart'; import '../models/journal_entry.dart'; class CsvDataService { static final CsvDataService _instance = CsvDataService._internal(); factory CsvDataService() => _instance; CsvDataService._internal(); List? _cachedBeans; List? _cachedMachines; List? _cachedRecipes; List? _cachedDrinks; List? _cachedJournalEntries; Map? _cachedOriginCountries; Future> getBeans() async { if (_cachedBeans != null) return _cachedBeans!; final csvBeans = await _loadBeansFromCsv(); _cachedBeans = [...csvBeans, ..._customBeans]; return _cachedBeans!; } Future> _loadBeansFromCsv() async { await _loadLookupData(); final csvData = await rootBundle.loadString('lib/database/Coffee_Beans.csv'); final lines = csvData.split('\n'); final headers = lines[0].split(','); final beans = []; for (int i = 1; i < lines.length; i++) { final line = lines[i].trim(); if (line.isEmpty) continue; try { final values = _parseCsvLine(line); if (values.length >= headers.length) { final bean = _createBeanFromCsv(headers, values); beans.add(bean); } } catch (e) { debugPrint('Error parsing bean line $i: $e'); } } return beans; } Future> getMachines() async { if (_cachedMachines != null) return _cachedMachines!; final csvMachines = await _loadMachinesFromCsv(); _cachedMachines = [...csvMachines, ..._customMachines]; return _cachedMachines!; } Future> _loadMachinesFromCsv() async { final csvData = await rootBundle.loadString('lib/database/Coffee_Machines.csv'); final lines = csvData.split('\n'); final headers = lines[0].split(','); final machines = []; for (int i = 1; i < lines.length; i++) { final line = lines[i].trim(); if (line.isEmpty) continue; try { final values = _parseCsvLine(line); if (values.length >= headers.length) { final machine = _createMachineFromCsv(headers, values); machines.add(machine); } } catch (e) { debugPrint('Error parsing machine line $i: $e'); } } return machines; } Future> getRecipes() async { if (_cachedRecipes != null) return _cachedRecipes!; final csvRecipes = await _loadRecipesFromCsv(); _cachedRecipes = [...csvRecipes, ..._customRecipes]; return _cachedRecipes!; } Future> _loadRecipesFromCsv() async { final csvData = await rootBundle.loadString('lib/database/Brew_Recipes.csv'); final lines = csvData.split('\n'); final headers = lines[0].split(','); final recipes = []; for (int i = 1; i < lines.length; i++) { final line = lines[i].trim(); if (line.isEmpty) continue; try { final values = _parseCsvLine(line); if (values.length >= headers.length) { final recipe = _createRecipeFromCsv(headers, values); recipes.add(recipe); } } catch (e) { debugPrint('Error parsing recipe line $i: $e'); } } return recipes; } Future> getDrinks() async { if (_cachedDrinks != null) return _cachedDrinks!; _cachedDrinks = []; // Empty for now - user will add their own return _cachedDrinks!; } Future> getJournalEntries() async { if (_cachedJournalEntries != null) return _cachedJournalEntries!; _cachedJournalEntries = []; // Empty for now - user will add their own return _cachedJournalEntries!; } Future _loadLookupData() async { if (_cachedOriginCountries != null) return; // Load Origin Countries final countriesData = await rootBundle.loadString('lib/database/Origin_Countries.csv'); final countryLines = countriesData.split('\n'); final countryHeaders = countryLines[0].split(','); _cachedOriginCountries = {}; for (int i = 1; i < countryLines.length; i++) { final line = countryLines[i].trim(); if (line.isEmpty) continue; try { final values = _parseCsvLine(line); if (values.length >= countryHeaders.length) { final country = _createOriginCountryFromCsv(countryHeaders, values); _cachedOriginCountries![country.id] = country; } } catch (e) { debugPrint('Error parsing country line $i: $e'); } } } List _parseCsvLine(String line) { final List result = []; bool inQuotes = false; String current = ''; for (int i = 0; i < line.length; i++) { final char = line[i]; if (char == '"') { inQuotes = !inQuotes; } else if (char == ',' && !inQuotes) { result.add(current.trim()); current = ''; } else { current += char; } } result.add(current.trim()); return result; } Bean _createBeanFromCsv(List headers, List values) { final Map row = {}; for (int i = 0; i < headers.length && i < values.length; i++) { row[headers[i]] = values[i]; } return Bean( id: row['id'] ?? '', name: row['name'] ?? '', origin: row['origin'] ?? '', farm: row['farm'] ?? '', producer: row['producer'] ?? '', varietal: row['varietal'] ?? '', altitude: _parseIntSafe(row['altitude']) ?? 1500, processingMethod: row['processingMethod'] ?? '', harvestSeason: row['harvestSeason'] ?? '', flavorNotes: _parseJsonList(row['flavorNotes']).map((e) => _parseTastingNote(e)).toList(), acidity: _parseAcidity(row['acidity']), body: _parseBody(row['body']), sweetness: _parseIntSafe(row['sweetness']) ?? 5, roastLevel: _parseRoastLevel(row['roastLevel']), cupScore: _parseDoubleSafe(row['cupScore']) ?? 85.0, price: _parseDoubleSafe(row['price']) ?? 15.0, availability: _parseAvailability(row['availability']), certifications: _parseJsonList(row['certifications']), roaster: row['roaster'] ?? '', roastDate: _parseDateSafe(row['roastDate']) ?? DateTime.now(), bestByDate: _parseDateSafe(row['bestByDate']) ?? DateTime.now().add(Duration(days: 365)), brewingMethods: _parseJsonList(row['brewingMethods']), isOwned: row['isOwned']?.toLowerCase() == 'true', quantity: _parseDoubleSafe(row['quantity']) ?? 0.0, notes: row['notes'] ?? '', ); } Machine _createMachineFromCsv(List headers, List values) { final Map row = {}; for (int i = 0; i < headers.length && i < values.length; i++) { row[headers[i]] = values[i]; } return Machine( id: row['id'] ?? '', manufacturer: row['manufacturer'] ?? '', model: row['model'] ?? '', year: _parseIntSafe(row['year']) ?? DateTime.now().year, type: _parseMachineType(row['type']), steamWand: row['steamWand']?.toLowerCase() == 'true', details: row['details'] ?? '', isOwned: row['isOwned']?.toLowerCase() == 'true', rating: _parseDoubleSafe(row['rating']) ?? 4.0, popularity: _parseIntSafe(row['popularity']) ?? 50, portafilters: _parsePortafilters(row['portafilters']), specifications: _parseJsonMap(row['specifications']), ); } Recipe _createRecipeFromCsv(List headers, List values) { final Map row = {}; for (int i = 0; i < headers.length && i < values.length; i++) { row[headers[i]] = values[i]; } return Recipe( id: row['id'] ?? '', name: row['name'] ?? '', servingTemp: _parseServingTemp(row['servingTemp']), milkType: _parseMilkType(row['milkType']), brewMethod: _parseBrewMethod(row['brewMethod']), grindSize: _parseGrindSize(row['grindSize']), coffeeAmount: _parseDoubleSafe(row['coffeeAmount']) ?? 0, waterAmount: _parseDoubleSafe(row['waterAmount']) ?? 0, brewTime: _parseIntSafe(row['brewTime']) ?? 0, instructions: row['instructions'] ?? '', notes: row['notes'], difficulty: _parseDifficulty(row['difficulty']), equipmentNeeded: _parseJsonList(row['equipmentNeeded']), yieldAmount: _parseDoubleSafe(row['yieldAmount']) ?? 0, caffeinePer100ml: _parseDoubleSafe(row['caffeinePer100ml']) ?? 0, waterTemperature: _parseIntSafe(row['waterTemperature']) ?? 93, bloomTime: _parseIntSafe(row['bloomTime']) ?? 30, totalExtractionTime: _parseIntSafe(row['totalExtractionTime']) ?? 240, grindToWaterRatio: row['grindToWaterRatio'] ?? '1:16', tags: _parseJsonList(row['tags']), origin: row['origin'] ?? '', rating: _parseDoubleSafe(row['rating']) ?? 4.0, popularity: _parseIntSafe(row['popularity']) ?? 50, createdBy: row['createdBy'] ?? '', isPublic: row['isPublic']?.toLowerCase() == 'true', lastModified: _parseDateSafe(row['lastModified']) ?? DateTime.now(), ); } OriginCountry _createOriginCountryFromCsv(List headers, List values) { final Map row = {}; for (int i = 0; i < headers.length && i < values.length; i++) { row[headers[i]] = values[i]; } return OriginCountry( id: row['id'] ?? '', continent: row['continent'] ?? '', avgElevation: _parseIntSafe(row['altitudeRange']?.split('-').first.replaceAll('m', '')) ?? 1500, details: row['characteristics'] ?? '', notes: row['flavorProfile'] ?? '', rating: _parseDoubleSafe(row['rating']) ?? 4.0, ); } // Parsing helper methods int? _parseIntSafe(String? value) { if (value == null || value.isEmpty) return null; return int.tryParse(value.replaceAll(RegExp(r'[^0-9]'), '')); } double? _parseDoubleSafe(String? value) { if (value == null || value.isEmpty) return null; return double.tryParse(value); } DateTime? _parseDateSafe(String? value) { if (value == null || value.isEmpty) return null; try { return DateTime.parse(value); } catch (e) { return null; } } List _parseJsonList(String? value) { if (value == null || value.isEmpty) return []; try { // Convert Python-style list to proper JSON String jsonValue = value.replaceAll("'", '"'); final decoded = json.decode(jsonValue); return List.from(decoded); } catch (e) { debugPrint('Error parsing JSON list: $e'); return []; } } TastingNotes _parseTastingNote(String note) { switch (note) { case 'Chocolate': return TastingNotes.chocolate; case 'Fruity': return TastingNotes.fruity; case 'Floral': return TastingNotes.floral; case 'Nutty': return TastingNotes.nutty; case 'Spicy': return TastingNotes.spicy; case 'Citrus': return TastingNotes.citrus; case 'Berry': return TastingNotes.berry; case 'Caramel': return TastingNotes.caramel; case 'Honey': return TastingNotes.honey; case 'Vanilla': return TastingNotes.vanilla; case 'Cocoa': return TastingNotes.cocoa; case 'Tobacco': return TastingNotes.tobacco; case 'Leather': return TastingNotes.leather; case 'Spice': return TastingNotes.spice; case 'Clove': return TastingNotes.clove; default: return TastingNotes.chocolate; } } RoastLevel _parseRoastLevel(String? value) { switch (value) { case 'Light': return RoastLevel.light; case 'Medium': return RoastLevel.medium; case 'Medium-Dark': return RoastLevel.mediumDark; case 'Dark': return RoastLevel.dark; case 'Medium-Light': return RoastLevel.mediumLight; default: return RoastLevel.medium; } } Acidity _parseAcidity(String? value) { switch (value) { case 'High': return Acidity.high; case 'Medium-High': return Acidity.mediumHigh; case 'Medium': return Acidity.medium; case 'Medium-Low': return Acidity.mediumLow; case 'Low': return Acidity.low; default: return Acidity.medium; } } Body _parseBody(String? value) { switch (value) { case 'Light': return Body.light; case 'Medium-Light': return Body.mediumLight; case 'Medium': return Body.medium; case 'Medium-Full': return Body.mediumFull; case 'Full': return Body.full; default: return Body.medium; } } Availability _parseAvailability(String? value) { switch (value) { case 'Available': return Availability.available; case 'Limited': return Availability.limited; case 'Seasonal': return Availability.seasonal; case 'Sold Out': return Availability.soldOut; default: return Availability.available; } } MachineType _parseMachineType(String? value) { switch (value) { case 'Espresso': return MachineType.espresso; case 'Drip': return MachineType.drip; case 'French Press': return MachineType.frenchPress; case 'Grinder': return MachineType.grinder; case 'Espresso Pod': return MachineType.espressoPod; case 'E61': return MachineType.e61; case 'Pod': return MachineType.pod; case 'Cold Brew': return MachineType.coldBrew; case 'Percolation': return MachineType.percolation; default: return MachineType.espresso; } } ServingTemp _parseServingTemp(String? value) { switch (value) { case 'Hot': return ServingTemp.hot; case 'Cold': return ServingTemp.cold; case 'Iced': return ServingTemp.iced; default: return ServingTemp.hot; } } MilkType? _parseMilkType(String? value) { if (value == null || value.isEmpty) return null; switch (value) { case 'Whole': return MilkType.whole; case 'Skim': return MilkType.skim; case 'Almond': return MilkType.almond; case 'Soy': return MilkType.soy; case 'Coconut': return MilkType.coconut; default: return MilkType.whole; } } BrewMethod _parseBrewMethod(String? value) { switch (value) { case 'Espresso': return BrewMethod.espresso; case 'Pour Over': return BrewMethod.pourOver; case 'French Press': return BrewMethod.frenchPress; case 'Drip': return BrewMethod.drip; default: return BrewMethod.espresso; } } GrindSize _parseGrindSize(String? value) { switch (value) { case 'Fine': return GrindSize.fine; case 'Medium': return GrindSize.medium; case 'Coarse': return GrindSize.coarse; default: return GrindSize.medium; } } Difficulty _parseDifficulty(String? value) { switch (value) { case 'Beginner': return Difficulty.beginner; case 'Intermediate': return Difficulty.intermediate; case 'Advanced': return Difficulty.advanced; default: return Difficulty.intermediate; } } List _parsePortafilters(String? value) { if (value == null || value.isEmpty) return []; try { // Convert Python-style list to proper JSON String jsonValue = value.replaceAll("'", '"'); final decoded = json.decode(jsonValue); if (decoded is List) { return decoded.map((item) => Portafilter( id: item['id'] ?? '', size: item['size'] ?? '58mm', material: item['material'] ?? 'Stainless Steel', )).toList(); } } catch (e) { debugPrint('Error parsing portafilters: $e'); } return []; } Map _parseJsonMap(String? value) { if (value == null || value.isEmpty) return {}; try { // Convert Python-style dict to proper JSON String jsonValue = value.replaceAll("'", '"'); final decoded = json.decode(jsonValue); if (decoded is Map) { return decoded; } } catch (e) { debugPrint('Error parsing JSON map: $e'); } return {}; } // Custom entries (user-created items that extend the CSV catalog) final List _customBeans = []; final List _customMachines = []; final List _customRecipes = []; // Methods for managing custom catalog entries Future saveBean(Bean bean) async { // Add to custom beans (user-created entries) final index = _customBeans.indexWhere((b) => b.id == bean.id); if (index >= 0) { _customBeans[index] = bean; } else { _customBeans.add(bean); } // Update cached beans to include custom entries if (_cachedBeans != null) { final csvBeans = await _loadBeansFromCsv(); _cachedBeans = [...csvBeans, ..._customBeans]; } } Future deleteBean(String id) async { // Only allow deletion of custom beans, not CSV beans final wasCustom = _customBeans.any((b) => b.id == id); if (wasCustom) { _customBeans.removeWhere((bean) => bean.id == id); // Update cached beans if (_cachedBeans != null) { final csvBeans = await _loadBeansFromCsv(); _cachedBeans = [...csvBeans, ..._customBeans]; } } else { throw Exception('Cannot delete catalog items. Only custom entries can be deleted.'); } } Future saveMachine(Machine machine) async { // Add to custom machines (user-created entries) final index = _customMachines.indexWhere((m) => m.id == machine.id); if (index >= 0) { _customMachines[index] = machine; } else { _customMachines.add(machine); } // Update cached machines to include custom entries if (_cachedMachines != null) { final csvMachines = await _loadMachinesFromCsv(); _cachedMachines = [...csvMachines, ..._customMachines]; } } Future deleteMachine(String id) async { // Only allow deletion of custom machines, not CSV machines final wasCustom = _customMachines.any((m) => m.id == id); if (wasCustom) { _customMachines.removeWhere((machine) => machine.id == id); // Update cached machines if (_cachedMachines != null) { final csvMachines = await _loadMachinesFromCsv(); _cachedMachines = [...csvMachines, ..._customMachines]; } } else { throw Exception('Cannot delete catalog items. Only custom entries can be deleted.'); } } Future saveRecipe(Recipe recipe) async { // Add to custom recipes (user-created entries) final index = _customRecipes.indexWhere((r) => r.id == recipe.id); if (index >= 0) { _customRecipes[index] = recipe; } else { _customRecipes.add(recipe); } // Update cached recipes to include custom entries if (_cachedRecipes != null) { final csvRecipes = await _loadRecipesFromCsv(); _cachedRecipes = [...csvRecipes, ..._customRecipes]; } } Future deleteRecipe(String id) async { // Only allow deletion of custom recipes, not CSV recipes final wasCustom = _customRecipes.any((r) => r.id == id); if (wasCustom) { _customRecipes.removeWhere((recipe) => recipe.id == id); // Update cached recipes if (_cachedRecipes != null) { final csvRecipes = await _loadRecipesFromCsv(); _cachedRecipes = [...csvRecipes, ..._customRecipes]; } } else { throw Exception('Cannot delete catalog items. Only custom entries can be deleted.'); } } Future saveDrink(Drink drink) async { // For CSV mode, add to cached list _cachedDrinks ??= []; final index = _cachedDrinks!.indexWhere((d) => d.id == drink.id); if (index >= 0) { _cachedDrinks![index] = drink; } else { _cachedDrinks!.add(drink); } } Future deleteDrink(String id) async { _cachedDrinks?.removeWhere((drink) => drink.id == id); } Future saveJournalEntry(JournalEntry entry) async { // For CSV mode, add to cached list _cachedJournalEntries ??= []; final index = _cachedJournalEntries!.indexWhere((e) => e.id == entry.id); if (index >= 0) { _cachedJournalEntries![index] = entry; } else { _cachedJournalEntries!.add(entry); } } Future deleteJournalEntry(String id) async { _cachedJournalEntries?.removeWhere((entry) => entry.id == id); } Future clearAllData() async { _cachedBeans = null; _cachedMachines = null; _cachedRecipes = null; _cachedDrinks = []; _cachedJournalEntries = []; // Clear custom entries _customBeans.clear(); _customMachines.clear(); _customRecipes.clear(); } // Helper methods to check if an item is from CSV or custom bool isCsvBean(String id) { return !_customBeans.any((bean) => bean.id == id); } bool isCsvMachine(String id) { return !_customMachines.any((machine) => machine.id == id); } bool isCsvRecipe(String id) { return !_customRecipes.any((recipe) => recipe.id == id); } // Get only custom entries List getCustomBeans() => List.from(_customBeans); List getCustomMachines() => List.from(_customMachines); List getCustomRecipes() => List.from(_customRecipes); // Get only CSV entries Future> getCsvBeans() async => await _loadBeansFromCsv(); Future> getCsvMachines() async => await _loadMachinesFromCsv(); Future> getCsvRecipes() async => await _loadRecipesFromCsv(); }