import 'dart:async'; import 'dart:math'; import '../models/ingredient.dart'; import '../models/nutrient.dart'; import 'nutrient_data_service.dart'; /// Represents an RDA/AI match for a user and nutrient class RdaResult { final String nutrientKey; final String unitLabel; // e.g., "mg/day", "µg/day (RAE)" final String rdaType; // "RDA/AI" or "AI" final double value; // RDA/AI value in the units of unitLabel final double? valueMin; // Optional minimum recommended value final double? valueMax; // Optional maximum recommended value final double? ul; // Upper limit (if provided) in the same base units as unitLabel (life-stage) final String matchedLifeStageLabel; // e.g., "19-30 y", "51-70 y" final String? lifeStageDescription; // Optional description for the life stage (e.g., maintenance/loading) final UpperLimit? nutrientUl; // Nutrient-level UL (object with unit/duration/note), if available final String? note; // Optional dataset note (e.g., magnesium UL is supplemental only) const RdaResult({ required this.nutrientKey, required this.unitLabel, required this.rdaType, required this.value, this.valueMin, this.valueMax, required this.ul, required this.matchedLifeStageLabel, this.lifeStageDescription, this.nutrientUl, this.note, }); } /// Aggregated daily overview by nutrient class RdaAggregate { final String nutrientKey; final String unitLabel; // RDA unit label final double rdaValue; // Midpoint of range when available final double? rdaValueMin; // Optional minimum recommended value final double? rdaValueMax; // Optional maximum recommended value final double? ulValue; final double totalAmountInRdaUnit; // Total intake converted to RDA units final double percentOfRda; // 0..100+ (may exceed 100) final double? percentOfUl; // 0..100+ (may exceed 100) final String? matchedLifeStageLabel; // e.g., "19-30 y" final String? matchedLifeStageDescription; // Optional description for the life stage final String? rdaType; // e.g., "RDA/AI" or "AI" final UpperLimit? nutrientUl; // Nutrient-level UL object (unit/duration/note) final String? note; // Optional dataset note const RdaAggregate({ required this.nutrientKey, required this.unitLabel, required this.rdaValue, this.rdaValueMin, this.rdaValueMax, required this.ulValue, required this.totalAmountInRdaUnit, required this.percentOfRda, required this.percentOfUl, this.matchedLifeStageLabel, this.matchedLifeStageDescription, this.rdaType, this.nutrientUl, this.note, }); } /// Service for working with Health Canada DRIs (RDA/AI and UL) /// - Maps app ingredient names to nutrient keys in canada_health.json /// - Computes user-specific RDA/AI and UL values based on age and gender /// - Converts units and calculates % of RDA/AI and % of UL class RdaService { RdaService._internal(); static final RdaService _instance = RdaService._internal(); factory RdaService() => _instance; final NutrientDataService _nutrientDataService = NutrientDataService(); // Cache nutrients by key: e.g., "vitamin_d" Map? _nutrientsByKey; // Known alias mapping for common ingredient names to nutrient keys // Keys must be lowercase for matching static const Map _aliasToNutrientKey = { // Vitamin C 'vitamin c': 'vitamin_c', 'ascorbic acid': 'vitamin_c', // Vitamin D 'vitamin d': 'vitamin_d', 'vitamin d3': 'vitamin_d', 'cholecalciferol': 'vitamin_d', 'vitamin d2': 'vitamin_d', // ergocalciferol - treat same RDA // Vitamin A (RAE) 'vitamin a': 'vitamin_a', 'retinol': 'vitamin_a', 'beta-carotene': 'vitamin_a', // Vitamin E (alpha-tocopherol) 'vitamin e': 'vitamin_e', 'alpha tocopherol': 'vitamin_e', 'alpha-tocopherol': 'vitamin_e', // Vitamin K (K1/K2 common mapping to total Vitamin K AI) 'vitamin k': 'vitamin_k', 'vitamin k1': 'vitamin_k', 'phylloquinone': 'vitamin_k', 'vitamin k2': 'vitamin_k', 'menaquinone': 'vitamin_k', // B1 (Thiamine) 'vitamin b1': 'vitamin_b1', 'thiamine': 'vitamin_b1', 'thiamin': 'vitamin_b1', // B2 (Riboflavin) 'vitamin b2': 'vitamin_b2', 'riboflavin': 'vitamin_b2', // Folate 'folate': 'folate_dfe', 'folic acid': 'folate_dfe', 'folate (dfe)': 'folate_dfe', 'dfe': 'folate_dfe', }; // RDA result and aggregate types moved to top-level (Dart doesn't support nested classes) /// Get a user-specific RDA result for a given ingredient name. /// - Resolves the ingredient to a nutrient key using aliases and simple heuristics. /// - Computes the appropriate life-stage record based on age and gender. /// /// If the ingredient doesn't map to a known nutrient or no life stage matches, /// returns null. Future getUserRdaForIngredient( String ingredientName, { DateTime? dateOfBirth, String? gender, // expected values similar to ['Male','Female','Other','Prefer not to say'] }) async { final key = await mapIngredientToNutrientKey(ingredientName); if (key == null) return null; return getUserRdaForNutrientKey( key, dateOfBirth: dateOfBirth, gender: gender, ); } /// Get a user-specific RDA result for a known nutrient key /// e.g., "vitamin_d", "vitamin_c". Future getUserRdaForNutrientKey( String nutrientKey, { DateTime? dateOfBirth, String? gender, }) async { final nutrient = await _getNutrientByKey(nutrientKey); if (nutrient == null) return null; final _UserProfile profile = _UserProfile.from(dateOfBirth: dateOfBirth, gender: gender); final LifeStage? stage = _matchLifeStageForProfile(nutrient.lifeStages, profile); if (stage == null) return null; return RdaResult( nutrientKey: nutrientKey, unitLabel: nutrient.unit, rdaType: nutrient.rdaType, value: stage.value, valueMin: stage.valueMin, valueMax: stage.valueMax, ul: stage.ul, matchedLifeStageLabel: stage.ageRange, lifeStageDescription: stage.description, nutrientUl: nutrient.ul, note: nutrient.note, ); } /// Compute % of RDA and % of UL for a single ingredient dose. /// - Resolves ingredient to nutrient key /// - Converts the amount+unit to the RDA unit base for that nutrient /// - Calculates percent of RDA and UL /// /// Returns null if the ingredient cannot be mapped or units cannot be converted. Future computePercentForDose( String ingredientName, double amount, String unit, { DateTime? dateOfBirth, String? gender, }) async { final rda = await getUserRdaForIngredient( ingredientName, dateOfBirth: dateOfBirth, gender: gender, ); if (rda == null) return null; final String rdaUnitSymbol = _unitSymbolFromLabel(rda.unitLabel); // "mg" or "ug" final String normalizedInputUnit = _normalizeUnit(unit); final double? amountInRdaUnit = _convertAmountToTargetUnit( ingredientName: ingredientName, amount: amount, fromUnit: normalizedInputUnit, toUnit: rdaUnitSymbol, ); if (amountInRdaUnit == null) return null; final double rdaForCalc = (rda.valueMin != null && rda.valueMax != null) ? ((rda.valueMin! + rda.valueMax!) / 2.0) : rda.value; final double percentOfRda = (amountInRdaUnit / rdaForCalc) * 100.0; final double? percentOfUl = rda.ul != null && rda.ul! > 0 ? (amountInRdaUnit / rda.ul!) * 100.0 : null; return RdaAggregate( nutrientKey: rda.nutrientKey, unitLabel: rda.unitLabel, rdaValue: rdaForCalc, rdaValueMin: rda.valueMin, rdaValueMax: rda.valueMax, ulValue: rda.ul, totalAmountInRdaUnit: amountInRdaUnit, percentOfRda: percentOfRda, percentOfUl: percentOfUl, matchedLifeStageLabel: rda.matchedLifeStageLabel, matchedLifeStageDescription: rda.lifeStageDescription, rdaType: rda.rdaType, nutrientUl: rda.nutrientUl, note: rda.note, ); } /// Aggregate multiple ingredients (e.g., full-day intake) into user-specific RDA overview. /// - Sums all ingredients mapped to the same nutrient /// - Converts units to the RDA base unit /// - Returns map keyed by nutrientKey Future> aggregateDailyIntake( List ingredients, { DateTime? dateOfBirth, String? gender, }) async { final Map totalsByNutrient = {}; final Map rdaByNutrient = {}; for (final ing in ingredients) { final key = await mapIngredientToNutrientKey(ing.name); if (key == null) continue; // Ensure RDA is loaded for the nutrient rdaByNutrient[key] = rdaByNutrient[key] ?? (await getUserRdaForNutrientKey(key, dateOfBirth: dateOfBirth, gender: gender))!; final rda = rdaByNutrient[key]; if (rda == null) continue; // no match for this nutrient final String rdaUnitSymbol = _unitSymbolFromLabel(rda.unitLabel); final double? converted = _convertAmountToTargetUnit( ingredientName: ing.name, amount: ing.amount, fromUnit: _normalizeUnit(ing.unit), toUnit: rdaUnitSymbol, ); if (converted == null) continue; totalsByNutrient[key] = (totalsByNutrient[key] ?? 0.0) + converted; } final Map result = {}; for (final entry in totalsByNutrient.entries) { final key = entry.key; final total = entry.value; final rda = rdaByNutrient[key]; if (rda == null) continue; final double rdaForCalc = (rda.valueMin != null && rda.valueMax != null) ? ((rda.valueMin! + rda.valueMax!) / 2.0) : rda.value; final double percentOfRda = (total / rdaForCalc) * 100.0; final double? percentOfUl = rda.ul != null && rda.ul! > 0 ? (total / rda.ul!) * 100.0 : null; result[key] = RdaAggregate( nutrientKey: key, unitLabel: rda.unitLabel, rdaValue: rdaForCalc, rdaValueMin: rda.valueMin, rdaValueMax: rda.valueMax, ulValue: rda.ul, totalAmountInRdaUnit: total, percentOfRda: percentOfRda, percentOfUl: percentOfUl, matchedLifeStageLabel: rda.matchedLifeStageLabel, matchedLifeStageDescription: rda.lifeStageDescription, rdaType: rda.rdaType, nutrientUl: rda.nutrientUl, note: rda.note, ); } return result; } /// Map an ingredient name (e.g., "Vitamin D3") to a nutrient key (e.g., "vitamin_d") used in canada_health.json /// Returns null if no mapping is found. Future mapIngredientToNutrientKey(String ingredientName) async { await _ensureNutrientsLoaded(); final String cleaned = _normalizeIngredientName(ingredientName); // Direct alias mapping final direct = _aliasToNutrientKey[cleaned]; if (direct != null && _nutrientsByKey!.containsKey(direct)) return direct; // Heuristic contains-based mapping if (cleaned.contains('vitamin d')) return _nutrientsByKey!.containsKey('vitamin_d') ? 'vitamin_d' : null; if (cleaned.contains('vitamin c')) return _nutrientsByKey!.containsKey('vitamin_c') ? 'vitamin_c' : null; if (cleaned.contains('vitamin a') || cleaned.contains('retinol') || cleaned.contains('beta carotene')) { return _nutrientsByKey!.containsKey('vitamin_a') ? 'vitamin_a' : null; } if (cleaned.contains('vitamin e') || cleaned.contains('alpha tocopherol') || cleaned.contains('alpha-tocopherol')) { return _nutrientsByKey!.containsKey('vitamin_e') ? 'vitamin_e' : null; } if (cleaned.contains('vitamin k') || cleaned.contains('phylloquinone') || cleaned.contains('menaquinone')) { return _nutrientsByKey!.containsKey('vitamin_k') ? 'vitamin_k' : null; } if (cleaned.contains('b1') || cleaned.contains('thiamin') || cleaned.contains('thiamine')) { return _nutrientsByKey!.containsKey('vitamin_b1') ? 'vitamin_b1' : null; } if (cleaned.contains('b2') || cleaned.contains('riboflavin')) { return _nutrientsByKey!.containsKey('vitamin_b2') ? 'vitamin_b2' : null; } if (cleaned.contains('folate') || cleaned.contains('folic')) { return _nutrientsByKey!.containsKey('folate_dfe') ? 'folate_dfe' : null; } if (cleaned.contains('vitamin b3') || cleaned.contains('niacin') || cleaned.contains('nicotinic acid') || cleaned.contains('niacinamide')) { return _nutrientsByKey!.containsKey('vitamin_b3') ? 'vitamin_b3' : null; } if (cleaned.contains('vitamin b5') || cleaned.contains('pantothenic')) { return _nutrientsByKey!.containsKey('vitamin_b5') ? 'vitamin_b5' : null; } if (cleaned.contains('vitamin b6') || cleaned.contains('pyridoxine')) { return _nutrientsByKey!.containsKey('vitamin_b6') ? 'vitamin_b6' : null; } if (cleaned.contains('vitamin b12') || cleaned.contains('cobalamin') || cleaned.contains('cyanocobalamin') || cleaned.contains('methylcobalamin')) { return _nutrientsByKey!.containsKey('vitamin_b12') ? 'vitamin_b12' : null; } if (cleaned.contains('magnesium')) { return _nutrientsByKey!.containsKey('magnesium') ? 'magnesium' : null; } if (cleaned.contains('zinc') || cleaned == 'zn') { return _nutrientsByKey!.containsKey('zinc') ? 'zinc' : null; } if (cleaned.contains('iron') || cleaned.contains('ferrous') || cleaned.contains('ferric')) { return _nutrientsByKey!.containsKey('iron') ? 'iron' : null; } if (cleaned.contains('creatine') || cleaned.contains('creapure') || cleaned.contains('creatine monohydrate')) { return _nutrientsByKey!.containsKey('creatine') ? 'creatine' : null; } return null; } // ----------------------- // Internal helpers // ----------------------- Future _ensureNutrientsLoaded() async { if (_nutrientsByKey != null) return; final list = await _nutrientDataService.nutrients; _nutrientsByKey = {for (final n in list) n.name: n}; } Future _getNutrientByKey(String key) async { await _ensureNutrientsLoaded(); return _nutrientsByKey![key]; } // Normalize units (user input and stored ingredients) // Supported return values: "mg", "ug", "g", "iu", others returned as lowercased original (e.g., "ml") String _normalizeUnit(String unit) { final u = unit.trim().toLowerCase(); if (u == 'mg') return 'mg'; if (u == 'g' || u == 'gram' || u == 'grams') return 'g'; if (u == 'µg' || u == 'μg' || u == 'mcg' || u == 'ug' || u == 'microgram' || u == 'micrograms') return 'ug'; if (u == 'iu') return 'iu'; return u; // e.g., "ml", "drops" etc. (unhandled for RDA calc) } // Extract the base unit symbol ("mg" or "ug") from the dataset unit label (e.g., "µg/day (RAE)") String _unitSymbolFromLabel(String label) { final lower = label.toLowerCase(); if (lower.startsWith('mg')) return 'mg'; if (lower.startsWith('g')) return 'g'; if (lower.startsWith('µg') || lower.startsWith('μg') || lower.startsWith('mcg')) return 'ug'; // Fallback: assume microgram if unknown return 'ug'; } // Convert an amount from one unit to another. // Supported: // - mg <-> ug <-> g // - IU->ug for Vitamin D only (1 µg = 40 IU) // Returns null if conversion cannot be performed. double? _convertAmountToTargetUnit({ required String ingredientName, required double amount, required String fromUnit, required String toUnit, }) { if (amount.isNaN || amount.isInfinite) return null; // Handle IU conversions only for Vitamin D final name = _normalizeIngredientName(ingredientName); final isVitaminD = name.contains('vitamin d') || name.contains('cholecalciferol') || name.contains('ergocalciferol'); // If fromUnit equals toUnit and it's one of our supported numeric units if ((fromUnit == toUnit) && (fromUnit == 'mg' || fromUnit == 'ug' || fromUnit == 'g')) { return amount; } // IU -> ug for Vitamin D if (fromUnit == 'iu' && isVitaminD) { // 1 µg = 40 IU => ug = IU / 40 final ug = amount / 40.0; if (toUnit == 'ug') return ug; if (toUnit == 'mg') return ug / 1000.0; if (toUnit == 'g') return ug / 1e6; return null; } // Mass conversions double? inUg; if (fromUnit == 'ug') { inUg = amount; } else if (fromUnit == 'mg') { inUg = amount * 1000.0; } else if (fromUnit == 'g') { inUg = amount * 1e6; } else { // Unsupported unit (e.g., ml, drops) return null; } if (toUnit == 'ug') return inUg; if (toUnit == 'mg') return inUg / 1000.0; if (toUnit == 'g') return inUg / 1e6; return null; } // Normalize an ingredient name for alias matching String _normalizeIngredientName(String name) { final lower = name.trim().toLowerCase(); // Replace common punctuation with spaces, then condense final replaced = lower .replaceAll(RegExp(r'[\(\)\[\]\{\},;:+/_-]+'), ' ') .replaceAll(RegExp(r'\s+'), ' ') .trim(); return replaced; } // ----------------------- // Life stage matching // ----------------------- LifeStage? _matchLifeStageForProfile(List stages, _UserProfile profile) { // Exclude pregnancy/lactation when we don't track that state yet final filtered = stages.where((s) { final l = s.ageRange.toLowerCase(); return !(l.contains('pregnancy') || l.contains('lactation')); }).toList(); // Try in order: // 1) Exact age match + exact sex final exactSexMatch = filtered.where((s) => _matchesAge(s.ageRange, profile) && _matchesSex(s.sex, profile)).toList(); if (exactSexMatch.isNotEmpty) return exactSexMatch.first; // 2) Age match + sex == 'both' final bothMatch = filtered.where((s) => _matchesAge(s.ageRange, profile) && s.sex.toLowerCase() == 'both').toList(); if (bothMatch.isNotEmpty) return bothMatch.first; // 3) Age match ignoring sex (fallback) final ageOnly = filtered.where((s) => _matchesAge(s.ageRange, profile)).toList(); if (ageOnly.isNotEmpty) return ageOnly.first; // 4) If nothing matches, try 'adult-like' fallback: pick a reasonable adult range final adultFallback = filtered.where((s) => s.ageRange.contains('19-30') || s.ageRange.contains('31-50')).toList(); if (adultFallback.isNotEmpty) return adultFallback.first; // 5) Any entry as last resort return filtered.isNotEmpty ? filtered.first : null; } bool _matchesSex(String stageSex, _UserProfile profile) { final s = stageSex.toLowerCase(); if (s == 'both') return true; if (profile.isInfant && s == 'infant') return true; if (profile.sex == _Sex.male && s == 'male') return true; if (profile.sex == _Sex.female && s == 'female') return true; // For 'Other' or 'Prefer not to say', accept 'both' if (profile.sex == _Sex.unknown && s == 'both') return true; return false; } bool _matchesAge(String ageRange, _UserProfile profile) { final ar = ageRange.toLowerCase().trim(); // Common shorthand: "adult" (assume >= 18 years) if (ar == 'adult' || ar == 'adults') { return profile.ageYears >= 18; } // Months range: e.g., "0-6 mo" or "7-12 mo" final moMatch = RegExp(r'^(\d+)\s*-\s*(\d+)\s*mo$').firstMatch(ar); if (moMatch != null) { if (!profile.isInfant) return false; final minMo = int.parse(moMatch.group(1)!); final maxMo = int.parse(moMatch.group(2)!); return profile.ageMonths >= minMo && profile.ageMonths <= maxMo; } // Years range: e.g., "1-3 y", "4-8 y", "9-13 y", "14-18 y", "19-30 y", "31-50 y", "51-70 y" final yearRange = RegExp(r'^(\d+)\s*-\s*(\d+)\s*y$').firstMatch(ar); if (yearRange != null) { final minY = int.parse(yearRange.group(1)!); final maxY = int.parse(yearRange.group(2)!); return profile.ageYears >= minY && profile.ageYears <= maxY; } // Greater than: e.g., ">70 y" final gtYear = RegExp(r'^>\s*(\d+)\s*y$').firstMatch(ar); if (gtYear != null) { final minExclusive = int.parse(gtYear.group(1)!); return profile.ageYears > minExclusive; } // "infant" buckets handled via months range above // Any unknown format: do a best-effort fallback if (profile.isInfant) { // If the stage mentions "infant", accept if (ar.contains('infant')) return true; // Else, if it starts at 0-? y, let's accept if upper bound >= 0 (rare) if (ar.contains('0-') && ar.contains('y')) return true; return false; } // If we are adult and stage is one of the adult ranges not following the patterns, // just return false to avoid false positives. return false; } } /// Internal representation of user profile for matching class _UserProfile { final int ageYears; // rounded down, e.g., 29 final int ageMonths; // total months (for infants). If >= 12, consider non-infant. final _Sex sex; bool get isInfant => ageYears < 1; _UserProfile({ required this.ageYears, required this.ageMonths, required this.sex, }); factory _UserProfile.from({DateTime? dateOfBirth, String? gender}) { final now = DateTime.now(); int years; int monthsTotal; if (dateOfBirth == null) { // Default to adult 30 years old when unknown years = 30; monthsTotal = 30 * 12; } else { years = now.year - dateOfBirth.year; final beforeBirthday = (now.month < dateOfBirth.month) || (now.month == dateOfBirth.month && now.day < dateOfBirth.day); if (beforeBirthday) years = max(0, years - 1); // Calculate total months difference int months = (now.year - dateOfBirth.year) * 12 + (now.month - dateOfBirth.month); if (now.day < dateOfBirth.day) { months = max(0, months - 1); } monthsTotal = max(0, months); } final s = _parseSex(gender); return _UserProfile( ageYears: years, ageMonths: monthsTotal, sex: s, ); } } enum _Sex { male, female, unknown } _Sex _parseSex(String? gender) { if (gender == null) return _Sex.unknown; final g = gender.trim().toLowerCase(); if (g == 'male') return _Sex.male; if (g == 'female') return _Sex.female; return _Sex.unknown; // 'Other', 'Prefer not to say' -> unknown }