mirror of
https://github.com/vleeuwenmenno/supplements.git
synced 2025-09-11 18:29:12 +02:00
feat: adds RDA for intake of vitamins and certain elements based on
canada health values
This commit is contained in:
40
lib/services/nutrient_data_service.dart
Normal file
40
lib/services/nutrient_data_service.dart
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
}
|
617
lib/services/rda_service.dart
Normal file
617
lib/services/rda_service.dart
Normal 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
|
||||
}
|
Reference in New Issue
Block a user