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

@@ -3,8 +3,10 @@ import 'package:provider/provider.dart';
import 'package:uuid/uuid.dart';
import '../models/ingredient.dart';
import '../models/nutrient.dart';
import '../models/supplement.dart';
import '../providers/supplement_provider.dart';
import '../services/nutrient_data_service.dart';
// Helper class to manage ingredient text controllers
class IngredientController {
@@ -51,6 +53,10 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
final _numberOfUnitsController = TextEditingController();
final _notesController = TextEditingController();
// Nutrient data for autocomplete
final NutrientDataService _nutrientDataService = NutrientDataService();
List<Nutrient> _nutrients = [];
// Multi-ingredient support with persistent controllers
List<IngredientController> _ingredientControllers = [];

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:supplements/widgets/info_chip.dart';
import '../models/supplement.dart';
import '../providers/supplement_provider.dart';
@@ -306,13 +307,13 @@ class _ArchivedSupplementCard extends StatelessWidget {
// Dosage info
Row(
children: [
_InfoChip(
InfoChip(
icon: Icons.schedule,
label: '${supplement.frequencyPerDay}x daily',
context: context,
),
const SizedBox(width: 8),
_InfoChip(
InfoChip(
icon: Icons.medication,
label: '${supplement.numberOfUnits} ${supplement.unitType}',
context: context,
@@ -322,7 +323,7 @@ class _ArchivedSupplementCard extends StatelessWidget {
if (supplement.reminderTimes.isNotEmpty) ...[
const SizedBox(height: 8),
_InfoChip(
InfoChip(
icon: Icons.notifications_off,
label: 'Was: ${supplement.reminderTimes.join(', ')}',
context: context,
@@ -336,51 +337,3 @@ class _ArchivedSupplementCard extends StatelessWidget {
);
}
}
class _InfoChip extends StatelessWidget {
final IconData icon;
final String label;
final BuildContext context;
final bool fullWidth;
const _InfoChip({
required this.icon,
required this.label,
required this.context,
this.fullWidth = false,
});
@override
Widget build(BuildContext context) {
return Container(
width: fullWidth ? double.infinity : null,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.4),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: fullWidth ? MainAxisSize.max : MainAxisSize.min,
children: [
Icon(
icon,
size: 14,
color: Theme.of(context).colorScheme.outline,
),
const SizedBox(width: 4),
Flexible(
child: Text(
label,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.outline,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:supplements/screens/home_screen.dart';
import '../providers/settings_provider.dart';
// Profile setup screen
class ProfileSetupScreen extends StatefulWidget {
const ProfileSetupScreen({super.key});
@override
State<ProfileSetupScreen> createState() => _ProfileSetupScreenState();
}
class _ProfileSetupScreenState extends State<ProfileSetupScreen> {
final _formKey = GlobalKey<FormState>();
DateTime? _dateOfBirth;
String? _gender;
final List<String> _genders = ['Male', 'Female', 'Other', 'Prefer not to say'];
@override
void initState() {
super.initState();
final settingsProvider = Provider.of<SettingsProvider>(context, listen: false);
_dateOfBirth = settingsProvider.dateOfBirth;
_gender = settingsProvider.gender;
}
void _saveProfile() {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
Provider.of<SettingsProvider>(context, listen: false).setDateOfBirthAndGender(_dateOfBirth!, _gender!);
Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (context) => HomeScreen()));
}
}
Future<void> _selectDate(BuildContext context) async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _dateOfBirth ?? DateTime.now(),
firstDate: DateTime(1900),
lastDate: DateTime.now(),
);
if (picked != null && picked != _dateOfBirth) {
setState(() {
_dateOfBirth = picked;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Set Up Your Profile'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
children: [
Text(
'To provide you with personalized ingredient insights, please provide your date of birth and gender.',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
TextFormField(
decoration: const InputDecoration(
labelText: 'Date of Birth',
border: OutlineInputBorder(),
suffixIcon: Icon(Icons.calendar_today),
),
readOnly: true,
controller: TextEditingController(
text: _dateOfBirth == null
? ''
: '${_dateOfBirth!.toLocal()}'.split(' ')[0],
),
onTap: () => _selectDate(context),
validator: (value) {
if (_dateOfBirth == null) {
return 'Please select your date of birth';
}
return null;
},
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
decoration: const InputDecoration(
labelText: 'Gender',
border: OutlineInputBorder(),
),
value: _gender,
items: _genders.map((String gender) {
return DropdownMenuItem<String>(
value: gender,
child: Text(gender),
);
}).toList(),
onChanged: (value) {
setState(() {
_gender = value;
});
},
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please select your gender';
}
return null;
},
onSaved: (value) {
_gender = value;
},
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _saveProfile,
child: const Text('Save and Continue'),
),
],
),
),
),
);
}
}

View File

@@ -2,9 +2,8 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/settings_provider.dart';
import '../providers/supplement_provider.dart';
import '../services/notification_service.dart';
import 'pending_notifications_screen.dart';
import 'profile_setup_screen.dart';
import 'simple_sync_settings_screen.dart';
class SettingsScreen extends StatelessWidget {
@@ -21,6 +20,22 @@ class SettingsScreen extends StatelessWidget {
return ListView(
padding: const EdgeInsets.all(16.0),
children: [
Card(
child: ListTile(
leading: const Icon(Icons.person),
title: const Text('Profile'),
subtitle: Text('Date of Birth: ${settingsProvider.dateOfBirth != null ? '${settingsProvider.dateOfBirth!.toLocal()}'.split(' ')[0] : 'Not set'}, Gender: ${settingsProvider.gender ?? 'Not set'}'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const ProfileSetupScreen(),
),
);
},
),
),
const SizedBox(height: 16),
Card(
child: ListTile(
leading: const Icon(Icons.cloud_sync),

View File

@@ -1,11 +1,15 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:supplements/widgets/info_chip.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/ingredient.dart';
import '../models/supplement.dart';
import '../providers/settings_provider.dart';
import '../providers/supplement_provider.dart';
import '../providers/simple_sync_provider.dart';
import '../providers/supplement_provider.dart';
import '../services/database_sync_service.dart';
import '../services/rda_service.dart';
import '../widgets/supplement_card.dart';
import 'add_supplement_screen.dart';
import 'archived_supplements_screen.dart';
@@ -107,11 +111,162 @@ class SupplementsListScreen extends StatelessWidget {
}
Widget _buildGroupedSupplementsList(BuildContext context, List<Supplement> supplements, SettingsProvider settingsProvider) {
final provider = Provider.of<SupplementProvider>(context, listen: false);
final groupedSupplements = _groupSupplementsByTimeOfDay(supplements, settingsProvider);
return ListView(
padding: const EdgeInsets.all(16),
children: [
// Daily RDA overview header
FutureBuilder<Map<String, Map<String, dynamic>>>(
future: (() async {
if (provider.todayIntakes.isEmpty) return <String, Map<String, dynamic>>{};
final dailyItems = <Ingredient>[];
for (final intake in provider.todayIntakes) {
final supId = intake['supplement_id'] as int;
final unitsRaw = intake['unitsTaken'];
final double units = unitsRaw is int ? unitsRaw.toDouble() : (unitsRaw as double? ?? 1.0);
final matching = provider.supplements.where((s) => s.id == supId);
if (matching.isEmpty) continue;
final sup = matching.first;
for (final ing in sup.ingredients) {
dailyItems.add(ing.copyWith(amount: ing.amount * units));
}
}
if (dailyItems.isEmpty) return <String, Map<String, dynamic>>{};
final service = RdaService();
final agg = await service.aggregateDailyIntake(
dailyItems,
dateOfBirth: settingsProvider.dateOfBirth,
gender: settingsProvider.gender,
);
// Convert to plain map for UI without depending on service types
final result = <String, Map<String, dynamic>>{};
agg.forEach((key, value) {
final v = value; // dynamic
result[key] = {
'unitLabel': v.unitLabel,
'rdaValue': v.rdaValue,
'rdaValueMin': v.rdaValueMin,
'rdaValueMax': v.rdaValueMax,
'ulValue': v.ulValue,
'total': v.totalAmountInRdaUnit,
'pctRda': v.percentOfRda,
'pctUl': v.percentOfUl,
'lifeStage': v.matchedLifeStageLabel,
'lifeStageDescription': v.matchedLifeStageDescription,
'rdaType': v.rdaType,
'note': v.note,
'nutrientUl': v.nutrientUl != null
? {
'value': v.nutrientUl!.value,
'unit': v.nutrientUl!.unit,
'duration': v.nutrientUl!.duration,
'note': v.nutrientUl!.note,
}
: null,
};
});
return result;
})(),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const SizedBox.shrink();
}
final data = snapshot.data;
if (data == null || data.isEmpty) {
return const SizedBox.shrink();
}
return Card(
margin: const EdgeInsets.only(bottom: 16),
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.25),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.health_and_safety, size: 18, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
Text(
"Today's intake ",
style: Theme.of(context).textTheme.titleSmall,
),
Text(
"(Vs Recommended Dietary Allowance)",
style: Theme.of(context).textTheme.labelSmall,
),
const Spacer(),
IconButton(
tooltip: 'Sources & disclaimer',
icon: const Icon(Icons.info_outline, size: 18),
onPressed: () => _showRdaSourcesSheet(context),
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: data.entries.map((e) {
final v = e.value;
final double pct = (v['pctRda'] as double?) ?? 0.0;
final double? pctUl = v['pctUl'] as double?;
final pretty = e.key.split('_').map((w) => w.isEmpty ? w : '${w[0].toUpperCase()}${w.substring(1)}').join(' ');
Color bg = Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5);
if (pctUl != null && pctUl > 100.0) {
bg = (pctUl <= 110.0) ? Colors.amber.withOpacity(0.25) : Colors.red.withOpacity(0.25);
} else if (pct >= 100.0) {
bg = Colors.green.withOpacity(0.25);
}
final color = Theme.of(context).colorScheme.onSurfaceVariant;
return InkWell(
onTap: () {
_showRdaDetailsSheet(context, pretty, v);
},
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Theme.of(context).colorScheme.outline.withOpacity(0.3)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
pretty,
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: color),
),
const SizedBox(width: 6),
Text(
'${pct.toStringAsFixed(pct >= 10 ? 0 : 1)}%',
style: TextStyle(fontSize: 12, color: color),
),
if (pctUl != null) ...[
const SizedBox(width: 6),
Icon(
pctUl > 100.0 ? Icons.warning_amber : Icons.shield_outlined,
size: 14,
color: pctUl > 100.0
? (pctUl <= 110.0 ? Colors.amber : Colors.red)
: color,
),
],
],
),
),
);
}).toList(),
),
],
),
),
);
},
),
if (groupedSupplements['morning']!.isNotEmpty) ...[
_buildSectionHeader('Morning (${settingsProvider.morningRange})', Icons.wb_sunny, Colors.orange, groupedSupplements['morning']!.length),
...groupedSupplements['morning']!.map((supplement) =>
@@ -121,6 +276,7 @@ class SupplementsListScreen extends StatelessWidget {
onEdit: () => _editSupplement(context, supplement),
onDelete: () => _deleteSupplement(context, supplement),
onArchive: () => _archiveSupplement(context, supplement),
onDuplicate: () => context.read<SupplementProvider>().duplicateSupplement(supplement.id!),
),
),
const SizedBox(height: 16),
@@ -135,6 +291,7 @@ class SupplementsListScreen extends StatelessWidget {
onEdit: () => _editSupplement(context, supplement),
onDelete: () => _deleteSupplement(context, supplement),
onArchive: () => _archiveSupplement(context, supplement),
onDuplicate: () => context.read<SupplementProvider>().duplicateSupplement(supplement.id!),
),
),
const SizedBox(height: 16),
@@ -149,6 +306,7 @@ class SupplementsListScreen extends StatelessWidget {
onEdit: () => _editSupplement(context, supplement),
onDelete: () => _deleteSupplement(context, supplement),
onArchive: () => _archiveSupplement(context, supplement),
onDuplicate: () => context.read<SupplementProvider>().duplicateSupplement(supplement.id!),
),
),
const SizedBox(height: 16),
@@ -163,6 +321,7 @@ class SupplementsListScreen extends StatelessWidget {
onEdit: () => _editSupplement(context, supplement),
onDelete: () => _deleteSupplement(context, supplement),
onArchive: () => _archiveSupplement(context, supplement),
onDuplicate: () => context.read<SupplementProvider>().duplicateSupplement(supplement.id!),
),
),
const SizedBox(height: 16),
@@ -177,6 +336,7 @@ class SupplementsListScreen extends StatelessWidget {
onEdit: () => _editSupplement(context, supplement),
onDelete: () => _deleteSupplement(context, supplement),
onArchive: () => _archiveSupplement(context, supplement),
onDuplicate: () => context.read<SupplementProvider>().duplicateSupplement(supplement.id!),
),
),
],
@@ -273,6 +433,248 @@ class SupplementsListScreen extends StatelessWidget {
return grouped;
}
void _showRdaSourcesSheet(BuildContext context) {
showModalBottomSheet(
context: context,
showDragHandle: true,
builder: (context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Dietary reference sources',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Source: Health Canada Dietary Reference Intakes. Values are matched by your age and sex. Some ULs (e.g., magnesium) apply to supplemental intake only.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.open_in_new, size: 18),
title: const Text(
'Vitamins reference values',
style: TextStyle(fontSize: 13),
),
subtitle: const Text(
'canada.ca • reference-values-vitamins',
style: TextStyle(fontSize: 11),
),
onTap: () => _launchUrl('https://www.canada.ca/en/health-canada/services/food-nutrition/healthy-eating/dietary-reference-intakes/tables/reference-values-vitamins.html'),
),
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.open_in_new, size: 18),
title: const Text(
'Elements (minerals) reference values',
style: TextStyle(fontSize: 13),
),
subtitle: const Text(
'canada.ca • reference-values-elements',
style: TextStyle(fontSize: 11),
),
onTap: () => _launchUrl('https://www.canada.ca/en/health-canada/services/food-nutrition/healthy-eating/dietary-reference-intakes/tables/reference-values-elements.html'),
),
const SizedBox(height: 8),
Text(
'Disclaimer: Informational only, some of the details in this app are parsed using AI, and may not be accurate. Always consult a healthcare professional for personalized advice.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
);
},
);
}
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
void _showRdaDetailsSheet(BuildContext context, String nutrientPretty, Map<String, dynamic> data) {
showModalBottomSheet(
context: context,
showDragHandle: true,
isScrollControlled: false,
builder: (context) {
final unitLabel = (data['unitLabel'] as String?) ?? '';
final rdaValue = data['rdaValue'] as double?;
final ulValue = data['ulValue'] as double?;
final total = data['total'] as double?;
final pctRda = data['pctRda'] as double?;
final pctUl = data['pctUl'] as double?;
final rdaType = data['rdaType'] as String? ?? '';
final lifeStage = data['lifeStage'] as String? ?? '';
final note = data['note'] as String?;
final lifeStageDesc = data['lifeStageDescription'] as String?;
final rdaValueMin = data['rdaValueMin'] as double?;
final rdaValueMax = data['rdaValueMax'] as double?;
final nutrientUl = (data['nutrientUl'] as Map?)?.cast<String, dynamic>();
return Padding(
padding: const EdgeInsets.all(16),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Header row: title and sources button
Row(
children: [
Expanded(
child: Text(
nutrientPretty,
style: Theme.of(context).textTheme.titleLarge,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
tooltip: 'Sources & disclaimer',
icon: const Icon(Icons.info_outline),
onPressed: () => _showRdaSourcesSheet(context),
),
],
),
if (lifeStage.isNotEmpty || (lifeStageDesc != null && lifeStageDesc.isNotEmpty)) ...[
const SizedBox(height: 4),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
if (lifeStage.isNotEmpty)
InfoChip(
icon: Icons.person_outline,
label: 'Life stage: $lifeStage',
context: context,
),
if (lifeStageDesc != null && lifeStageDesc.isNotEmpty)
InfoChip(
icon: Icons.info_outline,
label: lifeStageDesc!,
context: context,
),
],
),
],
const SizedBox(height: 8),
// Intake vs RDA chips
Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (total != null)
InfoChip(
icon: Icons.local_drink,
label: 'Intake: ${total.toStringAsFixed(total % 1 == 0 ? 0 : 1)} $unitLabel',
context: context,
),
if (rdaValueMin != null && rdaValueMax != null)
InfoChip(
icon: Icons.rule,
label: 'RDA: ${rdaValueMin!.toStringAsFixed(rdaValueMin! % 1 == 0 ? 0 : 1)}${rdaValueMax!.toStringAsFixed(rdaValueMax! % 1 == 0 ? 0 : 1)} $unitLabel',
context: context,
)
else if (rdaValue != null)
InfoChip(
icon: Icons.rule,
label: 'RDA: ${rdaValue!.toStringAsFixed(rdaValue! % 1 == 0 ? 0 : 1)} $unitLabel',
context: context,
),
if (pctRda != null)
InfoChip(
icon: Icons.percent,
label: '%RDA: ${pctRda.toStringAsFixed(pctRda >= 10 ? 0 : 1)}%',
context: context,
),
if (ulValue != null)
InfoChip(
icon: Icons.shield_outlined,
label: 'UL: ${ulValue.toStringAsFixed(ulValue % 1 == 0 ? 0 : 1)} $unitLabel',
context: context,
),
if (pctUl != null)
InfoChip(
icon: Icons.warning_amber,
label: '%UL: ${pctUl.toStringAsFixed(pctUl >= 10 ? 0 : 1)}%',
context: context,
),
],
),
if (rdaType.isNotEmpty || (note != null && note!.isNotEmpty) || nutrientUl != null) ...[
const SizedBox(height: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (rdaType.isNotEmpty)
Text(
'Basis: $rdaType',
style: Theme.of(context).textTheme.bodySmall,
),
if (nutrientUl != null) ...[
const SizedBox(height: 6),
Text(
'Upper limit guidance',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 4),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
InfoChip(
icon: Icons.shield_moon_outlined,
label: 'UL: ${nutrientUl['value']} ${nutrientUl['unit']}',
context: context,
),
if ((nutrientUl['duration'] as String?)?.isNotEmpty ?? false)
InfoChip(
icon: Icons.schedule,
label: 'Duration: ${nutrientUl['duration']}',
context: context,
),
],
),
if ((nutrientUl['note'] as String?)?.isNotEmpty ?? false) ...[
const SizedBox(height: 4),
Text(
nutrientUl['note'],
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
],
if (note != null && note!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
note!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
],
),
],
],
),
),
);
},
);
}
void _showTakeDialog(BuildContext context, Supplement supplement) {
final unitsController = TextEditingController(text: supplement.numberOfUnits.toString());
final notesController = TextEditingController();