4 Commits

26 changed files with 2742 additions and 532 deletions

View File

@@ -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.

896
assets/canada_health.json Normal file
View File

@@ -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
}
]
}
}
}

71
lib/data/rda_data.dart Normal file
View File

@@ -0,0 +1,71 @@
// Recommended Daily Allowances (RDA) for common vitamins and minerals.
// All values are in milligrams (mg) unless otherwise specified.
const Map<String, Map<String, Map<String, double>>> 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
},
},
};

View File

@@ -3,16 +3,23 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'providers/settings_provider.dart'; import 'providers/settings_provider.dart';
import 'providers/supplement_provider.dart';
import 'providers/simple_sync_provider.dart'; import 'providers/simple_sync_provider.dart';
import 'providers/supplement_provider.dart';
import 'screens/home_screen.dart'; import 'screens/home_screen.dart';
import 'screens/profile_setup_screen.dart';
void main() { void main() async {
runApp(const MyApp()); WidgetsFlutterBinding.ensureInitialized();
final settingsProvider = SettingsProvider();
await settingsProvider.initialize();
runApp(MyApp(settingsProvider: settingsProvider));
} }
class MyApp extends StatelessWidget { class MyApp extends StatelessWidget {
const MyApp({super.key}); final SettingsProvider settingsProvider;
const MyApp({super.key, required this.settingsProvider});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -21,8 +28,8 @@ class MyApp extends StatelessWidget {
ChangeNotifierProvider( ChangeNotifierProvider(
create: (context) => SupplementProvider()..initialize(), create: (context) => SupplementProvider()..initialize(),
), ),
ChangeNotifierProvider( ChangeNotifierProvider.value(
create: (context) => SettingsProvider()..initialize(), value: settingsProvider,
), ),
ChangeNotifierProvider( ChangeNotifierProvider(
create: (context) => SimpleSyncProvider(), create: (context) => SimpleSyncProvider(),
@@ -34,7 +41,7 @@ class MyApp extends StatelessWidget {
// and initialize auto-sync integration // and initialize auto-sync integration
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
final supplementProvider = context.read<SupplementProvider>(); final supplementProvider = context.read<SupplementProvider>();
// Set up sync completion callback // Set up sync completion callback
syncProvider.setOnSyncCompleteCallback(() async { syncProvider.setOnSyncCompleteCallback(() async {
if (kDebugMode) { if (kDebugMode) {
@@ -46,10 +53,10 @@ class MyApp extends StatelessWidget {
print('SupplementsLog: UI data refreshed after sync'); print('SupplementsLog: UI data refreshed after sync');
} }
}); });
// Initialize auto-sync service // Initialize auto-sync service
syncProvider.initializeAutoSync(settingsProvider); syncProvider.initializeAutoSync(settingsProvider);
// Set up auto-sync callback for data changes // Set up auto-sync callback for data changes
supplementProvider.setOnDataChangedCallback(() { supplementProvider.setOnDataChangedCallback(() {
syncProvider.triggerAutoSyncIfEnabled(); syncProvider.triggerAutoSyncIfEnabled();
@@ -73,7 +80,9 @@ class MyApp extends StatelessWidget {
useMaterial3: true, useMaterial3: true,
), ),
themeMode: settingsProvider.themeMode, themeMode: settingsProvider.themeMode,
home: const HomeScreen(), home: (settingsProvider.age == null || settingsProvider.gender == null)
? const ProfileSetupScreen()
: const HomeScreen(),
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
); );
}, },

87
lib/models/nutrient.dart Normal file
View File

@@ -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<LifeStage> lifeStages;
Nutrient({
required this.name,
required this.unit,
required this.rdaType,
this.note,
this.ul,
required this.lifeStages,
});
factory Nutrient.fromJson(String name, Map<String, dynamic> json) {
return Nutrient(
name: name,
unit: json['unit'],
rdaType: json['rda_type'],
note: json['note'],
ul: (json['ul'] is Map<String, dynamic>) ? UpperLimit.fromJson(json['ul'] as Map<String, dynamic>) : 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<String, dynamic> 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<String, dynamic> json) {
return UpperLimit(
value: (json['value'] as num).toDouble(),
unit: json['unit'] ?? '',
duration: json['duration'],
note: json['note'],
);
}
}

View File

@@ -2,8 +2,8 @@ import 'dart:convert';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import 'ingredient.dart';
import '../services/database_sync_service.dart'; import '../services/database_sync_service.dart';
import 'ingredient.dart';
class Supplement { class Supplement {
final int? id; final int? id;
@@ -69,8 +69,7 @@ class Supplement {
} }
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { final map = <String, dynamic>{
'id': id,
'name': name, 'name': name,
'brand': brand, 'brand': brand,
'ingredients': jsonEncode(ingredients.map((ingredient) => ingredient.toMap()).toList()), 'ingredients': jsonEncode(ingredients.map((ingredient) => ingredient.toMap()).toList()),
@@ -86,6 +85,12 @@ class Supplement {
'syncStatus': syncStatus.name, 'syncStatus': syncStatus.name,
'isDeleted': isDeleted ? 1 : 0, 'isDeleted': isDeleted ? 1 : 0,
}; };
if (id != null) {
map['id'] = id;
}
return map;
} }
factory Supplement.fromMap(Map<String, dynamic> map) { factory Supplement.fromMap(Map<String, dynamic> map) {
@@ -133,6 +138,7 @@ class Supplement {
Supplement copyWith({ Supplement copyWith({
int? id, int? id,
bool setNullId = false,
String? name, String? name,
String? brand, String? brand,
List<Ingredient>? ingredients, List<Ingredient>? ingredients,
@@ -144,12 +150,13 @@ class Supplement {
DateTime? createdAt, DateTime? createdAt,
bool? isActive, bool? isActive,
String? syncId, String? syncId,
bool newSyncId = false,
DateTime? lastModified, DateTime? lastModified,
RecordSyncStatus? syncStatus, RecordSyncStatus? syncStatus,
bool? isDeleted, bool? isDeleted,
}) { }) {
return Supplement( return Supplement(
id: id ?? this.id, id: setNullId ? null : (id ?? this.id),
name: name ?? this.name, name: name ?? this.name,
brand: brand ?? this.brand, brand: brand ?? this.brand,
ingredients: ingredients ?? this.ingredients, ingredients: ingredients ?? this.ingredients,
@@ -160,7 +167,7 @@ class Supplement {
notes: notes ?? this.notes, notes: notes ?? this.notes,
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
isActive: isActive ?? this.isActive, isActive: isActive ?? this.isActive,
syncId: syncId ?? this.syncId, syncId: newSyncId ? null : (syncId ?? this.syncId),
lastModified: lastModified ?? this.lastModified, lastModified: lastModified ?? this.lastModified,
syncStatus: syncStatus ?? this.syncStatus, syncStatus: syncStatus ?? this.syncStatus,
isDeleted: isDeleted ?? this.isDeleted, isDeleted: isDeleted ?? this.isDeleted,

View File

@@ -10,6 +10,10 @@ enum ThemeOption {
class SettingsProvider extends ChangeNotifier { class SettingsProvider extends ChangeNotifier {
ThemeOption _themeOption = ThemeOption.system; ThemeOption _themeOption = ThemeOption.system;
// Profile fields
DateTime? _dateOfBirth;
String? _gender;
// Time range settings (stored as hours, 0-23) // Time range settings (stored as hours, 0-23)
int _morningStart = 5; int _morningStart = 5;
int _morningEnd = 10; int _morningEnd = 10;
@@ -31,6 +35,19 @@ class SettingsProvider extends ChangeNotifier {
ThemeOption get themeOption => _themeOption; 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 // Time range getters
int get morningStart => _morningStart; int get morningStart => _morningStart;
int get morningEnd => _morningEnd; int get morningEnd => _morningEnd;
@@ -76,6 +93,13 @@ class SettingsProvider extends ChangeNotifier {
final themeIndex = prefs.getInt('theme_option') ?? 0; final themeIndex = prefs.getInt('theme_option') ?? 0;
_themeOption = ThemeOption.values[themeIndex]; _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 // Load time range settings
_morningStart = prefs.getInt('morning_start') ?? 5; _morningStart = prefs.getInt('morning_start') ?? 5;
_morningEnd = prefs.getInt('morning_end') ?? 10; _morningEnd = prefs.getInt('morning_end') ?? 10;
@@ -106,6 +130,16 @@ class SettingsProvider extends ChangeNotifier {
await prefs.setInt('theme_option', option.index); await prefs.setInt('theme_option', option.index);
} }
Future<void> 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<void> setTimeRanges({ Future<void> setTimeRanges({
required int morningStart, required int morningStart,
required int morningEnd, required int morningEnd,

View File

@@ -7,6 +7,7 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../models/supplement.dart'; import '../models/supplement.dart';
import '../models/supplement_intake.dart'; import '../models/supplement_intake.dart';
import '../services/database_helper.dart'; import '../services/database_helper.dart';
import '../services/database_sync_service.dart';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
@@ -280,6 +281,28 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
} }
} }
Future<void> 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<void> deleteSupplement(int id) async { Future<void> deleteSupplement(int id) async {
try { try {
await _databaseHelper.deleteSupplement(id); await _databaseHelper.deleteSupplement(id);
@@ -420,6 +443,22 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
return _todayIntakes.where((intake) => intake['supplement_id'] == supplementId).length; return _todayIntakes.where((intake) => intake['supplement_id'] == supplementId).length;
} }
Map<String, double> get dailyIngredientIntake {
final Map<String, double> 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) // Method to manually refresh daily status (useful for testing or manual refresh)
Future<void> refreshDailyStatus() async { Future<void> refreshDailyStatus() async {
if (kDebugMode) { if (kDebugMode) {

View File

@@ -3,8 +3,10 @@ import 'package:provider/provider.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../models/ingredient.dart'; import '../models/ingredient.dart';
import '../models/nutrient.dart';
import '../models/supplement.dart'; import '../models/supplement.dart';
import '../providers/supplement_provider.dart'; import '../providers/supplement_provider.dart';
import '../services/nutrient_data_service.dart';
// Helper class to manage ingredient text controllers // Helper class to manage ingredient text controllers
class IngredientController { class IngredientController {
@@ -51,6 +53,10 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
final _numberOfUnitsController = TextEditingController(); final _numberOfUnitsController = TextEditingController();
final _notesController = TextEditingController(); final _notesController = TextEditingController();
// Nutrient data for autocomplete
final NutrientDataService _nutrientDataService = NutrientDataService();
List<Nutrient> _nutrients = [];
// Multi-ingredient support with persistent controllers // Multi-ingredient support with persistent controllers
List<IngredientController> _ingredientControllers = []; List<IngredientController> _ingredientControllers = [];
@@ -557,21 +563,24 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
void _saveSupplement() async { void _saveSupplement() async {
if (_formKey.currentState!.validate()) { if (_formKey.currentState!.validate()) {
// Validate that we have at least one ingredient with name and amount // Validate that we have at least one ingredient with name and amount
final validIngredients = _ingredientControllers.where((controller) => final validIngredients = _ingredientControllers
controller.nameController.text.trim().isNotEmpty && .where((controller) =>
(double.tryParse(controller.amountController.text) ?? 0) > 0 controller.nameController.text.trim().isNotEmpty &&
).map((controller) => Ingredient( (double.tryParse(controller.amountController.text) ?? 0) > 0)
name: controller.nameController.text.trim(), .map((controller) => Ingredient(
amount: double.tryParse(controller.amountController.text) ?? 0.0, name: controller.nameController.text.trim(),
unit: controller.selectedUnit, amount: double.tryParse(controller.amountController.text) ?? 0.0,
syncId: const Uuid().v4(), unit: controller.selectedUnit,
lastModified: DateTime.now(), syncId: const Uuid().v4(),
)).toList(); lastModified: DateTime.now(),
))
.toList();
if (validIngredients.isEmpty) { if (validIngredients.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Please add at least one ingredient with name and amount'), content:
Text('Please add at least one ingredient with name and amount'),
), ),
); );
return; return;
@@ -580,14 +589,20 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
final supplement = Supplement( final supplement = Supplement(
id: widget.supplement?.id, id: widget.supplement?.id,
name: _nameController.text.trim(), name: _nameController.text.trim(),
brand: _brandController.text.trim().isNotEmpty ? _brandController.text.trim() : null, brand: _brandController.text.trim().isNotEmpty
? _brandController.text.trim()
: null,
ingredients: validIngredients, ingredients: validIngredients,
numberOfUnits: int.parse(_numberOfUnitsController.text), numberOfUnits: int.parse(_numberOfUnitsController.text),
unitType: _selectedUnitType, unitType: _selectedUnitType,
frequencyPerDay: _frequencyPerDay, frequencyPerDay: _frequencyPerDay,
reminderTimes: _reminderTimes, reminderTimes: _reminderTimes,
notes: _notesController.text.trim().isNotEmpty ? _notesController.text.trim() : null, notes: _notesController.text.trim().isNotEmpty
? _notesController.text.trim()
: null,
createdAt: widget.supplement?.createdAt ?? DateTime.now(), createdAt: widget.supplement?.createdAt ?? DateTime.now(),
syncId: widget.supplement?.syncId, // Preserve syncId on update
lastModified: DateTime.now(), // Always update lastModified on save
); );
final provider = context.read<SupplementProvider>(); final provider = context.read<SupplementProvider>();

View File

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

View File

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

View File

@@ -2,9 +2,8 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/settings_provider.dart'; import '../providers/settings_provider.dart';
import '../providers/supplement_provider.dart';
import '../services/notification_service.dart';
import 'pending_notifications_screen.dart'; import 'pending_notifications_screen.dart';
import 'profile_setup_screen.dart';
import 'simple_sync_settings_screen.dart'; import 'simple_sync_settings_screen.dart';
class SettingsScreen extends StatelessWidget { class SettingsScreen extends StatelessWidget {
@@ -21,6 +20,22 @@ class SettingsScreen extends StatelessWidget {
return ListView( return ListView(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
children: [ 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( Card(
child: ListTile( child: ListTile(
leading: const Icon(Icons.cloud_sync), leading: const Icon(Icons.cloud_sync),

View File

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

View File

@@ -27,40 +27,40 @@ enum RecordSyncStatus {
class DatabaseSyncService { class DatabaseSyncService {
static const String _remoteDbFileName = 'supplements.db'; static const String _remoteDbFileName = 'supplements.db';
// SharedPreferences keys for persistence // SharedPreferences keys for persistence
static const String _keyServerUrl = 'sync_server_url'; static const String _keyServerUrl = 'sync_server_url';
static const String _keyUsername = 'sync_username'; static const String _keyUsername = 'sync_username';
static const String _keyPassword = 'sync_password'; static const String _keyPassword = 'sync_password';
static const String _keyRemotePath = 'sync_remote_path'; static const String _keyRemotePath = 'sync_remote_path';
Client? _client; Client? _client;
String? _remotePath; String? _remotePath;
// Store configuration values // Store configuration values
String? _serverUrl; String? _serverUrl;
String? _username; String? _username;
String? _password; String? _password;
String? _configuredRemotePath; String? _configuredRemotePath;
final DatabaseHelper _databaseHelper = DatabaseHelper.instance; final DatabaseHelper _databaseHelper = DatabaseHelper.instance;
SyncStatus _status = SyncStatus.idle; SyncStatus _status = SyncStatus.idle;
String? _lastError; String? _lastError;
DateTime? _lastSyncTime; DateTime? _lastSyncTime;
// Getters // Getters
SyncStatus get status => _status; SyncStatus get status => _status;
String? get lastError => _lastError; String? get lastError => _lastError;
DateTime? get lastSyncTime => _lastSyncTime; DateTime? get lastSyncTime => _lastSyncTime;
bool get isConfigured => _client != null; bool get isConfigured => _client != null;
// Configuration getters // Configuration getters
String? get serverUrl => _serverUrl; String? get serverUrl => _serverUrl;
String? get username => _username; String? get username => _username;
String? get password => _password; String? get password => _password;
String? get remotePath => _configuredRemotePath; String? get remotePath => _configuredRemotePath;
// Callbacks for UI updates // Callbacks for UI updates
Function(SyncStatus)? onStatusChanged; Function(SyncStatus)? onStatusChanged;
Function(String)? onError; Function(String)? onError;
@@ -78,11 +78,11 @@ class DatabaseSyncService {
_username = prefs.getString(_keyUsername); _username = prefs.getString(_keyUsername);
_password = prefs.getString(_keyPassword); _password = prefs.getString(_keyPassword);
_configuredRemotePath = prefs.getString(_keyRemotePath); _configuredRemotePath = prefs.getString(_keyRemotePath);
// If we have saved configuration, set up the client // If we have saved configuration, set up the client
if (_serverUrl != null && _username != null && _password != null && _configuredRemotePath != null) { if (_serverUrl != null && _username != null && _password != null && _configuredRemotePath != null) {
_remotePath = _configuredRemotePath!.endsWith('/') ? _configuredRemotePath : '$_configuredRemotePath/'; _remotePath = _configuredRemotePath!.endsWith('/') ? _configuredRemotePath : '$_configuredRemotePath/';
_client = newClient( _client = newClient(
_serverUrl!, _serverUrl!,
user: _username!, user: _username!,
@@ -123,9 +123,9 @@ class DatabaseSyncService {
_username = username; _username = username;
_password = password; _password = password;
_configuredRemotePath = remotePath; _configuredRemotePath = remotePath;
_remotePath = remotePath.endsWith('/') ? remotePath : '$remotePath/'; _remotePath = remotePath.endsWith('/') ? remotePath : '$remotePath/';
_client = newClient( _client = newClient(
serverUrl, serverUrl,
user: username, user: username,
@@ -139,7 +139,7 @@ class DatabaseSyncService {
Future<bool> testConnection() async { Future<bool> testConnection() async {
if (_client == null) return false; if (_client == null) return false;
try { try {
await _client!.ping(); await _client!.ping();
return true; return true;
@@ -157,26 +157,26 @@ class DatabaseSyncService {
} }
_setStatus(SyncStatus.downloading); _setStatus(SyncStatus.downloading);
try { try {
// Step 1: Download remote database (if it exists) // Step 1: Download remote database (if it exists)
final remoteDbPath = await _downloadRemoteDatabase(); final remoteDbPath = await _downloadRemoteDatabase();
// Step 2: Merge databases // Step 2: Merge databases
_setStatus(SyncStatus.merging); _setStatus(SyncStatus.merging);
await _mergeDatabases(remoteDbPath); await _mergeDatabases(remoteDbPath);
// Step 3: Upload merged database // Step 3: Upload merged database
_setStatus(SyncStatus.uploading); _setStatus(SyncStatus.uploading);
await _uploadLocalDatabase(); await _uploadLocalDatabase();
// Step 4: Cleanup - for now we'll skip cleanup to avoid file issues // Step 4: Cleanup - for now we'll skip cleanup to avoid file issues
// TODO: Implement proper cleanup once file operations are working // TODO: Implement proper cleanup once file operations are working
_lastSyncTime = DateTime.now(); _lastSyncTime = DateTime.now();
_setStatus(SyncStatus.completed); _setStatus(SyncStatus.completed);
onSyncCompleted?.call(); onSyncCompleted?.call();
} catch (e) { } catch (e) {
_lastError = e.toString(); _lastError = e.toString();
_setStatus(SyncStatus.error); _setStatus(SyncStatus.error);
@@ -193,35 +193,35 @@ class DatabaseSyncService {
// Check if remote database exists // Check if remote database exists
final files = await _client!.readDir(_remotePath!); final files = await _client!.readDir(_remotePath!);
final remoteDbExists = files.any((file) => file.name == _remoteDbFileName); final remoteDbExists = files.any((file) => file.name == _remoteDbFileName);
if (!remoteDbExists) { if (!remoteDbExists) {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: No remote database found, will upload local database'); print('SupplementsLog: No remote database found, will upload local database');
} }
return null; return null;
} }
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Remote database found, downloading...'); print('SupplementsLog: Remote database found, downloading...');
} }
// Download the remote database // Download the remote database
final remoteDbBytes = await _client!.read('$_remotePath$_remoteDbFileName'); final remoteDbBytes = await _client!.read('$_remotePath$_remoteDbFileName');
// Create a temporary file path for the downloaded database // Create a temporary file path for the downloaded database
final tempDir = await getDatabasesPath(); final tempDir = await getDatabasesPath();
final tempDbPath = join(tempDir, 'remote_supplements.db'); final tempDbPath = join(tempDir, 'remote_supplements.db');
// Write the downloaded database to a temporary file // Write the downloaded database to a temporary file
final tempFile = io.File(tempDbPath); final tempFile = io.File(tempDbPath);
await tempFile.writeAsBytes(remoteDbBytes); await tempFile.writeAsBytes(remoteDbBytes);
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Downloaded remote database (${remoteDbBytes.length} bytes) to: $tempDbPath'); print('SupplementsLog: Downloaded remote database (${remoteDbBytes.length} bytes) to: $tempDbPath');
} }
return tempDbPath; return tempDbPath;
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Failed to download remote database: $e'); print('SupplementsLog: Failed to download remote database: $e');
@@ -237,20 +237,20 @@ class DatabaseSyncService {
} }
return; return;
} }
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Starting database merge from: $remoteDbPath'); print('SupplementsLog: Starting database merge from: $remoteDbPath');
} }
final localDb = await _databaseHelper.database; final localDb = await _databaseHelper.database;
final remoteDb = await openDatabase(remoteDbPath, readOnly: true); final remoteDb = await openDatabase(remoteDbPath, readOnly: true);
try { try {
// Check what tables exist in remote database // Check what tables exist in remote database
if (kDebugMode) { if (kDebugMode) {
final tables = await remoteDb.rawQuery("SELECT name FROM sqlite_master WHERE type='table'"); final tables = await remoteDb.rawQuery("SELECT name FROM sqlite_master WHERE type='table'");
print('SupplementsLog: Remote database tables: ${tables.map((t) => t['name']).toList()}'); print('SupplementsLog: Remote database tables: ${tables.map((t) => t['name']).toList()}');
// Count records in each table // Count records in each table
try { try {
final supplementCount = await remoteDb.rawQuery('SELECT COUNT(*) as count FROM supplements'); final supplementCount = await remoteDb.rawQuery('SELECT COUNT(*) as count FROM supplements');
@@ -258,7 +258,7 @@ class DatabaseSyncService {
} catch (e) { } catch (e) {
print('SupplementsLog: Error counting supplements: $e'); print('SupplementsLog: Error counting supplements: $e');
} }
try { try {
final intakeCount = await remoteDb.rawQuery('SELECT COUNT(*) as count FROM supplement_intakes'); final intakeCount = await remoteDb.rawQuery('SELECT COUNT(*) as count FROM supplement_intakes');
print('SupplementsLog: Remote intakes count: ${intakeCount.first['count']}'); print('SupplementsLog: Remote intakes count: ${intakeCount.first['count']}');
@@ -266,17 +266,17 @@ class DatabaseSyncService {
print('SupplementsLog: Error counting intakes: $e'); print('SupplementsLog: Error counting intakes: $e');
} }
} }
// Merge supplements // Merge supplements
await _mergeSupplements(localDb, remoteDb); await _mergeSupplements(localDb, remoteDb);
// Merge intakes // Merge intakes
await _mergeIntakes(localDb, remoteDb); await _mergeIntakes(localDb, remoteDb);
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Database merge completed successfully'); print('SupplementsLog: Database merge completed successfully');
} }
} finally { } finally {
await remoteDb.close(); await remoteDb.close();
} }
@@ -286,52 +286,62 @@ class DatabaseSyncService {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Starting supplement merge...'); print('SupplementsLog: Starting supplement merge...');
} }
// Get all supplements from remote database // Get all supplements from remote database
final remoteMaps = await remoteDb.query('supplements'); final remoteMaps = await remoteDb.query('supplements');
final remoteSupplements = remoteMaps.map((map) => Supplement.fromMap(map)).toList(); final remoteSupplements =
remoteMaps.map((map) => Supplement.fromMap(map)).toList();
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Found ${remoteSupplements.length} supplements in remote database'); print(
'SupplementsLog: Found ${remoteSupplements.length} supplements in remote database');
for (final supplement in remoteSupplements) { for (final supplement in remoteSupplements) {
print('SupplementsLog: Remote supplement: ${supplement.name} (syncId: ${supplement.syncId}, deleted: ${supplement.isDeleted})'); print(
'SupplementsLog: Remote supplement: ${supplement.name} (syncId: ${supplement.syncId}, deleted: ${supplement.isDeleted})');
} }
} }
for (final remoteSupplement in remoteSupplements) { for (final remoteSupplement in remoteSupplements) {
if (remoteSupplement.syncId.isEmpty) { if (remoteSupplement.syncId.isEmpty) {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Skipping supplement ${remoteSupplement.name} - no syncId'); print(
'SupplementsLog: Skipping supplement ${remoteSupplement.name} - no syncId');
} }
continue; continue;
} }
// Find existing supplement by syncId // Find existing supplement by syncId
final existingMaps = await localDb.query( final existingMaps = await localDb.query(
'supplements', 'supplements',
where: 'syncId = ?', where: 'syncId = ?',
whereArgs: [remoteSupplement.syncId], whereArgs: [remoteSupplement.syncId],
); );
if (existingMaps.isEmpty) { if (existingMaps.isEmpty) {
// New supplement from remote - insert it // New supplement from remote - insert it
if (!remoteSupplement.isDeleted) { if (!remoteSupplement.isDeleted) {
final supplementToInsert = remoteSupplement.copyWith(id: null); // Manually create a new map without the id to ensure it's null
await localDb.insert('supplements', supplementToInsert.toMap()); final mapToInsert = remoteSupplement.toMap();
mapToInsert.remove('id');
await localDb.insert('supplements', mapToInsert);
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: ✓ Inserted new supplement: ${remoteSupplement.name}'); print(
'SupplementsLog: ✓ Inserted new supplement: ${remoteSupplement.name}');
} }
} else { } else {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Skipping deleted supplement: ${remoteSupplement.name}'); print(
'SupplementsLog: Skipping deleted supplement: ${remoteSupplement.name}');
} }
} }
} else { } else {
// Existing supplement - update if remote is newer // Existing supplement - update if remote is newer
final existingSupplement = Supplement.fromMap(existingMaps.first); final existingSupplement = Supplement.fromMap(existingMaps.first);
if (remoteSupplement.lastModified.isAfter(existingSupplement.lastModified)) { if (remoteSupplement.lastModified
final supplementToUpdate = remoteSupplement.copyWith(id: existingSupplement.id); .isAfter(existingSupplement.lastModified)) {
final supplementToUpdate =
remoteSupplement.copyWith(id: existingSupplement.id);
await localDb.update( await localDb.update(
'supplements', 'supplements',
supplementToUpdate.toMap(), supplementToUpdate.toMap(),
@@ -339,16 +349,18 @@ class DatabaseSyncService {
whereArgs: [existingSupplement.id], whereArgs: [existingSupplement.id],
); );
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: ✓ Updated supplement: ${remoteSupplement.name}'); print(
'SupplementsLog: ✓ Updated supplement: ${remoteSupplement.name}');
} }
} else { } else {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Local supplement ${remoteSupplement.name} is newer, keeping local version'); print(
'SupplementsLog: Local supplement ${remoteSupplement.name} is newer, keeping local version');
} }
} }
} }
} }
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Supplement merge completed'); print('SupplementsLog: Supplement merge completed');
} }
@@ -358,15 +370,15 @@ class DatabaseSyncService {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Starting intake merge...'); print('SupplementsLog: Starting intake merge...');
} }
// Get all intakes from remote database // Get all intakes from remote database
final remoteMaps = await remoteDb.query('supplement_intakes'); final remoteMaps = await remoteDb.query('supplement_intakes');
final remoteIntakes = remoteMaps.map((map) => SupplementIntake.fromMap(map)).toList(); final remoteIntakes = remoteMaps.map((map) => SupplementIntake.fromMap(map)).toList();
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Found ${remoteIntakes.length} intakes in remote database'); print('SupplementsLog: Found ${remoteIntakes.length} intakes in remote database');
} }
for (final remoteIntake in remoteIntakes) { for (final remoteIntake in remoteIntakes) {
if (remoteIntake.syncId.isEmpty) { if (remoteIntake.syncId.isEmpty) {
if (kDebugMode) { if (kDebugMode) {
@@ -374,14 +386,14 @@ class DatabaseSyncService {
} }
continue; continue;
} }
// Find existing intake by syncId // Find existing intake by syncId
final existingMaps = await localDb.query( final existingMaps = await localDb.query(
'supplement_intakes', 'supplement_intakes',
where: 'syncId = ?', where: 'syncId = ?',
whereArgs: [remoteIntake.syncId], whereArgs: [remoteIntake.syncId],
); );
if (existingMaps.isEmpty) { if (existingMaps.isEmpty) {
// New intake from remote - need to find local supplement ID // New intake from remote - need to find local supplement ID
if (!remoteIntake.isDeleted) { if (!remoteIntake.isDeleted) {
@@ -408,7 +420,7 @@ class DatabaseSyncService {
} else { } else {
// Existing intake - update if remote is newer // Existing intake - update if remote is newer
final existingIntake = SupplementIntake.fromMap(existingMaps.first); final existingIntake = SupplementIntake.fromMap(existingMaps.first);
if (remoteIntake.lastModified.isAfter(existingIntake.lastModified)) { if (remoteIntake.lastModified.isAfter(existingIntake.lastModified)) {
final intakeToUpdate = remoteIntake.copyWith(id: existingIntake.id); final intakeToUpdate = remoteIntake.copyWith(id: existingIntake.id);
await localDb.update( await localDb.update(
@@ -427,7 +439,7 @@ class DatabaseSyncService {
} }
} }
} }
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Intake merge completed'); print('SupplementsLog: Intake merge completed');
} }
@@ -440,20 +452,20 @@ class DatabaseSyncService {
where: 'id = ?', where: 'id = ?',
whereArgs: [remoteSupplementId], whereArgs: [remoteSupplementId],
); );
if (remoteSupplementMaps.isEmpty) return null; if (remoteSupplementMaps.isEmpty) return null;
final remoteSupplement = Supplement.fromMap(remoteSupplementMaps.first); final remoteSupplement = Supplement.fromMap(remoteSupplementMaps.first);
// Find the local supplement with the same syncId // Find the local supplement with the same syncId
final localSupplementMaps = await localDb.query( final localSupplementMaps = await localDb.query(
'supplements', 'supplements',
where: 'syncId = ?', where: 'syncId = ?',
whereArgs: [remoteSupplement.syncId], whereArgs: [remoteSupplement.syncId],
); );
if (localSupplementMaps.isEmpty) return null; if (localSupplementMaps.isEmpty) return null;
return localSupplementMaps.first['id'] as int; return localSupplementMaps.first['id'] as int;
} }
@@ -462,27 +474,27 @@ class DatabaseSyncService {
// Get the local database path // Get the local database path
final localDb = await _databaseHelper.database; final localDb = await _databaseHelper.database;
final dbPath = localDb.path; final dbPath = localDb.path;
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Reading database from: $dbPath'); print('SupplementsLog: Reading database from: $dbPath');
} }
// Read the database file // Read the database file
final dbFile = io.File(dbPath); final dbFile = io.File(dbPath);
if (!await dbFile.exists()) { if (!await dbFile.exists()) {
throw Exception('Database file not found at: $dbPath'); throw Exception('Database file not found at: $dbPath');
} }
final dbBytes = await dbFile.readAsBytes(); final dbBytes = await dbFile.readAsBytes();
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Database file size: ${dbBytes.length} bytes'); print('SupplementsLog: Database file size: ${dbBytes.length} bytes');
} }
if (dbBytes.isEmpty) { if (dbBytes.isEmpty) {
throw Exception('Database file is empty'); throw Exception('Database file is empty');
} }
// Ensure remote directory exists // Ensure remote directory exists
try { try {
await _client!.readDir(_remotePath!); await _client!.readDir(_remotePath!);
@@ -492,15 +504,15 @@ class DatabaseSyncService {
} }
await _client!.mkdir(_remotePath!); await _client!.mkdir(_remotePath!);
} }
// Upload the database file // Upload the database file
final remoteUrl = '$_remotePath$_remoteDbFileName'; final remoteUrl = '$_remotePath$_remoteDbFileName';
await _client!.write(remoteUrl, dbBytes); await _client!.write(remoteUrl, dbBytes);
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Successfully uploaded database (${dbBytes.length} bytes) to: $remoteUrl'); print('SupplementsLog: Successfully uploaded database (${dbBytes.length} bytes) to: $remoteUrl');
} }
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Failed to upload database: $e'); print('SupplementsLog: Failed to upload database: $e');

View File

@@ -12,7 +12,7 @@ void notificationTapBackground(NotificationResponse notificationResponse) {
print('SupplementsLog: 📱 Payload: ${notificationResponse.payload}'); print('SupplementsLog: 📱 Payload: ${notificationResponse.payload}');
print('SupplementsLog: 📱 Notification ID: ${notificationResponse.id}'); print('SupplementsLog: 📱 Notification ID: ${notificationResponse.id}');
print('SupplementsLog: 📱 =========================================='); print('SupplementsLog: 📱 ==========================================');
// For now, just log the action. The main app handler will process it. // For now, just log the action. The main app handler will process it.
if (notificationResponse.actionId == 'take_supplement') { if (notificationResponse.actionId == 'take_supplement') {
print('SupplementsLog: 📱 BACKGROUND: Take action detected'); print('SupplementsLog: 📱 BACKGROUND: Take action detected');
@@ -30,7 +30,7 @@ class NotificationService {
bool _isInitialized = false; bool _isInitialized = false;
static bool _engineInitialized = false; static bool _engineInitialized = false;
bool _permissionsRequested = false; bool _permissionsRequested = false;
// Callback for handling supplement intake from notifications // Callback for handling supplement intake from notifications
Function(int supplementId, String supplementName, double units, String unitType)? _onTakeSupplementCallback; Function(int supplementId, String supplementName, double units, String unitType)? _onTakeSupplementCallback;
@@ -45,11 +45,11 @@ class NotificationService {
print('SupplementsLog: 📱 Already initialized'); print('SupplementsLog: 📱 Already initialized');
return; return;
} }
try { try {
print('SupplementsLog: 📱 Initializing timezones...'); print('SupplementsLog: 📱 Initializing timezones...');
print('SupplementsLog: 📱 Engine initialized flag: $_engineInitialized'); print('SupplementsLog: 📱 Engine initialized flag: $_engineInitialized');
if (!_engineInitialized) { if (!_engineInitialized) {
tz.initializeTimeZones(); tz.initializeTimeZones();
_engineInitialized = true; _engineInitialized = true;
@@ -61,15 +61,15 @@ class NotificationService {
print('SupplementsLog: 📱 Warning: Timezone initialization issue (may already be initialized): $e'); print('SupplementsLog: 📱 Warning: Timezone initialization issue (may already be initialized): $e');
_engineInitialized = true; // Mark as initialized to prevent retry _engineInitialized = true; // Mark as initialized to prevent retry
} }
// Try to detect and set the local timezone more reliably // Try to detect and set the local timezone more reliably
try { try {
// First try using the system timezone name // First try using the system timezone name
final String timeZoneName = DateTime.now().timeZoneName; final String timeZoneName = DateTime.now().timeZoneName;
print('SupplementsLog: 📱 System timezone name: $timeZoneName'); print('SupplementsLog: 📱 System timezone name: $timeZoneName');
tz.Location? location; tz.Location? location;
// Try common timezone mappings for your region // Try common timezone mappings for your region
if (timeZoneName.contains('CET') || timeZoneName.contains('CEST')) { if (timeZoneName.contains('CET') || timeZoneName.contains('CEST')) {
location = tz.getLocation('Europe/Amsterdam'); // Netherlands location = tz.getLocation('Europe/Amsterdam'); // Netherlands
@@ -84,19 +84,19 @@ class NotificationService {
location = tz.getLocation('Europe/Amsterdam'); location = tz.getLocation('Europe/Amsterdam');
} }
} }
tz.setLocalLocation(location); tz.setLocalLocation(location);
print('SupplementsLog: 📱 Timezone set to: ${location.name}'); print('SupplementsLog: 📱 Timezone set to: ${location.name}');
} catch (e) { } catch (e) {
print('SupplementsLog: 📱 Error setting timezone: $e, using default'); print('SupplementsLog: 📱 Error setting timezone: $e, using default');
// Fallback to a reasonable default for Netherlands // Fallback to a reasonable default for Netherlands
tz.setLocalLocation(tz.getLocation('Europe/Amsterdam')); tz.setLocalLocation(tz.getLocation('Europe/Amsterdam'));
} }
print('SupplementsLog: 📱 Current local time: ${tz.TZDateTime.now(tz.local)}'); print('SupplementsLog: 📱 Current local time: ${tz.TZDateTime.now(tz.local)}');
print('SupplementsLog: 📱 Current system time: ${DateTime.now()}'); print('SupplementsLog: 📱 Current system time: ${DateTime.now()}');
const AndroidInitializationSettings androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); const AndroidInitializationSettings androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const DarwinInitializationSettings iosSettings = DarwinInitializationSettings( const DarwinInitializationSettings iosSettings = DarwinInitializationSettings(
requestAlertPermission: false, // We'll request these separately requestAlertPermission: false, // We'll request these separately
@@ -119,10 +119,10 @@ class NotificationService {
onDidReceiveNotificationResponse: _onNotificationResponse, onDidReceiveNotificationResponse: _onNotificationResponse,
onDidReceiveBackgroundNotificationResponse: notificationTapBackground, onDidReceiveBackgroundNotificationResponse: notificationTapBackground,
); );
// Test if notification response callback is working // Test if notification response callback is working
print('SupplementsLog: 📱 Callback function is set and ready'); print('SupplementsLog: 📱 Callback function is set and ready');
_isInitialized = true; _isInitialized = true;
print('SupplementsLog: 📱 NotificationService initialization complete'); print('SupplementsLog: 📱 NotificationService initialization complete');
} }
@@ -135,7 +135,7 @@ class NotificationService {
print('SupplementsLog: 📱 Notification ID: ${response.id}'); print('SupplementsLog: 📱 Notification ID: ${response.id}');
print('SupplementsLog: 📱 Input: ${response.input}'); print('SupplementsLog: 📱 Input: ${response.input}');
print('SupplementsLog: 📱 ==============================='); print('SupplementsLog: 📱 ===============================');
if (response.actionId == 'take_supplement') { if (response.actionId == 'take_supplement') {
print('SupplementsLog: 📱 Processing TAKE action...'); print('SupplementsLog: 📱 Processing TAKE action...');
_handleTakeAction(response.payload, response.id); _handleTakeAction(response.payload, response.id);
@@ -151,43 +151,58 @@ class NotificationService {
Future<void> _handleTakeAction(String? payload, int? notificationId) async { Future<void> _handleTakeAction(String? payload, int? notificationId) async {
print('SupplementsLog: 📱 === HANDLING TAKE ACTION ==='); print('SupplementsLog: 📱 === HANDLING TAKE ACTION ===');
print('SupplementsLog: 📱 Payload received: $payload'); print('SupplementsLog: 📱 Payload received: $payload');
if (payload != null) { if (payload != null) {
try { try {
// Parse the payload to get supplement info // Parse the payload to get supplement info
final parts = payload.split('|'); final parts = payload.split('|');
print('SupplementsLog: 📱 Payload parts: $parts (length: ${parts.length})'); print('SupplementsLog: 📱 Payload parts: $parts (length: ${parts.length})');
if (parts.length >= 4) { if (parts.length >= 4) {
final supplementId = int.parse(parts[0]); final supplementId = int.parse(parts[0]);
final supplementName = parts[1]; final supplementName = parts[1];
final units = double.parse(parts[2]); final units = double.parse(parts[2]);
final unitType = parts[3]; final unitType = parts[3];
print('SupplementsLog: 📱 Parsed data:'); print('SupplementsLog: 📱 Parsed data:');
print('SupplementsLog: 📱 - ID: $supplementId'); print('SupplementsLog: 📱 - ID: $supplementId');
print('SupplementsLog: 📱 - Name: $supplementName'); print('SupplementsLog: 📱 - Name: $supplementName');
print('SupplementsLog: 📱 - Units: $units'); print('SupplementsLog: 📱 - Units: $units');
print('SupplementsLog: 📱 - Type: $unitType'); print('SupplementsLog: 📱 - Type: $unitType');
// Call the callback to record the intake // Call the callback to record the intake
if (_onTakeSupplementCallback != null) { if (_onTakeSupplementCallback != null) {
print('SupplementsLog: 📱 Calling supplement callback...'); print('SupplementsLog: 📱 Calling supplement callback...');
_onTakeSupplementCallback!(supplementId, supplementName, units, unitType); _onTakeSupplementCallback!(
supplementId, supplementName, units, unitType);
print('SupplementsLog: 📱 Callback completed'); print('SupplementsLog: 📱 Callback completed');
} else { } else {
print('SupplementsLog: 📱 ERROR: No callback registered!'); print('SupplementsLog: 📱 ERROR: No callback registered!');
} }
// Mark notification as taken in database (this will cancel any pending retries) // For retry notifications, the original notification ID is in the payload
if (notificationId != null) { int originalNotificationId;
print('SupplementsLog: 📱 Marking notification $notificationId as taken'); if (parts.length > 4 && int.tryParse(parts[4]) != null) {
await DatabaseHelper.instance.markNotificationTaken(notificationId); originalNotificationId = int.parse(parts[4]);
print(
// Cancel any pending retry notifications for this notification 'SupplementsLog: 📱 Retry notification detected. Original ID: $originalNotificationId');
_cancelRetryNotifications(notificationId); } else if (notificationId != null) {
originalNotificationId = notificationId;
} else {
print(
'SupplementsLog: 📱 ERROR: Could not determine notification ID to cancel.');
return;
} }
// Mark notification as taken in database (this will cancel any pending retries)
print(
'SupplementsLog: 📱 Marking notification $originalNotificationId as taken');
await DatabaseHelper.instance
.markNotificationTaken(originalNotificationId);
// Cancel any pending retry notifications for this notification
_cancelRetryNotifications(originalNotificationId);
// Show a confirmation notification // Show a confirmation notification
print('SupplementsLog: 📱 Showing confirmation notification...'); print('SupplementsLog: 📱 Showing confirmation notification...');
showInstantNotification( showInstantNotification(
@@ -195,7 +210,8 @@ class NotificationService {
'$supplementName has been recorded at ${DateTime.now().hour.toString().padLeft(2, '0')}:${DateTime.now().minute.toString().padLeft(2, '0')}', '$supplementName has been recorded at ${DateTime.now().hour.toString().padLeft(2, '0')}:${DateTime.now().minute.toString().padLeft(2, '0')}',
); );
} else { } else {
print('SupplementsLog: 📱 ERROR: Invalid payload format - not enough parts'); print(
'SupplementsLog: 📱 ERROR: Invalid payload format - not enough parts');
} }
} catch (e) { } catch (e) {
print('SupplementsLog: 📱 ERROR in _handleTakeAction: $e'); print('SupplementsLog: 📱 ERROR in _handleTakeAction: $e');
@@ -218,26 +234,26 @@ class NotificationService {
void _handleSnoozeAction(String? payload, int minutes, int? notificationId) { void _handleSnoozeAction(String? payload, int minutes, int? notificationId) {
print('SupplementsLog: 📱 === HANDLING SNOOZE ACTION ==='); print('SupplementsLog: 📱 === HANDLING SNOOZE ACTION ===');
print('SupplementsLog: 📱 Payload: $payload, Minutes: $minutes'); print('SupplementsLog: 📱 Payload: $payload, Minutes: $minutes');
if (payload != null) { if (payload != null) {
try { try {
final parts = payload.split('|'); final parts = payload.split('|');
if (parts.length >= 2) { if (parts.length >= 2) {
final supplementId = int.parse(parts[0]); final supplementId = int.parse(parts[0]);
final supplementName = parts[1]; final supplementName = parts[1];
print('SupplementsLog: 📱 Snoozing supplement for $minutes minutes: $supplementName'); print('SupplementsLog: 📱 Snoozing supplement for $minutes minutes: $supplementName');
// Mark notification as snoozed in database (increment retry count) // Mark notification as snoozed in database (increment retry count)
if (notificationId != null) { if (notificationId != null) {
print('SupplementsLog: 📱 Incrementing retry count for notification $notificationId'); print('SupplementsLog: 📱 Incrementing retry count for notification $notificationId');
DatabaseHelper.instance.incrementRetryCount(notificationId); DatabaseHelper.instance.incrementRetryCount(notificationId);
} }
// Schedule a new notification for the snooze time // Schedule a new notification for the snooze time
final snoozeTime = tz.TZDateTime.now(tz.local).add(Duration(minutes: minutes)); final snoozeTime = tz.TZDateTime.now(tz.local).add(Duration(minutes: minutes));
print('SupplementsLog: 📱 Snooze time: $snoozeTime'); print('SupplementsLog: 📱 Snooze time: $snoozeTime');
_notifications.zonedSchedule( _notifications.zonedSchedule(
supplementId * 1000 + minutes, // Unique ID for snooze notifications supplementId * 1000 + minutes, // Unique ID for snooze notifications
'Reminder: $supplementName', 'Reminder: $supplementName',
@@ -266,7 +282,7 @@ class NotificationService {
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
payload: payload, payload: payload,
); );
showInstantNotification( showInstantNotification(
'Reminder Snoozed', 'Reminder Snoozed',
'$supplementName reminder snoozed for $minutes minutes', '$supplementName reminder snoozed for $minutes minutes',
@@ -300,32 +316,32 @@ class NotificationService {
required int maxRetryAttempts, required int maxRetryAttempts,
}) async { }) async {
print('SupplementsLog: 📱 Checking for pending notifications to retry...'); print('SupplementsLog: 📱 Checking for pending notifications to retry...');
try { try {
if (!persistentReminders) { if (!persistentReminders) {
print('SupplementsLog: 📱 Persistent reminders disabled'); print('SupplementsLog: 📱 Persistent reminders disabled');
return; return;
} }
print('SupplementsLog: 📱 Retry settings: interval=$reminderRetryInterval min, max=$maxRetryAttempts attempts'); print('SupplementsLog: 📱 Retry settings: interval=$reminderRetryInterval min, max=$maxRetryAttempts attempts');
// Get all pending notifications from database // Get all pending notifications from database
final pendingNotifications = await DatabaseHelper.instance.getPendingNotifications(); final pendingNotifications = await DatabaseHelper.instance.getPendingNotifications();
print('SupplementsLog: 📱 Found ${pendingNotifications.length} pending notifications'); print('SupplementsLog: 📱 Found ${pendingNotifications.length} pending notifications');
final now = DateTime.now(); final now = DateTime.now();
for (final notification in pendingNotifications) { for (final notification in pendingNotifications) {
final scheduledTime = DateTime.parse(notification['scheduledTime']).toLocal(); final scheduledTime = DateTime.parse(notification['scheduledTime']).toLocal();
final retryCount = notification['retryCount'] as int; final retryCount = notification['retryCount'] as int;
final lastRetryTime = notification['lastRetryTime'] != null final lastRetryTime = notification['lastRetryTime'] != null
? DateTime.parse(notification['lastRetryTime']).toLocal() ? DateTime.parse(notification['lastRetryTime']).toLocal()
: null; : null;
// Check if notification is overdue // Check if notification is overdue
final timeSinceScheduled = now.difference(scheduledTime).inMinutes; final timeSinceScheduled = now.difference(scheduledTime).inMinutes;
final shouldRetry = timeSinceScheduled >= reminderRetryInterval; final shouldRetry = timeSinceScheduled >= reminderRetryInterval;
print('SupplementsLog: 📱 Checking notification ${notification['notificationId']}:'); print('SupplementsLog: 📱 Checking notification ${notification['notificationId']}:');
print('SupplementsLog: 📱 Scheduled: $scheduledTime (local)'); print('SupplementsLog: 📱 Scheduled: $scheduledTime (local)');
print('SupplementsLog: 📱 Now: $now'); print('SupplementsLog: 📱 Now: $now');
@@ -333,13 +349,13 @@ class NotificationService {
print('SupplementsLog: 📱 Retry interval: $reminderRetryInterval minutes'); print('SupplementsLog: 📱 Retry interval: $reminderRetryInterval minutes');
print('SupplementsLog: 📱 Should retry: $shouldRetry'); print('SupplementsLog: 📱 Should retry: $shouldRetry');
print('SupplementsLog: 📱 Retry count: $retryCount / $maxRetryAttempts'); print('SupplementsLog: 📱 Retry count: $retryCount / $maxRetryAttempts');
// Check if we haven't exceeded max retry attempts // Check if we haven't exceeded max retry attempts
if (retryCount >= maxRetryAttempts) { if (retryCount >= maxRetryAttempts) {
print('SupplementsLog: 📱 Notification ${notification['notificationId']} exceeded max attempts ($maxRetryAttempts)'); print('SupplementsLog: 📱 Notification ${notification['notificationId']} exceeded max attempts ($maxRetryAttempts)');
continue; continue;
} }
// Check if enough time has passed since last retry // Check if enough time has passed since last retry
if (lastRetryTime != null) { if (lastRetryTime != null) {
final timeSinceLastRetry = now.difference(lastRetryTime).inMinutes; final timeSinceLastRetry = now.difference(lastRetryTime).inMinutes;
@@ -348,7 +364,7 @@ class NotificationService {
continue; continue;
} }
} }
if (shouldRetry) { if (shouldRetry) {
print('SupplementsLog: 📱 ⚡ SCHEDULING RETRY for notification ${notification['notificationId']}'); print('SupplementsLog: 📱 ⚡ SCHEDULING RETRY for notification ${notification['notificationId']}');
await _scheduleRetryNotification(notification, retryCount + 1); await _scheduleRetryNotification(notification, retryCount + 1);
@@ -365,16 +381,16 @@ class NotificationService {
try { try {
final notificationId = notification['notificationId'] as int; final notificationId = notification['notificationId'] as int;
final supplementId = notification['supplementId'] as int; final supplementId = notification['supplementId'] as int;
// Generate a unique ID for this retry (200000 + original_id * 10 + retry_attempt) // Generate a unique ID for this retry (200000 + original_id * 10 + retry_attempt)
final retryNotificationId = 200000 + (notificationId * 10) + retryAttempt; final retryNotificationId = 200000 + (notificationId * 10) + retryAttempt;
print('SupplementsLog: 📱 Scheduling retry notification $retryNotificationId for supplement $supplementId (attempt $retryAttempt)'); print('SupplementsLog: 📱 Scheduling retry notification $retryNotificationId for supplement $supplementId (attempt $retryAttempt)');
// Get supplement details from database // Get supplement details from database
final supplements = await DatabaseHelper.instance.getAllSupplements(); final supplements = await DatabaseHelper.instance.getAllSupplements();
final supplement = supplements.firstWhere((s) => s.id == supplementId && s.isActive, orElse: () => throw Exception('Supplement not found')); final supplement = supplements.firstWhere((s) => s.id == supplementId && s.isActive, orElse: () => throw Exception('Supplement not found'));
// Schedule the retry notification immediately // Schedule the retry notification immediately
await _notifications.show( await _notifications.show(
retryNotificationId, retryNotificationId,
@@ -404,10 +420,10 @@ class NotificationService {
), ),
payload: '${supplement.id}|${supplement.name}|${supplement.numberOfUnits}|${supplement.unitType}|$notificationId', payload: '${supplement.id}|${supplement.name}|${supplement.numberOfUnits}|${supplement.unitType}|$notificationId',
); );
// Update the retry count in database // Update the retry count in database
await DatabaseHelper.instance.incrementRetryCount(notificationId); await DatabaseHelper.instance.incrementRetryCount(notificationId);
print('SupplementsLog: 📱 Retry notification scheduled successfully'); print('SupplementsLog: 📱 Retry notification scheduled successfully');
} catch (e) { } catch (e) {
print('SupplementsLog: 📱 Error scheduling retry notification: $e'); print('SupplementsLog: 📱 Error scheduling retry notification: $e');
@@ -420,10 +436,10 @@ class NotificationService {
print('SupplementsLog: 📱 Permissions already requested'); print('SupplementsLog: 📱 Permissions already requested');
return true; return true;
} }
try { try {
_permissionsRequested = true; _permissionsRequested = true;
final androidPlugin = _notifications.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>(); final androidPlugin = _notifications.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
if (androidPlugin != null) { if (androidPlugin != null) {
print('SupplementsLog: 📱 Requesting Android permissions...'); print('SupplementsLog: 📱 Requesting Android permissions...');
@@ -434,7 +450,7 @@ class NotificationService {
return false; return false;
} }
} }
final iosPlugin = _notifications.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>(); final iosPlugin = _notifications.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
if (iosPlugin != null) { if (iosPlugin != null) {
print('SupplementsLog: 📱 Requesting iOS permissions...'); print('SupplementsLog: 📱 Requesting iOS permissions...');
@@ -449,7 +465,7 @@ class NotificationService {
return false; return false;
} }
} }
print('SupplementsLog: 📱 All permissions granted successfully'); print('SupplementsLog: 📱 All permissions granted successfully');
return true; return true;
} catch (e) { } catch (e) {
@@ -462,7 +478,7 @@ class NotificationService {
Future<void> scheduleSupplementReminders(Supplement supplement) async { Future<void> scheduleSupplementReminders(Supplement supplement) async {
print('SupplementsLog: 📱 Scheduling reminders for ${supplement.name}'); print('SupplementsLog: 📱 Scheduling reminders for ${supplement.name}');
print('SupplementsLog: 📱 Reminder times: ${supplement.reminderTimes}'); print('SupplementsLog: 📱 Reminder times: ${supplement.reminderTimes}');
// Cancel existing notifications for this supplement // Cancel existing notifications for this supplement
await cancelSupplementReminders(supplement.id!); await cancelSupplementReminders(supplement.id!);
@@ -474,7 +490,7 @@ class NotificationService {
final notificationId = supplement.id! * 100 + i; // Unique ID for each reminder final notificationId = supplement.id! * 100 + i; // Unique ID for each reminder
final scheduledTime = _nextInstanceOfTime(hour, minute); final scheduledTime = _nextInstanceOfTime(hour, minute);
print('SupplementsLog: 📱 Scheduling notification ID $notificationId for ${timeStr} -> ${scheduledTime}'); print('SupplementsLog: 📱 Scheduling notification ID $notificationId for ${timeStr} -> ${scheduledTime}');
// Track this notification in the database // Track this notification in the database
@@ -505,7 +521,7 @@ class NotificationService {
), ),
AndroidNotificationAction( AndroidNotificationAction(
'snooze_10', 'snooze_10',
'Snooze 10min', 'Snooze 10min',
icon: DrawableResourceAndroidBitmap('@android:drawable/ic_menu_recent_history'), icon: DrawableResourceAndroidBitmap('@android:drawable/ic_menu_recent_history'),
showsUserInterface: true, // Changed to true to open app showsUserInterface: true, // Changed to true to open app
), ),
@@ -517,10 +533,10 @@ class NotificationService {
matchDateTimeComponents: DateTimeComponents.time, matchDateTimeComponents: DateTimeComponents.time,
payload: '${supplement.id}|${supplement.name}|${supplement.numberOfUnits}|${supplement.unitType}', payload: '${supplement.id}|${supplement.name}|${supplement.numberOfUnits}|${supplement.unitType}',
); );
print('SupplementsLog: 📱 Successfully scheduled notification ID $notificationId'); print('SupplementsLog: 📱 Successfully scheduled notification ID $notificationId');
} }
// Get all pending notifications to verify // Get all pending notifications to verify
final pendingNotifications = await _notifications.pendingNotificationRequests(); final pendingNotifications = await _notifications.pendingNotificationRequests();
print('SupplementsLog: 📱 Total pending notifications: ${pendingNotifications.length}'); print('SupplementsLog: 📱 Total pending notifications: ${pendingNotifications.length}');
@@ -535,7 +551,7 @@ class NotificationService {
final notificationId = supplementId * 100 + i; final notificationId = supplementId * 100 + i;
await _notifications.cancel(notificationId); await _notifications.cancel(notificationId);
} }
// Also clean up database tracking records for this supplement // Also clean up database tracking records for this supplement
await DatabaseHelper.instance.clearNotificationTracking(supplementId); await DatabaseHelper.instance.clearNotificationTracking(supplementId);
} }
@@ -547,18 +563,18 @@ class NotificationService {
tz.TZDateTime _nextInstanceOfTime(int hour, int minute) { tz.TZDateTime _nextInstanceOfTime(int hour, int minute) {
final tz.TZDateTime now = tz.TZDateTime.now(tz.local); final tz.TZDateTime now = tz.TZDateTime.now(tz.local);
tz.TZDateTime scheduledDate = tz.TZDateTime(tz.local, now.year, now.month, now.day, hour, minute); tz.TZDateTime scheduledDate = tz.TZDateTime(tz.local, now.year, now.month, now.day, hour, minute);
print('SupplementsLog: 📱 Current time: $now (${now.timeZoneName})'); print('SupplementsLog: 📱 Current time: $now (${now.timeZoneName})');
print('SupplementsLog: 📱 Target time: ${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}'); print('SupplementsLog: 📱 Target time: ${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}');
print('SupplementsLog: 📱 Initial scheduled date: $scheduledDate (${scheduledDate.timeZoneName})'); print('SupplementsLog: 📱 Initial scheduled date: $scheduledDate (${scheduledDate.timeZoneName})');
if (scheduledDate.isBefore(now)) { if (scheduledDate.isBefore(now)) {
scheduledDate = scheduledDate.add(const Duration(days: 1)); scheduledDate = scheduledDate.add(const Duration(days: 1));
print('SupplementsLog: 📱 Time has passed, scheduling for tomorrow: $scheduledDate (${scheduledDate.timeZoneName})'); print('SupplementsLog: 📱 Time has passed, scheduling for tomorrow: $scheduledDate (${scheduledDate.timeZoneName})');
} else { } else {
print('SupplementsLog: 📱 Time is in the future, scheduling for today: $scheduledDate (${scheduledDate.timeZoneName})'); print('SupplementsLog: 📱 Time is in the future, scheduling for today: $scheduledDate (${scheduledDate.timeZoneName})');
} }
return scheduledDate; return scheduledDate;
} }
@@ -595,9 +611,9 @@ class NotificationService {
print('SupplementsLog: 📱 Testing scheduled notification...'); print('SupplementsLog: 📱 Testing scheduled notification...');
final now = tz.TZDateTime.now(tz.local); final now = tz.TZDateTime.now(tz.local);
final testTime = now.add(const Duration(minutes: 1)); final testTime = now.add(const Duration(minutes: 1));
print('SupplementsLog: 📱 Scheduling test notification for: $testTime'); print('SupplementsLog: 📱 Scheduling test notification for: $testTime');
await _notifications.zonedSchedule( await _notifications.zonedSchedule(
99999, // Special ID for test notifications 99999, // Special ID for test notifications
'Test Scheduled Notification', 'Test Scheduled Notification',
@@ -615,7 +631,7 @@ class NotificationService {
), ),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
); );
print('SupplementsLog: 📱 Test notification scheduled successfully'); print('SupplementsLog: 📱 Test notification scheduled successfully');
} }
@@ -627,7 +643,7 @@ class NotificationService {
// Debug function to test notification actions // Debug function to test notification actions
Future<void> testNotificationWithActions() async { Future<void> testNotificationWithActions() async {
print('SupplementsLog: 📱 Creating test notification with actions...'); print('SupplementsLog: 📱 Creating test notification with actions...');
await _notifications.show( await _notifications.show(
88888, // Special test ID 88888, // Special test ID
'Test Action Notification', 'Test Action Notification',
@@ -658,14 +674,14 @@ class NotificationService {
), ),
payload: '999|Test Supplement|1.0|capsule', payload: '999|Test Supplement|1.0|capsule',
); );
print('SupplementsLog: 📱 Test notification with actions created'); print('SupplementsLog: 📱 Test notification with actions created');
} }
// Debug function to test basic notification tap response // Debug function to test basic notification tap response
Future<void> testBasicNotification() async { Future<void> testBasicNotification() async {
print('SupplementsLog: 📱 Creating basic test notification...'); print('SupplementsLog: 📱 Creating basic test notification...');
await _notifications.show( await _notifications.show(
77777, // Special test ID for basic notification 77777, // Special test ID for basic notification
'Basic Test Notification', 'Basic Test Notification',
@@ -682,7 +698,7 @@ class NotificationService {
), ),
payload: 'basic_test', payload: 'basic_test',
); );
print('SupplementsLog: 📱 Basic test notification created'); print('SupplementsLog: 📱 Basic test notification created');
} }
} }

View File

@@ -0,0 +1,40 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import '../models/nutrient.dart';
class NutrientDataService {
static final NutrientDataService _instance = NutrientDataService._internal();
factory NutrientDataService() {
return _instance;
}
NutrientDataService._internal();
List<Nutrient>? _nutrients;
Future<List<Nutrient>> get nutrients async {
if (_nutrients != null) {
return _nutrients!;
}
await _loadNutrientData();
return _nutrients!;
}
Future<void> _loadNutrientData() async {
try {
final String response = await rootBundle.loadString('assets/canada_health.json');
final data = await json.decode(response);
final nutrientsData = data['nutrients'] as Map<String, dynamic>;
_nutrients = nutrientsData.entries.map((entry) {
return Nutrient.fromJson(entry.key, entry.value);
}).toList();
} catch (e) {
print('Error loading nutrient data: $e');
_nutrients = [];
}
}
}

View File

@@ -0,0 +1,617 @@
import 'dart:async';
import 'dart:math';
import '../models/ingredient.dart';
import '../models/nutrient.dart';
import 'nutrient_data_service.dart';
/// Represents an RDA/AI match for a user and nutrient
class RdaResult {
final String nutrientKey;
final String unitLabel; // e.g., "mg/day", "µg/day (RAE)"
final String rdaType; // "RDA/AI" or "AI"
final double value; // RDA/AI value in the units of unitLabel
final double? valueMin; // Optional minimum recommended value
final double? valueMax; // Optional maximum recommended value
final double? ul; // Upper limit (if provided) in the same base units as unitLabel (life-stage)
final String matchedLifeStageLabel; // e.g., "19-30 y", "51-70 y"
final String? lifeStageDescription; // Optional description for the life stage (e.g., maintenance/loading)
final UpperLimit? nutrientUl; // Nutrient-level UL (object with unit/duration/note), if available
final String? note; // Optional dataset note (e.g., magnesium UL is supplemental only)
const RdaResult({
required this.nutrientKey,
required this.unitLabel,
required this.rdaType,
required this.value,
this.valueMin,
this.valueMax,
required this.ul,
required this.matchedLifeStageLabel,
this.lifeStageDescription,
this.nutrientUl,
this.note,
});
}
/// Aggregated daily overview by nutrient
class RdaAggregate {
final String nutrientKey;
final String unitLabel; // RDA unit label
final double rdaValue; // Midpoint of range when available
final double? rdaValueMin; // Optional minimum recommended value
final double? rdaValueMax; // Optional maximum recommended value
final double? ulValue;
final double totalAmountInRdaUnit; // Total intake converted to RDA units
final double percentOfRda; // 0..100+ (may exceed 100)
final double? percentOfUl; // 0..100+ (may exceed 100)
final String? matchedLifeStageLabel; // e.g., "19-30 y"
final String? matchedLifeStageDescription; // Optional description for the life stage
final String? rdaType; // e.g., "RDA/AI" or "AI"
final UpperLimit? nutrientUl; // Nutrient-level UL object (unit/duration/note)
final String? note; // Optional dataset note
const RdaAggregate({
required this.nutrientKey,
required this.unitLabel,
required this.rdaValue,
this.rdaValueMin,
this.rdaValueMax,
required this.ulValue,
required this.totalAmountInRdaUnit,
required this.percentOfRda,
required this.percentOfUl,
this.matchedLifeStageLabel,
this.matchedLifeStageDescription,
this.rdaType,
this.nutrientUl,
this.note,
});
}
/// Service for working with Health Canada DRIs (RDA/AI and UL)
/// - Maps app ingredient names to nutrient keys in canada_health.json
/// - Computes user-specific RDA/AI and UL values based on age and gender
/// - Converts units and calculates % of RDA/AI and % of UL
class RdaService {
RdaService._internal();
static final RdaService _instance = RdaService._internal();
factory RdaService() => _instance;
final NutrientDataService _nutrientDataService = NutrientDataService();
// Cache nutrients by key: e.g., "vitamin_d"
Map<String, Nutrient>? _nutrientsByKey;
// Known alias mapping for common ingredient names to nutrient keys
// Keys must be lowercase for matching
static const Map<String, String> _aliasToNutrientKey = {
// Vitamin C
'vitamin c': 'vitamin_c',
'ascorbic acid': 'vitamin_c',
// Vitamin D
'vitamin d': 'vitamin_d',
'vitamin d3': 'vitamin_d',
'cholecalciferol': 'vitamin_d',
'vitamin d2': 'vitamin_d', // ergocalciferol - treat same RDA
// Vitamin A (RAE)
'vitamin a': 'vitamin_a',
'retinol': 'vitamin_a',
'beta-carotene': 'vitamin_a',
// Vitamin E (alpha-tocopherol)
'vitamin e': 'vitamin_e',
'alpha tocopherol': 'vitamin_e',
'alpha-tocopherol': 'vitamin_e',
// Vitamin K (K1/K2 common mapping to total Vitamin K AI)
'vitamin k': 'vitamin_k',
'vitamin k1': 'vitamin_k',
'phylloquinone': 'vitamin_k',
'vitamin k2': 'vitamin_k',
'menaquinone': 'vitamin_k',
// B1 (Thiamine)
'vitamin b1': 'vitamin_b1',
'thiamine': 'vitamin_b1',
'thiamin': 'vitamin_b1',
// B2 (Riboflavin)
'vitamin b2': 'vitamin_b2',
'riboflavin': 'vitamin_b2',
// Folate
'folate': 'folate_dfe',
'folic acid': 'folate_dfe',
'folate (dfe)': 'folate_dfe',
'dfe': 'folate_dfe',
};
// RDA result and aggregate types moved to top-level (Dart doesn't support nested classes)
/// Get a user-specific RDA result for a given ingredient name.
/// - Resolves the ingredient to a nutrient key using aliases and simple heuristics.
/// - Computes the appropriate life-stage record based on age and gender.
///
/// If the ingredient doesn't map to a known nutrient or no life stage matches,
/// returns null.
Future<RdaResult?> getUserRdaForIngredient(
String ingredientName, {
DateTime? dateOfBirth,
String? gender, // expected values similar to ['Male','Female','Other','Prefer not to say']
}) async {
final key = await mapIngredientToNutrientKey(ingredientName);
if (key == null) return null;
return getUserRdaForNutrientKey(
key,
dateOfBirth: dateOfBirth,
gender: gender,
);
}
/// Get a user-specific RDA result for a known nutrient key
/// e.g., "vitamin_d", "vitamin_c".
Future<RdaResult?> getUserRdaForNutrientKey(
String nutrientKey, {
DateTime? dateOfBirth,
String? gender,
}) async {
final nutrient = await _getNutrientByKey(nutrientKey);
if (nutrient == null) return null;
final _UserProfile profile = _UserProfile.from(dateOfBirth: dateOfBirth, gender: gender);
final LifeStage? stage = _matchLifeStageForProfile(nutrient.lifeStages, profile);
if (stage == null) return null;
return RdaResult(
nutrientKey: nutrientKey,
unitLabel: nutrient.unit,
rdaType: nutrient.rdaType,
value: stage.value,
valueMin: stage.valueMin,
valueMax: stage.valueMax,
ul: stage.ul,
matchedLifeStageLabel: stage.ageRange,
lifeStageDescription: stage.description,
nutrientUl: nutrient.ul,
note: nutrient.note,
);
}
/// Compute % of RDA and % of UL for a single ingredient dose.
/// - Resolves ingredient to nutrient key
/// - Converts the amount+unit to the RDA unit base for that nutrient
/// - Calculates percent of RDA and UL
///
/// Returns null if the ingredient cannot be mapped or units cannot be converted.
Future<RdaAggregate?> computePercentForDose(
String ingredientName,
double amount,
String unit, {
DateTime? dateOfBirth,
String? gender,
}) async {
final rda = await getUserRdaForIngredient(
ingredientName,
dateOfBirth: dateOfBirth,
gender: gender,
);
if (rda == null) return null;
final String rdaUnitSymbol = _unitSymbolFromLabel(rda.unitLabel); // "mg" or "ug"
final String normalizedInputUnit = _normalizeUnit(unit);
final double? amountInRdaUnit = _convertAmountToTargetUnit(
ingredientName: ingredientName,
amount: amount,
fromUnit: normalizedInputUnit,
toUnit: rdaUnitSymbol,
);
if (amountInRdaUnit == null) return null;
final double rdaForCalc = (rda.valueMin != null && rda.valueMax != null)
? ((rda.valueMin! + rda.valueMax!) / 2.0)
: rda.value;
final double percentOfRda = (amountInRdaUnit / rdaForCalc) * 100.0;
final double? percentOfUl =
rda.ul != null && rda.ul! > 0 ? (amountInRdaUnit / rda.ul!) * 100.0 : null;
return RdaAggregate(
nutrientKey: rda.nutrientKey,
unitLabel: rda.unitLabel,
rdaValue: rdaForCalc,
rdaValueMin: rda.valueMin,
rdaValueMax: rda.valueMax,
ulValue: rda.ul,
totalAmountInRdaUnit: amountInRdaUnit,
percentOfRda: percentOfRda,
percentOfUl: percentOfUl,
matchedLifeStageLabel: rda.matchedLifeStageLabel,
matchedLifeStageDescription: rda.lifeStageDescription,
rdaType: rda.rdaType,
nutrientUl: rda.nutrientUl,
note: rda.note,
);
}
/// Aggregate multiple ingredients (e.g., full-day intake) into user-specific RDA overview.
/// - Sums all ingredients mapped to the same nutrient
/// - Converts units to the RDA base unit
/// - Returns map keyed by nutrientKey
Future<Map<String, RdaAggregate>> aggregateDailyIntake(
List<Ingredient> ingredients, {
DateTime? dateOfBirth,
String? gender,
}) async {
final Map<String, double> totalsByNutrient = {};
final Map<String, RdaResult> rdaByNutrient = {};
for (final ing in ingredients) {
final key = await mapIngredientToNutrientKey(ing.name);
if (key == null) continue;
// Ensure RDA is loaded for the nutrient
rdaByNutrient[key] = rdaByNutrient[key] ??
(await getUserRdaForNutrientKey(key, dateOfBirth: dateOfBirth, gender: gender))!;
final rda = rdaByNutrient[key];
if (rda == null) continue; // no match for this nutrient
final String rdaUnitSymbol = _unitSymbolFromLabel(rda.unitLabel);
final double? converted = _convertAmountToTargetUnit(
ingredientName: ing.name,
amount: ing.amount,
fromUnit: _normalizeUnit(ing.unit),
toUnit: rdaUnitSymbol,
);
if (converted == null) continue;
totalsByNutrient[key] = (totalsByNutrient[key] ?? 0.0) + converted;
}
final Map<String, RdaAggregate> result = {};
for (final entry in totalsByNutrient.entries) {
final key = entry.key;
final total = entry.value;
final rda = rdaByNutrient[key];
if (rda == null) continue;
final double rdaForCalc = (rda.valueMin != null && rda.valueMax != null)
? ((rda.valueMin! + rda.valueMax!) / 2.0)
: rda.value;
final double percentOfRda = (total / rdaForCalc) * 100.0;
final double? percentOfUl = rda.ul != null && rda.ul! > 0 ? (total / rda.ul!) * 100.0 : null;
result[key] = RdaAggregate(
nutrientKey: key,
unitLabel: rda.unitLabel,
rdaValue: rdaForCalc,
rdaValueMin: rda.valueMin,
rdaValueMax: rda.valueMax,
ulValue: rda.ul,
totalAmountInRdaUnit: total,
percentOfRda: percentOfRda,
percentOfUl: percentOfUl,
matchedLifeStageLabel: rda.matchedLifeStageLabel,
matchedLifeStageDescription: rda.lifeStageDescription,
rdaType: rda.rdaType,
nutrientUl: rda.nutrientUl,
note: rda.note,
);
}
return result;
}
/// Map an ingredient name (e.g., "Vitamin D3") to a nutrient key (e.g., "vitamin_d") used in canada_health.json
/// Returns null if no mapping is found.
Future<String?> mapIngredientToNutrientKey(String ingredientName) async {
await _ensureNutrientsLoaded();
final String cleaned = _normalizeIngredientName(ingredientName);
// Direct alias mapping
final direct = _aliasToNutrientKey[cleaned];
if (direct != null && _nutrientsByKey!.containsKey(direct)) return direct;
// Heuristic contains-based mapping
if (cleaned.contains('vitamin d')) return _nutrientsByKey!.containsKey('vitamin_d') ? 'vitamin_d' : null;
if (cleaned.contains('vitamin c')) return _nutrientsByKey!.containsKey('vitamin_c') ? 'vitamin_c' : null;
if (cleaned.contains('vitamin a') || cleaned.contains('retinol') || cleaned.contains('beta carotene')) {
return _nutrientsByKey!.containsKey('vitamin_a') ? 'vitamin_a' : null;
}
if (cleaned.contains('vitamin e') || cleaned.contains('alpha tocopherol') || cleaned.contains('alpha-tocopherol')) {
return _nutrientsByKey!.containsKey('vitamin_e') ? 'vitamin_e' : null;
}
if (cleaned.contains('vitamin k') || cleaned.contains('phylloquinone') || cleaned.contains('menaquinone')) {
return _nutrientsByKey!.containsKey('vitamin_k') ? 'vitamin_k' : null;
}
if (cleaned.contains('b1') || cleaned.contains('thiamin') || cleaned.contains('thiamine')) {
return _nutrientsByKey!.containsKey('vitamin_b1') ? 'vitamin_b1' : null;
}
if (cleaned.contains('b2') || cleaned.contains('riboflavin')) {
return _nutrientsByKey!.containsKey('vitamin_b2') ? 'vitamin_b2' : null;
}
if (cleaned.contains('folate') || cleaned.contains('folic')) {
return _nutrientsByKey!.containsKey('folate_dfe') ? 'folate_dfe' : null;
}
if (cleaned.contains('vitamin b3') || cleaned.contains('niacin') || cleaned.contains('nicotinic acid') || cleaned.contains('niacinamide')) {
return _nutrientsByKey!.containsKey('vitamin_b3') ? 'vitamin_b3' : null;
}
if (cleaned.contains('vitamin b5') || cleaned.contains('pantothenic')) {
return _nutrientsByKey!.containsKey('vitamin_b5') ? 'vitamin_b5' : null;
}
if (cleaned.contains('vitamin b6') || cleaned.contains('pyridoxine')) {
return _nutrientsByKey!.containsKey('vitamin_b6') ? 'vitamin_b6' : null;
}
if (cleaned.contains('vitamin b12') || cleaned.contains('cobalamin') || cleaned.contains('cyanocobalamin') || cleaned.contains('methylcobalamin')) {
return _nutrientsByKey!.containsKey('vitamin_b12') ? 'vitamin_b12' : null;
}
if (cleaned.contains('magnesium')) {
return _nutrientsByKey!.containsKey('magnesium') ? 'magnesium' : null;
}
if (cleaned.contains('zinc') || cleaned == 'zn') {
return _nutrientsByKey!.containsKey('zinc') ? 'zinc' : null;
}
if (cleaned.contains('iron') || cleaned.contains('ferrous') || cleaned.contains('ferric')) {
return _nutrientsByKey!.containsKey('iron') ? 'iron' : null;
}
if (cleaned.contains('creatine') || cleaned.contains('creapure') || cleaned.contains('creatine monohydrate')) {
return _nutrientsByKey!.containsKey('creatine') ? 'creatine' : null;
}
return null;
}
// -----------------------
// Internal helpers
// -----------------------
Future<void> _ensureNutrientsLoaded() async {
if (_nutrientsByKey != null) return;
final list = await _nutrientDataService.nutrients;
_nutrientsByKey = {for (final n in list) n.name: n};
}
Future<Nutrient?> _getNutrientByKey(String key) async {
await _ensureNutrientsLoaded();
return _nutrientsByKey![key];
}
// Normalize units (user input and stored ingredients)
// Supported return values: "mg", "ug", "g", "iu", others returned as lowercased original (e.g., "ml")
String _normalizeUnit(String unit) {
final u = unit.trim().toLowerCase();
if (u == 'mg') return 'mg';
if (u == 'g' || u == 'gram' || u == 'grams') return 'g';
if (u == 'µg' || u == 'μg' || u == 'mcg' || u == 'ug' || u == 'microgram' || u == 'micrograms') return 'ug';
if (u == 'iu') return 'iu';
return u; // e.g., "ml", "drops" etc. (unhandled for RDA calc)
}
// Extract the base unit symbol ("mg" or "ug") from the dataset unit label (e.g., "µg/day (RAE)")
String _unitSymbolFromLabel(String label) {
final lower = label.toLowerCase();
if (lower.startsWith('mg')) return 'mg';
if (lower.startsWith('g')) return 'g';
if (lower.startsWith('µg') || lower.startsWith('μg') || lower.startsWith('mcg')) return 'ug';
// Fallback: assume microgram if unknown
return 'ug';
}
// Convert an amount from one unit to another.
// Supported:
// - mg <-> ug <-> g
// - IU->ug for Vitamin D only (1 µg = 40 IU)
// Returns null if conversion cannot be performed.
double? _convertAmountToTargetUnit({
required String ingredientName,
required double amount,
required String fromUnit,
required String toUnit,
}) {
if (amount.isNaN || amount.isInfinite) return null;
// Handle IU conversions only for Vitamin D
final name = _normalizeIngredientName(ingredientName);
final isVitaminD = name.contains('vitamin d') || name.contains('cholecalciferol') || name.contains('ergocalciferol');
// If fromUnit equals toUnit and it's one of our supported numeric units
if ((fromUnit == toUnit) && (fromUnit == 'mg' || fromUnit == 'ug' || fromUnit == 'g')) {
return amount;
}
// IU -> ug for Vitamin D
if (fromUnit == 'iu' && isVitaminD) {
// 1 µg = 40 IU => ug = IU / 40
final ug = amount / 40.0;
if (toUnit == 'ug') return ug;
if (toUnit == 'mg') return ug / 1000.0;
if (toUnit == 'g') return ug / 1e6;
return null;
}
// Mass conversions
double? inUg;
if (fromUnit == 'ug') {
inUg = amount;
} else if (fromUnit == 'mg') {
inUg = amount * 1000.0;
} else if (fromUnit == 'g') {
inUg = amount * 1e6;
} else {
// Unsupported unit (e.g., ml, drops)
return null;
}
if (toUnit == 'ug') return inUg;
if (toUnit == 'mg') return inUg / 1000.0;
if (toUnit == 'g') return inUg / 1e6;
return null;
}
// Normalize an ingredient name for alias matching
String _normalizeIngredientName(String name) {
final lower = name.trim().toLowerCase();
// Replace common punctuation with spaces, then condense
final replaced = lower
.replaceAll(RegExp(r'[\(\)\[\]\{\},;:+/_-]+'), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
return replaced;
}
// -----------------------
// Life stage matching
// -----------------------
LifeStage? _matchLifeStageForProfile(List<LifeStage> stages, _UserProfile profile) {
// Exclude pregnancy/lactation when we don't track that state yet
final filtered = stages.where((s) {
final l = s.ageRange.toLowerCase();
return !(l.contains('pregnancy') || l.contains('lactation'));
}).toList();
// Try in order:
// 1) Exact age match + exact sex
final exactSexMatch = filtered.where((s) => _matchesAge(s.ageRange, profile) && _matchesSex(s.sex, profile)).toList();
if (exactSexMatch.isNotEmpty) return exactSexMatch.first;
// 2) Age match + sex == 'both'
final bothMatch = filtered.where((s) => _matchesAge(s.ageRange, profile) && s.sex.toLowerCase() == 'both').toList();
if (bothMatch.isNotEmpty) return bothMatch.first;
// 3) Age match ignoring sex (fallback)
final ageOnly = filtered.where((s) => _matchesAge(s.ageRange, profile)).toList();
if (ageOnly.isNotEmpty) return ageOnly.first;
// 4) If nothing matches, try 'adult-like' fallback: pick a reasonable adult range
final adultFallback = filtered.where((s) => s.ageRange.contains('19-30') || s.ageRange.contains('31-50')).toList();
if (adultFallback.isNotEmpty) return adultFallback.first;
// 5) Any entry as last resort
return filtered.isNotEmpty ? filtered.first : null;
}
bool _matchesSex(String stageSex, _UserProfile profile) {
final s = stageSex.toLowerCase();
if (s == 'both') return true;
if (profile.isInfant && s == 'infant') return true;
if (profile.sex == _Sex.male && s == 'male') return true;
if (profile.sex == _Sex.female && s == 'female') return true;
// For 'Other' or 'Prefer not to say', accept 'both'
if (profile.sex == _Sex.unknown && s == 'both') return true;
return false;
}
bool _matchesAge(String ageRange, _UserProfile profile) {
final ar = ageRange.toLowerCase().trim();
// Common shorthand: "adult" (assume >= 18 years)
if (ar == 'adult' || ar == 'adults') {
return profile.ageYears >= 18;
}
// Months range: e.g., "0-6 mo" or "7-12 mo"
final moMatch = RegExp(r'^(\d+)\s*-\s*(\d+)\s*mo$').firstMatch(ar);
if (moMatch != null) {
if (!profile.isInfant) return false;
final minMo = int.parse(moMatch.group(1)!);
final maxMo = int.parse(moMatch.group(2)!);
return profile.ageMonths >= minMo && profile.ageMonths <= maxMo;
}
// Years range: e.g., "1-3 y", "4-8 y", "9-13 y", "14-18 y", "19-30 y", "31-50 y", "51-70 y"
final yearRange = RegExp(r'^(\d+)\s*-\s*(\d+)\s*y$').firstMatch(ar);
if (yearRange != null) {
final minY = int.parse(yearRange.group(1)!);
final maxY = int.parse(yearRange.group(2)!);
return profile.ageYears >= minY && profile.ageYears <= maxY;
}
// Greater than: e.g., ">70 y"
final gtYear = RegExp(r'^>\s*(\d+)\s*y$').firstMatch(ar);
if (gtYear != null) {
final minExclusive = int.parse(gtYear.group(1)!);
return profile.ageYears > minExclusive;
}
// "infant" buckets handled via months range above
// Any unknown format: do a best-effort fallback
if (profile.isInfant) {
// If the stage mentions "infant", accept
if (ar.contains('infant')) return true;
// Else, if it starts at 0-? y, let's accept if upper bound >= 0 (rare)
if (ar.contains('0-') && ar.contains('y')) return true;
return false;
}
// If we are adult and stage is one of the adult ranges not following the patterns,
// just return false to avoid false positives.
return false;
}
}
/// Internal representation of user profile for matching
class _UserProfile {
final int ageYears; // rounded down, e.g., 29
final int ageMonths; // total months (for infants). If >= 12, consider non-infant.
final _Sex sex;
bool get isInfant => ageYears < 1;
_UserProfile({
required this.ageYears,
required this.ageMonths,
required this.sex,
});
factory _UserProfile.from({DateTime? dateOfBirth, String? gender}) {
final now = DateTime.now();
int years;
int monthsTotal;
if (dateOfBirth == null) {
// Default to adult 30 years old when unknown
years = 30;
monthsTotal = 30 * 12;
} else {
years = now.year - dateOfBirth.year;
final beforeBirthday = (now.month < dateOfBirth.month) ||
(now.month == dateOfBirth.month && now.day < dateOfBirth.day);
if (beforeBirthday) years = max(0, years - 1);
// Calculate total months difference
int months = (now.year - dateOfBirth.year) * 12 + (now.month - dateOfBirth.month);
if (now.day < dateOfBirth.day) {
months = max(0, months - 1);
}
monthsTotal = max(0, months);
}
final s = _parseSex(gender);
return _UserProfile(
ageYears: years,
ageMonths: monthsTotal,
sex: s,
);
}
}
enum _Sex { male, female, unknown }
_Sex _parseSex(String? gender) {
if (gender == null) return _Sex.unknown;
final g = gender.trim().toLowerCase();
if (g == 'male') return _Sex.male;
if (g == 'female') return _Sex.female;
return _Sex.unknown; // 'Other', 'Prefer not to say' -> unknown
}

View File

@@ -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,
),
),
],
),
);
}
}

View File

@@ -9,6 +9,7 @@ class SupplementCard extends StatefulWidget {
final VoidCallback onEdit; final VoidCallback onEdit;
final VoidCallback onDelete; final VoidCallback onDelete;
final VoidCallback onArchive; final VoidCallback onArchive;
final VoidCallback onDuplicate;
const SupplementCard({ const SupplementCard({
super.key, super.key,
@@ -17,6 +18,7 @@ class SupplementCard extends StatefulWidget {
required this.onEdit, required this.onEdit,
required this.onDelete, required this.onDelete,
required this.onArchive, required this.onArchive,
required this.onDuplicate,
}); });
@override @override
@@ -33,7 +35,7 @@ class _SupplementCardState extends State<SupplementCard> {
final bool isTakenToday = provider.hasBeenTakenToday(widget.supplement.id!); final bool isTakenToday = provider.hasBeenTakenToday(widget.supplement.id!);
final int todayIntakeCount = provider.getTodayIntakeCount(widget.supplement.id!); final int todayIntakeCount = provider.getTodayIntakeCount(widget.supplement.id!);
final bool isCompletelyTaken = todayIntakeCount >= widget.supplement.frequencyPerDay; final bool isCompletelyTaken = todayIntakeCount >= widget.supplement.frequencyPerDay;
// Get today's intake times for this supplement // Get today's intake times for this supplement
final todayIntakes = provider.todayIntakes final todayIntakes = provider.todayIntakes
.where((intake) => intake['supplement_id'] == widget.supplement.id) .where((intake) => intake['supplement_id'] == widget.supplement.id)
@@ -45,7 +47,7 @@ class _SupplementCardState extends State<SupplementCard> {
'units': unitsTaken is int ? unitsTaken.toDouble() : unitsTaken as double, 'units': unitsTaken is int ? unitsTaken.toDouble() : unitsTaken as double,
}; };
}).toList(); }).toList();
return Card( return Card(
margin: const EdgeInsets.only(bottom: 16), margin: const EdgeInsets.only(bottom: 16),
elevation: 3, elevation: 3,
@@ -175,7 +177,7 @@ class _SupplementCardState extends State<SupplementCard> {
), ),
), ),
ElevatedButton( ElevatedButton(
onPressed: isCompletelyTaken ? null : widget.onTake, onPressed: widget.onTake,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: isCompletelyTaken backgroundColor: isCompletelyTaken
? Colors.green.shade500 ? Colors.green.shade500
@@ -209,6 +211,9 @@ class _SupplementCardState extends State<SupplementCard> {
case 'edit': case 'edit':
widget.onEdit(); widget.onEdit();
break; break;
case 'duplicate':
widget.onDuplicate();
break;
case 'archive': case 'archive':
widget.onArchive(); widget.onArchive();
break; break;
@@ -218,6 +223,18 @@ class _SupplementCardState extends State<SupplementCard> {
} }
}, },
itemBuilder: (context) => [ 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( const PopupMenuItem(
value: 'edit', value: 'edit',
child: Row( child: Row(
@@ -228,6 +245,16 @@ class _SupplementCardState extends State<SupplementCard> {
], ],
), ),
), ),
const PopupMenuItem(
value: 'duplicate',
child: Row(
children: [
Icon(Icons.copy),
SizedBox(width: 8),
Text('Duplicate'),
],
),
),
const PopupMenuItem( const PopupMenuItem(
value: 'archive', value: 'archive',
child: Row( child: Row(
@@ -297,10 +324,10 @@ class _SupplementCardState extends State<SupplementCard> {
runSpacing: 4, runSpacing: 4,
children: todayIntakes.map((intake) { children: todayIntakes.map((intake) {
final units = intake['units'] as double; final units = intake['units'] as double;
final unitsText = units == 1.0 final unitsText = units == 1.0
? '${widget.supplement.unitType}' ? '${widget.supplement.unitType}'
: '${units.toStringAsFixed(units % 1 == 0 ? 0 : 1)} ${widget.supplement.unitType}'; : '${units.toStringAsFixed(units % 1 == 0 ? 0 : 1)} ${widget.supplement.unitType}';
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -327,7 +354,7 @@ class _SupplementCardState extends State<SupplementCard> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
// Ingredients section // Ingredients section
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
@@ -374,9 +401,9 @@ class _SupplementCardState extends State<SupplementCard> {
], ],
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Schedule and dosage info // Schedule and dosage info
Row( Row(
children: [ children: [
@@ -397,7 +424,7 @@ class _SupplementCardState extends State<SupplementCard> {
), ),
], ],
), ),
if (widget.supplement.reminderTimes.isNotEmpty) ...[ if (widget.supplement.reminderTimes.isNotEmpty) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
_InfoChip( _InfoChip(
@@ -407,7 +434,7 @@ class _SupplementCardState extends State<SupplementCard> {
fullWidth: true, fullWidth: true,
), ),
], ],
if (widget.supplement.notes != null && widget.supplement.notes!.isNotEmpty) ...[ if (widget.supplement.notes != null && widget.supplement.notes!.isNotEmpty) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
@@ -427,14 +454,14 @@ class _SupplementCardState extends State<SupplementCard> {
), ),
), ),
], ],
const SizedBox(height: 16), const SizedBox(height: 16),
// Take button // Take button
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: isCompletelyTaken ? null : widget.onTake, onPressed: widget.onTake,
icon: Icon( icon: Icon(
isCompletelyTaken ? Icons.check_circle : Icons.medication, isCompletelyTaken ? Icons.check_circle : Icons.medication,
size: 18, size: 18,

View File

@@ -7,9 +7,13 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h> #include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); 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);
} }

View File

@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_linux flutter_secure_storage_linux
url_launcher_linux
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@@ -11,6 +11,7 @@ import flutter_secure_storage_darwin
import path_provider_foundation import path_provider_foundation
import shared_preferences_foundation import shared_preferences_foundation
import sqflite_darwin import sqflite_darwin
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
@@ -19,4 +20,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
} }

View File

@@ -638,6 +638,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" 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: uuid:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@@ -1,7 +1,7 @@
name: supplements name: supplements
description: "A supplement tracking app for managing your daily supplements" description: "A supplement tracking app for managing your daily supplements"
publish_to: "none" publish_to: "none"
version: 1.0.5+27082025 version: 1.0.6+28082025
environment: environment:
sdk: ^3.9.0 sdk: ^3.9.0
@@ -35,6 +35,7 @@ dependencies:
flutter_secure_storage: ^10.0.0-beta.4 flutter_secure_storage: ^10.0.0-beta.4
uuid: ^4.5.1 uuid: ^4.5.1
crypto: ^3.0.6 crypto: ^3.0.6
url_launcher: ^6.3.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -52,3 +53,6 @@ dependency_overrides:
flutter: flutter:
uses-material-design: true uses-material-design: true
assets:
- assets/canada_health.json

View File

@@ -8,10 +8,13 @@
#include <connectivity_plus/connectivity_plus_windows_plugin.h> #include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h> #include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
ConnectivityPlusWindowsPluginRegisterWithRegistrar( ConnectivityPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar( FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
} }

View File

@@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus connectivity_plus
flutter_secure_storage_windows flutter_secure_storage_windows
url_launcher_windows
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST