CoffeeAtHome/lib/services/csv_data_service.dart
2026-03-29 08:13:38 -07:00

730 lines
22 KiB
Dart

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<Bean>? _cachedBeans;
List<Machine>? _cachedMachines;
List<Recipe>? _cachedRecipes;
List<Drink>? _cachedDrinks;
List<JournalEntry>? _cachedJournalEntries;
Map<String, OriginCountry>? _cachedOriginCountries;
Future<List<Bean>> getBeans() async {
if (_cachedBeans != null) return _cachedBeans!;
final csvBeans = await _loadBeansFromCsv();
_cachedBeans = [...csvBeans, ..._customBeans];
return _cachedBeans!;
}
Future<List<Bean>> _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 = <Bean>[];
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<List<Machine>> getMachines() async {
if (_cachedMachines != null) return _cachedMachines!;
final csvMachines = await _loadMachinesFromCsv();
_cachedMachines = [...csvMachines, ..._customMachines];
return _cachedMachines!;
}
Future<List<Machine>> _loadMachinesFromCsv() async {
final csvData = await rootBundle.loadString('lib/database/Coffee_Machines.csv');
final lines = csvData.split('\n');
final headers = lines[0].split(',');
final machines = <Machine>[];
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<List<Recipe>> getRecipes() async {
if (_cachedRecipes != null) return _cachedRecipes!;
final csvRecipes = await _loadRecipesFromCsv();
_cachedRecipes = [...csvRecipes, ..._customRecipes];
return _cachedRecipes!;
}
Future<List<Recipe>> _loadRecipesFromCsv() async {
final csvData = await rootBundle.loadString('lib/database/Brew_Recipes.csv');
final lines = csvData.split('\n');
final headers = lines[0].split(',');
final recipes = <Recipe>[];
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<List<Drink>> getDrinks() async {
if (_cachedDrinks != null) return _cachedDrinks!;
_cachedDrinks = []; // Empty for now - user will add their own
return _cachedDrinks!;
}
Future<List<JournalEntry>> getJournalEntries() async {
if (_cachedJournalEntries != null) return _cachedJournalEntries!;
_cachedJournalEntries = []; // Empty for now - user will add their own
return _cachedJournalEntries!;
}
Future<void> _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<String> _parseCsvLine(String line) {
final List<String> 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<String> headers, List<String> values) {
final Map<String, String> 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<String> headers, List<String> values) {
final Map<String, String> 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<String> headers, List<String> values) {
final Map<String, String> 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<String> headers, List<String> values) {
final Map<String, String> 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<String> _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<String>.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<Portafilter> _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<String, dynamic> _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<String, dynamic>) {
return decoded;
}
} catch (e) {
debugPrint('Error parsing JSON map: $e');
}
return {};
}
// Custom entries (user-created items that extend the CSV catalog)
final List<Bean> _customBeans = [];
final List<Machine> _customMachines = [];
final List<Recipe> _customRecipes = [];
// Methods for managing custom catalog entries
Future<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> deleteDrink(String id) async {
_cachedDrinks?.removeWhere((drink) => drink.id == id);
}
Future<void> 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<void> deleteJournalEntry(String id) async {
_cachedJournalEntries?.removeWhere((entry) => entry.id == id);
}
Future<void> 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<Bean> getCustomBeans() => List.from(_customBeans);
List<Machine> getCustomMachines() => List.from(_customMachines);
List<Recipe> getCustomRecipes() => List.from(_customRecipes);
// Get only CSV entries
Future<List<Bean>> getCsvBeans() async => await _loadBeansFromCsv();
Future<List<Machine>> getCsvMachines() async => await _loadMachinesFromCsv();
Future<List<Recipe>> getCsvRecipes() async => await _loadRecipesFromCsv();
}