mirror of
https://github.com/vleeuwenmenno/supplements.git
synced 2025-09-11 18:29:12 +02:00
notification overhaul
This commit is contained in:
@@ -1,16 +1,13 @@
|
||||
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/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 '../widgets/dialogs/take_supplement_dialog.dart';
|
||||
import 'add_supplement_screen.dart';
|
||||
import 'archived_supplements_screen.dart';
|
||||
|
||||
@@ -117,162 +114,12 @@ class SupplementsListScreen extends StatelessWidget {
|
||||
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) =>
|
||||
SupplementCard(
|
||||
supplement: supplement,
|
||||
onTake: () => _showTakeDialog(context, supplement),
|
||||
onTake: () => showTakeSupplementDialog(context, supplement),
|
||||
onEdit: () => _editSupplement(context, supplement),
|
||||
onDelete: () => _deleteSupplement(context, supplement),
|
||||
onArchive: () => _archiveSupplement(context, supplement),
|
||||
@@ -287,7 +134,7 @@ class SupplementsListScreen extends StatelessWidget {
|
||||
...groupedSupplements['afternoon']!.map((supplement) =>
|
||||
SupplementCard(
|
||||
supplement: supplement,
|
||||
onTake: () => _showTakeDialog(context, supplement),
|
||||
onTake: () => showTakeSupplementDialog(context, supplement),
|
||||
onEdit: () => _editSupplement(context, supplement),
|
||||
onDelete: () => _deleteSupplement(context, supplement),
|
||||
onArchive: () => _archiveSupplement(context, supplement),
|
||||
@@ -302,7 +149,7 @@ class SupplementsListScreen extends StatelessWidget {
|
||||
...groupedSupplements['evening']!.map((supplement) =>
|
||||
SupplementCard(
|
||||
supplement: supplement,
|
||||
onTake: () => _showTakeDialog(context, supplement),
|
||||
onTake: () => showTakeSupplementDialog(context, supplement),
|
||||
onEdit: () => _editSupplement(context, supplement),
|
||||
onDelete: () => _deleteSupplement(context, supplement),
|
||||
onArchive: () => _archiveSupplement(context, supplement),
|
||||
@@ -317,7 +164,7 @@ class SupplementsListScreen extends StatelessWidget {
|
||||
...groupedSupplements['night']!.map((supplement) =>
|
||||
SupplementCard(
|
||||
supplement: supplement,
|
||||
onTake: () => _showTakeDialog(context, supplement),
|
||||
onTake: () => showTakeSupplementDialog(context, supplement),
|
||||
onEdit: () => _editSupplement(context, supplement),
|
||||
onDelete: () => _deleteSupplement(context, supplement),
|
||||
onArchive: () => _archiveSupplement(context, supplement),
|
||||
@@ -332,7 +179,7 @@ class SupplementsListScreen extends StatelessWidget {
|
||||
...groupedSupplements['anytime']!.map((supplement) =>
|
||||
SupplementCard(
|
||||
supplement: supplement,
|
||||
onTake: () => _showTakeDialog(context, supplement),
|
||||
onTake: () => showTakeSupplementDialog(context, supplement),
|
||||
onEdit: () => _editSupplement(context, supplement),
|
||||
onDelete: () => _deleteSupplement(context, supplement),
|
||||
onArchive: () => _archiveSupplement(context, supplement),
|
||||
@@ -433,248 +280,6 @@ 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();
|
||||
|
Reference in New Issue
Block a user