mirror of
https://github.com/vleeuwenmenno/supplements.git
synced 2025-12-07 21:52:35 +00:00
618 lines
22 KiB
Dart
618 lines
22 KiB
Dart
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
|
|
}
|