glp-companion/lib/features/meal_planner/domain/entities/recipe_ingredient.dart

144 lines
4.9 KiB
Dart

/// 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<String> _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<String, Object?> toMap() => <String, Object?>{
'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<String, Object?> 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)';
}