From 31e04fe260953eae96a59af0a20f94f1c6929eb8 Mon Sep 17 00:00:00 2001 From: Menno van Leeuwen Date: Thu, 28 Aug 2025 15:29:20 +0200 Subject: [PATCH] feat: adds RDA for intake of vitamins and certain elements based on canada health values --- WEBDAV_SYNC_IMPLEMENTATION.md | 287 ------ assets/canada_health.json | 896 ++++++++++++++++++ lib/data/rda_data.dart | 71 ++ lib/main.dart | 29 +- lib/models/nutrient.dart | 87 ++ lib/models/supplement.dart | 17 +- lib/providers/settings_provider.dart | 34 + lib/providers/supplement_provider.dart | 39 + lib/screens/add_supplement_screen.dart | 6 + lib/screens/archived_supplements_screen.dart | 55 +- lib/screens/profile_setup_screen.dart | 129 +++ lib/screens/settings_screen.dart | 19 +- lib/screens/supplements_list_screen.dart | 404 +++++++- lib/services/nutrient_data_service.dart | 40 + lib/services/rda_service.dart | 617 ++++++++++++ lib/widgets/info_chip.dart | 49 + lib/widgets/supplement_card.dart | 53 +- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 64 ++ pubspec.yaml | 4 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 24 files changed, 2542 insertions(+), 369 deletions(-) delete mode 100644 WEBDAV_SYNC_IMPLEMENTATION.md create mode 100644 assets/canada_health.json create mode 100644 lib/data/rda_data.dart create mode 100644 lib/models/nutrient.dart create mode 100644 lib/screens/profile_setup_screen.dart create mode 100644 lib/services/nutrient_data_service.dart create mode 100644 lib/services/rda_service.dart create mode 100644 lib/widgets/info_chip.dart diff --git a/WEBDAV_SYNC_IMPLEMENTATION.md b/WEBDAV_SYNC_IMPLEMENTATION.md deleted file mode 100644 index c90a50c..0000000 --- a/WEBDAV_SYNC_IMPLEMENTATION.md +++ /dev/null @@ -1,287 +0,0 @@ -# WebDAV Cloud Sync Implementation - -## Overview - -This document describes the WebDAV cloud sync implementation for the Supplements Tracker Flutter app. The implementation allows users to synchronize their supplement data across multiple devices using WebDAV-compatible servers like Nextcloud, ownCloud, or any standard WebDAV server. - -## Architecture - -### Core Components - -#### 1. Data Models Enhanced with Sync Metadata - -All core data models (`Supplement`, `SupplementIntake`, `Ingredient`) have been enhanced with sync metadata: - -- `syncId: String` - Unique identifier for sync operations (UUID) -- `lastModified: DateTime` - Timestamp of last modification -- `syncStatus: SyncStatus` - Current sync state (pending, synced, modified, conflict, etc.) -- `isDeleted: bool` - Soft delete flag for sync purposes - -#### 2. Sync Enumerations (`lib/models/sync_enums.dart`) - -- `SyncStatus` - Track individual record sync states -- `SyncOperationStatus` - Overall sync operation states -- `ConflictResolutionStrategy` - How to handle conflicts -- `SyncFrequency` - Auto-sync timing options -- `ConflictType` - Types of sync conflicts - -#### 3. Sync Data Models (`lib/models/sync_data.dart`) - -- `SyncData` - Complete data structure for WebDAV JSON format -- `SyncConflict` - Represents conflicts between local and remote data -- `SyncResult` - Results and statistics from sync operations -- `SyncStatistics` - Detailed sync operation metrics - -#### 4. WebDAV Sync Service (`lib/services/webdav_sync_service.dart`) - -Core service handling WebDAV communication: -- Server configuration and authentication -- Data upload/download operations -- Conflict detection and basic resolution -- Network connectivity checking -- Device identification - -#### 5. Sync Provider (`lib/providers/sync_provider.dart`) - -State management layer for sync operations: -- Manages sync status and progress -- Handles user configuration -- Coordinates between WebDAV service and UI -- Manages conflict resolution workflow - -#### 6. UI Components - -- `SyncSettingsScreen` - Complete WebDAV configuration interface -- Integration with existing settings screen -- Real-time sync status indicators -- Conflict resolution dialogs - -### Database Schema Changes - -The database has been upgraded to version 6 with sync support: - -```sql --- New sync columns added to existing tables -ALTER TABLE supplements ADD COLUMN syncId TEXT NOT NULL UNIQUE; -ALTER TABLE supplements ADD COLUMN lastModified TEXT NOT NULL; -ALTER TABLE supplements ADD COLUMN syncStatus TEXT NOT NULL DEFAULT 'pending'; -ALTER TABLE supplements ADD COLUMN isDeleted INTEGER NOT NULL DEFAULT 0; - --- Similar columns added to supplement_intakes table - --- New sync metadata table -CREATE TABLE sync_metadata ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - key TEXT NOT NULL UNIQUE, - value TEXT NOT NULL, - lastUpdated TEXT NOT NULL -); - --- Device info table for multi-device conflict resolution -CREATE TABLE device_info ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - deviceId TEXT NOT NULL UNIQUE, - deviceName TEXT NOT NULL, - lastSyncTime TEXT, - createdAt TEXT NOT NULL -); -``` - -## Sync Process Flow - -### 1. Configuration Phase -1. User enters WebDAV server URL, username, and password/app password -2. System tests connection and validates credentials -3. Creates sync directory on server if needed -4. Stores encrypted credentials locally using `flutter_secure_storage` - -### 2. Sync Operation -1. Check network connectivity -2. Download remote sync data (JSON file from WebDAV) -3. Compare with local data using timestamps and sync IDs -4. Detect conflicts (modification, deletion, creation conflicts) -5. Merge data according to conflict resolution strategy -6. Upload merged data back to server -7. Update local sync status - -### 3. Conflict Resolution -- **Last-write-wins**: Prefer most recently modified record -- **Prefer Local**: Always keep local changes -- **Prefer Remote**: Always keep remote changes -- **Manual**: Present conflicts to user for individual resolution - -## Security Considerations - -### Data Protection -- Credentials stored using `flutter_secure_storage` with platform encryption -- Support for app passwords instead of main account passwords -- No sensitive data logged in release builds - -### Network Security -- HTTPS enforced for WebDAV connections -- Connection testing before storing credentials -- Proper error handling for authentication failures - -## Supported WebDAV Servers - -### Tested Compatibility -- **Nextcloud** - Full compatibility with automatic path detection -- **ownCloud** - Full compatibility with automatic path detection -- **Generic WebDAV** - Manual path configuration required - -### Server Requirements -- WebDAV protocol support -- File read/write permissions -- Directory creation permissions -- Basic or digest authentication - -## Data Synchronization Format - -### JSON Structure -```json -{ - "version": 1, - "deviceId": "device_12345_67890", - "deviceName": "John's Phone", - "syncTimestamp": "2024-01-15T10:30:00.000Z", - "supplements": [ - { - "syncId": "uuid-supplement-1", - "name": "Vitamin D3", - "ingredients": [...], - "lastModified": "2024-01-15T09:15:00.000Z", - "syncStatus": "synced", - "isDeleted": false, - // ... other supplement fields - } - ], - "intakes": [ - { - "syncId": "uuid-intake-1", - "supplementId": 1, - "takenAt": "2024-01-15T08:00:00.000Z", - "lastModified": "2024-01-15T08:00:30.000Z", - "syncStatus": "synced", - "isDeleted": false, - // ... other intake fields - } - ], - "metadata": { - "totalSupplements": 5, - "totalIntakes": 150 - } -} -``` - -## Usage Instructions - -### Initial Setup -1. Navigate to Settings → Cloud Sync -2. Enter your WebDAV server details: - - Server URL (e.g., `https://cloud.example.com`) - - Username - - Password or app password - - Optional device name -3. Test connection -4. Configure sync preferences - -### Sync Options -- **Manual Sync**: Sync on demand via button press -- **Auto Sync**: Automatic background sync at configured intervals - - Every 15 minutes - - Hourly - - Every 6 hours - - Daily - -### Conflict Resolution -- Configure preferred resolution strategy in settings -- Review individual conflicts when they occur -- Auto-resolve based on chosen strategy - -## Implementation Status - -### ✅ Completed (Phase 1) -- Core sync architecture and data models -- WebDAV service implementation -- Database schema with sync support -- Basic UI for configuration and management -- Manual sync operations -- Conflict detection -- Secure credential storage - -### 🔄 Future Enhancements (Phase 2+) -- Background sync scheduling -- Advanced conflict resolution UI -- Sync history and logs -- Client-side encryption option -- Multiple server support -- Bandwidth optimization -- Offline queue management - -## Dependencies Added - -```yaml -dependencies: - webdav_client: ^1.2.2 # WebDAV protocol client - connectivity_plus: ^6.0.5 # Network connectivity checking - flutter_secure_storage: ^9.2.2 # Secure credential storage - uuid: ^4.5.0 # UUID generation for sync IDs - crypto: ^3.0.5 # Data integrity verification -``` - -## File Structure - -``` -lib/ -├── models/ -│ ├── sync_enums.dart # Sync-related enumerations -│ ├── sync_data.dart # Sync data models -│ ├── supplement.dart # Enhanced with sync metadata -│ ├── supplement_intake.dart # Enhanced with sync metadata -│ └── ingredient.dart # Enhanced with sync metadata -├── services/ -│ ├── webdav_sync_service.dart # WebDAV communication -│ └── database_helper.dart # Enhanced with sync methods -├── providers/ -│ └── sync_provider.dart # Sync state management -└── screens/ - └── sync_settings_screen.dart # Sync configuration UI -``` - -## Error Handling - -The implementation includes comprehensive error handling for: -- Network connectivity issues -- Authentication failures -- Server unavailability -- Malformed sync data -- Storage permission issues -- Conflicting concurrent modifications - -## Testing Recommendations - -### Unit Tests -- Sync data serialization/deserialization -- Conflict detection algorithms -- Merge logic validation - -### Integration Tests -- WebDAV server communication -- Database sync operations -- Cross-device sync scenarios - -### User Testing -- Multi-device sync workflows -- Network interruption recovery -- Large dataset synchronization -- Conflict resolution user experience - -## Performance Considerations - -- Incremental sync (only changed data) -- Compression for large datasets -- Connection timeout handling -- Memory-efficient JSON processing -- Background processing for large operations - -This implementation provides a solid foundation for cloud synchronization while maintaining data integrity and user experience across multiple devices. diff --git a/assets/canada_health.json b/assets/canada_health.json new file mode 100644 index 0000000..2367ef5 --- /dev/null +++ b/assets/canada_health.json @@ -0,0 +1,896 @@ +{ + "source": "Health Canada Dietary Reference Intakes (vitamins & elements)", + "note": "Values come from Health Canada DRI tables. rda_type denotes whether the value is an RDA or an AI as published.", + "nutrients": { + "vitamin_a": { + "unit": "µg/day (RAE)", + "rda_type": "RDA/AI", + "life_stages": [ + { "age_range": "0-6 mo", "sex": "infant", "value": 400, "ul": 600 }, + { "age_range": "7-12 mo", "sex": "infant", "value": 500, "ul": 600 }, + { "age_range": "1-3 y", "sex": "both", "value": 300, "ul": 600 }, + { "age_range": "4-8 y", "sex": "both", "value": 400, "ul": 900 }, + { "age_range": "9-13 y", "sex": "male", "value": 600, "ul": 1700 }, + { "age_range": "9-13 y", "sex": "female", "value": 600, "ul": 1700 }, + { "age_range": "14-18 y", "sex": "male", "value": 900, "ul": 2800 }, + { "age_range": "14-18 y", "sex": "female", "value": 700, "ul": 2800 }, + { "age_range": "19-30 y", "sex": "male", "value": 900, "ul": 3000 }, + { "age_range": "19-30 y", "sex": "female", "value": 700, "ul": 3000 }, + { "age_range": "31-50 y", "sex": "male", "value": 900, "ul": 3000 }, + { "age_range": "31-50 y", "sex": "female", "value": 700, "ul": 3000 }, + { "age_range": "51-70 y", "sex": "male", "value": 900, "ul": 3000 }, + { "age_range": "51-70 y", "sex": "female", "value": 700, "ul": 3000 }, + { "age_range": ">70 y", "sex": "male", "value": 900, "ul": 3000 }, + { "age_range": ">70 y", "sex": "female", "value": 700, "ul": 3000 }, + { + "age_range": "pregnancy ≤18 y", + "sex": "female", + "value": 750, + "ul": 2800 + }, + { + "age_range": "pregnancy 19-30 y", + "sex": "female", + "value": 770, + "ul": 3000 + }, + { + "age_range": "lactation ≤18 y", + "sex": "female", + "value": 1200, + "ul": 2800 + }, + { + "age_range": "lactation 19-30 y", + "sex": "female", + "value": 1300, + "ul": 3000 + } + ] + }, + "vitamin_c": { + "unit": "mg/day", + "rda_type": "RDA/AI", + "life_stages": [ + { "age_range": "0-6 mo", "sex": "infant", "value": 40, "ul": null }, + { "age_range": "7-12 mo", "sex": "infant", "value": 50, "ul": null }, + { "age_range": "1-3 y", "sex": "both", "value": 15, "ul": 400 }, + { "age_range": "4-8 y", "sex": "both", "value": 25, "ul": 650 }, + { "age_range": "9-13 y", "sex": "male", "value": 45, "ul": 1200 }, + { "age_range": "9-13 y", "sex": "female", "value": 45, "ul": 1200 }, + { "age_range": "14-18 y", "sex": "male", "value": 75, "ul": 1800 }, + { "age_range": "14-18 y", "sex": "female", "value": 65, "ul": 1800 }, + { "age_range": "19-30 y", "sex": "male", "value": 75, "ul": 1800 }, + { "age_range": "19-30 y", "sex": "female", "value": 90, "ul": 2000 }, + { "age_range": "31-50 y", "sex": "male", "value": 75, "ul": 2000 }, + { "age_range": "31-50 y", "sex": "female", "value": 75, "ul": 2000 }, + { "age_range": "51-70 y", "sex": "both", "value": 75, "ul": 2000 }, + { "age_range": ">70 y", "sex": "both", "value": 75, "ul": 2000 }, + { + "age_range": "pregnancy ≤18 y", + "sex": "female", + "value": 80, + "ul": 1800 + }, + { + "age_range": "pregnancy 19-30 y", + "sex": "female", + "value": 85, + "ul": 2000 + }, + { + "age_range": "lactation ≤18 y", + "sex": "female", + "value": 115, + "ul": 1800 + }, + { + "age_range": "lactation 19-30 y", + "sex": "female", + "value": 120, + "ul": 2000 + } + ] + }, + "vitamin_d": { + "unit": "µg/day", + "rda_type": "RDA/AI", + "life_stages": [ + { "age_range": "0-6 mo", "sex": "infant", "value": 10, "ul": 25 }, + { "age_range": "7-12 mo", "sex": "infant", "value": 15, "ul": 25 }, + { "age_range": "1-3 y", "sex": "both", "value": 15, "ul": 50 }, + { "age_range": "4-8 y", "sex": "both", "value": 15, "ul": 50 }, + { "age_range": "9-13 y", "sex": "both", "value": 15, "ul": 100 }, + { "age_range": "14-18 y", "sex": "both", "value": 15, "ul": 100 }, + { "age_range": "19-30 y", "sex": "both", "value": 15, "ul": 100 }, + { "age_range": "31-50 y", "sex": "both", "value": 15, "ul": 100 }, + { "age_range": "51-70 y", "sex": "both", "value": 20, "ul": 100 }, + { "age_range": ">70 y", "sex": "both", "value": 20, "ul": 100 }, + { + "age_range": "pregnancy ≤18 y", + "sex": "female", + "value": 15, + "ul": 100 + }, + { + "age_range": "pregnancy 19-30 y", + "sex": "female", + "value": 15, + "ul": 100 + }, + { + "age_range": "lactation ≤18 y", + "sex": "female", + "value": 15, + "ul": 100 + }, + { + "age_range": "lactation 19-30 y", + "sex": "female", + "value": 15, + "ul": 100 + } + ] + }, + "vitamin_e": { + "unit": "mg/day (alpha-tocopherol)", + "rda_type": "RDA/AI", + "life_stages": [ + { "age_range": "0-6 mo", "sex": "infant", "value": 4, "ul": null }, + { "age_range": "7-12 mo", "sex": "infant", "value": 5, "ul": null }, + { "age_range": "1-3 y", "sex": "both", "value": 6, "ul": 200 }, + { "age_range": "4-8 y", "sex": "both", "value": 7, "ul": 300 }, + { "age_range": "9-13 y", "sex": "both", "value": 11, "ul": 600 }, + { "age_range": "14-18 y", "sex": "both", "value": 15, "ul": 800 }, + { "age_range": "19-30 y", "sex": "both", "value": 15, "ul": 800 }, + { "age_range": "31-50 y", "sex": "both", "value": 15, "ul": 800 }, + { "age_range": "51-70 y", "sex": "both", "value": 15, "ul": 800 }, + { "age_range": ">70 y", "sex": "both", "value": 15, "ul": 800 }, + { + "age_range": "pregnancy ≤18 y", + "sex": "female", + "value": 19, + "ul": 800 + }, + { + "age_range": "pregnancy 19-30 y", + "sex": "female", + "value": 19, + "ul": 800 + }, + { + "age_range": "lactation ≤18 y", + "sex": "female", + "value": 19, + "ul": 800 + }, + { + "age_range": "lactation 19-30 y", + "sex": "female", + "value": 19, + "ul": 800 + } + ] + }, + "vitamin_k": { + "unit": "µg/day", + "rda_type": "AI", + "life_stages": [ + { "age_range": "0-6 mo", "sex": "infant", "value": 2, "ul": null }, + { "age_range": "7-12 mo", "sex": "infant", "value": 2.5, "ul": null }, + { "age_range": "1-3 y", "sex": "both", "value": 30, "ul": null }, + { "age_range": "4-8 y", "sex": "both", "value": 55, "ul": null }, + { "age_range": "9-13 y", "sex": "both", "value": 60, "ul": null }, + { "age_range": "14-18 y", "sex": "male", "value": 75, "ul": null }, + { "age_range": "14-18 y", "sex": "female", "value": 75, "ul": null }, + { "age_range": "19-30 y", "sex": "male", "value": 75, "ul": null }, + { "age_range": "19-30 y", "sex": "female", "value": 75, "ul": null }, + { "age_range": "31-50 y", "sex": "male", "value": 75, "ul": null }, + { "age_range": "31-50 y", "sex": "female", "value": 75, "ul": null }, + { "age_range": "51-70 y", "sex": "male", "value": 75, "ul": null }, + { "age_range": "51-70 y", "sex": "female", "value": 75, "ul": null }, + { "age_range": ">70 y", "sex": "male", "value": 75, "ul": null }, + { "age_range": ">70 y", "sex": "female", "value": 75, "ul": null }, + { + "age_range": "pregnancy ≤18 y", + "sex": "female", + "value": 75, + "ul": null + }, + { + "age_range": "pregnancy 19-30 y", + "sex": "female", + "value": 75, + "ul": null + }, + { + "age_range": "lactation ≤18 y", + "sex": "female", + "value": 75, + "ul": null + }, + { + "age_range": "lactation 19-30 y", + "sex": "female", + "value": 75, + "ul": null + } + ] + }, + "vitamin_b1": { + "unit": "mg/day", + "rda_type": "RDA/AI", + "life_stages": [ + { "age_range": "0-6 mo", "sex": "infant", "value": 0.2, "ul": null }, + { "age_range": "7-12 mo", "sex": "infant", "value": 0.3, "ul": null }, + { "age_range": "1-3 y", "sex": "both", "value": 0.5, "ul": null }, + { "age_range": "4-8 y", "sex": "both", "value": 0.6, "ul": null }, + { "age_range": "9-13 y", "sex": "male", "value": 0.9, "ul": null }, + { "age_range": "9-13 y", "sex": "female", "value": 0.9, "ul": null }, + { "age_range": "14-18 y", "sex": "male", "value": 1.2, "ul": null }, + { "age_range": "14-18 y", "sex": "female", "value": 1.0, "ul": null }, + { "age_range": "19-30 y", "sex": "male", "value": 1.2, "ul": null }, + { "age_range": "19-30 y", "sex": "female", "value": 1.1, "ul": null }, + { "age_range": "31-50 y", "sex": "male", "value": 1.2, "ul": null }, + { "age_range": "31-50 y", "sex": "female", "value": 1.1, "ul": null }, + { "age_range": "51-70 y", "sex": "male", "value": 1.2, "ul": null }, + { "age_range": "51-70 y", "sex": "female", "value": 1.1, "ul": null }, + { "age_range": ">70 y", "sex": "male", "value": 1.2, "ul": null }, + { "age_range": ">70 y", "sex": "female", "value": 1.1, "ul": null }, + { + "age_range": "pregnancy ≤18 y", + "sex": "female", + "value": 1.4, + "ul": null + }, + { + "age_range": "pregnancy 19-30 y", + "sex": "female", + "value": 1.4, + "ul": null + }, + { + "age_range": "lactation ≤18 y", + "sex": "female", + "value": 1.5, + "ul": null + }, + { + "age_range": "lactation 19-30 y", + "sex": "female", + "value": 1.5, + "ul": null + } + ] + }, + "vitamin_b2": { + "unit": "mg/day", + "rda_type": "RDA/AI", + "life_stages": [ + { "age_range": "0-6 mo", "sex": "infant", "value": 0.3, "ul": null }, + { "age_range": "7-12 mo", "sex": "infant", "value": 0.4, "ul": null }, + { "age_range": "1-3 y", "sex": "both", "value": 0.5, "ul": null }, + { "age_range": "4-8 y", "sex": "both", "value": 0.6, "ul": null }, + { "age_range": "9-13 y", "sex": "male", "value": 0.9, "ul": null }, + { "age_range": "9-13 y", "sex": "female", "value": 0.9, "ul": null }, + { "age_range": "14-18 y", "sex": "male", "value": 1.3, "ul": null }, + { "age_range": "14-18 y", "sex": "female", "value": 1.0, "ul": null }, + { "age_range": "19-30 y", "sex": "male", "value": 1.3, "ul": null }, + { "age_range": "19-30 y", "sex": "female", "value": 1.1, "ul": null }, + { "age_range": "31-50 y", "sex": "male", "value": 1.3, "ul": null }, + { "age_range": "31-50 y", "sex": "female", "value": 1.1, "ul": null }, + { "age_range": "51-70 y", "sex": "male", "value": 1.3, "ul": null }, + { "age_range": "51-70 y", "sex": "female", "value": 1.1, "ul": null }, + { "age_range": ">70 y", "sex": "male", "value": 1.3, "ul": null }, + { "age_range": ">70 y", "sex": "female", "value": 1.1, "ul": null }, + { + "age_range": "pregnancy ≤18 y", + "sex": "female", + "value": 1.4, + "ul": null + }, + { + "age_range": "pregnancy 19-30 y", + "sex": "female", + "value": 1.4, + "ul": null + }, + { + "age_range": "lactation ≤18 y", + "sex": "female", + "value": 1.6, + "ul": null + }, + { + "age_range": "lactation 19-30 y", + "sex": "female", + "value": 1.6, + "ul": null + } + ] + }, + "vitamin_b3": { + "unit": "mg/day (niacin equivalents, NE)", + "rda_type": "RDA/AI", + "life_stages": [ + { "age_range": "0-6 mo", "sex": "infant", "value": 2, "ul": null }, + { "age_range": "7-12 mo", "sex": "infant", "value": 4, "ul": null }, + { "age_range": "1-3 y", "sex": "both", "value": 6, "ul": 10 }, + { "age_range": "4-8 y", "sex": "both", "value": 8, "ul": 15 }, + { "age_range": "9-13 y", "sex": "both", "value": 12, "ul": 20 }, + { "age_range": "14-18 y", "sex": "male", "value": 16, "ul": 30 }, + { "age_range": "14-18 y", "sex": "female", "value": 14, "ul": 30 }, + { "age_range": "19-30 y", "sex": "male", "value": 16, "ul": 35 }, + { "age_range": "19-30 y", "sex": "female", "value": 14, "ul": 35 }, + { "age_range": "31-50 y", "sex": "male", "value": 16, "ul": 35 }, + { "age_range": "31-50 y", "sex": "female", "value": 14, "ul": 35 }, + { "age_range": "51-70 y", "sex": "male", "value": 16, "ul": 35 }, + { "age_range": "51-70 y", "sex": "female", "value": 14, "ul": 35 }, + { "age_range": ">70 y", "sex": "male", "value": 16, "ul": 35 }, + { "age_range": ">70 y", "sex": "female", "value": 14, "ul": 35 }, + { + "age_range": "pregnancy ≤18 y", + "sex": "female", + "value": 18, + "ul": 35 + }, + { + "age_range": "pregnancy 19-30 y", + "sex": "female", + "value": 18, + "ul": 35 + }, + { + "age_range": "lactation ≤18 y", + "sex": "female", + "value": 17, + "ul": 35 + }, + { + "age_range": "lactation 19-30 y", + "sex": "female", + "value": 17, + "ul": 35 + } + ] + }, + "vitamin_b5": { + "unit": "mg/day", + "rda_type": "AI", + "life_stages": [ + { "age_range": "0-6 mo", "sex": "infant", "value": 1.7, "ul": null }, + { "age_range": "7-12 mo", "sex": "infant", "value": 1.8, "ul": null }, + { "age_range": "1-3 y", "sex": "both", "value": 2.0, "ul": null }, + { "age_range": "4-8 y", "sex": "both", "value": 3.0, "ul": null }, + { "age_range": "9-13 y", "sex": "both", "value": 4.0, "ul": null }, + { "age_range": "14-18 y", "sex": "both", "value": 5.0, "ul": null }, + { "age_range": "19-30 y", "sex": "both", "value": 5.0, "ul": null }, + { "age_range": "31-50 y", "sex": "both", "value": 5.0, "ul": null }, + { "age_range": "51-70 y", "sex": "both", "value": 5.0, "ul": null }, + { "age_range": ">70 y", "sex": "both", "value": 5.0, "ul": null }, + { + "age_range": "pregnancy ≤18 y", + "sex": "female", + "value": 6.0, + "ul": null + }, + { + "age_range": "pregnancy 19-30 y", + "sex": "female", + "value": 6.0, + "ul": null + }, + { + "age_range": "pregnancy 31-50 y", + "sex": "female", + "value": 6.0, + "ul": null + }, + { + "age_range": "lactation ≤18 y", + "sex": "female", + "value": 7.0, + "ul": null + }, + { + "age_range": "lactation 19-30 y", + "sex": "female", + "value": 7.0, + "ul": null + }, + { + "age_range": "lactation 31-50 y", + "sex": "female", + "value": 7.0, + "ul": null + } + ] + }, + "vitamin_b6": { + "unit": "mg/day", + "rda_type": "RDA/AI", + "life_stages": [ + { "age_range": "0-6 mo", "sex": "infant", "value": 0.1, "ul": null }, + { "age_range": "7-12 mo", "sex": "infant", "value": 0.3, "ul": null }, + { "age_range": "1-3 y", "sex": "both", "value": 0.5, "ul": 30 }, + { "age_range": "4-8 y", "sex": "both", "value": 0.6, "ul": 40 }, + { "age_range": "9-13 y", "sex": "both", "value": 1.0, "ul": 60 }, + { "age_range": "14-18 y", "sex": "male", "value": 1.3, "ul": 80 }, + { "age_range": "14-18 y", "sex": "female", "value": 1.2, "ul": 80 }, + { "age_range": "19-50 y", "sex": "male", "value": 1.3, "ul": 100 }, + { "age_range": "19-50 y", "sex": "female", "value": 1.3, "ul": 100 }, + { "age_range": "51-70 y", "sex": "male", "value": 1.7, "ul": 100 }, + { "age_range": "51-70 y", "sex": "female", "value": 1.5, "ul": 100 }, + { "age_range": ">70 y", "sex": "male", "value": 1.7, "ul": 100 }, + { "age_range": ">70 y", "sex": "female", "value": 1.5, "ul": 100 }, + { + "age_range": "pregnancy ≤18 y", + "sex": "female", + "value": 1.9, + "ul": 80 + }, + { + "age_range": "pregnancy 19-30 y", + "sex": "female", + "value": 1.9, + "ul": 80 + }, + { + "age_range": "lactation ≤18 y", + "sex": "female", + "value": 2.0, + "ul": 100 + }, + { + "age_range": "lactation 19-30 y", + "sex": "female", + "value": 2.0, + "ul": 100 + } + ] + }, + "vitamin_b12": { + "unit": "µg/day", + "rda_type": "RDA/AI", + "life_stages": [ + { "age_range": "0-6 mo", "sex": "infant", "value": 0.4, "ul": null }, + { "age_range": "7-12 mo", "sex": "infant", "value": 0.5, "ul": null }, + { "age_range": "1-3 y", "sex": "both", "value": 0.9, "ul": null }, + { "age_range": "4-8 y", "sex": "both", "value": 1.2, "ul": null }, + { "age_range": "9-13 y", "sex": "both", "value": 1.8, "ul": null }, + { "age_range": "14-18 y", "sex": "both", "value": 2.4, "ul": null }, + { "age_range": "19-30 y", "sex": "both", "value": 2.4, "ul": null }, + { "age_range": "31-50 y", "sex": "both", "value": 2.4, "ul": null }, + { "age_range": "51-70 y", "sex": "both", "value": 2.4, "ul": null }, + { "age_range": ">70 y", "sex": "both", "value": 2.4, "ul": null }, + { + "age_range": "pregnancy ≤18 y", + "sex": "female", + "value": 2.6, + "ul": null + }, + { + "age_range": "pregnancy 19-30 y", + "sex": "female", + "value": 2.6, + "ul": null + }, + { + "age_range": "lactation ≤18 y", + "sex": "female", + "value": 2.8, + "ul": null + }, + { + "age_range": "lactation 19-30 y", + "sex": "female", + "value": 2.8, + "ul": null + } + ] + }, + "iron": { + "unit": "mg/day", + "rda_type": "RDA/AI", + "life_stages": [ + { "age_range": "0-6 mo", "sex": "infant", "value": 0.27, "ul": 40 }, + { "age_range": "7-12 mo", "sex": "infant", "value": 11, "ul": 40 }, + { "age_range": "1-3 y", "sex": "both", "value": 7, "ul": 40 }, + { "age_range": "4-8 y", "sex": "both", "value": 10, "ul": 40 }, + { "age_range": "9-13 y", "sex": "male", "value": 8, "ul": 45 }, + { "age_range": "9-13 y", "sex": "female", "value": 8, "ul": 45 }, + { "age_range": "14-18 y", "sex": "male", "value": 11, "ul": 45 }, + { "age_range": "14-18 y", "sex": "female", "value": 15, "ul": 45 }, + { "age_range": "19-30 y", "sex": "male", "value": 8, "ul": 45 }, + { "age_range": "19-30 y", "sex": "female", "value": 18, "ul": 45 }, + { "age_range": "31-50 y", "sex": "male", "value": 8, "ul": 45 }, + { "age_range": "31-50 y", "sex": "female", "value": 18, "ul": 45 }, + { "age_range": "51-70 y", "sex": "male", "value": 8, "ul": 45 }, + { "age_range": "51-70 y", "sex": "female", "value": 8, "ul": 45 }, + { "age_range": ">70 y", "sex": "male", "value": 8, "ul": 45 }, + { "age_range": ">70 y", "sex": "female", "value": 8, "ul": 45 }, + { + "age_range": "pregnancy ≤18 y", + "sex": "female", + "value": 27, + "ul": 45 + }, + { + "age_range": "pregnancy 19-30 y", + "sex": "female", + "value": 27, + "ul": 45 + }, + { + "age_range": "pregnancy 31-50 y", + "sex": "female", + "value": 27, + "ul": 45 + }, + { + "age_range": "lactation ≤18 y", + "sex": "female", + "value": 10, + "ul": 45 + }, + { + "age_range": "lactation 19-30 y", + "sex": "female", + "value": 9, + "ul": 45 + }, + { + "age_range": "lactation 31-50 y", + "sex": "female", + "value": 9, + "ul": 45 + } + ] + }, + "magnesium": { + "unit": "mg/day", + "rda_type": "RDA/AI", + "note": "UL represents intake from pharmacological agents only, not food or water.", + "life_stages": [ + { + "age_range": "0-6 mo", + "sex": "infant", + "value": 30, + "rda_type": "AI", + "ul": 40 + }, + { + "age_range": "7-12 mo", + "sex": "infant", + "value": 75, + "rda_type": "AI", + "ul": 40 + }, + { + "age_range": "1-3 y", + "sex": "both", + "value": 80, + "rda_type": "RDA", + "ul": 65 + }, + { + "age_range": "4-8 y", + "sex": "both", + "value": 130, + "rda_type": "RDA", + "ul": 110 + }, + { + "age_range": "9-13 y", + "sex": "male", + "value": 240, + "rda_type": "RDA", + "ul": 350 + }, + { + "age_range": "9-13 y", + "sex": "female", + "value": 240, + "rda_type": "RDA", + "ul": 350 + }, + { + "age_range": "14-18 y", + "sex": "male", + "value": 410, + "rda_type": "RDA", + "ul": 350 + }, + { + "age_range": "14-18 y", + "sex": "female", + "value": 360, + "rda_type": "RDA", + "ul": 350 + }, + { + "age_range": "19-30 y", + "sex": "male", + "value": 400, + "rda_type": "RDA", + "ul": 350 + }, + { + "age_range": "19-30 y", + "sex": "female", + "value": 310, + "rda_type": "RDA", + "ul": 350 + }, + { + "age_range": "31-50 y", + "sex": "male", + "value": 420, + "rda_type": "RDA", + "ul": 350 + }, + { + "age_range": "31-50 y", + "sex": "female", + "value": 320, + "rda_type": "RDA", + "ul": 350 + }, + { + "age_range": "51-70 y", + "sex": "male", + "value": 420, + "rda_type": "RDA", + "ul": 350 + }, + { + "age_range": "51-70 y", + "sex": "female", + "value": 320, + "rda_type": "RDA", + "ul": 350 + }, + { + "age_range": ">70 y", + "sex": "male", + "value": 420, + "rda_type": "RDA", + "ul": 350 + }, + { + "age_range": ">70 y", + "sex": "female", + "value": 320, + "rda_type": "RDA", + "ul": 350 + }, + { + "age_range": "pregnancy ≤18 y", + "sex": "female", + "value": 400, + "rda_type": "RDA", + "ul": 350 + }, + { + "age_range": "pregnancy 19-30 y", + "sex": "female", + "value": 350, + "rda_type": "RDA", + "ul": 350 + }, + { + "age_range": "pregnancy 31-50 y", + "sex": "female", + "value": 360, + "rda_type": "RDA", + "ul": 350 + }, + { + "age_range": "lactation ≤18 y", + "sex": "female", + "value": 360, + "rda_type": "RDA", + "ul": 350 + }, + { + "age_range": "lactation 19-30 y", + "sex": "female", + "value": 320, + "rda_type": "RDA", + "ul": 350 + }, + { + "age_range": "lactation 31-50 y", + "sex": "female", + "value": 320, + "rda_type": "RDA", + "ul": 350 + } + ] + }, + "zinc": { + "unit": "mg/day", + "rda_type": "RDA/AI", + "life_stages": [ + { "age_range": "0-6 mo", "sex": "infant", "value": 2, "ul": 4 }, + { "age_range": "7-12 mo", "sex": "infant", "value": 3, "ul": 5 }, + { "age_range": "1-3 y", "sex": "both", "value": 3, "ul": 7 }, + { "age_range": "4-8 y", "sex": "both", "value": 5, "ul": 12 }, + { "age_range": "9-13 y", "sex": "male", "value": 8, "ul": 23 }, + { "age_range": "9-13 y", "sex": "female", "value": 8, "ul": 23 }, + { "age_range": "14-18 y", "sex": "male", "value": 11, "ul": 34 }, + { "age_range": "14-18 y", "sex": "female", "value": 9, "ul": 34 }, + { "age_range": "19-30 y", "sex": "male", "value": 11, "ul": 40 }, + { "age_range": "19-30 y", "sex": "female", "value": 8, "ul": 40 }, + { "age_range": "31-50 y", "sex": "male", "value": 11, "ul": 40 }, + { "age_range": "31-50 y", "sex": "female", "value": 8, "ul": 40 }, + { "age_range": "51-70 y", "sex": "male", "value": 11, "ul": 40 }, + { "age_range": "51-70 y", "sex": "female", "value": 8, "ul": 40 }, + { "age_range": ">70 y", "sex": "male", "value": 11, "ul": 40 }, + { "age_range": ">70 y", "sex": "female", "value": 8, "ul": 40 }, + { + "age_range": "pregnancy ≤18 y", + "sex": "female", + "value": 12, + "ul": 40 + }, + { + "age_range": "pregnancy 19-30 y", + "sex": "female", + "value": 11, + "ul": 40 + }, + { + "age_range": "pregnancy 31-50 y", + "sex": "female", + "value": 11, + "ul": 40 + }, + { + "age_range": "lactation ≤18 y", + "sex": "female", + "value": 13, + "ul": 40 + }, + { + "age_range": "lactation 19-30 y", + "sex": "female", + "value": 12, + "ul": 40 + }, + { + "age_range": "lactation 31-50 y", + "sex": "female", + "value": 12, + "ul": 40 + } + ] + }, + "creatine": { + "unit": "g/day", + "rda_type": "Recommended Dosage", + "note": "Creatine monohydrate supplementation typically involves a loading phase of 20-25 g/day divided into 4-5 g doses for 5-7 days, followed by a maintenance dose of 3-5 g/day. The UL is not a strict daily maximum but based on safe dosages studied for various durations.", + "life_stages": [ + { + "age_range": "adult", + "sex": "both", + "value_min": 3, + "value_max": 5, + "description": "Maintenance dose (typical daily intake)" + }, + { + "age_range": "adult", + "sex": "both", + "value": 20, + "description": "Loading phase daily dose (divided into 4-5 g servings for 5-7 days)" + } + ], + "ul": { + "value": 25, + "unit": "g/day", + "duration": "up to 14 days", + "note": "Doses up to 25 g/day for up to 14 days have been safely used. Long-term doses of 3-5 g/day are considered safe." + } + }, + "manganese": { + "unit": "mg/day", + "rda_type": "AI", + "life_stages": [ + { "age_range": "0-6 mo", "sex": "infant", "value": 0.003, "ul": null }, + { "age_range": "7-12 mo", "sex": "infant", "value": 0.6, "ul": null }, + { "age_range": "1-3 y", "sex": "both", "value": 1.2, "ul": 2 }, + { "age_range": "4-8 y", "sex": "both", "value": 1.5, "ul": 3 }, + { "age_range": "9-13 y", "sex": "male", "value": 1.9, "ul": 6 }, + { "age_range": "9-13 y", "sex": "female", "value": 1.6, "ul": 6 }, + { "age_range": "14-18 y", "sex": "male", "value": 2.2, "ul": 9 }, + { "age_range": "14-18 y", "sex": "female", "value": 1.6, "ul": 9 }, + { "age_range": "19-30 y", "sex": "male", "value": 2.3, "ul": 11 }, + { "age_range": "19-30 y", "sex": "female", "value": 1.8, "ul": 11 }, + { "age_range": "31-50 y", "sex": "male", "value": 2.3, "ul": 11 }, + { "age_range": "31-50 y", "sex": "female", "value": 1.8, "ul": 11 }, + { "age_range": "51-70 y", "sex": "male", "value": 2.3, "ul": 11 }, + { "age_range": "51-70 y", "sex": "female", "value": 1.8, "ul": 11 }, + { "age_range": ">70 y", "sex": "male", "value": 2.3, "ul": 11 }, + { "age_range": ">70 y", "sex": "female", "value": 1.8, "ul": 11 }, + { + "age_range": "pregnancy ≤18 y", + "sex": "female", + "value": 1.9, + "ul": 11 + }, + { + "age_range": "pregnancy 19-30 y", + "sex": "female", + "value": 1.8, + "ul": 11 + }, + { + "age_range": "pregnancy 31-50 y", + "sex": "female", + "value": 1.8, + "ul": 11 + }, + { + "age_range": "lactation ≤18 y", + "sex": "female", + "value": 2.6, + "ul": 11 + }, + { + "age_range": "lactation 19-30 y", + "sex": "female", + "value": 2.3, + "ul": 11 + }, + { + "age_range": "lactation 31-50 y", + "sex": "female", + "value": 2.3, + "ul": 11 + } + ] + }, + "folate_dfe": { + "unit": "µg/day (DFE)", + "rda_type": "RDA/AI", + "life_stages": [ + { "age_range": "0-6 mo", "sex": "infant", "value": 65, "ul": null }, + { "age_range": "7-12 mo", "sex": "infant", "value": 80, "ul": null }, + { "age_range": "1-3 y", "sex": "both", "value": 150, "ul": 300 }, + { "age_range": "4-8 y", "sex": "both", "value": 200, "ul": 400 }, + { "age_range": "9-13 y", "sex": "male", "value": 300, "ul": 600 }, + { "age_range": "9-13 y", "sex": "female", "value": 300, "ul": 600 }, + { "age_range": "14-18 y", "sex": "both", "value": 400, "ul": 800 }, + { "age_range": "19-30 y", "sex": "female", "value": 400, "ul": 1000 }, + { "age_range": "19-30 y", "sex": "male", "value": 400, "ul": 1000 }, + { "age_range": "31-50 y", "sex": "both", "value": 400, "ul": 1000 }, + { "age_range": "51-70 y", "sex": "both", "value": 400, "ul": 1000 }, + { "age_range": ">70 y", "sex": "both", "value": 400, "ul": 1000 }, + { + "age_range": "pregnancy ≤18 y", + "sex": "female", + "value": 600, + "ul": 800 + }, + { + "age_range": "pregnancy 19-30 y", + "sex": "female", + "value": 600, + "ul": 1000 + }, + { + "age_range": "lactation ≤18 y", + "sex": "female", + "value": 500, + "ul": 800 + }, + { + "age_range": "lactation 19-30 y", + "sex": "female", + "value": 500, + "ul": 1000 + } + ] + } + } +} diff --git a/lib/data/rda_data.dart b/lib/data/rda_data.dart new file mode 100644 index 0000000..c1e1bcb --- /dev/null +++ b/lib/data/rda_data.dart @@ -0,0 +1,71 @@ +// Recommended Daily Allowances (RDA) for common vitamins and minerals. +// All values are in milligrams (mg) unless otherwise specified. +const Map>> rdaData = { + 'Vitamin C': { + 'Male': { + '19-70': 90, // mg + }, + 'Female': { + '19-70': 75, // mg + }, + }, + 'Vitamin D': { + 'Male': { + '19-70': 15, // mcg + }, + 'Female': { + '19-70': 15, // mcg + }, + }, + 'Vitamin D3': { + 'Male': { + '19-70': 15, // mcg + }, + 'Female': { + '19-70': 15, // mcg + }, + }, + 'Vitamin K': { + 'Male': { + '19-100': 120, // mcg + }, + 'Female': { + '19-100': 90, // mcg + }, + }, + 'Vitamin K2': { + 'Male': { + '19-100': 120, // mcg + }, + 'Female': { + '19-100': 90, // mcg + }, + }, + 'Calcium': { + 'Male': { + '19-50': 1000, // mg + '51-70': 1000, // mg + }, + 'Female': { + '19-50': 1000, // mg + '51-70': 1200, // mg + }, + }, + 'Iron': { + 'Male': { + '19-50': 8, // mg + }, + 'Female': { + '19-50': 18, // mg + '51-70': 8, // mg + }, + }, + 'DHA & EPA': { + 'Male': { + '19-100': 250, // mg + }, + 'Female': { + '19-100': 250, // mg + }, + }, +}; diff --git a/lib/main.dart b/lib/main.dart index e707fcf..befb96f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,16 +3,23 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'providers/settings_provider.dart'; -import 'providers/supplement_provider.dart'; import 'providers/simple_sync_provider.dart'; +import 'providers/supplement_provider.dart'; import 'screens/home_screen.dart'; +import 'screens/profile_setup_screen.dart'; -void main() { - runApp(const MyApp()); +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + final settingsProvider = SettingsProvider(); + await settingsProvider.initialize(); + + runApp(MyApp(settingsProvider: settingsProvider)); } class MyApp extends StatelessWidget { - const MyApp({super.key}); + final SettingsProvider settingsProvider; + + const MyApp({super.key, required this.settingsProvider}); @override Widget build(BuildContext context) { @@ -21,8 +28,8 @@ class MyApp extends StatelessWidget { ChangeNotifierProvider( create: (context) => SupplementProvider()..initialize(), ), - ChangeNotifierProvider( - create: (context) => SettingsProvider()..initialize(), + ChangeNotifierProvider.value( + value: settingsProvider, ), ChangeNotifierProvider( create: (context) => SimpleSyncProvider(), @@ -34,7 +41,7 @@ class MyApp extends StatelessWidget { // and initialize auto-sync integration WidgetsBinding.instance.addPostFrameCallback((_) { final supplementProvider = context.read(); - + // Set up sync completion callback syncProvider.setOnSyncCompleteCallback(() async { if (kDebugMode) { @@ -46,10 +53,10 @@ class MyApp extends StatelessWidget { print('SupplementsLog: UI data refreshed after sync'); } }); - + // Initialize auto-sync service syncProvider.initializeAutoSync(settingsProvider); - + // Set up auto-sync callback for data changes supplementProvider.setOnDataChangedCallback(() { syncProvider.triggerAutoSyncIfEnabled(); @@ -73,7 +80,9 @@ class MyApp extends StatelessWidget { useMaterial3: true, ), themeMode: settingsProvider.themeMode, - home: const HomeScreen(), + home: (settingsProvider.age == null || settingsProvider.gender == null) + ? const ProfileSetupScreen() + : const HomeScreen(), debugShowCheckedModeBanner: false, ); }, diff --git a/lib/models/nutrient.dart b/lib/models/nutrient.dart new file mode 100644 index 0000000..1c0fcc3 --- /dev/null +++ b/lib/models/nutrient.dart @@ -0,0 +1,87 @@ + + +class Nutrient { + final String name; + final String unit; + final String rdaType; + final String? note; + final UpperLimit? ul; // nutrient-level UL (optional) + final List lifeStages; + + Nutrient({ + required this.name, + required this.unit, + required this.rdaType, + this.note, + this.ul, + required this.lifeStages, + }); + + factory Nutrient.fromJson(String name, Map json) { + return Nutrient( + name: name, + unit: json['unit'], + rdaType: json['rda_type'], + note: json['note'], + ul: (json['ul'] is Map) ? UpperLimit.fromJson(json['ul'] as Map) : null, + lifeStages: (json['life_stages'] as List) + .map((stage) => LifeStage.fromJson(stage)) + .toList(), + ); + } +} + +class LifeStage { + final String ageRange; + final String sex; + final double value; + final double? valueMin; + final double? valueMax; + final double? ul; + final String? description; + + LifeStage({ + required this.ageRange, + required this.sex, + required this.value, + this.valueMin, + this.valueMax, + this.ul, + this.description, + }); + + factory LifeStage.fromJson(Map json) { + return LifeStage( + ageRange: json['age_range'], + sex: json['sex'], + value: (json['value'] as num?)?.toDouble() ?? 0.0, + valueMin: json['value_min'] != null ? (json['value_min'] as num).toDouble() : null, + valueMax: json['value_max'] != null ? (json['value_max'] as num).toDouble() : null, + ul: json['ul'] != null ? (json['ul'] as num).toDouble() : null, + description: json['description'], + ); + } +} + +class UpperLimit { + final double value; + final String unit; + final String? duration; + final String? note; + + const UpperLimit({ + required this.value, + required this.unit, + this.duration, + this.note, + }); + + factory UpperLimit.fromJson(Map json) { + return UpperLimit( + value: (json['value'] as num).toDouble(), + unit: json['unit'] ?? '', + duration: json['duration'], + note: json['note'], + ); + } +} diff --git a/lib/models/supplement.dart b/lib/models/supplement.dart index 8514318..406aefc 100644 --- a/lib/models/supplement.dart +++ b/lib/models/supplement.dart @@ -2,8 +2,8 @@ import 'dart:convert'; import 'package:uuid/uuid.dart'; -import 'ingredient.dart'; import '../services/database_sync_service.dart'; +import 'ingredient.dart'; class Supplement { final int? id; @@ -69,8 +69,7 @@ class Supplement { } Map toMap() { - return { - 'id': id, + final map = { 'name': name, 'brand': brand, 'ingredients': jsonEncode(ingredients.map((ingredient) => ingredient.toMap()).toList()), @@ -86,6 +85,12 @@ class Supplement { 'syncStatus': syncStatus.name, 'isDeleted': isDeleted ? 1 : 0, }; + + if (id != null) { + map['id'] = id; + } + + return map; } factory Supplement.fromMap(Map map) { @@ -133,6 +138,7 @@ class Supplement { Supplement copyWith({ int? id, + bool setNullId = false, String? name, String? brand, List? ingredients, @@ -144,12 +150,13 @@ class Supplement { DateTime? createdAt, bool? isActive, String? syncId, + bool newSyncId = false, DateTime? lastModified, RecordSyncStatus? syncStatus, bool? isDeleted, }) { return Supplement( - id: id ?? this.id, + id: setNullId ? null : (id ?? this.id), name: name ?? this.name, brand: brand ?? this.brand, ingredients: ingredients ?? this.ingredients, @@ -160,7 +167,7 @@ class Supplement { notes: notes ?? this.notes, createdAt: createdAt ?? this.createdAt, isActive: isActive ?? this.isActive, - syncId: syncId ?? this.syncId, + syncId: newSyncId ? null : (syncId ?? this.syncId), lastModified: lastModified ?? this.lastModified, syncStatus: syncStatus ?? this.syncStatus, isDeleted: isDeleted ?? this.isDeleted, diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index e862cf8..f07017c 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -10,6 +10,10 @@ enum ThemeOption { class SettingsProvider extends ChangeNotifier { ThemeOption _themeOption = ThemeOption.system; + // Profile fields + DateTime? _dateOfBirth; + String? _gender; + // Time range settings (stored as hours, 0-23) int _morningStart = 5; int _morningEnd = 10; @@ -31,6 +35,19 @@ class SettingsProvider extends ChangeNotifier { ThemeOption get themeOption => _themeOption; + // Profile getters + DateTime? get dateOfBirth => _dateOfBirth; + String? get gender => _gender; + int? get age { + if (_dateOfBirth == null) return null; + final now = DateTime.now(); + int years = now.year - _dateOfBirth!.year; + final hasHadBirthday = (now.month > _dateOfBirth!.month) || + (now.month == _dateOfBirth!.month && now.day >= _dateOfBirth!.day); + if (!hasHadBirthday) years--; + return years; + } + // Time range getters int get morningStart => _morningStart; int get morningEnd => _morningEnd; @@ -76,6 +93,13 @@ class SettingsProvider extends ChangeNotifier { final themeIndex = prefs.getInt('theme_option') ?? 0; _themeOption = ThemeOption.values[themeIndex]; + // Load profile fields + final dobString = prefs.getString('date_of_birth'); + if (dobString != null) { + _dateOfBirth = DateTime.tryParse(dobString); + } + _gender = prefs.getString('gender'); + // Load time range settings _morningStart = prefs.getInt('morning_start') ?? 5; _morningEnd = prefs.getInt('morning_end') ?? 10; @@ -106,6 +130,16 @@ class SettingsProvider extends ChangeNotifier { await prefs.setInt('theme_option', option.index); } + Future setDateOfBirthAndGender(DateTime dateOfBirth, String gender) async { + _dateOfBirth = dateOfBirth; + _gender = gender; + notifyListeners(); + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('date_of_birth', dateOfBirth.toIso8601String()); + await prefs.setString('gender', gender); + } + Future setTimeRanges({ required int morningStart, required int morningEnd, diff --git a/lib/providers/supplement_provider.dart b/lib/providers/supplement_provider.dart index 60410b8..5cd23f0 100644 --- a/lib/providers/supplement_provider.dart +++ b/lib/providers/supplement_provider.dart @@ -7,6 +7,7 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import '../models/supplement.dart'; import '../models/supplement_intake.dart'; import '../services/database_helper.dart'; +import '../services/database_sync_service.dart'; import '../services/notification_service.dart'; class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { @@ -280,6 +281,28 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { } } + Future duplicateSupplement(int supplementId) async { + try { + final originalSupplement = await _databaseHelper.getSupplement(supplementId); + if (originalSupplement != null) { + final newSupplement = originalSupplement.copyWith( + setNullId: true, // This will be a new entry + newSyncId: true, // Generate a new syncId + name: '${originalSupplement.name} (Copy)', + createdAt: DateTime.now(), + lastModified: DateTime.now(), + syncStatus: RecordSyncStatus.pending, + isDeleted: false, + ); + await addSupplement(newSupplement); + } + } catch (e) { + if (kDebugMode) { + print('SupplementsLog: Error duplicating supplement: $e'); + } + } + } + Future deleteSupplement(int id) async { try { await _databaseHelper.deleteSupplement(id); @@ -420,6 +443,22 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { return _todayIntakes.where((intake) => intake['supplement_id'] == supplementId).length; } + Map get dailyIngredientIntake { + final Map ingredientIntake = {}; + + for (final intake in _todayIntakes) { + final supplement = _supplements.firstWhere((s) => s.id == intake['supplement_id']); + final unitsTaken = intake['unitsTaken'] as double; + + for (final ingredient in supplement.ingredients) { + final currentAmount = ingredientIntake[ingredient.name] ?? 0; + ingredientIntake[ingredient.name] = currentAmount + (ingredient.amount * unitsTaken); + } + } + + return ingredientIntake; + } + // Method to manually refresh daily status (useful for testing or manual refresh) Future refreshDailyStatus() async { if (kDebugMode) { diff --git a/lib/screens/add_supplement_screen.dart b/lib/screens/add_supplement_screen.dart index b5a2f51..aa6cf60 100644 --- a/lib/screens/add_supplement_screen.dart +++ b/lib/screens/add_supplement_screen.dart @@ -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 { final _numberOfUnitsController = TextEditingController(); final _notesController = TextEditingController(); + // Nutrient data for autocomplete + final NutrientDataService _nutrientDataService = NutrientDataService(); + List _nutrients = []; + // Multi-ingredient support with persistent controllers List _ingredientControllers = []; diff --git a/lib/screens/archived_supplements_screen.dart b/lib/screens/archived_supplements_screen.dart index e8e12e0..55dcfab 100644 --- a/lib/screens/archived_supplements_screen.dart +++ b/lib/screens/archived_supplements_screen.dart @@ -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, - ), - ), - ], - ), - ); - } -} diff --git a/lib/screens/profile_setup_screen.dart b/lib/screens/profile_setup_screen.dart new file mode 100644 index 0000000..12fb256 --- /dev/null +++ b/lib/screens/profile_setup_screen.dart @@ -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 createState() => _ProfileSetupScreenState(); +} + +class _ProfileSetupScreenState extends State { + final _formKey = GlobalKey(); + DateTime? _dateOfBirth; + String? _gender; + + final List _genders = ['Male', 'Female', 'Other', 'Prefer not to say']; + + @override + void initState() { + super.initState(); + final settingsProvider = Provider.of(context, listen: false); + _dateOfBirth = settingsProvider.dateOfBirth; + _gender = settingsProvider.gender; + } + + void _saveProfile() { + if (_formKey.currentState!.validate()) { + _formKey.currentState!.save(); + Provider.of(context, listen: false).setDateOfBirthAndGender(_dateOfBirth!, _gender!); + Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (context) => HomeScreen())); + } + } + + Future _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( + decoration: const InputDecoration( + labelText: 'Gender', + border: OutlineInputBorder(), + ), + value: _gender, + items: _genders.map((String gender) { + return DropdownMenuItem( + 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'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 43913c6..4728a55 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -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), diff --git a/lib/screens/supplements_list_screen.dart b/lib/screens/supplements_list_screen.dart index ebeb8a4..40cdb67 100644 --- a/lib/screens/supplements_list_screen.dart +++ b/lib/screens/supplements_list_screen.dart @@ -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 supplements, SettingsProvider settingsProvider) { + final provider = Provider.of(context, listen: false); final groupedSupplements = _groupSupplementsByTimeOfDay(supplements, settingsProvider); return ListView( padding: const EdgeInsets.all(16), children: [ + // Daily RDA overview header + FutureBuilder>>( + future: (() async { + if (provider.todayIntakes.isEmpty) return >{}; + final dailyItems = []; + 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 >{}; + 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 = >{}; + 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().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().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().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().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().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 _launchUrl(String url) async { + final uri = Uri.parse(url); + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + + void _showRdaDetailsSheet(BuildContext context, String nutrientPretty, Map 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(); + 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(); diff --git a/lib/services/nutrient_data_service.dart b/lib/services/nutrient_data_service.dart new file mode 100644 index 0000000..86290cd --- /dev/null +++ b/lib/services/nutrient_data_service.dart @@ -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? _nutrients; + + Future> get nutrients async { + if (_nutrients != null) { + return _nutrients!; + } + await _loadNutrientData(); + return _nutrients!; + } + + Future _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; + + _nutrients = nutrientsData.entries.map((entry) { + return Nutrient.fromJson(entry.key, entry.value); + }).toList(); + } catch (e) { + print('Error loading nutrient data: $e'); + _nutrients = []; + } + } +} diff --git a/lib/services/rda_service.dart b/lib/services/rda_service.dart new file mode 100644 index 0000000..e9e6260 --- /dev/null +++ b/lib/services/rda_service.dart @@ -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? _nutrientsByKey; + + // Known alias mapping for common ingredient names to nutrient keys + // Keys must be lowercase for matching + static const Map _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 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 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 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> aggregateDailyIntake( + List ingredients, { + DateTime? dateOfBirth, + String? gender, + }) async { + final Map totalsByNutrient = {}; + final Map 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 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 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 _ensureNutrientsLoaded() async { + if (_nutrientsByKey != null) return; + final list = await _nutrientDataService.nutrients; + _nutrientsByKey = {for (final n in list) n.name: n}; + } + + Future _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 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 +} diff --git a/lib/widgets/info_chip.dart b/lib/widgets/info_chip.dart new file mode 100644 index 0000000..d5f75a3 --- /dev/null +++ b/lib/widgets/info_chip.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +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, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/supplement_card.dart b/lib/widgets/supplement_card.dart index 978edc2..86c883d 100644 --- a/lib/widgets/supplement_card.dart +++ b/lib/widgets/supplement_card.dart @@ -9,6 +9,7 @@ class SupplementCard extends StatefulWidget { final VoidCallback onEdit; final VoidCallback onDelete; final VoidCallback onArchive; + final VoidCallback onDuplicate; const SupplementCard({ super.key, @@ -17,6 +18,7 @@ class SupplementCard extends StatefulWidget { required this.onEdit, required this.onDelete, required this.onArchive, + required this.onDuplicate, }); @override @@ -33,7 +35,7 @@ class _SupplementCardState extends State { final bool isTakenToday = provider.hasBeenTakenToday(widget.supplement.id!); final int todayIntakeCount = provider.getTodayIntakeCount(widget.supplement.id!); final bool isCompletelyTaken = todayIntakeCount >= widget.supplement.frequencyPerDay; - + // Get today's intake times for this supplement final todayIntakes = provider.todayIntakes .where((intake) => intake['supplement_id'] == widget.supplement.id) @@ -45,7 +47,7 @@ class _SupplementCardState extends State { 'units': unitsTaken is int ? unitsTaken.toDouble() : unitsTaken as double, }; }).toList(); - + return Card( margin: const EdgeInsets.only(bottom: 16), elevation: 3, @@ -175,7 +177,7 @@ class _SupplementCardState extends State { ), ), ElevatedButton( - onPressed: isCompletelyTaken ? null : widget.onTake, + onPressed: widget.onTake, style: ElevatedButton.styleFrom( backgroundColor: isCompletelyTaken ? Colors.green.shade500 @@ -209,6 +211,9 @@ class _SupplementCardState extends State { case 'edit': widget.onEdit(); break; + case 'duplicate': + widget.onDuplicate(); + break; case 'archive': widget.onArchive(); break; @@ -218,6 +223,18 @@ class _SupplementCardState extends State { } }, itemBuilder: (context) => [ + if (isTakenToday) + PopupMenuItem( + value: 'take', + onTap: widget.onTake, + child: const Row( + children: [ + Icon(Icons.add_circle_outline), + SizedBox(width: 8), + Text('Take Again'), + ], + ), + ), const PopupMenuItem( value: 'edit', child: Row( @@ -228,6 +245,16 @@ class _SupplementCardState extends State { ], ), ), + const PopupMenuItem( + value: 'duplicate', + child: Row( + children: [ + Icon(Icons.copy), + SizedBox(width: 8), + Text('Duplicate'), + ], + ), + ), const PopupMenuItem( value: 'archive', child: Row( @@ -297,10 +324,10 @@ class _SupplementCardState extends State { runSpacing: 4, children: todayIntakes.map((intake) { final units = intake['units'] as double; - final unitsText = units == 1.0 + final unitsText = units == 1.0 ? '${widget.supplement.unitType}' : '${units.toStringAsFixed(units % 1 == 0 ? 0 : 1)} ${widget.supplement.unitType}'; - + return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( @@ -327,7 +354,7 @@ class _SupplementCardState extends State { ), const SizedBox(height: 16), ], - + // Ingredients section Container( padding: const EdgeInsets.all(12), @@ -374,9 +401,9 @@ class _SupplementCardState extends State { ], ), ), - + const SizedBox(height: 12), - + // Schedule and dosage info Row( children: [ @@ -397,7 +424,7 @@ class _SupplementCardState extends State { ), ], ), - + if (widget.supplement.reminderTimes.isNotEmpty) ...[ const SizedBox(height: 8), _InfoChip( @@ -407,7 +434,7 @@ class _SupplementCardState extends State { fullWidth: true, ), ], - + if (widget.supplement.notes != null && widget.supplement.notes!.isNotEmpty) ...[ const SizedBox(height: 8), Container( @@ -427,14 +454,14 @@ class _SupplementCardState extends State { ), ), ], - + const SizedBox(height: 16), - + // Take button SizedBox( width: double.infinity, child: ElevatedButton.icon( - onPressed: isCompletelyTaken ? null : widget.onTake, + onPressed: widget.onTake, icon: Icon( isCompletelyTaken ? Icons.check_circle : Icons.medication, size: 18, diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index d0e7f79..38dd0bc 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,9 +7,13 @@ #include "generated_plugin_registrant.h" #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index b29e9ba..65240e9 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_linux + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 5a094bf..304a8de 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -11,6 +11,7 @@ import flutter_secure_storage_darwin import path_provider_foundation import shared_preferences_foundation import sqflite_darwin +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) @@ -19,4 +20,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index e4ad175..29f1fb6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -638,6 +638,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "69ee86740f2847b9a4ba6cffa74ed12ce500bbe2b07f3dc1e643439da60637b7" + url: "https://pub.dev" + source: hosted + version: "6.3.18" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7 + url: "https://pub.dev" + source: hosted + version: "6.3.4" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + url: "https://pub.dev" + source: hosted + version: "3.1.4" uuid: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 5375902..a4ea63e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,6 +35,7 @@ dependencies: flutter_secure_storage: ^10.0.0-beta.4 uuid: ^4.5.1 crypto: ^3.0.6 + url_launcher: ^6.3.2 dev_dependencies: flutter_test: @@ -52,3 +53,6 @@ dependency_overrides: flutter: uses-material-design: true + + assets: + - assets/canada_health.json diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index af1f996..80422a9 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,10 +8,13 @@ #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { ConnectivityPlusWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 71d65ea..517b5a8 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST connectivity_plus flutter_secure_storage_windows + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST