/// A single ingredient line from a recipe with best-effort structured /// parsing of quantity, unit, and item name. /// /// Pure Dart (no framework imports). class RecipeIngredient { const RecipeIngredient({ required this.rawText, required this.parsedItemName, this.quantity, this.unit, }); /// Unit vocabulary recognized during parsing (lowercase). Anything else /// stays part of [parsedItemName] (e.g. "1 large egg" keeps "large egg"). static const Set _knownUnits = { 'cup', 'cups', 'tbsp', 'tbsps', 'tablespoon', 'tablespoons', 'tablespoonful', 'tablespoonfuls', 'tsp', 'tsps', 'teaspoon', 'teaspoons', 'teaspoonful', 'teaspoonfuls', 'ounce', 'ounces', 'oz', 'pound', 'pounds', 'lb', 'lbs', 'gram', 'grams', 'g', 'kilogram', 'kilograms', 'kg', 'milligram', 'milligrams', 'mg', 'milliliter', 'milliliters', 'millilitre', 'millilitres', 'ml', 'liter', 'liters', 'litre', 'litres', 'l', 'pinch', 'pinches', 'dash', 'dashes', 'clove', 'cloves', 'slice', 'slices', 'piece', 'pieces', 'can', 'cans', 'bottle', 'bottles', 'jar', 'jars', 'package', 'packages', 'pack', 'bag', 'bags', 'bunch', 'bunches', 'sprig', 'sprigs', 'head', 'heads', 'stick', 'sticks', 'cube', 'cubes', 'dozen', 'bowl', 'bowls', 'handful', 'handfuls', 'serving', 'servings', 'rib', 'ribs', 'stalk', 'stalks', }; static final RegExp _numberToken = RegExp(r'^\d+(?:\.\d+)?$'); static final RegExp _fractionToken = RegExp(r'^(\d+)/(\d+)$'); /// The original (trimmed) ingredient line exactly as published. final String rawText; /// Remaining text after quantity/unit extraction, e.g. `chicken breasts`. final String parsedItemName; /// Leading numeric quantity (`1.5`, `0.25`, mixed `1 1/2` → `1.5`). final double? quantity; /// Lowercased unit word when recognized (e.g. `cup`, `tsp`). final String? unit; /// Splits [rawText] into quantity / unit / item name. /// /// Handles decimals (`2.5`), ASCII fractions (`1/2`), mixed numbers /// (`1 1/2 cups`), and unicode fractions (`1½`), and only consumes a unit /// token when a leading quantity was present and the token belongs to /// [_knownUnits]. factory RecipeIngredient.parse(String rawText) { // Normalize unicode fractions to plain decimal tokens first, so // "1½ cup sugar" becomes "1 0.5 cup sugar" and sums to 1.5. var normalized = rawText .trim() .replaceAll('¼', ' 0.25 ') .replaceAll('½', ' 0.5 ') .replaceAll('¾', ' 0.75 '); normalized = normalized.replaceAll(RegExp(r'\s+'), ' ').trim(); if (normalized.isEmpty) { return RecipeIngredient(rawText: rawText.trim(), parsedItemName: ''); } final tokens = normalized.split(' '); var index = 0; double? quantity; if (_numberToken.hasMatch(tokens[index])) { quantity = double.parse(tokens[index]); index++; // Mixed number: "1 1/2" — or the normalized "1 0.5" of "1½". if (index < tokens.length) { final fraction = _fractionToken.firstMatch(tokens[index]); if (fraction != null) { quantity += _fractionValue(fraction); index++; } else if (_numberToken.hasMatch(tokens[index]) && quantity == quantity.roundToDouble()) { quantity += double.parse(tokens[index]); index++; } } } else { final fraction = _fractionToken.firstMatch(tokens[index]); if (fraction != null) { quantity = _fractionValue(fraction); index++; } } String? unit; if (quantity != null && index < tokens.length) { final candidate = tokens[index].toLowerCase(); if (_knownUnits.contains(candidate)) { unit = candidate; index++; } } return RecipeIngredient( rawText: rawText.trim(), parsedItemName: tokens.sublist(index).join(' '), quantity: quantity, unit: unit, ); } static double _fractionValue(Match match) { final numerator = double.tryParse(match.group(1) ?? '') ?? 0; final denominator = double.tryParse(match.group(2) ?? '') ?? 1; return denominator == 0 ? 0 : numerator / denominator; } /// Serialization helper for the `rawIngredientsJson` persistence column. Map toMap() => { 'rawText': rawText, 'parsedItemName': parsedItemName, 'quantity': quantity, 'unit': unit, }; /// Deserializes a map produced by [toMap]; malformed entries fall back to /// the raw text only. factory RecipeIngredient.fromMap(Map map) { final rawText = map['rawText']?.toString() ?? ''; return RecipeIngredient( rawText: rawText, parsedItemName: map['parsedItemName']?.toString() ?? rawText, quantity: (map['quantity'] as num?)?.toDouble(), unit: map['unit']?.toString(), ); } @override String toString() => 'RecipeIngredient($rawText → qty $quantity ${unit ?? ''} ' '$parsedItemName)'; }