feat: adds RDA for intake of vitamins and certain elements based on

canada health values
This commit is contained in:
2025-08-28 15:29:20 +02:00
parent 6524e625d8
commit 31e04fe260
24 changed files with 2542 additions and 369 deletions

View File

@@ -0,0 +1,40 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import '../models/nutrient.dart';
class NutrientDataService {
static final NutrientDataService _instance = NutrientDataService._internal();
factory NutrientDataService() {
return _instance;
}
NutrientDataService._internal();
List<Nutrient>? _nutrients;
Future<List<Nutrient>> get nutrients async {
if (_nutrients != null) {
return _nutrients!;
}
await _loadNutrientData();
return _nutrients!;
}
Future<void> _loadNutrientData() async {
try {
final String response = await rootBundle.loadString('assets/canada_health.json');
final data = await json.decode(response);
final nutrientsData = data['nutrients'] as Map<String, dynamic>;
_nutrients = nutrientsData.entries.map((entry) {
return Nutrient.fromJson(entry.key, entry.value);
}).toList();
} catch (e) {
print('Error loading nutrient data: $e');
_nutrients = [];
}
}
}

View File

@@ -0,0 +1,617 @@
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<String, Nutrient>? _nutrientsByKey;
// Known alias mapping for common ingredient names to nutrient keys
// Keys must be lowercase for matching
static const Map<String, String> _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<RdaResult?> 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<RdaResult?> 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<RdaAggregate?> 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<Map<String, RdaAggregate>> aggregateDailyIntake(
List<Ingredient> ingredients, {
DateTime? dateOfBirth,
String? gender,
}) async {
final Map<String, double> totalsByNutrient = {};
final Map<String, RdaResult> 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<String, RdaAggregate> 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<String?> 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<void> _ensureNutrientsLoaded() async {
if (_nutrientsByKey != null) return;
final list = await _nutrientDataService.nutrients;
_nutrientsByKey = {for (final n in list) n.name: n};
}
Future<Nutrient?> _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<LifeStage> 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
}