10 Commits

41 changed files with 5365 additions and 3835 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

@@ -1,17 +1,25 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; 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/simple_sync_provider.dart';
import 'providers/supplement_provider.dart'; import 'providers/supplement_provider.dart';
import 'providers/sync_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) {
@@ -20,17 +28,41 @@ class MyApp extends StatelessWidget {
ChangeNotifierProvider( ChangeNotifierProvider(
create: (context) => SupplementProvider()..initialize(), create: (context) => SupplementProvider()..initialize(),
), ),
ChangeNotifierProvider( ChangeNotifierProvider.value(
create: (context) => SettingsProvider()..initialize(), value: settingsProvider,
), ),
ChangeNotifierProxyProvider<SupplementProvider, SyncProvider>( ChangeNotifierProvider(
create: (context) => SyncProvider(context.read<SupplementProvider>())..initialize(), create: (context) => SimpleSyncProvider(),
update: (context, supplementProvider, syncProvider) =>
syncProvider ?? SyncProvider(supplementProvider)..initialize(),
), ),
], ],
child: Consumer<SettingsProvider>( child: Consumer2<SettingsProvider, SimpleSyncProvider>(
builder: (context, settingsProvider, child) { builder: (context, settingsProvider, syncProvider, child) {
// Set up the sync completion callback to refresh supplement data
// and initialize auto-sync integration
WidgetsBinding.instance.addPostFrameCallback((_) {
final supplementProvider = context.read<SupplementProvider>();
// Set up sync completion callback
syncProvider.setOnSyncCompleteCallback(() async {
if (kDebugMode) {
print('SupplementsLog: Sync completed, refreshing UI data...');
}
await supplementProvider.loadSupplements();
await supplementProvider.loadTodayIntakes();
if (kDebugMode) {
print('SupplementsLog: UI data refreshed after sync');
}
});
// Initialize auto-sync service
syncProvider.initializeAutoSync(settingsProvider);
// Set up auto-sync callback for data changes
supplementProvider.setOnDataChangedCallback(() {
syncProvider.triggerAutoSyncIfEnabled();
});
});
return MaterialApp( return MaterialApp(
title: 'Supplements Tracker', title: 'Supplements Tracker',
theme: ThemeData( theme: ThemeData(
@@ -48,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,
); );
}, },

View File

@@ -1,4 +1,4 @@
import 'sync_enums.dart'; import '../services/database_sync_service.dart';
class Ingredient { class Ingredient {
final int? id; final int? id;
@@ -9,7 +9,7 @@ class Ingredient {
// Sync metadata // Sync metadata
final String syncId; final String syncId;
final DateTime lastModified; final DateTime lastModified;
final SyncStatus syncStatus; final RecordSyncStatus syncStatus;
final bool isDeleted; final bool isDeleted;
const Ingredient({ const Ingredient({
@@ -19,7 +19,7 @@ class Ingredient {
required this.unit, required this.unit,
required this.syncId, required this.syncId,
required this.lastModified, required this.lastModified,
this.syncStatus = SyncStatus.pending, this.syncStatus = RecordSyncStatus.pending,
this.isDeleted = false, this.isDeleted = false,
}); });
@@ -47,11 +47,11 @@ class Ingredient {
? DateTime.parse(map['lastModified']) ? DateTime.parse(map['lastModified'])
: DateTime.now(), : DateTime.now(),
syncStatus: map['syncStatus'] != null syncStatus: map['syncStatus'] != null
? SyncStatus.values.firstWhere( ? RecordSyncStatus.values.firstWhere(
(e) => e.name == map['syncStatus'], (e) => e.name == map['syncStatus'],
orElse: () => SyncStatus.pending, orElse: () => RecordSyncStatus.pending,
) )
: SyncStatus.pending, : RecordSyncStatus.pending,
isDeleted: (map['isDeleted'] ?? 0) == 1, isDeleted: (map['isDeleted'] ?? 0) == 1,
); );
} }
@@ -63,7 +63,7 @@ class Ingredient {
String? unit, String? unit,
String? syncId, String? syncId,
DateTime? lastModified, DateTime? lastModified,
SyncStatus? syncStatus, RecordSyncStatus? syncStatus,
bool? isDeleted, bool? isDeleted,
}) { }) {
return Ingredient( return Ingredient(

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 '../services/database_sync_service.dart';
import 'ingredient.dart'; import 'ingredient.dart';
import 'sync_enums.dart';
class Supplement { class Supplement {
final int? id; final int? id;
@@ -21,7 +21,7 @@ class Supplement {
// Sync metadata // Sync metadata
final String syncId; final String syncId;
final DateTime lastModified; final DateTime lastModified;
final SyncStatus syncStatus; final RecordSyncStatus syncStatus;
final bool isDeleted; final bool isDeleted;
Supplement({ Supplement({
@@ -38,7 +38,7 @@ class Supplement {
this.isActive = true, this.isActive = true,
String? syncId, String? syncId,
DateTime? lastModified, DateTime? lastModified,
this.syncStatus = SyncStatus.pending, this.syncStatus = RecordSyncStatus.pending,
this.isDeleted = false, this.isDeleted = false,
}) : syncId = syncId ?? const Uuid().v4(), }) : syncId = syncId ?? const Uuid().v4(),
lastModified = lastModified ?? DateTime.now(); lastModified = lastModified ?? DateTime.now();
@@ -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) {
@@ -122,17 +127,18 @@ class Supplement {
? DateTime.parse(map['lastModified']) ? DateTime.parse(map['lastModified'])
: DateTime.now(), : DateTime.now(),
syncStatus: map['syncStatus'] != null syncStatus: map['syncStatus'] != null
? SyncStatus.values.firstWhere( ? RecordSyncStatus.values.firstWhere(
(e) => e.name == map['syncStatus'], (e) => e.name == map['syncStatus'],
orElse: () => SyncStatus.pending, orElse: () => RecordSyncStatus.pending,
) )
: SyncStatus.pending, : RecordSyncStatus.pending,
isDeleted: (map['isDeleted'] ?? 0) == 1, isDeleted: (map['isDeleted'] ?? 0) == 1,
); );
} }
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,
SyncStatus? 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,
@@ -171,14 +178,14 @@ class Supplement {
Supplement markAsModified() { Supplement markAsModified() {
return copyWith( return copyWith(
lastModified: DateTime.now(), lastModified: DateTime.now(),
syncStatus: SyncStatus.modified, syncStatus: RecordSyncStatus.modified,
); );
} }
/// Create a new supplement marked as synced /// Create a new supplement marked as synced
Supplement markAsSynced() { Supplement markAsSynced() {
return copyWith( return copyWith(
syncStatus: SyncStatus.synced, syncStatus: RecordSyncStatus.synced,
); );
} }
@@ -187,7 +194,7 @@ class Supplement {
return copyWith( return copyWith(
isDeleted: true, isDeleted: true,
lastModified: DateTime.now(), lastModified: DateTime.now(),
syncStatus: SyncStatus.modified, syncStatus: RecordSyncStatus.modified,
); );
} }
} }

View File

@@ -1,6 +1,6 @@
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import 'sync_enums.dart'; import '../services/database_sync_service.dart';
class SupplementIntake { class SupplementIntake {
final int? id; final int? id;
@@ -13,7 +13,7 @@ class SupplementIntake {
// Sync metadata // Sync metadata
final String syncId; final String syncId;
final DateTime lastModified; final DateTime lastModified;
final SyncStatus syncStatus; final RecordSyncStatus syncStatus;
final bool isDeleted; final bool isDeleted;
SupplementIntake({ SupplementIntake({
@@ -25,7 +25,7 @@ class SupplementIntake {
this.notes, this.notes,
String? syncId, String? syncId,
DateTime? lastModified, DateTime? lastModified,
this.syncStatus = SyncStatus.pending, this.syncStatus = RecordSyncStatus.pending,
this.isDeleted = false, this.isDeleted = false,
}) : syncId = syncId ?? const Uuid().v4(), }) : syncId = syncId ?? const Uuid().v4(),
lastModified = lastModified ?? DateTime.now(); lastModified = lastModified ?? DateTime.now();
@@ -58,11 +58,11 @@ class SupplementIntake {
? DateTime.parse(map['lastModified']) ? DateTime.parse(map['lastModified'])
: DateTime.now(), : DateTime.now(),
syncStatus: map['syncStatus'] != null syncStatus: map['syncStatus'] != null
? SyncStatus.values.firstWhere( ? RecordSyncStatus.values.firstWhere(
(e) => e.name == map['syncStatus'], (e) => e.name == map['syncStatus'],
orElse: () => SyncStatus.pending, orElse: () => RecordSyncStatus.pending,
) )
: SyncStatus.pending, : RecordSyncStatus.pending,
isDeleted: (map['isDeleted'] ?? 0) == 1, isDeleted: (map['isDeleted'] ?? 0) == 1,
); );
} }
@@ -76,7 +76,7 @@ class SupplementIntake {
String? notes, String? notes,
String? syncId, String? syncId,
DateTime? lastModified, DateTime? lastModified,
SyncStatus? syncStatus, RecordSyncStatus? syncStatus,
bool? isDeleted, bool? isDeleted,
}) { }) {
return SupplementIntake( return SupplementIntake(
@@ -96,7 +96,7 @@ class SupplementIntake {
/// Create a new intake marked as synced /// Create a new intake marked as synced
SupplementIntake markAsSynced() { SupplementIntake markAsSynced() {
return copyWith( return copyWith(
syncStatus: SyncStatus.synced, syncStatus: RecordSyncStatus.synced,
); );
} }
@@ -105,7 +105,7 @@ class SupplementIntake {
return copyWith( return copyWith(
isDeleted: true, isDeleted: true,
lastModified: DateTime.now(), lastModified: DateTime.now(),
syncStatus: SyncStatus.modified, syncStatus: RecordSyncStatus.modified,
); );
} }
} }

View File

@@ -1,314 +0,0 @@
import 'dart:convert';
import 'supplement.dart';
import 'supplement_intake.dart';
import 'sync_enums.dart';
/// Model representing the complete sync data structure
/// This is what gets serialized to JSON and synced via WebDAV
class SyncData {
final int version;
final String deviceId;
final String deviceName;
final DateTime syncTimestamp;
final List<Supplement> supplements;
final List<SupplementIntake> intakes;
final Map<String, dynamic> metadata;
const SyncData({
required this.version,
required this.deviceId,
required this.deviceName,
required this.syncTimestamp,
required this.supplements,
required this.intakes,
this.metadata = const {},
});
/// Convert sync data to JSON map
Map<String, dynamic> toJson() {
return {
'version': version,
'deviceId': deviceId,
'deviceName': deviceName,
'syncTimestamp': syncTimestamp.toIso8601String(),
'supplements': supplements.map((s) => s.toMap()).toList(),
'intakes': intakes.map((i) => i.toMap()).toList(),
'metadata': metadata,
};
}
/// Create sync data from JSON map
factory SyncData.fromJson(Map<String, dynamic> json) {
return SyncData(
version: json['version'] ?? 1,
deviceId: json['deviceId'] ?? '',
deviceName: json['deviceName'] ?? 'Unknown Device',
syncTimestamp: json['syncTimestamp'] != null
? DateTime.parse(json['syncTimestamp'])
: DateTime.now(),
supplements: (json['supplements'] as List<dynamic>? ?? [])
.map((s) => Supplement.fromMap(s as Map<String, dynamic>))
.toList(),
intakes: (json['intakes'] as List<dynamic>? ?? [])
.map((i) => SupplementIntake.fromMap(i as Map<String, dynamic>))
.toList(),
metadata: json['metadata'] as Map<String, dynamic>? ?? {},
);
}
/// Convert sync data to JSON string
String toJsonString() {
return jsonEncode(toJson());
}
/// Create sync data from JSON string
factory SyncData.fromJsonString(String jsonString) {
final json = jsonDecode(jsonString) as Map<String, dynamic>;
return SyncData.fromJson(json);
}
/// Create a copy with updated values
SyncData copyWith({
int? version,
String? deviceId,
String? deviceName,
DateTime? syncTimestamp,
List<Supplement>? supplements,
List<SupplementIntake>? intakes,
Map<String, dynamic>? metadata,
}) {
return SyncData(
version: version ?? this.version,
deviceId: deviceId ?? this.deviceId,
deviceName: deviceName ?? this.deviceName,
syncTimestamp: syncTimestamp ?? this.syncTimestamp,
supplements: supplements ?? this.supplements,
intakes: intakes ?? this.intakes,
metadata: metadata ?? this.metadata,
);
}
@override
String toString() {
return 'SyncData(version: $version, deviceId: $deviceId, '
'deviceName: $deviceName, syncTimestamp: $syncTimestamp, '
'supplements: ${supplements.length}, intakes: ${intakes.length})';
}
}
/// Model representing a sync conflict
class SyncConflict {
final String syncId;
final ConflictType type;
final DateTime localTimestamp;
final DateTime remoteTimestamp;
final Map<String, dynamic> localData;
final Map<String, dynamic> remoteData;
final ConflictResolutionStrategy? suggestedResolution;
const SyncConflict({
required this.syncId,
required this.type,
required this.localTimestamp,
required this.remoteTimestamp,
required this.localData,
required this.remoteData,
this.suggestedResolution,
});
Map<String, dynamic> toJson() {
return {
'syncId': syncId,
'type': type.name,
'localTimestamp': localTimestamp.toIso8601String(),
'remoteTimestamp': remoteTimestamp.toIso8601String(),
'localData': localData,
'remoteData': remoteData,
'suggestedResolution': suggestedResolution?.name,
};
}
factory SyncConflict.fromJson(Map<String, dynamic> json) {
return SyncConflict(
syncId: json['syncId'],
type: ConflictType.values.firstWhere(
(e) => e.name == json['type'],
orElse: () => ConflictType.modification,
),
localTimestamp: DateTime.parse(json['localTimestamp']),
remoteTimestamp: DateTime.parse(json['remoteTimestamp']),
localData: json['localData'] as Map<String, dynamic>,
remoteData: json['remoteData'] as Map<String, dynamic>,
suggestedResolution: json['suggestedResolution'] != null
? ConflictResolutionStrategy.values.firstWhere(
(e) => e.name == json['suggestedResolution'],
orElse: () => ConflictResolutionStrategy.manual,
)
: null,
);
}
/// Get human-readable description of the conflict
String get description {
switch (type) {
case ConflictType.modification:
return 'Item modified on both devices';
case ConflictType.deletion:
return 'Item deleted on one device, modified on another';
case ConflictType.creation:
return 'Item created with same ID on both devices';
}
}
/// Determine if local version is newer
bool get isLocalNewer => localTimestamp.isAfter(remoteTimestamp);
/// Determine if remote version is newer
bool get isRemoteNewer => remoteTimestamp.isAfter(localTimestamp);
/// Check if timestamps are identical
bool get haveSameTimestamp => localTimestamp.isAtSameMomentAs(remoteTimestamp);
}
/// Types of sync conflicts
enum ConflictType {
/// Both local and remote versions were modified
modification,
/// One version was deleted while the other was modified
deletion,
/// Same item was created on both devices
creation,
}
/// Model for sync operation results
class SyncResult {
final bool success;
final DateTime timestamp;
final SyncOperationStatus status;
final String? error;
final List<SyncConflict> conflicts;
final SyncStatistics statistics;
const SyncResult({
required this.success,
required this.timestamp,
required this.status,
this.error,
this.conflicts = const [],
this.statistics = const SyncStatistics(),
});
Map<String, dynamic> toJson() {
return {
'success': success,
'timestamp': timestamp.toIso8601String(),
'status': status.name,
'error': error,
'conflicts': conflicts.map((c) => c.toJson()).toList(),
'statistics': statistics.toJson(),
};
}
factory SyncResult.fromJson(Map<String, dynamic> json) {
return SyncResult(
success: json['success'] ?? false,
timestamp: DateTime.parse(json['timestamp']),
status: SyncOperationStatus.values.firstWhere(
(e) => e.name == json['status'],
orElse: () => SyncOperationStatus.idle,
),
error: json['error'],
conflicts: (json['conflicts'] as List<dynamic>? ?? [])
.map((c) => SyncConflict.fromJson(c as Map<String, dynamic>))
.toList(),
statistics: json['statistics'] != null
? SyncStatistics.fromJson(json['statistics'] as Map<String, dynamic>)
: const SyncStatistics(),
);
}
/// Create a failed sync result
factory SyncResult.failure({
required String error,
required SyncOperationStatus status,
List<SyncConflict> conflicts = const [],
}) {
return SyncResult(
success: false,
timestamp: DateTime.now(),
status: status,
error: error,
conflicts: conflicts,
);
}
/// Create a successful sync result
factory SyncResult.success({
List<SyncConflict> conflicts = const [],
SyncStatistics statistics = const SyncStatistics(),
}) {
return SyncResult(
success: true,
timestamp: DateTime.now(),
status: conflicts.isEmpty
? SyncOperationStatus.success
: SyncOperationStatus.conflictsDetected,
conflicts: conflicts,
statistics: statistics,
);
}
}
/// Statistics about sync operations
class SyncStatistics {
final int supplementsUploaded;
final int supplementsDownloaded;
final int intakesUploaded;
final int intakesDownloaded;
final int conflictsResolved;
final Duration syncDuration;
const SyncStatistics({
this.supplementsUploaded = 0,
this.supplementsDownloaded = 0,
this.intakesUploaded = 0,
this.intakesDownloaded = 0,
this.conflictsResolved = 0,
this.syncDuration = Duration.zero,
});
Map<String, dynamic> toJson() {
return {
'supplementsUploaded': supplementsUploaded,
'supplementsDownloaded': supplementsDownloaded,
'intakesUploaded': intakesUploaded,
'intakesDownloaded': intakesDownloaded,
'conflictsResolved': conflictsResolved,
'syncDurationMs': syncDuration.inMilliseconds,
};
}
factory SyncStatistics.fromJson(Map<String, dynamic> json) {
return SyncStatistics(
supplementsUploaded: json['supplementsUploaded'] ?? 0,
supplementsDownloaded: json['supplementsDownloaded'] ?? 0,
intakesUploaded: json['intakesUploaded'] ?? 0,
intakesDownloaded: json['intakesDownloaded'] ?? 0,
conflictsResolved: json['conflictsResolved'] ?? 0,
syncDuration: Duration(milliseconds: json['syncDurationMs'] ?? 0),
);
}
int get totalUploaded => supplementsUploaded + intakesUploaded;
int get totalDownloaded => supplementsDownloaded + intakesDownloaded;
int get totalSynced => totalUploaded + totalDownloaded;
@override
String toString() {
return 'SyncStatistics(uploaded: $totalUploaded, downloaded: $totalDownloaded, '
'conflicts: $conflictsResolved, duration: ${syncDuration.inSeconds}s)';
}
}

View File

@@ -1,178 +0,0 @@
/// Enumeration for sync status of individual records
enum SyncStatus {
/// Record is newly created and needs to be synced
pending,
/// Record is in sync with remote server
synced,
/// Record has been modified locally and needs to be synced
modified,
/// Record has a conflict that needs resolution
conflict,
/// Record is being synced (temporary state)
syncing,
/// Record sync failed and will be retried
failed,
}
/// Enumeration for overall sync operation status
enum SyncOperationStatus {
/// No sync operation in progress
idle,
/// Currently syncing data
syncing,
/// Last sync completed successfully
success,
/// Last sync failed due to network issues
networkError,
/// Last sync failed due to authentication issues
authenticationError,
/// Last sync failed due to server issues
serverError,
/// Last sync had conflicts that need resolution
conflictsDetected,
/// Sync cancelled by user
cancelled,
}
/// Enumeration for conflict resolution strategies
enum ConflictResolutionStrategy {
/// Always prefer local changes
preferLocal,
/// Always prefer remote changes
preferRemote,
/// Prefer the most recently modified record
preferNewer,
/// Ask user to resolve conflicts manually
manual,
}
/// Enumeration for sync frequency options
enum SyncFrequency {
/// Manual sync only
manual,
/// Sync every 15 minutes
every15Minutes,
/// Sync every hour
hourly,
/// Sync every 6 hours
every6Hours,
/// Sync once per day
daily,
}
/// Extension to get human-readable names for SyncStatus
extension SyncStatusExtension on SyncStatus {
String get displayName {
switch (this) {
case SyncStatus.pending:
return 'Pending Sync';
case SyncStatus.synced:
return 'Synced';
case SyncStatus.modified:
return 'Modified';
case SyncStatus.conflict:
return 'Conflict';
case SyncStatus.syncing:
return 'Syncing...';
case SyncStatus.failed:
return 'Sync Failed';
}
}
}
/// Extension to get human-readable names for SyncOperationStatus
extension SyncOperationStatusExtension on SyncOperationStatus {
String get displayName {
switch (this) {
case SyncOperationStatus.idle:
return 'Ready';
case SyncOperationStatus.syncing:
return 'Syncing...';
case SyncOperationStatus.success:
return 'Sync Complete';
case SyncOperationStatus.networkError:
return 'Network Error';
case SyncOperationStatus.authenticationError:
return 'Authentication Failed';
case SyncOperationStatus.serverError:
return 'Server Error';
case SyncOperationStatus.conflictsDetected:
return 'Conflicts Detected';
case SyncOperationStatus.cancelled:
return 'Sync Cancelled';
}
}
}
/// Extension to get duration for SyncFrequency
extension SyncFrequencyExtension on SyncFrequency {
Duration get duration {
switch (this) {
case SyncFrequency.manual:
return Duration.zero; // Manual sync only
case SyncFrequency.every15Minutes:
return const Duration(minutes: 15);
case SyncFrequency.hourly:
return const Duration(hours: 1);
case SyncFrequency.every6Hours:
return const Duration(hours: 6);
case SyncFrequency.daily:
return const Duration(days: 1);
}
}
String get displayName {
switch (this) {
case SyncFrequency.manual:
return 'Manual Only';
case SyncFrequency.every15Minutes:
return 'Every 15 Minutes';
case SyncFrequency.hourly:
return 'Hourly';
case SyncFrequency.every6Hours:
return 'Every 6 Hours';
case SyncFrequency.daily:
return 'Daily';
}
}
}
/// Constants for sync operations
class SyncConstants {
static const String syncFileName = 'supplements_sync.json';
static const String syncFileBackupName = 'supplements_sync_backup.json';
static const int currentSyncVersion = 1;
static const int maxRetryAttempts = 3;
static const Duration networkTimeout = Duration(seconds: 30);
static const Duration conflictResolutionTimeout = Duration(minutes: 5);
/// Default WebDAV paths for different cloud providers
static const String nextcloudWebdavPath = '/remote.php/dav/files/';
static const String owncloudWebdavPath = '/remote.php/webdav/';
/// Supported cloud providers
static const List<String> supportedProviders = [
'Nextcloud',
'ownCloud',
'Generic WebDAV',
];
}

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;
@@ -25,8 +29,25 @@ class SettingsProvider extends ChangeNotifier {
int _reminderRetryInterval = 5; // minutes int _reminderRetryInterval = 5; // minutes
int _maxRetryAttempts = 3; int _maxRetryAttempts = 3;
// Auto-sync settings
bool _autoSyncEnabled = false;
int _autoSyncDebounceSeconds = 5;
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;
@@ -42,6 +63,10 @@ class SettingsProvider extends ChangeNotifier {
int get reminderRetryInterval => _reminderRetryInterval; int get reminderRetryInterval => _reminderRetryInterval;
int get maxRetryAttempts => _maxRetryAttempts; int get maxRetryAttempts => _maxRetryAttempts;
// Auto-sync getters
bool get autoSyncEnabled => _autoSyncEnabled;
int get autoSyncDebounceSeconds => _autoSyncDebounceSeconds;
// Helper method to get formatted time ranges for display // Helper method to get formatted time ranges for display
String get morningRange => '${_formatHour(_morningStart)} - ${_formatHour((_morningEnd + 1) % 24)}'; String get morningRange => '${_formatHour(_morningStart)} - ${_formatHour((_morningEnd + 1) % 24)}';
String get afternoonRange => '${_formatHour(_afternoonStart)} - ${_formatHour((_afternoonEnd + 1) % 24)}'; String get afternoonRange => '${_formatHour(_afternoonStart)} - ${_formatHour((_afternoonEnd + 1) % 24)}';
@@ -68,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;
@@ -83,6 +115,10 @@ class SettingsProvider extends ChangeNotifier {
_reminderRetryInterval = prefs.getInt('reminder_retry_interval') ?? 5; _reminderRetryInterval = prefs.getInt('reminder_retry_interval') ?? 5;
_maxRetryAttempts = prefs.getInt('max_retry_attempts') ?? 3; _maxRetryAttempts = prefs.getInt('max_retry_attempts') ?? 3;
// Load auto-sync settings
_autoSyncEnabled = prefs.getBool('auto_sync_enabled') ?? false;
_autoSyncDebounceSeconds = prefs.getInt('auto_sync_debounce_seconds') ?? 30;
notifyListeners(); notifyListeners();
} }
@@ -94,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,
@@ -256,4 +302,21 @@ class SettingsProvider extends ChangeNotifier {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setInt('max_retry_attempts', attempts); await prefs.setInt('max_retry_attempts', attempts);
} }
// Auto-sync setters
Future<void> setAutoSyncEnabled(bool enabled) async {
_autoSyncEnabled = enabled;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('auto_sync_enabled', enabled);
}
Future<void> setAutoSyncDebounceSeconds(int seconds) async {
_autoSyncDebounceSeconds = seconds;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('auto_sync_debounce_seconds', seconds);
}
} }

View File

@@ -0,0 +1,170 @@
import 'package:flutter/foundation.dart';
import '../services/database_sync_service.dart';
import '../services/auto_sync_service.dart';
import 'settings_provider.dart';
class SimpleSyncProvider with ChangeNotifier {
final DatabaseSyncService _syncService = DatabaseSyncService();
// Callback for UI refresh after sync
VoidCallback? _onSyncCompleteCallback;
// Auto-sync service
AutoSyncService? _autoSyncService;
// Track if current sync is auto-triggered
bool _isAutoSync = false;
// Getters
SyncStatus get status => _syncService.status;
String? get lastError => _syncService.lastError;
DateTime? get lastSyncTime => _syncService.lastSyncTime;
bool get isConfigured => _syncService.isConfigured;
bool get isSyncing => status == SyncStatus.downloading ||
status == SyncStatus.merging ||
status == SyncStatus.uploading;
bool get isAutoSync => _isAutoSync;
AutoSyncService? get autoSyncService => _autoSyncService;
// Auto-sync error handling getters
bool get isAutoSyncDisabledDueToErrors => _autoSyncService?.isAutoDisabledDueToErrors ?? false;
int get autoSyncConsecutiveFailures => _autoSyncService?.consecutiveFailures ?? 0;
String? get autoSyncLastError => _autoSyncService?.lastErrorMessage;
bool get hasAutoSyncScheduledRetry => _autoSyncService?.hasScheduledRetry ?? false;
// Configuration getters
String? get serverUrl => _syncService.serverUrl;
String? get username => _syncService.username;
String? get password => _syncService.password;
String? get remotePath => _syncService.remotePath;
SimpleSyncProvider() {
// Set up callbacks to notify listeners
_syncService.onStatusChanged = (_) => notifyListeners();
_syncService.onError = (_) => notifyListeners();
_syncService.onSyncCompleted = () {
notifyListeners();
// Trigger UI refresh callback if set
_onSyncCompleteCallback?.call();
};
// Load saved configuration and notify listeners when done
_loadConfiguration();
}
/// Set callback to refresh UI data after sync completes
void setOnSyncCompleteCallback(VoidCallback? callback) {
_onSyncCompleteCallback = callback;
}
/// Initialize auto-sync service with settings provider
void initializeAutoSync(SettingsProvider settingsProvider) {
_autoSyncService = AutoSyncService(
syncProvider: this,
settingsProvider: settingsProvider,
);
if (kDebugMode) {
print('SimpleSyncProvider: Auto-sync service initialized');
}
}
/// Triggers auto-sync if enabled and configured
void triggerAutoSyncIfEnabled() {
_autoSyncService?.triggerAutoSync();
}
Future<void> _loadConfiguration() async {
await _syncService.loadSavedConfiguration();
notifyListeners(); // Notify UI that configuration might be available
}
Future<void> configure({
required String serverUrl,
required String username,
required String password,
required String remotePath,
}) async {
_syncService.configure(
serverUrl: serverUrl,
username: username,
password: password,
remotePath: remotePath,
);
notifyListeners();
}
Future<bool> testConnection() async {
return await _syncService.testConnection();
}
Future<void> syncDatabase({bool isAutoSync = false}) async {
if (!isConfigured) {
throw Exception('Sync not configured');
}
_isAutoSync = isAutoSync;
notifyListeners();
try {
await _syncService.syncDatabase();
} catch (e) {
if (kDebugMode) {
print('SupplementsLog: Sync failed in provider: $e');
}
rethrow;
} finally {
_isAutoSync = false;
notifyListeners();
}
}
void clearError() {
_syncService.clearError();
notifyListeners();
}
/// Resets auto-sync error state and re-enables auto-sync if it was disabled
void resetAutoSyncErrors() {
_autoSyncService?.resetErrorState();
notifyListeners();
}
String getStatusText() {
final syncType = _isAutoSync ? 'Auto-sync' : 'Sync';
// Check for auto-sync specific errors first
if (isAutoSyncDisabledDueToErrors) {
return 'Auto-sync disabled due to repeated failures. ${autoSyncLastError ?? 'Check sync settings.'}';
}
switch (status) {
case SyncStatus.idle:
if (hasAutoSyncScheduledRetry) {
return 'Auto-sync will retry shortly...';
}
return 'Ready to sync';
case SyncStatus.downloading:
return '$syncType: Downloading remote database...';
case SyncStatus.merging:
return '$syncType: Merging databases...';
case SyncStatus.uploading:
return '$syncType: Uploading database...';
case SyncStatus.completed:
return '$syncType completed successfully';
case SyncStatus.error:
// For auto-sync errors, show more specific messages
if (_isAutoSync && autoSyncLastError != null) {
return 'Auto-sync failed: $autoSyncLastError';
}
return '$syncType failed: ${lastError ?? 'Unknown error'}';
}
}
@override
void dispose() {
_autoSyncService?.dispose();
super.dispose();
}
}

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 {
@@ -46,31 +47,31 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
await _notificationService.initialize(); await _notificationService.initialize();
// Set up the callback for handling supplement intake from notifications // Set up the callback for handling supplement intake from notifications
print('📱 Setting up notification callback...'); print('SupplementsLog: 📱 Setting up notification callback...');
_notificationService.setTakeSupplementCallback((supplementId, supplementName, units, unitType) { _notificationService.setTakeSupplementCallback((supplementId, supplementName, units, unitType) {
print('📱 === NOTIFICATION CALLBACK TRIGGERED ==='); print('SupplementsLog: 📱 === NOTIFICATION CALLBACK TRIGGERED ===');
print('📱 Supplement ID: $supplementId'); print('SupplementsLog: 📱 Supplement ID: $supplementId');
print('📱 Supplement Name: $supplementName'); print('SupplementsLog: 📱 Supplement Name: $supplementName');
print('📱 Units: $units'); print('SupplementsLog: 📱 Units: $units');
print('📱 Unit Type: $unitType'); print('SupplementsLog: 📱 Unit Type: $unitType');
// Record the intake when user taps "Take" on notification // Record the intake when user taps "Take" on notification
recordIntake(supplementId, 0.0, unitsTaken: units); recordIntake(supplementId, 0.0, unitsTaken: units);
print('📱 Intake recorded successfully'); print('SupplementsLog: 📱 Intake recorded successfully');
print('📱 === CALLBACK COMPLETE ==='); print('SupplementsLog: 📱 === CALLBACK COMPLETE ===');
if (kDebugMode) { if (kDebugMode) {
print('📱 Recorded intake from notification: $supplementName ($units $unitType)'); print('SupplementsLog: 📱 Recorded intake from notification: $supplementName ($units $unitType)');
} }
}); });
print('📱 Notification callback setup complete'); print('SupplementsLog: 📱 Notification callback setup complete');
// Request permissions with error handling // Request permissions with error handling
try { try {
await _notificationService.requestPermissions(); await _notificationService.requestPermissions();
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('Error requesting notification permissions: $e'); print('SupplementsLog: Error requesting notification permissions: $e');
} }
// Continue without notifications rather than crashing // Continue without notifications rather than crashing
} }
@@ -99,7 +100,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
await _checkPersistentReminders(); await _checkPersistentReminders();
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('Error checking persistent reminders: $e'); print('SupplementsLog: Error checking persistent reminders: $e');
} }
} }
}); });
@@ -120,8 +121,8 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
if (currentDate != lastCheckDate) { if (currentDate != lastCheckDate) {
if (kDebugMode) { if (kDebugMode) {
print('Date changed detected: ${lastCheckDate} -> ${currentDate}'); print('SupplementsLog: Date changed detected: ${lastCheckDate} -> ${currentDate}');
print('Refreshing today\'s intakes for new day...'); print('SupplementsLog: Refreshing today\'s intakes for new day...');
} }
// Date has changed, refresh today's intakes // Date has changed, refresh today's intakes
@@ -129,7 +130,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
await loadTodayIntakes(); await loadTodayIntakes();
if (kDebugMode) { if (kDebugMode) {
print('Today\'s intakes refreshed for new day'); print('SupplementsLog: Today\'s intakes refreshed for new day');
} }
} }
}); });
@@ -140,7 +141,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
// For now, we'll check with default settings // For now, we'll check with default settings
// In practice, the UI should call checkPersistentRemindersWithSettings // In practice, the UI should call checkPersistentRemindersWithSettings
if (kDebugMode) { if (kDebugMode) {
print('📱 Checking persistent reminders with default settings'); print('SupplementsLog: 📱 Checking persistent reminders with default settings');
} }
} }
@@ -150,7 +151,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
required int reminderRetryInterval, required int reminderRetryInterval,
required int maxRetryAttempts, required int maxRetryAttempts,
}) async { }) async {
print('📱 🔄 MANUAL CHECK: Persistent reminders called from UI'); print('SupplementsLog: 📱 🔄 MANUAL CHECK: Persistent reminders called from UI');
await _notificationService.checkPersistentReminders( await _notificationService.checkPersistentReminders(
persistentReminders, persistentReminders,
reminderRetryInterval, reminderRetryInterval,
@@ -160,7 +161,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
// Add a manual trigger method for testing // Add a manual trigger method for testing
Future<void> triggerRetryCheck() async { Future<void> triggerRetryCheck() async {
print('📱 🚨 MANUAL TRIGGER: Forcing retry check...'); print('SupplementsLog: 📱 🚨 MANUAL TRIGGER: Forcing retry check...');
await checkPersistentRemindersWithSettings( await checkPersistentRemindersWithSettings(
persistentReminders: true, persistentReminders: true,
reminderRetryInterval: 5, // Force 5 minute interval for testing reminderRetryInterval: 5, // Force 5 minute interval for testing
@@ -183,7 +184,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
if (state == AppLifecycleState.resumed) { if (state == AppLifecycleState.resumed) {
// App came back to foreground, check if date changed // App came back to foreground, check if date changed
if (kDebugMode) { if (kDebugMode) {
print('App resumed, checking for date change...'); print('SupplementsLog: App resumed, checking for date change...');
} }
forceCheckDateChange(); forceCheckDateChange();
} }
@@ -191,7 +192,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
Future<void> _rescheduleAllNotifications() async { Future<void> _rescheduleAllNotifications() async {
if (kDebugMode) { if (kDebugMode) {
print('📱 Rescheduling notifications for all active supplements...'); print('SupplementsLog: 📱 Rescheduling notifications for all active supplements...');
} }
for (final supplement in _supplements) { for (final supplement in _supplements) {
@@ -200,14 +201,14 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
await _notificationService.scheduleSupplementReminders(supplement); await _notificationService.scheduleSupplementReminders(supplement);
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('📱 Error rescheduling notifications for ${supplement.name}: $e'); print('SupplementsLog: 📱 Error rescheduling notifications for ${supplement.name}: $e');
} }
} }
} }
} }
if (kDebugMode) { if (kDebugMode) {
print('📱 Finished rescheduling notifications'); print('SupplementsLog: 📱 Finished rescheduling notifications');
} }
} }
@@ -216,16 +217,16 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
notifyListeners(); notifyListeners();
try { try {
print('Loading supplements from database...'); print('SupplementsLog: Loading supplements from database...');
_supplements = await _databaseHelper.getAllSupplements(); _supplements = await _databaseHelper.getAllSupplements();
print('Loaded ${_supplements.length} supplements'); print('SupplementsLog: Loaded ${_supplements.length} supplements');
for (var supplement in _supplements) { for (var supplement in _supplements) {
print('Supplement: ${supplement.name}'); print('SupplementsLog: Supplement: ${supplement.name}');
} }
} catch (e) { } catch (e) {
print('Error loading supplements: $e'); print('SupplementsLog: Error loading supplements: $e');
if (kDebugMode) { if (kDebugMode) {
print('Error loading supplements: $e'); print('SupplementsLog: Error loading supplements: $e');
} }
} finally { } finally {
_isLoading = false; _isLoading = false;
@@ -235,28 +236,28 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
Future<void> addSupplement(Supplement supplement) async { Future<void> addSupplement(Supplement supplement) async {
try { try {
print('Adding supplement: ${supplement.name}'); print('SupplementsLog: Adding supplement: ${supplement.name}');
final id = await _databaseHelper.insertSupplement(supplement); final id = await _databaseHelper.insertSupplement(supplement);
print('Supplement inserted with ID: $id'); print('SupplementsLog: Supplement inserted with ID: $id');
final newSupplement = supplement.copyWith(id: id); final newSupplement = supplement.copyWith(id: id);
// Schedule notifications (skip if there's an error) // Schedule notifications (skip if there's an error)
try { try {
await _notificationService.scheduleSupplementReminders(newSupplement); await _notificationService.scheduleSupplementReminders(newSupplement);
print('Notifications scheduled'); print('SupplementsLog: Notifications scheduled');
} catch (notificationError) { } catch (notificationError) {
print('Warning: Could not schedule notifications: $notificationError'); print('SupplementsLog: Warning: Could not schedule notifications: $notificationError');
} }
await loadSupplements(); await loadSupplements();
print('Supplements reloaded, count: ${_supplements.length}'); print('SupplementsLog: Supplements reloaded, count: ${_supplements.length}');
// Trigger sync after adding supplement // Trigger sync after adding supplement
_triggerSyncIfEnabled(); _triggerSyncIfEnabled();
} catch (e) { } catch (e) {
print('Error adding supplement: $e'); print('SupplementsLog: Error adding supplement: $e');
if (kDebugMode) { if (kDebugMode) {
print('Error adding supplement: $e'); print('SupplementsLog: Error adding supplement: $e');
} }
rethrow; rethrow;
} }
@@ -275,7 +276,29 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
_triggerSyncIfEnabled(); _triggerSyncIfEnabled();
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('Error updating supplement: $e'); print('SupplementsLog: Error updating supplement: $e');
}
}
}
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');
} }
} }
} }
@@ -293,7 +316,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
_triggerSyncIfEnabled(); _triggerSyncIfEnabled();
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('Error deleting supplement: $e'); print('SupplementsLog: Error deleting supplement: $e');
} }
} }
} }
@@ -323,7 +346,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
); );
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('Error recording intake: $e'); print('SupplementsLog: Error recording intake: $e');
} }
} }
} }
@@ -332,22 +355,22 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
try { try {
final today = DateTime.now(); final today = DateTime.now();
if (kDebugMode) { if (kDebugMode) {
print('Loading intakes for date: ${today.year}-${today.month}-${today.day}'); print('SupplementsLog: Loading intakes for date: ${today.year}-${today.month}-${today.day}');
} }
_todayIntakes = await _databaseHelper.getIntakesWithSupplementsForDate(today); _todayIntakes = await _databaseHelper.getIntakesWithSupplementsForDate(today);
if (kDebugMode) { if (kDebugMode) {
print('Loaded ${_todayIntakes.length} intakes for today'); print('SupplementsLog: Loaded ${_todayIntakes.length} intakes for today');
for (var intake in _todayIntakes) { for (var intake in _todayIntakes) {
print(' - Supplement ID: ${intake['supplement_id']}, taken at: ${intake['takenAt']}'); print('SupplementsLog: - Supplement ID: ${intake['supplement_id']}, taken at: ${intake['takenAt']}');
} }
} }
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('Error loading today\'s intakes: $e'); print('SupplementsLog: Error loading today\'s intakes: $e');
} }
} }
} }
@@ -358,7 +381,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('Error loading monthly intakes: $e'); print('SupplementsLog: Error loading monthly intakes: $e');
} }
} }
} }
@@ -368,7 +391,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
return await _databaseHelper.getIntakesWithSupplementsForDate(date); return await _databaseHelper.getIntakesWithSupplementsForDate(date);
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('Error loading intakes for date: $e'); print('SupplementsLog: Error loading intakes for date: $e');
} }
return []; return [];
} }
@@ -388,7 +411,26 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
_triggerSyncIfEnabled(); _triggerSyncIfEnabled();
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('Error deleting intake: $e'); print('SupplementsLog: Error deleting intake: $e');
}
}
}
Future<void> permanentlyDeleteIntake(int intakeId) async {
try {
await _databaseHelper.permanentlyDeleteIntake(intakeId);
await loadTodayIntakes();
// Also refresh monthly intakes if they're loaded
if (_monthlyIntakes.isNotEmpty) {
await loadMonthlyIntakes(DateTime.now().year, DateTime.now().month);
}
notifyListeners();
// Trigger sync after permanently deleting intake
_triggerSyncIfEnabled();
} catch (e) {
if (kDebugMode) {
print('SupplementsLog: Error permanently deleting intake: $e');
} }
} }
} }
@@ -401,10 +443,26 @@ 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) {
print('Manually refreshing daily status...'); print('SupplementsLog: Manually refreshing daily status...');
} }
_lastDateCheck = DateTime.now(); _lastDateCheck = DateTime.now();
await loadTodayIntakes(); await loadTodayIntakes();
@@ -417,20 +475,20 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
final lastCheckDate = DateTime(_lastDateCheck.year, _lastDateCheck.month, _lastDateCheck.day); final lastCheckDate = DateTime(_lastDateCheck.year, _lastDateCheck.month, _lastDateCheck.day);
if (kDebugMode) { if (kDebugMode) {
print('Force checking date change...'); print('SupplementsLog: Force checking date change...');
print('Current date: $currentDate'); print('SupplementsLog: Current date: $currentDate');
print('Last check date: $lastCheckDate'); print('SupplementsLog: Last check date: $lastCheckDate');
} }
if (currentDate != lastCheckDate) { if (currentDate != lastCheckDate) {
if (kDebugMode) { if (kDebugMode) {
print('Date change detected, refreshing intakes...'); print('SupplementsLog: Date change detected, refreshing intakes...');
} }
_lastDateCheck = now; _lastDateCheck = now;
await loadTodayIntakes(); await loadTodayIntakes();
} else { } else {
if (kDebugMode) { if (kDebugMode) {
print('No date change detected'); print('SupplementsLog: No date change detected');
} }
} }
} }
@@ -445,7 +503,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('Error loading archived supplements: $e'); print('SupplementsLog: Error loading archived supplements: $e');
} }
} }
} }
@@ -460,7 +518,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
_triggerSyncIfEnabled(); _triggerSyncIfEnabled();
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('Error archiving supplement: $e'); print('SupplementsLog: Error archiving supplement: $e');
} }
} }
} }
@@ -475,21 +533,21 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
_triggerSyncIfEnabled(); _triggerSyncIfEnabled();
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('Error unarchiving supplement: $e'); print('SupplementsLog: Error unarchiving supplement: $e');
} }
} }
} }
Future<void> deleteArchivedSupplement(int supplementId) async { Future<void> deleteArchivedSupplement(int supplementId) async {
try { try {
await _databaseHelper.deleteSupplement(supplementId); await _databaseHelper.permanentlyDeleteSupplement(supplementId);
await loadArchivedSupplements(); // Refresh archived supplements await loadArchivedSupplements(); // Refresh archived supplements
// Trigger sync after deleting archived supplement // Trigger sync after permanently deleting archived supplement
_triggerSyncIfEnabled(); _triggerSyncIfEnabled();
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('Error deleting archived supplement: $e'); print('SupplementsLog: Error permanently deleting archived supplement: $e');
} }
} }
} }

View File

@@ -1,417 +0,0 @@
import 'package:flutter/foundation.dart';
import '../models/sync_data.dart';
import '../models/sync_enums.dart';
import '../services/webdav_sync_service.dart';
import 'supplement_provider.dart';
/// Provider for managing sync operations with WebDAV servers
class SyncProvider with ChangeNotifier {
final WebDAVSyncService _syncService = WebDAVSyncService();
final SupplementProvider _supplementProvider;
SyncOperationStatus _status = SyncOperationStatus.idle;
SyncResult? _lastSyncResult;
DateTime? _lastSyncTime;
String? _currentError;
bool _isAutoSyncEnabled = false;
bool _autoSyncOnDataChanges = true;
SyncFrequency _syncFrequency = SyncFrequency.hourly;
ConflictResolutionStrategy _conflictStrategy = ConflictResolutionStrategy.manual;
List<SyncConflict> _pendingConflicts = [];
double _syncProgress = 0.0;
// WebDAV Configuration
String? _serverUrl;
String? _username;
String? _detectedServerType;
String? _finalWebdavUrl;
String? _syncFolderName;
bool _isConfigured = false;
SyncProvider(this._supplementProvider);
// Getters
SyncOperationStatus get status => _status;
SyncResult? get lastSyncResult => _lastSyncResult;
DateTime? get lastSyncTime => _lastSyncTime;
String? get currentError => _currentError;
bool get isAutoSyncEnabled => _isAutoSyncEnabled;
bool get autoSyncOnDataChanges => _autoSyncOnDataChanges;
SyncFrequency get syncFrequency => _syncFrequency;
ConflictResolutionStrategy get conflictStrategy => _conflictStrategy;
List<SyncConflict> get pendingConflicts => List.unmodifiable(_pendingConflicts);
double get syncProgress => _syncProgress;
String? get serverUrl => _serverUrl;
String? get username => _username;
String? get detectedServerType => _detectedServerType;
String? get finalWebdavUrl => _finalWebdavUrl;
String? get syncFolderName => _syncFolderName;
bool get isConfigured => _isConfigured;
bool get isSyncing => _status == SyncOperationStatus.syncing;
bool get hasError => _currentError != null;
bool get hasPendingConflicts => _pendingConflicts.isNotEmpty;
/// Initialize the sync provider
Future<void> initialize() async {
await _syncService.initialize();
_isConfigured = await _syncService.isConfigured();
_lastSyncTime = await _syncService.getLastSyncTime();
if (_isConfigured) {
// Load existing configuration
_serverUrl = await _syncService.getServerUrl();
_username = await _syncService.getUsername();
_syncFolderName = await _syncService.getSyncFolderName();
_finalWebdavUrl = await _syncService.getLastWorkingUrl();
_detectedServerType = _detectServerTypeFromUrl(_finalWebdavUrl ?? _serverUrl ?? '');
}
// Set up data change callback on supplement provider
_supplementProvider.setOnDataChangedCallback(() {
triggerAutoSyncOnDataChange();
});
notifyListeners();
}
/// Configure WebDAV connection
Future<bool> configure({
required String serverUrl,
required String username,
String? password,
String? deviceName,
String? syncFolderName,
}) async {
try {
_setStatus(SyncOperationStatus.syncing);
_clearError();
final success = await _syncService.configure(
baseUrl: serverUrl,
username: username,
password: password,
deviceName: deviceName,
syncFolderName: syncFolderName ?? 'Supplements',
);
if (success) {
_serverUrl = serverUrl;
_username = username;
_syncFolderName = syncFolderName ?? 'Supplements';
_isConfigured = true;
// Get server detection info
_finalWebdavUrl = await _syncService.getLastWorkingUrl();
_detectedServerType = _detectServerTypeFromUrl(_finalWebdavUrl ?? serverUrl);
_setStatus(SyncOperationStatus.success);
} else {
_setError('Failed to configure WebDAV connection', SyncOperationStatus.authenticationError);
}
return success;
} catch (e) {
_setError(e.toString(), SyncOperationStatus.authenticationError);
return false;
}
}
/// Test WebDAV connection
Future<bool> testConnection() async {
if (!_isConfigured) return false;
try {
_clearError();
return await _syncService.testConnection();
} catch (e) {
_setError(e.toString(), SyncOperationStatus.networkError);
return false;
}
}
/// Clear WebDAV configuration
Future<void> clearConfiguration() async {
await _syncService.clearConfiguration();
_serverUrl = null;
_username = null;
_detectedServerType = null;
_finalWebdavUrl = null;
_syncFolderName = null;
_isConfigured = false;
_setStatus(SyncOperationStatus.idle);
_clearError();
}
/// Perform manual sync
Future<bool> performManualSync() async {
if (!_isConfigured || _status == SyncOperationStatus.syncing) {
return false;
}
try {
_setStatus(SyncOperationStatus.syncing);
_clearError();
_syncProgress = 0.0;
// Check connectivity first
if (!await _syncService.hasConnectivity()) {
_setError('No internet connection', SyncOperationStatus.networkError);
return false;
}
_syncProgress = 0.2;
notifyListeners();
// Perform the sync
final result = await _syncService.performSync();
_lastSyncResult = result;
_lastSyncTime = result.timestamp;
_syncProgress = 0.8;
notifyListeners();
if (result.success) {
if (result.conflicts.isNotEmpty) {
_pendingConflicts = result.conflicts;
_setStatus(SyncOperationStatus.conflictsDetected);
} else {
_setStatus(SyncOperationStatus.success);
}
// Reload local data after successful sync
await _supplementProvider.loadSupplements();
await _supplementProvider.loadTodayIntakes();
} else {
_setError(result.error ?? 'Sync failed', result.status);
}
_syncProgress = 1.0;
notifyListeners();
return result.success;
} catch (e) {
_setError(e.toString(), SyncOperationStatus.serverError);
return false;
}
}
/// Resolve a conflict
Future<void> resolveConflict(String syncId, ConflictResolution resolution) async {
_pendingConflicts.removeWhere((conflict) => conflict.syncId == syncId);
// TODO: Implement actual conflict resolution logic
// This would involve updating the local database with the chosen resolution
if (_pendingConflicts.isEmpty && _status == SyncOperationStatus.conflictsDetected) {
_setStatus(SyncOperationStatus.success);
}
notifyListeners();
}
/// Resolve all conflicts using the configured strategy
Future<void> resolveAllConflicts() async {
if (_conflictStrategy == ConflictResolutionStrategy.manual) {
return; // Manual conflicts need individual resolution
}
final resolvedConflicts = <SyncConflict>[];
for (final conflict in _pendingConflicts) {
ConflictResolution resolution;
switch (_conflictStrategy) {
case ConflictResolutionStrategy.preferLocal:
resolution = ConflictResolution.useLocal;
break;
case ConflictResolutionStrategy.preferRemote:
resolution = ConflictResolution.useRemote;
break;
case ConflictResolutionStrategy.preferNewer:
resolution = conflict.isLocalNewer
? ConflictResolution.useLocal
: ConflictResolution.useRemote;
break;
case ConflictResolutionStrategy.manual:
continue; // Skip manual conflicts
}
await resolveConflict(conflict.syncId, resolution);
resolvedConflicts.add(conflict);
}
if (resolvedConflicts.isNotEmpty) {
// Perform another sync to apply resolutions
await performManualSync();
}
}
/// Enable or disable auto sync
Future<void> setAutoSyncEnabled(bool enabled) async {
_isAutoSyncEnabled = enabled;
notifyListeners();
if (enabled) {
_scheduleNextAutoSync();
}
// TODO: Cancel existing timers when disabled
}
/// Set sync frequency
Future<void> setSyncFrequency(SyncFrequency frequency) async {
_syncFrequency = frequency;
notifyListeners();
if (_isAutoSyncEnabled) {
_scheduleNextAutoSync();
}
}
/// Set conflict resolution strategy
Future<void> setConflictResolutionStrategy(ConflictResolutionStrategy strategy) async {
_conflictStrategy = strategy;
notifyListeners();
}
/// Enable or disable auto sync on data changes
Future<void> setAutoSyncOnDataChanges(bool enabled) async {
_autoSyncOnDataChanges = enabled;
notifyListeners();
}
/// Trigger sync automatically when data changes (if enabled)
Future<void> triggerAutoSyncOnDataChange() async {
if (!_autoSyncOnDataChanges || !_isConfigured || _status == SyncOperationStatus.syncing) {
return;
}
// Perform sync without blocking the UI
performManualSync();
}
/// Get sync statistics from last result
SyncStatistics? get lastSyncStatistics => _lastSyncResult?.statistics;
/// Get formatted last sync time
String get formattedLastSyncTime {
if (_lastSyncTime == null) return 'Never';
final now = DateTime.now();
final difference = now.difference(_lastSyncTime!);
if (difference.inMinutes < 1) {
return 'Just now';
} else if (difference.inMinutes < 60) {
return '${difference.inMinutes}m ago';
} else if (difference.inHours < 24) {
return '${difference.inHours}h ago';
} else {
return '${difference.inDays}d ago';
}
}
/// Get sync status message
String get statusMessage {
switch (_status) {
case SyncOperationStatus.idle:
return 'Ready to sync';
case SyncOperationStatus.syncing:
return 'Syncing... ${(_syncProgress * 100).round()}%';
case SyncOperationStatus.success:
return 'Sync completed successfully';
case SyncOperationStatus.networkError:
return 'Network connection failed';
case SyncOperationStatus.authenticationError:
return 'Authentication failed';
case SyncOperationStatus.serverError:
return 'Server error occurred';
case SyncOperationStatus.conflictsDetected:
return '${_pendingConflicts.length} conflict(s) need resolution';
case SyncOperationStatus.cancelled:
return 'Sync was cancelled';
}
}
/// Get device info
Future<Map<String, String?>> getDeviceInfo() async {
return await _syncService.getDeviceInfo();
}
// Private methods
void _setStatus(SyncOperationStatus status) {
_status = status;
notifyListeners();
}
void _setError(String error, SyncOperationStatus status) {
_currentError = error;
_status = status;
_syncProgress = 0.0;
notifyListeners();
}
void _clearError() {
_currentError = null;
notifyListeners();
}
void _scheduleNextAutoSync() {
if (!_isAutoSyncEnabled || _syncFrequency == SyncFrequency.manual) {
return;
}
// TODO: Implement actual scheduling logic using Timer or WorkManager
// This would schedule the next automatic sync based on the frequency
if (kDebugMode) {
print('Next auto sync scheduled for ${_syncFrequency.displayName}');
}
}
/// Detect server type from URL for display purposes
String _detectServerTypeFromUrl(String url) {
final lowerUrl = url.toLowerCase();
if (lowerUrl.contains('/remote.php/dav/files/')) {
return 'Nextcloud';
} else if (lowerUrl.contains('/remote.php/webdav/')) {
return 'ownCloud';
} else if (lowerUrl.contains('nextcloud')) {
return 'Nextcloud';
} else if (lowerUrl.contains('owncloud')) {
return 'ownCloud';
} else if (lowerUrl.contains('/webdav/') || lowerUrl.contains('/dav/')) {
return 'Generic WebDAV';
} else {
return 'WebDAV Server';
}
}
@override
void dispose() {
// TODO: Cancel any pending timers or background tasks
super.dispose();
}
}
/// Enum for conflict resolution choices
enum ConflictResolution {
useLocal,
useRemote,
merge, // For future implementation
}
/// Extension methods for ConflictResolution
extension ConflictResolutionExtension on ConflictResolution {
String get displayName {
switch (this) {
case ConflictResolution.useLocal:
return 'Use Local Version';
case ConflictResolution.useRemote:
return 'Use Remote Version';
case ConflictResolution.merge:
return 'Merge Changes';
}
}
}

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 = [];
@@ -224,6 +230,13 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
appBar: AppBar( appBar: AppBar(
title: Text(isEditing ? 'Edit Supplement' : 'Add Supplement'), title: Text(isEditing ? 'Edit Supplement' : 'Add Supplement'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary, backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: [
IconButton(
tooltip: isEditing ? 'Update Supplement' : 'Save Supplement',
onPressed: _saveSupplement,
icon: const Icon(Icons.save),
),
],
), ),
body: Form( body: Form(
key: _formKey, key: _formKey,
@@ -482,17 +495,7 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Save button // Save is now in the AppBar for consistency with app-wide pattern
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _saveSupplement,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16),
),
child: Text(isEditing ? 'Update Supplement' : 'Add Supplement'),
),
),
], ],
), ),
), ),
@@ -560,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;
@@ -583,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,9 +1,9 @@
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';
import '../providers/sync_provider.dart';
class ArchivedSupplementsScreen extends StatefulWidget { class ArchivedSupplementsScreen extends StatefulWidget {
const ArchivedSupplementsScreen({super.key}); const ArchivedSupplementsScreen({super.key});
@@ -27,35 +27,9 @@ class _ArchivedSupplementsScreenState extends State<ArchivedSupplementsScreen> {
appBar: AppBar( appBar: AppBar(
title: const Text('Archived Supplements'), title: const Text('Archived Supplements'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary, backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: [
Consumer<SyncProvider>(
builder: (context, syncProvider, child) {
if (!syncProvider.isConfigured) {
return const SizedBox.shrink();
}
return IconButton(
icon: syncProvider.isSyncing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: syncProvider.status.name == 'success' &&
DateTime.now().difference(syncProvider.lastSyncTime ?? DateTime.now()).inSeconds < 5
? const Icon(Icons.check, color: Colors.green)
: const Icon(Icons.sync),
onPressed: syncProvider.isSyncing ? null : () {
syncProvider.performManualSync();
},
tooltip: syncProvider.isSyncing ? 'Syncing...' : 'Force Sync',
);
},
),
],
), ),
body: Consumer2<SupplementProvider, SyncProvider>( body: Consumer<SupplementProvider>(
builder: (context, provider, syncProvider, child) { builder: (context, provider, child) {
if (provider.archivedSupplements.isEmpty) { if (provider.archivedSupplements.isEmpty) {
return Center( return Center(
child: Column( child: Column(
@@ -333,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,
@@ -349,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,
@@ -363,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

@@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/settings_provider.dart';
import '../providers/supplement_provider.dart'; import '../providers/supplement_provider.dart';
import '../providers/sync_provider.dart';
class HistoryScreen extends StatefulWidget { class HistoryScreen extends StatefulWidget {
const HistoryScreen({super.key}); const HistoryScreen({super.key});
@@ -32,32 +32,6 @@ class _HistoryScreenState extends State<HistoryScreen> {
appBar: AppBar( appBar: AppBar(
title: const Text('Intake History'), title: const Text('Intake History'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary, backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: [
Consumer<SyncProvider>(
builder: (context, syncProvider, child) {
if (!syncProvider.isConfigured) {
return const SizedBox.shrink();
}
return IconButton(
icon: syncProvider.isSyncing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: syncProvider.status.name == 'success' &&
DateTime.now().difference(syncProvider.lastSyncTime ?? DateTime.now()).inSeconds < 5
? const Icon(Icons.check, color: Colors.green)
: const Icon(Icons.sync),
onPressed: syncProvider.isSyncing ? null : () {
syncProvider.performManualSync();
},
tooltip: syncProvider.isSyncing ? 'Syncing...' : 'Force Sync',
);
},
),
],
), ),
body: _buildCalendarView(), body: _buildCalendarView(),
); );
@@ -152,27 +126,33 @@ class _HistoryScreenState extends State<HistoryScreen> {
Expanded( Expanded(
flex: 3, flex: 3,
child: Container( child: Container(
margin: const EdgeInsets.fromLTRB(0, 16, 16, 16), // add a bit more horizontal spacing between calendar and card
child: _buildSelectedDayDetails(groupedIntakes), margin: const EdgeInsets.fromLTRB(8, 16, 16, 16),
child: SingleChildScrollView(
child: _buildSelectedDayDetails(groupedIntakes),
),
), ),
), ),
], ],
); );
} else { } else {
// Mobile layout: vertical stack // Mobile layout: vertical stack
return Column( return SingleChildScrollView(
children: [ child: Column(
// Calendar children: [
Container( // Calendar
margin: const EdgeInsets.symmetric(horizontal: 16), Container(
child: _buildCalendar(groupedIntakes), margin: const EdgeInsets.symmetric(horizontal: 16),
), child: _buildCalendar(groupedIntakes),
const SizedBox(height: 16), ),
// Selected day details const SizedBox(height: 16),
Expanded( // Selected day details
child: _buildSelectedDayDetails(groupedIntakes), Container(
), margin: const EdgeInsets.symmetric(horizontal: 16),
], child: _buildSelectedDayDetails(groupedIntakes),
),
],
),
); );
} }
}, },
@@ -506,78 +486,208 @@ class _HistoryScreenState extends State<HistoryScreen> {
], ],
), ),
), ),
Expanded( Padding(
child: ListView.builder( padding: EdgeInsets.all(isWideScreen ? 20 : 16),
padding: EdgeInsets.all(isWideScreen ? 20 : 16), child: Builder(
itemCount: dayIntakes.length, builder: (context) {
itemBuilder: (context, index) { final settingsProvider = Provider.of<SettingsProvider>(context, listen: false);
final intake = dayIntakes[index]; // Sort once per render
final takenAt = DateTime.parse(intake['takenAt']); final sortedDayIntakes = List<Map<String, dynamic>>.from(dayIntakes)
final units = (intake['unitsTaken'] as num?)?.toDouble() ?? 1.0; ..sort((a, b) => DateTime.parse(a['takenAt']).compareTo(DateTime.parse(b['takenAt'])));
// Helpers
return Card( String timeCategory(DateTime dt) {
margin: const EdgeInsets.only(bottom: 12), final h = dt.hour;
elevation: 2, if (h >= settingsProvider.morningStart && h <= settingsProvider.morningEnd) return 'morning';
child: Padding( if (h >= settingsProvider.afternoonStart && h <= settingsProvider.afternoonEnd) return 'afternoon';
padding: EdgeInsets.all(isWideScreen ? 16 : 12), if (h >= settingsProvider.eveningStart && h <= settingsProvider.eveningEnd) return 'evening';
final ns = settingsProvider.nightStart;
final ne = settingsProvider.nightEnd;
final inNight = ns <= ne ? (h >= ns && h <= ne) : (h >= ns || h <= ne);
return inNight ? 'night' : 'anytime';
}
String? sectionRange(String cat) {
switch (cat) {
case 'morning':
return settingsProvider.morningRange;
case 'afternoon':
return settingsProvider.afternoonRange;
case 'evening':
return settingsProvider.eveningRange;
case 'night':
return settingsProvider.nightRange;
default:
return null;
}
}
Widget headerFor(String cat) {
late final IconData icon;
late final Color color;
late final String title;
switch (cat) {
case 'morning':
icon = Icons.wb_sunny;
color = Colors.orange;
title = 'Morning';
break;
case 'afternoon':
icon = Icons.light_mode;
color = Colors.blue;
title = 'Afternoon';
break;
case 'evening':
icon = Icons.nightlight_round;
color = Colors.indigo;
title = 'Evening';
break;
case 'night':
icon = Icons.bedtime;
color = Colors.purple;
title = 'Night';
break;
default:
icon = Icons.schedule;
color = Colors.grey;
title = 'Anytime';
}
final range = sectionRange(cat);
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: color.withOpacity(0.3),
width: 1,
),
),
child: Row( child: Row(
children: [ children: [
CircleAvatar( Container(
backgroundColor: Theme.of(context).colorScheme.primary, padding: const EdgeInsets.all(8),
radius: isWideScreen ? 24 : 20, decoration: BoxDecoration(
color: color.withOpacity(0.2),
shape: BoxShape.circle,
),
child: Icon( child: Icon(
Icons.medication, icon,
color: Theme.of(context).colorScheme.onPrimary, size: 20,
size: isWideScreen ? 24 : 20, color: color,
), ),
), ),
SizedBox(width: isWideScreen ? 16 : 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
intake['supplementName'], title,
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.w600, fontSize: 18,
fontSize: isWideScreen ? 16 : 14, fontWeight: FontWeight.bold,
color: color,
), ),
), ),
const SizedBox(height: 4), if (range != null) ...[
Text(
'${units.toStringAsFixed(units % 1 == 0 ? 0 : 1)} ${intake['supplementUnitType'] ?? 'units'} at ${DateFormat('HH:mm').format(takenAt)}',
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w500,
fontSize: isWideScreen ? 14 : 12,
),
),
if (intake['notes'] != null && intake['notes'].toString().isNotEmpty) ...[
const SizedBox(height: 4),
Text( Text(
intake['notes'], '($range)',
style: TextStyle( style: TextStyle(
fontSize: isWideScreen ? 13 : 12, fontSize: 12,
fontStyle: FontStyle.italic, fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurfaceVariant, color: color.withOpacity(0.8),
), ),
), ),
], ],
], ],
), ),
), ),
IconButton(
icon: Icon(
Icons.delete_outline,
color: Colors.red.shade400,
size: isWideScreen ? 24 : 20,
),
onPressed: () => _deleteIntake(context, intake['id'], intake['supplementName']),
tooltip: 'Delete intake',
),
], ],
), ),
), );
}
// Build a non-scrollable list so the card auto-expands to fit content
final List<Widget> children = [];
for (int index = 0; index < sortedDayIntakes.length; index++) {
final intake = sortedDayIntakes[index];
final takenAt = DateTime.parse(intake['takenAt']);
final units = (intake['unitsTaken'] as num?)?.toDouble() ?? 1.0;
final currentCategory = timeCategory(takenAt);
final needsHeader = index == 0
? true
: currentCategory != timeCategory(DateTime.parse(sortedDayIntakes[index - 1]['takenAt']));
if (needsHeader) {
children.add(headerFor(currentCategory));
}
children.add(
Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 2,
child: Padding(
padding: EdgeInsets.all(isWideScreen ? 16 : 12),
child: Row(
children: [
CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.primary,
radius: isWideScreen ? 24 : 20,
child: Icon(
Icons.medication,
color: Theme.of(context).colorScheme.onPrimary,
size: isWideScreen ? 24 : 20,
),
),
SizedBox(width: isWideScreen ? 16 : 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
intake['supplementName'],
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: isWideScreen ? 16 : 14,
),
),
const SizedBox(height: 4),
Text(
'${units.toStringAsFixed(units % 1 == 0 ? 0 : 1)} ${intake['supplementUnitType'] ?? 'units'} at ${DateFormat('HH:mm').format(takenAt)}',
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w500,
fontSize: isWideScreen ? 14 : 12,
),
),
if (intake['notes'] != null && intake['notes'].toString().isNotEmpty) ...[
const SizedBox(height: 4),
Text(
intake['notes'],
style: TextStyle(
fontSize: isWideScreen ? 13 : 12,
fontStyle: FontStyle.italic,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
],
),
),
IconButton(
icon: Icon(
Icons.delete_outline,
color: Colors.red.shade400,
size: isWideScreen ? 24 : 20,
),
onPressed: () => _deleteIntake(context, intake['id'], intake['supplementName']),
tooltip: 'Delete intake',
),
],
),
),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
); );
}, },
), ),

View File

@@ -51,20 +51,20 @@ class _HomeScreenState extends State<HomeScreen> {
if (!mounted) return; if (!mounted) return;
try { try {
print('📱 === HOME SCREEN: Checking persistent reminders ==='); print('SupplementsLog: 📱 === HOME SCREEN: Checking persistent reminders ===');
final supplementProvider = context.read<SupplementProvider>(); final supplementProvider = context.read<SupplementProvider>();
final settingsProvider = context.read<SettingsProvider>(); final settingsProvider = context.read<SettingsProvider>();
print('📱 Settings: persistent=${settingsProvider.persistentReminders}, interval=${settingsProvider.reminderRetryInterval}, max=${settingsProvider.maxRetryAttempts}'); print('SupplementsLog: 📱 Settings: persistent=${settingsProvider.persistentReminders}, interval=${settingsProvider.reminderRetryInterval}, max=${settingsProvider.maxRetryAttempts}');
await supplementProvider.checkPersistentRemindersWithSettings( await supplementProvider.checkPersistentRemindersWithSettings(
persistentReminders: settingsProvider.persistentReminders, persistentReminders: settingsProvider.persistentReminders,
reminderRetryInterval: settingsProvider.reminderRetryInterval, reminderRetryInterval: settingsProvider.reminderRetryInterval,
maxRetryAttempts: settingsProvider.maxRetryAttempts, maxRetryAttempts: settingsProvider.maxRetryAttempts,
); );
print('📱 === HOME SCREEN: Persistent reminder check complete ==='); print('SupplementsLog: 📱 === HOME SCREEN: Persistent reminder check complete ===');
} catch (e) { } catch (e) {
print('Error checking persistent reminders: $e'); print('SupplementsLog: Error checking persistent reminders: $e');
} }
} }

View File

@@ -132,7 +132,7 @@ class _PendingNotificationsScreenState extends State<PendingNotificationsScreen>
_isLoading = false; _isLoading = false;
}); });
} catch (e) { } catch (e) {
print('Error loading notifications: $e'); print('SupplementsLog: Error loading notifications: $e');
setState(() { setState(() {
_isLoading = false; _isLoading = false;
}); });

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,10 +2,9 @@ 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 'sync_settings_screen.dart'; import 'profile_setup_screen.dart';
import 'simple_sync_settings_screen.dart';
class SettingsScreen extends StatelessWidget { class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@@ -21,6 +20,38 @@ 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(
child: ListTile(
leading: const Icon(Icons.cloud_sync),
title: const Text('Cloud Sync'),
subtitle: const Text('Configure WebDAV sync settings'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const SimpleSyncSettingsScreen(),
),
);
},
),
),
const SizedBox(height: 16),
Card( Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
@@ -71,18 +102,98 @@ class SettingsScreen extends StatelessWidget {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Card( Card(
child: ListTile( child: Padding(
leading: const Icon(Icons.cloud_sync), padding: const EdgeInsets.all(16.0),
title: const Text('Cloud Sync'), child: Column(
subtitle: const Text('Configure WebDAV sync settings'), crossAxisAlignment: CrossAxisAlignment.start,
trailing: const Icon(Icons.chevron_right), children: [
onTap: () { Row(
Navigator.of(context).push( children: [
MaterialPageRoute( Icon(Icons.notifications_active, color: Colors.blue),
builder: (context) => const SyncSettingsScreen(), const SizedBox(width: 8),
Text(
'Reminders',
style: Theme.of(context).textTheme.titleMedium,
),
],
), ),
); const SizedBox(height: 8),
}, Text(
'Configure reminders and how often they are retried when ignored',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('Enable Persistent Reminders'),
subtitle: const Text('Resend notifications if ignored after a specific time'),
value: settingsProvider.persistentReminders,
onChanged: (value) {
settingsProvider.setPersistentReminders(value);
},
),
if (settingsProvider.persistentReminders) ...[
const SizedBox(height: 16),
Text(
'Retry Interval',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
SegmentedButton<int>(
segments: const [
ButtonSegment(value: 5, label: Text('5 min')),
ButtonSegment(value: 10, label: Text('10 min')),
ButtonSegment(value: 15, label: Text('15 min')),
ButtonSegment(value: 30, label: Text('30 min')),
],
selected: {settingsProvider.reminderRetryInterval},
onSelectionChanged: (values) {
settingsProvider.setReminderRetryInterval(values.first);
},
),
const SizedBox(height: 16),
Text(
'Maximum Retry Attempts',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
SegmentedButton<int>(
segments: const [
ButtonSegment(value: 1, label: Text('1')),
ButtonSegment(value: 2, label: Text('2')),
ButtonSegment(value: 3, label: Text('3')),
ButtonSegment(value: 4, label: Text('4')),
ButtonSegment(value: 5, label: Text('5')),
],
selected: {settingsProvider.maxRetryAttempts},
onSelectionChanged: (values) {
settingsProvider.setMaxRetryAttempts(values.first);
},
),
const SizedBox(height: 16),
Text(
'Notification Actions',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
SizedBox(
width: 320,
child: ElevatedButton.icon(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const PendingNotificationsScreen(),
),
);
},
icon: const Icon(Icons.list),
label: const Text('View Pending Notifications'),
),
),
],
],
),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -155,354 +266,6 @@ class SettingsScreen extends StatelessWidget {
), ),
), ),
), ),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.notifications_active, color: Colors.blue),
const SizedBox(width: 8),
Text(
'Persistent Reminders',
style: Theme.of(context).textTheme.titleMedium,
),
],
),
const SizedBox(height: 8),
Text(
'Configure automatic reminder retries for ignored notifications',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('Enable Persistent Reminders'),
subtitle: const Text('Resend notifications if ignored'),
value: settingsProvider.persistentReminders,
onChanged: (value) {
settingsProvider.setPersistentReminders(value);
},
),
if (settingsProvider.persistentReminders) ...[
const SizedBox(height: 16),
Text(
'Retry Interval',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
SegmentedButton<int>(
segments: const [
ButtonSegment(value: 5, label: Text('5 min')),
ButtonSegment(value: 10, label: Text('10 min')),
ButtonSegment(value: 15, label: Text('15 min')),
ButtonSegment(value: 30, label: Text('30 min')),
],
selected: {settingsProvider.reminderRetryInterval},
onSelectionChanged: (values) {
settingsProvider.setReminderRetryInterval(values.first);
},
),
const SizedBox(height: 16),
Text(
'Maximum Retry Attempts',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
SegmentedButton<int>(
segments: const [
ButtonSegment(value: 1, label: Text('1')),
ButtonSegment(value: 2, label: Text('2')),
ButtonSegment(value: 3, label: Text('3')),
ButtonSegment(value: 4, label: Text('4')),
ButtonSegment(value: 5, label: Text('5')),
],
selected: {settingsProvider.maxRetryAttempts},
onSelectionChanged: (values) {
settingsProvider.setMaxRetryAttempts(values.first);
},
),
],
],
),
),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.notifications_outlined),
const SizedBox(width: 8),
Text(
'Notifications',
style: Theme.of(context).textTheme.titleMedium,
),
],
),
const SizedBox(height: 8),
Text(
'View and manage pending notifications',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const PendingNotificationsScreen(),
),
);
},
icon: const Icon(Icons.list),
label: const Text('View Pending Notifications'),
),
),
],
),
),
),
const SizedBox(height: 16),
if (Theme.of(context).brightness == Brightness.dark) // Only show in debug mode for now
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.bug_report, color: Colors.orange),
const SizedBox(width: 8),
Text(
'Debug - Notifications',
style: Theme.of(context).textTheme.titleMedium,
),
],
),
const SizedBox(height: 16),
Consumer<SupplementProvider>(
builder: (context, supplementProvider, child) {
return Column(
children: [
ElevatedButton.icon(
onPressed: () async {
await supplementProvider.testNotifications();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Test notification sent!')),
);
}
},
icon: const Icon(Icons.notifications_active),
label: const Text('Test Instant'),
),
const SizedBox(height: 8),
ElevatedButton.icon(
onPressed: () async {
await supplementProvider.testScheduledNotification();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Scheduled test notification for 1 minute from now!')),
);
}
},
icon: const Icon(Icons.schedule),
label: const Text('Test Scheduled (1min)'),
),
const SizedBox(height: 8),
ElevatedButton.icon(
onPressed: () async {
await supplementProvider.testNotificationActions();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Test notification with actions sent! Try the Take/Snooze buttons.')),
);
}
},
icon: const Icon(Icons.touch_app),
label: const Text('Test Actions'),
),
const SizedBox(height: 8),
ElevatedButton.icon(
onPressed: () async {
await NotificationService().testBasicNotification();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Basic test notification sent! Tap it to test callback.')),
);
}
},
icon: const Icon(Icons.tap_and_play),
label: const Text('Test Basic Tap'),
),
const SizedBox(height: 8),
ElevatedButton.icon(
onPressed: () async {
await supplementProvider.rescheduleAllNotifications();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('All notifications rescheduled!')),
);
}
},
icon: const Icon(Icons.refresh),
label: const Text('Reschedule All'),
),
const SizedBox(height: 8),
ElevatedButton.icon(
onPressed: () async {
await supplementProvider.cancelAllNotifications();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('All notifications cancelled!')),
);
}
},
icon: const Icon(Icons.cancel),
label: const Text('Cancel All'),
),
const SizedBox(height: 8),
ElevatedButton.icon(
onPressed: () async {
final pending = await supplementProvider.getPendingNotifications();
if (context.mounted) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Pending Notifications'),
content: pending.isEmpty
? const Text('No pending notifications')
: SizedBox(
width: double.maxFinite,
child: Consumer<SupplementProvider>(
builder: (context, provider, child) {
return ListView.builder(
shrinkWrap: true,
itemCount: pending.length,
itemBuilder: (context, index) {
final notification = pending[index];
// Calculate scheduled time inline
String scheduledTime = '';
try {
final notificationId = notification.id;
if (notificationId == 99999) {
scheduledTime = 'Test notification';
} else if (notificationId > 1000) {
final snoozeMinutes = notificationId % 1000;
scheduledTime = 'Snoozed ($snoozeMinutes min)';
} else {
final supplementId = notificationId ~/ 100;
final reminderIndex = notificationId % 100;
final supplement = provider.supplements.firstWhere(
(s) => s.id == supplementId,
orElse: () => provider.supplements.first,
);
if (reminderIndex < supplement.reminderTimes.length) {
final reminderTime = supplement.reminderTimes[reminderIndex];
final now = DateTime.now();
final timeParts = reminderTime.split(':');
final hour = int.parse(timeParts[0]);
final minute = int.parse(timeParts[1]);
final today = DateTime(now.year, now.month, now.day, hour, minute);
final isToday = today.isAfter(now);
scheduledTime = '${isToday ? 'Today' : 'Tomorrow'} at $reminderTime';
} else {
scheduledTime = 'Unknown time';
}
}
} catch (e) {
scheduledTime = 'ID: ${notification.id}';
}
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.primary,
child: Text(
'${index + 1}',
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
fontWeight: FontWeight.bold,
),
),
),
title: Text(
notification.title ?? 'No title',
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('ID: ${notification.id}'),
Text(notification.body ?? 'No body'),
if (scheduledTime.isNotEmpty) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'$scheduledTime',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
],
],
),
isThreeLine: true,
),
);
},
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'),
),
],
),
);
}
},
icon: const Icon(Icons.list),
label: const Text('Show Pending'),
),
],
);
},
),
],
),
),
),
], ],
); );
}, },

View File

@@ -0,0 +1,849 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/settings_provider.dart';
import '../providers/simple_sync_provider.dart';
import '../services/database_sync_service.dart';
class SimpleSyncSettingsScreen extends StatefulWidget {
const SimpleSyncSettingsScreen({super.key});
@override
State<SimpleSyncSettingsScreen> createState() => _SimpleSyncSettingsScreenState();
}
class _SimpleSyncSettingsScreenState extends State<SimpleSyncSettingsScreen> {
final _formKey = GlobalKey<FormState>();
final _serverUrlController = TextEditingController();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _remotePathController = TextEditingController();
String _previewUrl = '';
@override
void initState() {
super.initState();
_serverUrlController.addListener(_updatePreviewUrl);
_usernameController.addListener(_updatePreviewUrl);
_loadSavedConfiguration();
}
void _loadSavedConfiguration() {
WidgetsBinding.instance.addPostFrameCallback((_) {
final syncProvider = context.read<SimpleSyncProvider>();
if (syncProvider.serverUrl != null) {
_serverUrlController.text = _extractHostnameFromUrl(syncProvider.serverUrl!);
}
if (syncProvider.username != null) {
_usernameController.text = syncProvider.username!;
}
if (syncProvider.password != null) {
_passwordController.text = syncProvider.password!;
}
if (syncProvider.remotePath != null) {
_remotePathController.text = syncProvider.remotePath!;
}
_updatePreviewUrl();
});
}
String _extractHostnameFromUrl(String fullUrl) {
try {
final uri = Uri.parse(fullUrl);
return uri.host;
} catch (e) {
return fullUrl; // Return as-is if parsing fails
}
}
void _updatePreviewUrl() {
setState(() {
if (_serverUrlController.text.isNotEmpty && _usernameController.text.isNotEmpty) {
_previewUrl = _constructWebDAVUrl(_serverUrlController.text, _usernameController.text);
} else {
_previewUrl = '';
}
});
}
@override
void dispose() {
_serverUrlController.removeListener(_updatePreviewUrl);
_usernameController.removeListener(_updatePreviewUrl);
_serverUrlController.dispose();
_usernameController.dispose();
_passwordController.dispose();
_remotePathController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final syncProvider = context.watch<SimpleSyncProvider>();
return Scaffold(
appBar: AppBar(
title: const Text('Database Sync Settings'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: [
IconButton(
tooltip: 'Save Configuration',
onPressed: syncProvider.isSyncing ? null : _configureSync,
icon: const Icon(Icons.save),
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildStatusCard(syncProvider),
const SizedBox(height: 20),
_buildConfigurationSection(syncProvider),
const SizedBox(height: 20),
_buildActionButtons(),
],
),
),
),
);
}
Widget _buildStatusCard(SimpleSyncProvider syncProvider) {
IconData icon;
Color color;
String statusText = syncProvider.getStatusText();
switch (syncProvider.status) {
case SyncStatus.idle:
icon = syncProvider.isAutoSync ? Icons.sync_alt : Icons.sync;
color = Colors.blue;
break;
case SyncStatus.downloading:
case SyncStatus.merging:
case SyncStatus.uploading:
icon = syncProvider.isAutoSync ? Icons.sync_alt : Icons.sync;
color = syncProvider.isAutoSync ? Colors.deepOrange : Colors.orange;
break;
case SyncStatus.completed:
icon = syncProvider.isAutoSync ? Icons.check_circle_outline : Icons.check_circle;
color = Colors.green;
break;
case SyncStatus.error:
icon = Icons.error;
color = Colors.red;
break;
}
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Row(
children: [
Icon(icon, color: color, size: 24),
const SizedBox(width: 12),
Expanded(
child: Text(
statusText,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: color,
),
),
),
// Sync action inside the status card
if (syncProvider.isSyncing) ...[
const SizedBox(width: 12),
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2.2,
valueColor: AlwaysStoppedAnimation<Color>(color),
),
),
] else ...[
IconButton(
tooltip: 'Sync Database',
onPressed: (!syncProvider.isConfigured || syncProvider.isSyncing) ? null : _syncDatabase,
icon: const Icon(Icons.sync),
color: Theme.of(context).colorScheme.primary,
),
],
],
),
_buildAutoSyncStatusIndicator(syncProvider),
if (syncProvider.lastSyncTime != null) ...[
const SizedBox(height: 8),
Text(
'Last sync: ${_formatDateTime(syncProvider.lastSyncTime!)}',
style: Theme.of(context).textTheme.bodySmall,
),
],
// Show auto-sync specific errors
if (syncProvider.isAutoSyncDisabledDueToErrors) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.warning, color: Colors.red, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'Auto-sync disabled due to repeated failures',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Colors.red,
fontWeight: FontWeight.w600,
),
),
),
],
),
if (syncProvider.autoSyncLastError != null) ...[
const SizedBox(height: 8),
Text(
syncProvider.autoSyncLastError!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.red[700],
),
),
],
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: () => syncProvider.resetAutoSyncErrors(),
icon: const Icon(Icons.refresh, size: 16),
label: const Text('Reset & Re-enable'),
style: TextButton.styleFrom(
foregroundColor: Colors.red,
),
),
],
),
],
),
),
] else if (syncProvider.lastError != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Row(
children: [
Expanded(
child: Text(
_getErrorMessage(syncProvider),
style: const TextStyle(color: Colors.red),
),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.red),
onPressed: () => syncProvider.clearError(),
),
],
),
),
],
],
),
),
);
}
Widget _buildConfigurationSection(SimpleSyncProvider syncProvider) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Sync Configuration',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
_buildAutoSyncSection(),
],
),
),
),
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'WebDAV Settings',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
TextFormField(
controller: _serverUrlController,
decoration: const InputDecoration(
labelText: 'Server URL',
hintText: 'your-nextcloud.com',
helperText: 'Enter just the hostname. We\'ll auto-detect the full WebDAV path.',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a server URL';
}
return null;
},
),
const SizedBox(height: 8),
TextFormField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: 'Username',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a username';
}
return null;
},
),
if (_previewUrl.isNotEmpty) ...[
const SizedBox(height: 6),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'WebDAV URL Preview:',
style: Theme.of(context).textTheme.labelMedium,
),
const SizedBox(height: 4),
SelectableText(
_previewUrl,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 6),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ElevatedButton.icon(
onPressed: syncProvider.isSyncing ? null : _testConnection,
icon: const Icon(Icons.link),
label: const Text('Test'),
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
elevation: 0,
),
),
],
),
],
),
),
],
const SizedBox(height: 8),
TextFormField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Password',
border: OutlineInputBorder(),
),
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a password';
}
return null;
},
),
const SizedBox(height: 8),
TextFormField(
controller: _remotePathController,
decoration: const InputDecoration(
labelText: 'Remote Path (optional)',
hintText: 'Supplements/',
border: OutlineInputBorder(),
),
),
],
),
),
),
],
);
}
Widget _buildActionButtons() {
// Buttons have been moved into the AppBar / cards. Keep a small spacer here for layout.
return const SizedBox.shrink();
}
Widget _buildAutoSyncSection() {
return Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return Consumer<SimpleSyncProvider>(
builder: (context, syncProvider, child) {
return Container(
padding: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile(
title: Row(
children: [
const Text(
'Auto-sync',
style: TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(width: 8),
_buildAutoSyncStatusBadge(settingsProvider, syncProvider),
],
),
subtitle: Text(
settingsProvider.autoSyncEnabled
? 'Automatically sync when you make changes'
: 'Sync manually using the sync button',
style: Theme.of(context).textTheme.bodySmall,
),
value: settingsProvider.autoSyncEnabled,
onChanged: (bool value) async {
await settingsProvider.setAutoSyncEnabled(value);
},
contentPadding: EdgeInsets.zero,
),
if (settingsProvider.autoSyncEnabled) ...[
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Text(
'Changes are debounced for ${settingsProvider.autoSyncDebounceSeconds} seconds to prevent excessive syncing.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Debounce timeout',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
SegmentedButton<int>(
segments: const [
ButtonSegment(value: 1, label: Text('1s')),
ButtonSegment(value: 5, label: Text('5s')),
ButtonSegment(value: 15, label: Text('15s')),
ButtonSegment(value: 30, label: Text('30s')),
],
selected: {settingsProvider.autoSyncDebounceSeconds},
onSelectionChanged: (values) {
settingsProvider.setAutoSyncDebounceSeconds(values.first);
},
),
],
),
),
],
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.info_outline,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Auto-sync triggers when you add, update, or delete supplements and intakes. Configure your WebDAV settings below to enable syncing.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
],
),
),
],
),
);
},
);
},
);
}
Future<void> _testConnection() async {
if (!_formKey.currentState!.validate()) return;
final syncProvider = context.read<SimpleSyncProvider>();
try {
// Construct the full WebDAV URL from the simple hostname
final fullWebDAVUrl = _constructWebDAVUrl(
_serverUrlController.text.trim(),
_usernameController.text.trim(),
);
// Configure temporarily for testing
await syncProvider.configure(
serverUrl: fullWebDAVUrl,
username: _usernameController.text.trim(),
password: _passwordController.text.trim(),
remotePath: _remotePathController.text.trim().isEmpty
? 'Supplements'
: _remotePathController.text.trim(),
);
final success = await syncProvider.testConnection();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success
? 'Connection successful!'
: 'Connection failed. Check your settings.'),
backgroundColor: success ? Colors.green : Colors.red,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Connection test failed: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
Future<void> _configureSync() async {
if (!_formKey.currentState!.validate()) return;
final syncProvider = context.read<SimpleSyncProvider>();
try {
// Construct the full WebDAV URL from the simple hostname
final fullWebDAVUrl = _constructWebDAVUrl(
_serverUrlController.text.trim(),
_usernameController.text.trim(),
);
await syncProvider.configure(
serverUrl: fullWebDAVUrl,
username: _usernameController.text.trim(),
password: _passwordController.text.trim(),
remotePath: _remotePathController.text.trim().isEmpty
? 'Supplements'
: _remotePathController.text.trim(),
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Configuration saved successfully!'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to save configuration: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
Future<void> _syncDatabase() async {
final syncProvider = context.read<SimpleSyncProvider>();
try {
await syncProvider.syncDatabase(isAutoSync: false);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Manual sync completed!'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Manual sync failed: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
String _constructWebDAVUrl(String serverUrl, String username) {
// Remove any protocol prefix if present
String cleanUrl = serverUrl.trim();
if (cleanUrl.startsWith('http://')) {
cleanUrl = cleanUrl.substring(7);
} else if (cleanUrl.startsWith('https://')) {
cleanUrl = cleanUrl.substring(8);
}
// Remove trailing slash if present
if (cleanUrl.endsWith('/')) {
cleanUrl = cleanUrl.substring(0, cleanUrl.length - 1);
}
// For Nextcloud instances, construct the standard WebDAV path
// Default to HTTPS for security
return 'https://$cleanUrl/remote.php/dav/files/$username/';
}
Widget _buildAutoSyncStatusIndicator(SimpleSyncProvider syncProvider) {
return Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
// Only show auto-sync status if auto-sync is enabled
if (!settingsProvider.autoSyncEnabled) {
return const SizedBox.shrink();
}
// Check if auto-sync service has pending sync
final autoSyncService = syncProvider.autoSyncService;
if (autoSyncService == null) {
return const SizedBox.shrink();
}
// Show pending auto-sync indicator
if (autoSyncService.hasPendingSync && !syncProvider.isSyncing) {
return Container(
margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 1.5,
valueColor: const AlwaysStoppedAnimation<Color>(Colors.blue),
),
),
const SizedBox(width: 8),
Text(
'Auto-sync pending...',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.blue,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
// Show auto-sync active indicator (when sync is running and it's auto-triggered)
if (syncProvider.isSyncing && syncProvider.isAutoSync) {
return Container(
margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.deepOrange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.deepOrange.withValues(alpha: 0.3)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.sync_alt,
size: 12,
color: Colors.deepOrange,
),
const SizedBox(width: 8),
Text(
'Auto-sync active',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.deepOrange,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
return const SizedBox.shrink();
},
);
}
Widget _buildAutoSyncStatusBadge(SettingsProvider settingsProvider, SimpleSyncProvider syncProvider) {
if (!settingsProvider.autoSyncEnabled) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'OFF',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.grey[600],
fontWeight: FontWeight.w600,
),
),
);
}
// Check if auto-sync is disabled due to errors
if (syncProvider.isAutoSyncDisabledDueToErrors) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'ERROR',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.red[700],
fontWeight: FontWeight.w600,
),
),
);
}
// Check if sync is configured
if (!syncProvider.isConfigured) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'NOT CONFIGURED',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.orange[700],
fontWeight: FontWeight.w600,
),
),
);
}
// Check if there are recent failures but not disabled yet
if (syncProvider.autoSyncConsecutiveFailures > 0) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'RETRYING',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.orange[700],
fontWeight: FontWeight.w600,
),
),
);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'ACTIVE',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.green[700],
fontWeight: FontWeight.w600,
),
),
);
}
String _getErrorMessage(SimpleSyncProvider syncProvider) {
final error = syncProvider.lastError ?? 'Unknown error';
// Add context for auto-sync errors
if (syncProvider.isAutoSync) {
return 'Auto-sync error: $error';
}
return error;
}
String _formatDateTime(DateTime dateTime) {
return '${dateTime.day}/${dateTime.month}/${dateTime.year} ${dateTime.hour}:${dateTime.minute.toString().padLeft(2, '0')}';
}
}

View File

@@ -1,10 +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/simple_sync_provider.dart';
import '../providers/supplement_provider.dart'; import '../providers/supplement_provider.dart';
import '../providers/sync_provider.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';
@@ -19,7 +24,7 @@ class SupplementsListScreen extends StatelessWidget {
title: const Text('My Supplements'), title: const Text('My Supplements'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary, backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: [ actions: [
Consumer<SyncProvider>( Consumer<SimpleSyncProvider>(
builder: (context, syncProvider, child) { builder: (context, syncProvider, child) {
if (!syncProvider.isConfigured) { if (!syncProvider.isConfigured) {
return const SizedBox.shrink(); return const SizedBox.shrink();
@@ -32,12 +37,13 @@ class SupplementsListScreen extends StatelessWidget {
height: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: syncProvider.status.name == 'success' && : syncProvider.status == SyncStatus.completed &&
DateTime.now().difference(syncProvider.lastSyncTime ?? DateTime.now()).inSeconds < 5 syncProvider.lastSyncTime != null &&
DateTime.now().difference(syncProvider.lastSyncTime!).inSeconds < 5
? const Icon(Icons.check, color: Colors.green) ? const Icon(Icons.check, color: Colors.green)
: const Icon(Icons.sync), : const Icon(Icons.sync),
onPressed: syncProvider.isSyncing ? null : () { onPressed: syncProvider.isSyncing ? null : () {
syncProvider.performManualSync(); syncProvider.syncDatabase();
}, },
tooltip: syncProvider.isSyncing ? 'Syncing...' : 'Force Sync', tooltip: syncProvider.isSyncing ? 'Syncing...' : 'Force Sync',
); );
@@ -56,8 +62,8 @@ class SupplementsListScreen extends StatelessWidget {
), ),
], ],
), ),
body: Consumer3<SupplementProvider, SettingsProvider, SyncProvider>( body: Consumer2<SupplementProvider, SettingsProvider>(
builder: (context, provider, settingsProvider, syncProvider, child) { builder: (context, provider, settingsProvider, child) {
if (provider.isLoading) { if (provider.isLoading) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
@@ -105,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) =>
@@ -119,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),
@@ -133,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),
@@ -147,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),
@@ -161,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),
@@ -175,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!),
), ),
), ),
], ],
@@ -271,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

@@ -1,782 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/sync_enums.dart';
import '../providers/sync_provider.dart';
/// Screen for configuring WebDAV sync settings
class SyncSettingsScreen extends StatefulWidget {
const SyncSettingsScreen({super.key});
@override
State<SyncSettingsScreen> createState() => _SyncSettingsScreenState();
}
class _SyncSettingsScreenState extends State<SyncSettingsScreen> {
final _formKey = GlobalKey<FormState>();
final _serverUrlController = TextEditingController();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _deviceNameController = TextEditingController();
final _syncFolderController = TextEditingController();
bool _isPasswordVisible = false;
bool _isTestingConnection = false;
bool _isConfiguring = false;
@override
void initState() {
super.initState();
_loadCurrentSettings();
}
void _loadCurrentSettings() {
final syncProvider = context.read<SyncProvider>();
_serverUrlController.text = syncProvider.serverUrl ?? '';
_usernameController.text = syncProvider.username ?? '';
_syncFolderController.text = syncProvider.syncFolderName ?? 'Supplements';
// Note: We don't load the password for security reasons
}
@override
void dispose() {
_serverUrlController.dispose();
_usernameController.dispose();
_passwordController.dispose();
_deviceNameController.dispose();
_syncFolderController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Cloud Sync Settings'),
actions: [
Consumer<SyncProvider>(
builder: (context, syncProvider, child) {
if (!syncProvider.isConfigured) return const SizedBox.shrink();
return PopupMenuButton<String>(
onSelected: (value) {
switch (value) {
case 'test':
_testConnection();
break;
case 'sync':
_performSync();
break;
case 'clear':
_showClearConfigDialog();
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'test',
child: ListTile(
leading: Icon(Icons.wifi_protected_setup),
title: Text('Test Connection'),
),
),
const PopupMenuItem(
value: 'sync',
child: ListTile(
leading: Icon(Icons.sync),
title: Text('Sync Now'),
),
),
const PopupMenuItem(
value: 'clear',
child: ListTile(
leading: Icon(Icons.clear),
title: Text('Clear Configuration'),
),
),
],
);
},
),
],
),
body: Consumer<SyncProvider>(
builder: (context, syncProvider, child) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildStatusCard(syncProvider),
const SizedBox(height: 24),
_buildDeviceInfoSection(syncProvider),
const SizedBox(height: 24),
_buildConfigurationSection(syncProvider),
const SizedBox(height: 24),
_buildSyncSettingsSection(syncProvider),
if (syncProvider.hasPendingConflicts) ...[
const SizedBox(height: 24),
_buildConflictsSection(syncProvider),
],
],
),
),
);
},
),
);
}
Widget _buildStatusCard(SyncProvider syncProvider) {
Color statusColor;
IconData statusIcon;
switch (syncProvider.status) {
case SyncOperationStatus.success:
statusColor = Colors.green;
statusIcon = Icons.check_circle;
break;
case SyncOperationStatus.syncing:
statusColor = Colors.blue;
statusIcon = Icons.sync;
break;
case SyncOperationStatus.networkError:
case SyncOperationStatus.authenticationError:
case SyncOperationStatus.serverError:
statusColor = Colors.red;
statusIcon = Icons.error;
break;
case SyncOperationStatus.conflictsDetected:
statusColor = Colors.orange;
statusIcon = Icons.warning;
break;
default:
statusColor = Colors.grey;
statusIcon = Icons.cloud_off;
}
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(statusIcon, color: statusColor, size: 24),
const SizedBox(width: 12),
Expanded(
child: Text(
syncProvider.statusMessage,
style: Theme.of(context).textTheme.titleMedium,
),
),
],
),
if (syncProvider.lastSyncTime != null) ...[
const SizedBox(height: 8),
Text(
'Last sync: ${syncProvider.formattedLastSyncTime}',
style: Theme.of(context).textTheme.bodySmall,
),
],
if (syncProvider.isConfigured && syncProvider.detectedServerType != null) ...[
const SizedBox(height: 8),
Row(
children: [
Icon(Icons.check_circle, size: 16, color: Colors.green),
const SizedBox(width: 4),
Text(
'Detected: ${syncProvider.detectedServerType}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.green[700],
fontWeight: FontWeight.w500,
),
),
],
),
],
if (syncProvider.hasError) ...[
const SizedBox(height: 8),
Text(
syncProvider.currentError!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.red,
),
),
],
if (syncProvider.isSyncing) ...[
const SizedBox(height: 12),
LinearProgressIndicator(
value: syncProvider.syncProgress,
),
],
],
),
),
);
}
Widget _buildConfigurationSection(SyncProvider syncProvider) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'WebDAV Configuration',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
TextFormField(
controller: _serverUrlController,
decoration: const InputDecoration(
labelText: 'Server URL',
hintText: 'cloud.example.com or drive.mydomain.com',
prefixIcon: Icon(Icons.cloud),
helperText: 'Just enter your server domain - we\'ll auto-detect the rest!',
helperMaxLines: 2,
),
validator: (value) {
if (value?.isEmpty ?? true) {
return 'Please enter server URL';
}
return null;
},
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.withOpacity(0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.lightbulb_outline, size: 16, color: Colors.blue[700]),
const SizedBox(width: 8),
Text(
'Smart URL Detection',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Colors.blue[700],
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 8),
Text(
'You can enter simple URLs like:',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 4),
Text(
'• cloud.example.com\n'
'• drive.mydomain.com\n'
'• nextcloud.company.org\n'
'• my-server.duckdns.org:8080',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
),
),
const SizedBox(height: 8),
Text(
'We\'ll automatically detect if it\'s Nextcloud, ownCloud, or generic WebDAV and build the correct URL for you!',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontStyle: FontStyle.italic,
),
),
],
),
),
const SizedBox(height: 16),
TextFormField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: 'Username',
prefixIcon: Icon(Icons.person),
),
validator: (value) {
if (value?.isEmpty ?? true) {
return 'Please enter username';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: !_isPasswordVisible,
decoration: InputDecoration(
labelText: 'Password / App Password',
prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton(
icon: Icon(
_isPasswordVisible ? Icons.visibility : Icons.visibility_off,
),
onPressed: () {
setState(() {
_isPasswordVisible = !_isPasswordVisible;
});
},
),
helperText: 'Use app passwords for better security',
),
validator: (value) {
if (value?.isEmpty ?? true) {
return 'Please enter password';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _syncFolderController,
decoration: const InputDecoration(
labelText: 'Sync Folder Name',
prefixIcon: Icon(Icons.folder),
hintText: 'Supplements',
helperText: 'Folder name on your cloud server for syncing data',
),
validator: (value) {
if (value?.isEmpty ?? true) {
return 'Please enter folder name';
}
if (value!.contains('/') || value.contains('\\')) {
return 'Folder name cannot contain slashes';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _deviceNameController,
decoration: const InputDecoration(
labelText: 'Device Name (Optional)',
prefixIcon: Icon(Icons.phone_android),
hintText: 'My Phone',
),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _isConfiguring || syncProvider.isSyncing
? null
: () => _configureWebDAV(syncProvider),
icon: _isConfiguring
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save),
label: Text(syncProvider.isConfigured ? 'Update' : 'Configure'),
),
),
if (syncProvider.isConfigured) ...[
const SizedBox(width: 12),
ElevatedButton.icon(
onPressed: _isTestingConnection || syncProvider.isSyncing
? null
: _testConnection,
icon: _isTestingConnection
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.wifi_protected_setup),
label: const Text('Test'),
),
],
],
),
],
),
),
);
}
Widget _buildSyncSettingsSection(SyncProvider syncProvider) {
if (!syncProvider.isConfigured) return const SizedBox.shrink();
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Sync Settings',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('Auto Sync on Data Changes'),
subtitle: const Text('Automatically sync when you add, modify, or take supplements'),
value: syncProvider.autoSyncOnDataChanges,
onChanged: syncProvider.setAutoSyncOnDataChanges,
),
const SizedBox(height: 8),
ListTile(
title: const Text('Conflict Resolution'),
subtitle: Text(_getConflictStrategyDescription(syncProvider.conflictStrategy)),
trailing: DropdownButton<ConflictResolutionStrategy>(
value: syncProvider.conflictStrategy,
onChanged: (strategy) {
if (strategy != null) {
syncProvider.setConflictResolutionStrategy(strategy);
}
},
items: ConflictResolutionStrategy.values
.map((strategy) => DropdownMenuItem(
value: strategy,
child: Text(_getConflictStrategyName(strategy)),
))
.toList(),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: syncProvider.isSyncing ? null : _performSync,
icon: syncProvider.isSyncing
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync),
label: const Text('Sync Now'),
),
),
],
),
),
);
}
Widget _buildConflictsSection(SyncProvider syncProvider) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.warning, color: Colors.orange),
const SizedBox(width: 8),
Text(
'Sync Conflicts',
style: Theme.of(context).textTheme.titleMedium,
),
],
),
const SizedBox(height: 16),
Text(
'${syncProvider.pendingConflicts.length} conflicts need your attention',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () => _showConflictsDialog(syncProvider),
child: const Text('Review Conflicts'),
),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: syncProvider.resolveAllConflicts,
child: const Text('Auto Resolve'),
),
],
),
],
),
),
);
}
Widget _buildDeviceInfoSection(SyncProvider syncProvider) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Connection Information',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
FutureBuilder<Map<String, String?>>(
future: syncProvider.getDeviceInfo(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final deviceInfo = snapshot.data!;
return Column(
children: [
if (syncProvider.detectedServerType != null)
ListTile(
leading: const Icon(Icons.cloud_done),
title: const Text('Server Type'),
subtitle: Text(syncProvider.detectedServerType!),
),
if (syncProvider.finalWebdavUrl != null)
ListTile(
leading: const Icon(Icons.link),
title: const Text('WebDAV URL'),
subtitle: Text(
syncProvider.finalWebdavUrl!,
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
),
),
ListTile(
leading: const Icon(Icons.fingerprint),
title: const Text('Device ID'),
subtitle: Text(deviceInfo['deviceId'] ?? 'Unknown'),
),
ListTile(
leading: const Icon(Icons.devices),
title: const Text('Device Name'),
subtitle: Text(deviceInfo['deviceName'] ?? 'Unknown'),
),
],
);
},
),
],
),
),
);
}
String _getConflictStrategyName(ConflictResolutionStrategy strategy) {
switch (strategy) {
case ConflictResolutionStrategy.preferLocal:
return 'Prefer Local';
case ConflictResolutionStrategy.preferRemote:
return 'Prefer Remote';
case ConflictResolutionStrategy.preferNewer:
return 'Prefer Newer';
case ConflictResolutionStrategy.manual:
return 'Manual';
}
}
String _getConflictStrategyDescription(ConflictResolutionStrategy strategy) {
switch (strategy) {
case ConflictResolutionStrategy.preferLocal:
return 'Always keep local changes';
case ConflictResolutionStrategy.preferRemote:
return 'Always keep remote changes';
case ConflictResolutionStrategy.preferNewer:
return 'Keep most recent changes';
case ConflictResolutionStrategy.manual:
return 'Review each conflict manually';
}
}
Future<void> _configureWebDAV(SyncProvider syncProvider) async {
// For updates, allow empty password to keep existing one
if (syncProvider.isConfigured && _passwordController.text.isEmpty) {
// Skip validation for password on updates
if (_serverUrlController.text.trim().isEmpty || _usernameController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please fill in server URL and username'),
backgroundColor: Colors.red,
),
);
return;
}
} else if (!_formKey.currentState!.validate()) {
return;
}
setState(() => _isConfiguring = true);
final success = await syncProvider.configure(
serverUrl: _serverUrlController.text.trim(),
username: _usernameController.text.trim(),
password: _passwordController.text.isEmpty ? null : _passwordController.text,
deviceName: _deviceNameController.text.trim().isEmpty
? null
: _deviceNameController.text.trim(),
syncFolderName: _syncFolderController.text.trim().isEmpty
? 'Supplements'
: _syncFolderController.text.trim(),
);
setState(() => _isConfiguring = false);
if (mounted) {
final message = success
? 'WebDAV configured successfully!'
: 'Failed to configure WebDAV';
final detectionInfo = success && syncProvider.detectedServerType != null
? '\nDetected: ${syncProvider.detectedServerType}'
: '';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$message$detectionInfo'),
backgroundColor: success ? Colors.green : Colors.red,
duration: Duration(seconds: success ? 4 : 3),
),
);
if (success) {
_passwordController.clear(); // Clear password for security
}
}
}
Future<void> _testConnection() async {
setState(() => _isTestingConnection = true);
final syncProvider = context.read<SyncProvider>();
final success = await syncProvider.testConnection();
setState(() => _isTestingConnection = false);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success
? 'Connection test successful!'
: 'Connection test failed'),
backgroundColor: success ? Colors.green : Colors.red,
),
);
}
}
Future<void> _performSync() async {
final syncProvider = context.read<SyncProvider>();
final success = await syncProvider.performManualSync();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success
? 'Sync completed successfully!'
: 'Sync failed'),
backgroundColor: success ? Colors.green : Colors.red,
),
);
}
}
Future<void> _showClearConfigDialog() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear Configuration'),
content: const Text(
'Are you sure you want to clear the WebDAV configuration? '
'This will disable cloud sync.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Clear'),
),
],
),
);
if (confirmed == true) {
final syncProvider = context.read<SyncProvider>();
await syncProvider.clearConfiguration();
_serverUrlController.clear();
_usernameController.clear();
_passwordController.clear();
_deviceNameController.clear();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Configuration cleared'),
backgroundColor: Colors.orange,
),
);
}
}
}
Future<void> _showConflictsDialog(SyncProvider syncProvider) async {
await showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Sync Conflicts'),
content: SizedBox(
width: double.maxFinite,
height: 300,
child: ListView.builder(
itemCount: syncProvider.pendingConflicts.length,
itemBuilder: (context, index) {
final conflict = syncProvider.pendingConflicts[index];
return Card(
child: ListTile(
title: Text(conflict.type.name),
subtitle: Text(conflict.description),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: () {
syncProvider.resolveConflict(
conflict.syncId,
ConflictResolution.useLocal,
);
},
child: const Text('Local'),
),
TextButton(
onPressed: () {
syncProvider.resolveConflict(
conflict.syncId,
ConflictResolution.useRemote,
);
},
child: const Text('Remote'),
),
],
),
),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'),
),
],
),
);
}
}

View File

@@ -0,0 +1,470 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart';
import '../providers/settings_provider.dart';
import '../providers/simple_sync_provider.dart';
/// Error types for auto-sync operations
enum AutoSyncErrorType {
network,
configuration,
authentication,
server,
unknown,
}
/// Represents an auto-sync error with context
class AutoSyncError {
final AutoSyncErrorType type;
final String message;
final DateTime timestamp;
final dynamic originalError;
AutoSyncError({
required this.type,
required this.message,
required this.timestamp,
this.originalError,
});
@override
String toString() => 'AutoSyncError($type): $message';
}
/// Service that handles automatic synchronization with debouncing logic
/// to prevent excessive sync requests when multiple data changes occur rapidly.
class AutoSyncService {
Timer? _debounceTimer;
bool _syncInProgress = false;
bool _hasPendingSync = false;
// Error handling and retry logic
final List<AutoSyncError> _recentErrors = [];
int _consecutiveFailures = 0;
DateTime? _lastFailureTime;
Timer? _retryTimer;
bool _autoDisabledDueToErrors = false;
// Exponential backoff configuration
static const int _maxRetryAttempts = 5;
static const int _baseRetryDelaySeconds = 30;
static const int _maxRetryDelaySeconds = 300; // 5 minutes
static const int _errorHistoryMaxSize = 10;
static const int _autoDisableThreshold = 3; // Consecutive failures before auto-disable
final SimpleSyncProvider _syncProvider;
final SettingsProvider _settingsProvider;
AutoSyncService({
required SimpleSyncProvider syncProvider,
required SettingsProvider settingsProvider,
}) : _syncProvider = syncProvider,
_settingsProvider = settingsProvider;
/// Triggers an auto-sync if enabled in settings.
/// Uses debouncing to prevent excessive sync requests.
void triggerAutoSync() {
// Check if auto-sync is enabled
if (!_settingsProvider.autoSyncEnabled) {
if (kDebugMode) {
print('AutoSyncService: Auto-sync is disabled, skipping trigger');
}
return;
}
// Check if auto-sync was disabled due to persistent errors
if (_autoDisabledDueToErrors) {
if (kDebugMode) {
print('AutoSyncService: Auto-sync disabled due to persistent errors, skipping trigger');
}
return;
}
// Check if sync is configured
if (!_syncProvider.isConfigured) {
_recordError(AutoSyncError(
type: AutoSyncErrorType.configuration,
message: 'Sync not configured. Please configure cloud sync settings.',
timestamp: DateTime.now(),
));
if (kDebugMode) {
print('AutoSyncService: Sync not configured, skipping auto-sync');
}
return;
}
// If sync is already in progress, mark that we have a pending sync
if (_syncInProgress || _syncProvider.isSyncing) {
_hasPendingSync = true;
if (kDebugMode) {
print('AutoSyncService: Sync in progress, marking pending sync');
}
return;
}
// Cancel existing timer if one is running
_cancelPendingSync();
// Check if we should apply exponential backoff
final backoffDelay = _calculateBackoffDelay();
if (backoffDelay > 0) {
if (kDebugMode) {
print('AutoSyncService: Applying backoff delay of ${backoffDelay}s due to recent failures');
}
_debounceTimer = Timer(Duration(seconds: backoffDelay), () {
_executePendingSync();
});
return;
}
// Start new debounce timer
final debounceSeconds = _settingsProvider.autoSyncDebounceSeconds;
_debounceTimer = Timer(Duration(seconds: debounceSeconds), () {
_executePendingSync();
});
if (kDebugMode) {
print('AutoSyncService: Auto-sync scheduled in ${debounceSeconds}s');
}
}
/// Executes the pending sync operation
Future<void> _executePendingSync() async {
// Double-check conditions before executing
if (!_settingsProvider.autoSyncEnabled) {
if (kDebugMode) {
print('AutoSyncService: Auto-sync disabled during execution, aborting');
}
return;
}
if (_autoDisabledDueToErrors) {
if (kDebugMode) {
print('AutoSyncService: Auto-sync disabled due to errors during execution, aborting');
}
return;
}
if (!_syncProvider.isConfigured) {
_recordError(AutoSyncError(
type: AutoSyncErrorType.configuration,
message: 'Sync not configured during execution',
timestamp: DateTime.now(),
));
if (kDebugMode) {
print('AutoSyncService: Sync not configured during execution, aborting');
}
return;
}
if (_syncInProgress || _syncProvider.isSyncing) {
if (kDebugMode) {
print('AutoSyncService: Sync already in progress during execution, aborting');
}
return;
}
_syncInProgress = true;
_hasPendingSync = false;
try {
if (kDebugMode) {
print('AutoSyncService: Executing auto-sync (attempt ${_consecutiveFailures + 1})');
}
// Check network connectivity before attempting sync
if (!await _isNetworkAvailable()) {
throw AutoSyncError(
type: AutoSyncErrorType.network,
message: 'Network is not available',
timestamp: DateTime.now(),
);
}
await _syncProvider.syncDatabase(isAutoSync: true);
// Reset failure count on successful sync
_consecutiveFailures = 0;
_lastFailureTime = null;
_autoDisabledDueToErrors = false;
if (kDebugMode) {
print('AutoSyncService: Auto-sync completed successfully');
}
} catch (e) {
if (kDebugMode) {
print('AutoSyncService: Auto-sync failed: $e');
}
// Handle specific error types
_handleSyncError(e);
} finally {
_syncInProgress = false;
// If there was a pending sync request while we were syncing, trigger it
if (_hasPendingSync && !_autoDisabledDueToErrors) {
if (kDebugMode) {
print('AutoSyncService: Processing queued sync request');
}
_hasPendingSync = false;
// Use a small delay to avoid immediate re-triggering
Timer(const Duration(milliseconds: 500), () {
triggerAutoSync();
});
}
}
}
/// Handles sync errors with appropriate recovery strategies
void _handleSyncError(dynamic error) {
_consecutiveFailures++;
_lastFailureTime = DateTime.now();
final autoSyncError = _categorizeError(error);
_recordError(autoSyncError);
// Check if we should disable auto-sync due to persistent errors
if (_consecutiveFailures >= _autoDisableThreshold) {
_autoDisabledDueToErrors = true;
if (kDebugMode) {
print('AutoSyncService: Auto-sync disabled due to ${_consecutiveFailures} consecutive failures');
}
// For configuration errors, disable immediately
if (autoSyncError.type == AutoSyncErrorType.configuration ||
autoSyncError.type == AutoSyncErrorType.authentication) {
if (kDebugMode) {
print('AutoSyncService: Auto-sync disabled due to configuration/authentication error');
}
}
}
// Schedule retry for recoverable errors (unless auto-disabled)
if (!_autoDisabledDueToErrors && _shouldRetry(autoSyncError.type)) {
_scheduleRetry();
}
}
/// Categorizes an error into a specific AutoSyncError type
AutoSyncError _categorizeError(dynamic error) {
final errorString = error.toString().toLowerCase();
// Network-related errors
if (error is SocketException ||
errorString.contains('network') ||
errorString.contains('connection') ||
errorString.contains('timeout') ||
errorString.contains('unreachable') ||
errorString.contains('host lookup failed') ||
errorString.contains('no route to host')) {
return AutoSyncError(
type: AutoSyncErrorType.network,
message: 'Network connection failed. Check your internet connection.',
timestamp: DateTime.now(),
originalError: error,
);
}
// Configuration-related errors
if (errorString.contains('not configured') ||
errorString.contains('invalid url') ||
errorString.contains('malformed url')) {
return AutoSyncError(
type: AutoSyncErrorType.configuration,
message: 'Sync configuration is invalid. Please check your sync settings.',
timestamp: DateTime.now(),
originalError: error,
);
}
// Authentication errors
if (errorString.contains('authentication') ||
errorString.contains('unauthorized') ||
errorString.contains('401') ||
errorString.contains('403') ||
errorString.contains('invalid credentials')) {
return AutoSyncError(
type: AutoSyncErrorType.authentication,
message: 'Authentication failed. Please check your username and password.',
timestamp: DateTime.now(),
originalError: error,
);
}
// Server errors
if (errorString.contains('500') ||
errorString.contains('502') ||
errorString.contains('503') ||
errorString.contains('504') ||
errorString.contains('server error')) {
return AutoSyncError(
type: AutoSyncErrorType.server,
message: 'Server error occurred. The sync server may be temporarily unavailable.',
timestamp: DateTime.now(),
originalError: error,
);
}
// Unknown errors
return AutoSyncError(
type: AutoSyncErrorType.unknown,
message: 'An unexpected error occurred during sync: ${error.toString()}',
timestamp: DateTime.now(),
originalError: error,
);
}
/// Records an error in the recent errors list
void _recordError(AutoSyncError error) {
_recentErrors.add(error);
// Keep only recent errors
if (_recentErrors.length > _errorHistoryMaxSize) {
_recentErrors.removeAt(0);
}
if (kDebugMode) {
print('AutoSyncService: Recorded error: $error');
}
}
/// Determines if we should retry for a given error type
bool _shouldRetry(AutoSyncErrorType errorType) {
switch (errorType) {
case AutoSyncErrorType.network:
case AutoSyncErrorType.server:
return _consecutiveFailures < _maxRetryAttempts;
case AutoSyncErrorType.configuration:
case AutoSyncErrorType.authentication:
return false; // Don't retry config/auth errors
case AutoSyncErrorType.unknown:
return _consecutiveFailures < _maxRetryAttempts;
}
}
/// Calculates the backoff delay based on consecutive failures
int _calculateBackoffDelay() {
if (_consecutiveFailures == 0 || _lastFailureTime == null) {
return 0;
}
// Calculate exponential backoff: base * (2^failures)
final backoffSeconds = min(
_baseRetryDelaySeconds * pow(2, _consecutiveFailures - 1).toInt(),
_maxRetryDelaySeconds,
);
// Check if enough time has passed since last failure
final timeSinceLastFailure = DateTime.now().difference(_lastFailureTime!).inSeconds;
if (timeSinceLastFailure >= backoffSeconds) {
return 0; // No additional delay needed
}
return backoffSeconds - timeSinceLastFailure;
}
/// Schedules a retry attempt with exponential backoff
void _scheduleRetry() {
final retryDelay = _calculateBackoffDelay();
if (retryDelay <= 0) return;
_retryTimer?.cancel();
_retryTimer = Timer(Duration(seconds: retryDelay), () {
if (kDebugMode) {
print('AutoSyncService: Retrying auto-sync after backoff delay');
}
triggerAutoSync();
});
if (kDebugMode) {
print('AutoSyncService: Scheduled retry in ${retryDelay}s');
}
}
/// Checks if network is available
Future<bool> _isNetworkAvailable() async {
try {
final result = await InternetAddress.lookup('google.com');
return result.isNotEmpty && result[0].rawAddress.isNotEmpty;
} catch (e) {
if (kDebugMode) {
print('AutoSyncService: Network check failed: $e');
}
return false;
}
}
/// Cancels any pending sync operation
void cancelPendingSync() {
_cancelPendingSync();
_retryTimer?.cancel();
_retryTimer = null;
_hasPendingSync = false;
if (kDebugMode) {
print('AutoSyncService: Cancelled pending sync and retry timer');
}
}
/// Internal method to cancel the debounce timer
void _cancelPendingSync() {
_debounceTimer?.cancel();
_debounceTimer = null;
}
/// Resets error state and re-enables auto-sync if it was disabled
void resetErrorState() {
_consecutiveFailures = 0;
_lastFailureTime = null;
_autoDisabledDueToErrors = false;
_recentErrors.clear();
_retryTimer?.cancel();
_retryTimer = null;
if (kDebugMode) {
print('AutoSyncService: Error state reset, auto-sync re-enabled');
}
}
/// Disposes of the service and cleans up resources
void dispose() {
_cancelPendingSync();
_retryTimer?.cancel();
_retryTimer = null;
_hasPendingSync = false;
_syncInProgress = false;
_recentErrors.clear();
if (kDebugMode) {
print('AutoSyncService: Disposed');
}
}
/// Returns true if there is a pending sync operation
bool get hasPendingSync => _hasPendingSync || _debounceTimer != null;
/// Returns true if a sync is currently in progress
bool get isSyncInProgress => _syncInProgress;
/// Returns true if auto-sync was disabled due to persistent errors
bool get isAutoDisabledDueToErrors => _autoDisabledDueToErrors;
/// Returns the number of consecutive failures
int get consecutiveFailures => _consecutiveFailures;
/// Returns a copy of recent errors
List<AutoSyncError> get recentErrors => List.unmodifiable(_recentErrors);
/// Returns the last error message suitable for display to users
String? get lastErrorMessage {
if (_recentErrors.isEmpty) return null;
return _recentErrors.last.message;
}
/// Returns true if a retry is currently scheduled
bool get hasScheduledRetry => _retryTimer != null;
}

View File

@@ -6,7 +6,7 @@ import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import '../models/supplement.dart'; import '../models/supplement.dart';
import '../models/supplement_intake.dart'; import '../models/supplement_intake.dart';
import '../models/sync_enums.dart'; import 'database_sync_service.dart';
class DatabaseHelper { class DatabaseHelper {
static const _databaseName = 'supplements.db'; static const _databaseName = 'supplements.db';
@@ -399,7 +399,43 @@ class DatabaseHelper {
Database db = await database; Database db = await database;
return await db.update( return await db.update(
supplementsTable, supplementsTable,
{'isActive': 0}, {
'isActive': 0,
'isDeleted': 1,
'lastModified': DateTime.now().toIso8601String(),
},
where: 'id = ?',
whereArgs: [id],
);
}
Future<int> permanentlyDeleteSupplement(int id) async {
Database db = await database;
// For sync compatibility, we should mark as deleted rather than completely removing
// This prevents the supplement from reappearing during sync
// First mark all related intakes as deleted
await db.update(
intakesTable,
{
'isDeleted': 1,
'lastModified': DateTime.now().toIso8601String(),
'syncStatus': RecordSyncStatus.modified.name,
},
where: 'supplementId = ? AND isDeleted = ?',
whereArgs: [id, 0],
);
// Then mark the supplement as deleted instead of removing it completely
return await db.update(
supplementsTable,
{
'isDeleted': 1,
'isActive': 0, // Also ensure it's archived
'lastModified': DateTime.now().toIso8601String(),
'syncStatus': RecordSyncStatus.modified.name,
},
where: 'id = ?', where: 'id = ?',
whereArgs: [id], whereArgs: [id],
); );
@@ -411,6 +447,36 @@ class DatabaseHelper {
return await db.insert(intakesTable, intake.toMap()); return await db.insert(intakesTable, intake.toMap());
} }
Future<int> deleteIntake(int id) async {
Database db = await database;
return await db.update(
intakesTable,
{
'isDeleted': 1,
'lastModified': DateTime.now().toIso8601String(),
},
where: 'id = ?',
whereArgs: [id],
);
}
Future<int> permanentlyDeleteIntake(int id) async {
Database db = await database;
// For sync compatibility, mark as deleted rather than completely removing
// This prevents the intake from reappearing during sync
return await db.update(
intakesTable,
{
'isDeleted': 1,
'lastModified': DateTime.now().toIso8601String(),
'syncStatus': RecordSyncStatus.modified.name,
},
where: 'id = ?',
whereArgs: [id],
);
}
Future<List<SupplementIntake>> getIntakesForDate(DateTime date) async { Future<List<SupplementIntake>> getIntakesForDate(DateTime date) async {
Database db = await database; Database db = await database;
String startDate = DateTime(date.year, date.month, date.day).toIso8601String(); String startDate = DateTime(date.year, date.month, date.day).toIso8601String();
@@ -477,15 +543,6 @@ class DatabaseHelper {
return result; return result;
} }
Future<int> deleteIntake(int id) async {
Database db = await database;
return await db.delete(
intakesTable,
where: 'id = ?',
whereArgs: [id],
);
}
// Notification tracking methods // Notification tracking methods
Future<int> trackNotification({ Future<int> trackNotification({
required int notificationId, required int notificationId,
@@ -637,7 +694,7 @@ class DatabaseHelper {
List<Map<String, dynamic>> maps = await db.query( List<Map<String, dynamic>> maps = await db.query(
supplementsTable, supplementsTable,
where: 'syncStatus IN (?, ?)', where: 'syncStatus IN (?, ?)',
whereArgs: [SyncStatus.pending.name, SyncStatus.modified.name], whereArgs: [RecordSyncStatus.pending.name, RecordSyncStatus.modified.name],
orderBy: 'lastModified ASC', orderBy: 'lastModified ASC',
); );
return List.generate(maps.length, (i) => Supplement.fromMap(maps[i])); return List.generate(maps.length, (i) => Supplement.fromMap(maps[i]));
@@ -648,7 +705,7 @@ class DatabaseHelper {
List<Map<String, dynamic>> maps = await db.query( List<Map<String, dynamic>> maps = await db.query(
intakesTable, intakesTable,
where: 'syncStatus IN (?, ?)', where: 'syncStatus IN (?, ?)',
whereArgs: [SyncStatus.pending.name, SyncStatus.modified.name], whereArgs: [RecordSyncStatus.pending.name, RecordSyncStatus.modified.name],
orderBy: 'lastModified ASC', orderBy: 'lastModified ASC',
); );
return List.generate(maps.length, (i) => SupplementIntake.fromMap(maps[i])); return List.generate(maps.length, (i) => SupplementIntake.fromMap(maps[i]));
@@ -658,7 +715,7 @@ class DatabaseHelper {
Database db = await database; Database db = await database;
await db.update( await db.update(
supplementsTable, supplementsTable,
{'syncStatus': SyncStatus.synced.name}, {'syncStatus': RecordSyncStatus.synced.name},
where: 'syncId = ?', where: 'syncId = ?',
whereArgs: [syncId], whereArgs: [syncId],
); );
@@ -668,7 +725,7 @@ class DatabaseHelper {
Database db = await database; Database db = await database;
await db.update( await db.update(
intakesTable, intakesTable,
{'syncStatus': SyncStatus.synced.name}, {'syncStatus': RecordSyncStatus.synced.name},
where: 'syncId = ?', where: 'syncId = ?',
whereArgs: [syncId], whereArgs: [syncId],
); );

View File

@@ -0,0 +1,532 @@
import 'dart:io' as io;
import 'package:flutter/foundation.dart';
import 'package:path/path.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sqflite/sqflite.dart';
import 'package:webdav_client/webdav_client.dart';
import '../models/supplement.dart';
import '../models/supplement_intake.dart';
import 'database_helper.dart';
enum SyncStatus {
idle,
downloading,
merging,
uploading,
completed,
error,
}
// Legacy record-level sync status for models
enum RecordSyncStatus {
pending,
synced,
modified,
}
class DatabaseSyncService {
static const String _remoteDbFileName = 'supplements.db';
// SharedPreferences keys for persistence
static const String _keyServerUrl = 'sync_server_url';
static const String _keyUsername = 'sync_username';
static const String _keyPassword = 'sync_password';
static const String _keyRemotePath = 'sync_remote_path';
Client? _client;
String? _remotePath;
// Store configuration values
String? _serverUrl;
String? _username;
String? _password;
String? _configuredRemotePath;
final DatabaseHelper _databaseHelper = DatabaseHelper.instance;
SyncStatus _status = SyncStatus.idle;
String? _lastError;
DateTime? _lastSyncTime;
// Getters
SyncStatus get status => _status;
String? get lastError => _lastError;
DateTime? get lastSyncTime => _lastSyncTime;
bool get isConfigured => _client != null;
// Configuration getters
String? get serverUrl => _serverUrl;
String? get username => _username;
String? get password => _password;
String? get remotePath => _configuredRemotePath;
// Callbacks for UI updates
Function(SyncStatus)? onStatusChanged;
Function(String)? onError;
Function()? onSyncCompleted;
DatabaseSyncService() {
loadSavedConfiguration();
}
// Load saved configuration from SharedPreferences
Future<void> loadSavedConfiguration() async {
try {
final prefs = await SharedPreferences.getInstance();
_serverUrl = prefs.getString(_keyServerUrl);
_username = prefs.getString(_keyUsername);
_password = prefs.getString(_keyPassword);
_configuredRemotePath = prefs.getString(_keyRemotePath);
// If we have saved configuration, set up the client
if (_serverUrl != null && _username != null && _password != null && _configuredRemotePath != null) {
_remotePath = _configuredRemotePath!.endsWith('/') ? _configuredRemotePath : '$_configuredRemotePath/';
_client = newClient(
_serverUrl!,
user: _username!,
password: _password!,
debug: kDebugMode,
);
}
} catch (e) {
if (kDebugMode) {
print('SupplementsLog: Error loading saved sync configuration: $e');
}
}
}
// Save configuration to SharedPreferences
Future<void> _saveConfiguration() async {
try {
final prefs = await SharedPreferences.getInstance();
if (_serverUrl != null) await prefs.setString(_keyServerUrl, _serverUrl!);
if (_username != null) await prefs.setString(_keyUsername, _username!);
if (_password != null) await prefs.setString(_keyPassword, _password!);
if (_configuredRemotePath != null) await prefs.setString(_keyRemotePath, _configuredRemotePath!);
} catch (e) {
if (kDebugMode) {
print('SupplementsLog: Error saving sync configuration: $e');
}
}
}
void configure({
required String serverUrl,
required String username,
required String password,
required String remotePath,
}) {
// Store configuration values
_serverUrl = serverUrl;
_username = username;
_password = password;
_configuredRemotePath = remotePath;
_remotePath = remotePath.endsWith('/') ? remotePath : '$remotePath/';
_client = newClient(
serverUrl,
user: username,
password: password,
debug: kDebugMode,
);
// Save configuration to persistent storage
_saveConfiguration();
}
Future<bool> testConnection() async {
if (_client == null) return false;
try {
await _client!.ping();
return true;
} catch (e) {
if (kDebugMode) {
print('SupplementsLog: Connection test failed: $e');
}
return false;
}
}
Future<void> syncDatabase() async {
if (_client == null) {
throw Exception('Sync not configured');
}
_setStatus(SyncStatus.downloading);
try {
// Step 1: Download remote database (if it exists)
final remoteDbPath = await _downloadRemoteDatabase();
// Step 2: Merge databases
_setStatus(SyncStatus.merging);
await _mergeDatabases(remoteDbPath);
// Step 3: Upload merged database
_setStatus(SyncStatus.uploading);
await _uploadLocalDatabase();
// Step 4: Cleanup - for now we'll skip cleanup to avoid file issues
// TODO: Implement proper cleanup once file operations are working
_lastSyncTime = DateTime.now();
_setStatus(SyncStatus.completed);
onSyncCompleted?.call();
} catch (e) {
_lastError = e.toString();
_setStatus(SyncStatus.error);
onError?.call(_lastError!);
if (kDebugMode) {
print('SupplementsLog: Sync failed: $e');
}
rethrow;
}
}
Future<String?> _downloadRemoteDatabase() async {
try {
// Check if remote database exists
final files = await _client!.readDir(_remotePath!);
final remoteDbExists = files.any((file) => file.name == _remoteDbFileName);
if (!remoteDbExists) {
if (kDebugMode) {
print('SupplementsLog: No remote database found, will upload local database');
}
return null;
}
if (kDebugMode) {
print('SupplementsLog: Remote database found, downloading...');
}
// Download the remote database
final remoteDbBytes = await _client!.read('$_remotePath$_remoteDbFileName');
// Create a temporary file path for the downloaded database
final tempDir = await getDatabasesPath();
final tempDbPath = join(tempDir, 'remote_supplements.db');
// Write the downloaded database to a temporary file
final tempFile = io.File(tempDbPath);
await tempFile.writeAsBytes(remoteDbBytes);
if (kDebugMode) {
print('SupplementsLog: Downloaded remote database (${remoteDbBytes.length} bytes) to: $tempDbPath');
}
return tempDbPath;
} catch (e) {
if (kDebugMode) {
print('SupplementsLog: Failed to download remote database: $e');
}
return null;
}
}
Future<void> _mergeDatabases(String? remoteDbPath) async {
if (remoteDbPath == null) {
if (kDebugMode) {
print('SupplementsLog: No remote database to merge');
}
return;
}
if (kDebugMode) {
print('SupplementsLog: Starting database merge from: $remoteDbPath');
}
final localDb = await _databaseHelper.database;
final remoteDb = await openDatabase(remoteDbPath, readOnly: true);
try {
// Check what tables exist in remote database
if (kDebugMode) {
final tables = await remoteDb.rawQuery("SELECT name FROM sqlite_master WHERE type='table'");
print('SupplementsLog: Remote database tables: ${tables.map((t) => t['name']).toList()}');
// Count records in each table
try {
final supplementCount = await remoteDb.rawQuery('SELECT COUNT(*) as count FROM supplements');
print('SupplementsLog: Remote supplements count: ${supplementCount.first['count']}');
} catch (e) {
print('SupplementsLog: Error counting supplements: $e');
}
try {
final intakeCount = await remoteDb.rawQuery('SELECT COUNT(*) as count FROM supplement_intakes');
print('SupplementsLog: Remote intakes count: ${intakeCount.first['count']}');
} catch (e) {
print('SupplementsLog: Error counting intakes: $e');
}
}
// Merge supplements
await _mergeSupplements(localDb, remoteDb);
// Merge intakes
await _mergeIntakes(localDb, remoteDb);
if (kDebugMode) {
print('SupplementsLog: Database merge completed successfully');
}
} finally {
await remoteDb.close();
}
}
Future<void> _mergeSupplements(Database localDb, Database remoteDb) async {
if (kDebugMode) {
print('SupplementsLog: Starting supplement merge...');
}
// Get all supplements from remote database
final remoteMaps = await remoteDb.query('supplements');
final remoteSupplements =
remoteMaps.map((map) => Supplement.fromMap(map)).toList();
if (kDebugMode) {
print(
'SupplementsLog: Found ${remoteSupplements.length} supplements in remote database');
for (final supplement in remoteSupplements) {
print(
'SupplementsLog: Remote supplement: ${supplement.name} (syncId: ${supplement.syncId}, deleted: ${supplement.isDeleted})');
}
}
for (final remoteSupplement in remoteSupplements) {
if (remoteSupplement.syncId.isEmpty) {
if (kDebugMode) {
print(
'SupplementsLog: Skipping supplement ${remoteSupplement.name} - no syncId');
}
continue;
}
// Find existing supplement by syncId
final existingMaps = await localDb.query(
'supplements',
where: 'syncId = ?',
whereArgs: [remoteSupplement.syncId],
);
if (existingMaps.isEmpty) {
// New supplement from remote - insert it
if (!remoteSupplement.isDeleted) {
// Manually create a new map without the id to ensure it's null
final mapToInsert = remoteSupplement.toMap();
mapToInsert.remove('id');
await localDb.insert('supplements', mapToInsert);
if (kDebugMode) {
print(
'SupplementsLog: ✓ Inserted new supplement: ${remoteSupplement.name}');
}
} else {
if (kDebugMode) {
print(
'SupplementsLog: Skipping deleted supplement: ${remoteSupplement.name}');
}
}
} else {
// Existing supplement - update if remote is newer
final existingSupplement = Supplement.fromMap(existingMaps.first);
if (remoteSupplement.lastModified
.isAfter(existingSupplement.lastModified)) {
final supplementToUpdate =
remoteSupplement.copyWith(id: existingSupplement.id);
await localDb.update(
'supplements',
supplementToUpdate.toMap(),
where: 'id = ?',
whereArgs: [existingSupplement.id],
);
if (kDebugMode) {
print(
'SupplementsLog: ✓ Updated supplement: ${remoteSupplement.name}');
}
} else {
if (kDebugMode) {
print(
'SupplementsLog: Local supplement ${remoteSupplement.name} is newer, keeping local version');
}
}
}
}
if (kDebugMode) {
print('SupplementsLog: Supplement merge completed');
}
}
Future<void> _mergeIntakes(Database localDb, Database remoteDb) async {
if (kDebugMode) {
print('SupplementsLog: Starting intake merge...');
}
// Get all intakes from remote database
final remoteMaps = await remoteDb.query('supplement_intakes');
final remoteIntakes = remoteMaps.map((map) => SupplementIntake.fromMap(map)).toList();
if (kDebugMode) {
print('SupplementsLog: Found ${remoteIntakes.length} intakes in remote database');
}
for (final remoteIntake in remoteIntakes) {
if (remoteIntake.syncId.isEmpty) {
if (kDebugMode) {
print('SupplementsLog: Skipping intake - no syncId');
}
continue;
}
// Find existing intake by syncId
final existingMaps = await localDb.query(
'supplement_intakes',
where: 'syncId = ?',
whereArgs: [remoteIntake.syncId],
);
if (existingMaps.isEmpty) {
// New intake from remote - need to find local supplement ID
if (!remoteIntake.isDeleted) {
final localSupplementId = await _findLocalSupplementId(localDb, remoteIntake.supplementId, remoteDb);
if (localSupplementId != null) {
final intakeToInsert = remoteIntake.copyWith(
id: null,
supplementId: localSupplementId,
);
await localDb.insert('supplement_intakes', intakeToInsert.toMap());
if (kDebugMode) {
print('SupplementsLog: ✓ Inserted new intake: ${remoteIntake.syncId}');
}
} else {
if (kDebugMode) {
print('SupplementsLog: Could not find local supplement for intake ${remoteIntake.syncId}');
}
}
} else {
if (kDebugMode) {
print('SupplementsLog: Skipping deleted intake: ${remoteIntake.syncId}');
}
}
} else {
// Existing intake - update if remote is newer
final existingIntake = SupplementIntake.fromMap(existingMaps.first);
if (remoteIntake.lastModified.isAfter(existingIntake.lastModified)) {
final intakeToUpdate = remoteIntake.copyWith(id: existingIntake.id);
await localDb.update(
'supplement_intakes',
intakeToUpdate.toMap(),
where: 'id = ?',
whereArgs: [existingIntake.id],
);
if (kDebugMode) {
print('SupplementsLog: ✓ Updated intake: ${remoteIntake.syncId}');
}
} else {
if (kDebugMode) {
print('SupplementsLog: Local intake ${remoteIntake.syncId} is newer, keeping local version');
}
}
}
}
if (kDebugMode) {
print('SupplementsLog: Intake merge completed');
}
}
Future<int?> _findLocalSupplementId(Database localDb, int remoteSupplementId, Database remoteDb) async {
// Get the remote supplement
final remoteSupplementMaps = await remoteDb.query(
'supplements',
where: 'id = ?',
whereArgs: [remoteSupplementId],
);
if (remoteSupplementMaps.isEmpty) return null;
final remoteSupplement = Supplement.fromMap(remoteSupplementMaps.first);
// Find the local supplement with the same syncId
final localSupplementMaps = await localDb.query(
'supplements',
where: 'syncId = ?',
whereArgs: [remoteSupplement.syncId],
);
if (localSupplementMaps.isEmpty) return null;
return localSupplementMaps.first['id'] as int;
}
Future<void> _uploadLocalDatabase() async {
try {
// Get the local database path
final localDb = await _databaseHelper.database;
final dbPath = localDb.path;
if (kDebugMode) {
print('SupplementsLog: Reading database from: $dbPath');
}
// Read the database file
final dbFile = io.File(dbPath);
if (!await dbFile.exists()) {
throw Exception('Database file not found at: $dbPath');
}
final dbBytes = await dbFile.readAsBytes();
if (kDebugMode) {
print('SupplementsLog: Database file size: ${dbBytes.length} bytes');
}
if (dbBytes.isEmpty) {
throw Exception('Database file is empty');
}
// Ensure remote directory exists
try {
await _client!.readDir(_remotePath!);
} catch (e) {
if (kDebugMode) {
print('SupplementsLog: Creating remote directory: $_remotePath');
}
await _client!.mkdir(_remotePath!);
}
// Upload the database file
final remoteUrl = '$_remotePath$_remoteDbFileName';
await _client!.write(remoteUrl, dbBytes);
if (kDebugMode) {
print('SupplementsLog: Successfully uploaded database (${dbBytes.length} bytes) to: $remoteUrl');
}
} catch (e) {
if (kDebugMode) {
print('SupplementsLog: Failed to upload database: $e');
}
rethrow;
}
}
void _setStatus(SyncStatus status) {
_status = status;
onStatusChanged?.call(status);
}
void clearError() {
_lastError = null;
}
}

View File

@@ -7,17 +7,17 @@ import 'database_helper.dart';
// Top-level function to handle notification responses when app is running // Top-level function to handle notification responses when app is running
@pragma('vm:entry-point') @pragma('vm:entry-point')
void notificationTapBackground(NotificationResponse notificationResponse) { void notificationTapBackground(NotificationResponse notificationResponse) {
print('📱 === BACKGROUND NOTIFICATION RESPONSE ==='); print('SupplementsLog: 📱 === BACKGROUND NOTIFICATION RESPONSE ===');
print('📱 Action ID: ${notificationResponse.actionId}'); print('SupplementsLog: 📱 Action ID: ${notificationResponse.actionId}');
print('📱 Payload: ${notificationResponse.payload}'); print('SupplementsLog: 📱 Payload: ${notificationResponse.payload}');
print('📱 Notification ID: ${notificationResponse.id}'); print('SupplementsLog: 📱 Notification ID: ${notificationResponse.id}');
print('📱 =========================================='); 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('📱 BACKGROUND: Take action detected'); print('SupplementsLog: 📱 BACKGROUND: Take action detected');
} else if (notificationResponse.actionId == 'snooze_10') { } else if (notificationResponse.actionId == 'snooze_10') {
print('📱 BACKGROUND: Snooze action detected'); print('SupplementsLog: 📱 BACKGROUND: Snooze action detected');
} }
} }
@@ -40,25 +40,25 @@ class NotificationService {
} }
Future<void> initialize() async { Future<void> initialize() async {
print('📱 Initializing NotificationService...'); print('SupplementsLog: 📱 Initializing NotificationService...');
if (_isInitialized) { if (_isInitialized) {
print('📱 Already initialized'); print('SupplementsLog: 📱 Already initialized');
return; return;
} }
try { try {
print('📱 Initializing timezones...'); print('SupplementsLog: 📱 Initializing timezones...');
print('📱 Engine initialized flag: $_engineInitialized'); print('SupplementsLog: 📱 Engine initialized flag: $_engineInitialized');
if (!_engineInitialized) { if (!_engineInitialized) {
tz.initializeTimeZones(); tz.initializeTimeZones();
_engineInitialized = true; _engineInitialized = true;
print('📱 Timezones initialized successfully'); print('SupplementsLog: 📱 Timezones initialized successfully');
} else { } else {
print('📱 Timezones already initialized, skipping'); print('SupplementsLog: 📱 Timezones already initialized, skipping');
} }
} catch (e) { } catch (e) {
print('📱 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
} }
@@ -66,7 +66,7 @@ class NotificationService {
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('📱 System timezone name: $timeZoneName'); print('SupplementsLog: 📱 System timezone name: $timeZoneName');
tz.Location? location; tz.Location? location;
@@ -80,22 +80,22 @@ class NotificationService {
try { try {
location = tz.getLocation(timeZoneName); location = tz.getLocation(timeZoneName);
} catch (e) { } catch (e) {
print('📱 Could not find timezone $timeZoneName, using Europe/Amsterdam as default'); print('SupplementsLog: 📱 Could not find timezone $timeZoneName, using Europe/Amsterdam as default');
location = tz.getLocation('Europe/Amsterdam'); location = tz.getLocation('Europe/Amsterdam');
} }
} }
tz.setLocalLocation(location); tz.setLocalLocation(location);
print('📱 Timezone set to: ${location.name}'); print('SupplementsLog: 📱 Timezone set to: ${location.name}');
} catch (e) { } catch (e) {
print('📱 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('📱 Current local time: ${tz.TZDateTime.now(tz.local)}'); print('SupplementsLog: 📱 Current local time: ${tz.TZDateTime.now(tz.local)}');
print('📱 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(
@@ -113,7 +113,7 @@ class NotificationService {
linux: linuxSettings, linux: linuxSettings,
); );
print('📱 Initializing flutter_local_notifications...'); print('SupplementsLog: 📱 Initializing flutter_local_notifications...');
await _notifications.initialize( await _notifications.initialize(
initSettings, initSettings,
onDidReceiveNotificationResponse: _onNotificationResponse, onDidReceiveNotificationResponse: _onNotificationResponse,
@@ -121,42 +121,42 @@ class NotificationService {
); );
// Test if notification response callback is working // Test if notification response callback is working
print('📱 Callback function is set and ready'); print('SupplementsLog: 📱 Callback function is set and ready');
_isInitialized = true; _isInitialized = true;
print('📱 NotificationService initialization complete'); print('SupplementsLog: 📱 NotificationService initialization complete');
} }
// Handle notification responses (when user taps on notification or action) // Handle notification responses (when user taps on notification or action)
void _onNotificationResponse(NotificationResponse response) { void _onNotificationResponse(NotificationResponse response) {
print('📱 === NOTIFICATION RESPONSE ==='); print('SupplementsLog: 📱 === NOTIFICATION RESPONSE ===');
print('📱 Action ID: ${response.actionId}'); print('SupplementsLog: 📱 Action ID: ${response.actionId}');
print('📱 Payload: ${response.payload}'); print('SupplementsLog: 📱 Payload: ${response.payload}');
print('📱 Notification ID: ${response.id}'); print('SupplementsLog: 📱 Notification ID: ${response.id}');
print('📱 Input: ${response.input}'); print('SupplementsLog: 📱 Input: ${response.input}');
print('📱 ==============================='); print('SupplementsLog: 📱 ===============================');
if (response.actionId == 'take_supplement') { if (response.actionId == 'take_supplement') {
print('📱 Processing TAKE action...'); print('SupplementsLog: 📱 Processing TAKE action...');
_handleTakeAction(response.payload, response.id); _handleTakeAction(response.payload, response.id);
} else if (response.actionId == 'snooze_10') { } else if (response.actionId == 'snooze_10') {
print('📱 Processing SNOOZE action...'); print('SupplementsLog: 📱 Processing SNOOZE action...');
_handleSnoozeAction(response.payload, 10, response.id); _handleSnoozeAction(response.payload, 10, response.id);
} else { } else {
print('📱 Default notification tap (no specific action)'); print('SupplementsLog: 📱 Default notification tap (no specific action)');
// Default tap (no actionId) opens the app normally // Default tap (no actionId) opens the app normally
} }
} }
Future<void> _handleTakeAction(String? payload, int? notificationId) async { Future<void> _handleTakeAction(String? payload, int? notificationId) async {
print('📱 === HANDLING TAKE ACTION ==='); print('SupplementsLog: 📱 === HANDLING TAKE ACTION ===');
print('📱 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('📱 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]);
@@ -164,46 +164,62 @@ class NotificationService {
final units = double.parse(parts[2]); final units = double.parse(parts[2]);
final unitType = parts[3]; final unitType = parts[3];
print('📱 Parsed data:'); print('SupplementsLog: 📱 Parsed data:');
print('📱 - ID: $supplementId'); print('SupplementsLog: 📱 - ID: $supplementId');
print('📱 - Name: $supplementName'); print('SupplementsLog: 📱 - Name: $supplementName');
print('📱 - Units: $units'); print('SupplementsLog: 📱 - Units: $units');
print('📱 - 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('📱 Calling supplement callback...'); print('SupplementsLog: 📱 Calling supplement callback...');
_onTakeSupplementCallback!(supplementId, supplementName, units, unitType); _onTakeSupplementCallback!(
print('📱 Callback completed'); supplementId, supplementName, units, unitType);
print('SupplementsLog: 📱 Callback completed');
} else { } else {
print('📱 ERROR: No callback registered!'); print('SupplementsLog: 📱 ERROR: No callback registered!');
}
// For retry notifications, the original notification ID is in the payload
int originalNotificationId;
if (parts.length > 4 && int.tryParse(parts[4]) != null) {
originalNotificationId = int.parse(parts[4]);
print(
'SupplementsLog: 📱 Retry notification detected. Original ID: $originalNotificationId');
} 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) // Mark notification as taken in database (this will cancel any pending retries)
if (notificationId != null) { print(
print('📱 Marking notification $notificationId as taken'); 'SupplementsLog: 📱 Marking notification $originalNotificationId as taken');
await DatabaseHelper.instance.markNotificationTaken(notificationId); await DatabaseHelper.instance
.markNotificationTaken(originalNotificationId);
// Cancel any pending retry notifications for this notification // Cancel any pending retry notifications for this notification
_cancelRetryNotifications(notificationId); _cancelRetryNotifications(originalNotificationId);
}
// Show a confirmation notification // Show a confirmation notification
print('📱 Showing confirmation notification...'); print('SupplementsLog: 📱 Showing confirmation notification...');
showInstantNotification( showInstantNotification(
'Supplement Taken!', 'Supplement Taken!',
'$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('📱 ERROR: Invalid payload format - not enough parts'); print(
'SupplementsLog: 📱 ERROR: Invalid payload format - not enough parts');
} }
} catch (e) { } catch (e) {
print('📱 ERROR in _handleTakeAction: $e'); print('SupplementsLog: 📱 ERROR in _handleTakeAction: $e');
} }
} else { } else {
print('📱 ERROR: Payload is null'); print('SupplementsLog: 📱 ERROR: Payload is null');
} }
print('📱 === TAKE ACTION COMPLETE ==='); print('SupplementsLog: 📱 === TAKE ACTION COMPLETE ===');
} }
void _cancelRetryNotifications(int notificationId) { void _cancelRetryNotifications(int notificationId) {
@@ -211,13 +227,13 @@ class NotificationService {
for (int i = 0; i < 10; i++) { // Cancel up to 10 potential retries for (int i = 0; i < 10; i++) { // Cancel up to 10 potential retries
int retryId = 200000 + (notificationId * 10) + i; int retryId = 200000 + (notificationId * 10) + i;
_notifications.cancel(retryId); _notifications.cancel(retryId);
print('📱 Cancelled retry notification ID: $retryId'); print('SupplementsLog: 📱 Cancelled retry notification ID: $retryId');
} }
} }
void _handleSnoozeAction(String? payload, int minutes, int? notificationId) { void _handleSnoozeAction(String? payload, int minutes, int? notificationId) {
print('📱 === HANDLING SNOOZE ACTION ==='); print('SupplementsLog: 📱 === HANDLING SNOOZE ACTION ===');
print('📱 Payload: $payload, Minutes: $minutes'); print('SupplementsLog: 📱 Payload: $payload, Minutes: $minutes');
if (payload != null) { if (payload != null) {
try { try {
@@ -226,17 +242,17 @@ class NotificationService {
final supplementId = int.parse(parts[0]); final supplementId = int.parse(parts[0]);
final supplementName = parts[1]; final supplementName = parts[1];
print('📱 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('📱 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('📱 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
@@ -271,13 +287,13 @@ class NotificationService {
'Reminder Snoozed', 'Reminder Snoozed',
'$supplementName reminder snoozed for $minutes minutes', '$supplementName reminder snoozed for $minutes minutes',
); );
print('📱 Snooze scheduled successfully'); print('SupplementsLog: 📱 Snooze scheduled successfully');
} }
} catch (e) { } catch (e) {
print('📱 Error handling snooze action: $e'); print('SupplementsLog: 📱 Error handling snooze action: $e');
} }
} }
print('📱 === SNOOZE ACTION COMPLETE ==='); print('SupplementsLog: 📱 === SNOOZE ACTION COMPLETE ===');
} }
/// Check for persistent reminders from app context with settings /// Check for persistent reminders from app context with settings
@@ -299,19 +315,19 @@ class NotificationService {
required int reminderRetryInterval, required int reminderRetryInterval,
required int maxRetryAttempts, required int maxRetryAttempts,
}) async { }) async {
print('📱 Checking for pending notifications to retry...'); print('SupplementsLog: 📱 Checking for pending notifications to retry...');
try { try {
if (!persistentReminders) { if (!persistentReminders) {
print('📱 Persistent reminders disabled'); print('SupplementsLog: 📱 Persistent reminders disabled');
return; return;
} }
print('📱 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('📱 Found ${pendingNotifications.length} pending notifications'); print('SupplementsLog: 📱 Found ${pendingNotifications.length} pending notifications');
final now = DateTime.now(); final now = DateTime.now();
@@ -326,17 +342,17 @@ class NotificationService {
final timeSinceScheduled = now.difference(scheduledTime).inMinutes; final timeSinceScheduled = now.difference(scheduledTime).inMinutes;
final shouldRetry = timeSinceScheduled >= reminderRetryInterval; final shouldRetry = timeSinceScheduled >= reminderRetryInterval;
print('📱 Checking notification ${notification['notificationId']}:'); print('SupplementsLog: 📱 Checking notification ${notification['notificationId']}:');
print('📱 Scheduled: $scheduledTime (local)'); print('SupplementsLog: 📱 Scheduled: $scheduledTime (local)');
print('📱 Now: $now'); print('SupplementsLog: 📱 Now: $now');
print('📱 Time since scheduled: $timeSinceScheduled minutes'); print('SupplementsLog: 📱 Time since scheduled: $timeSinceScheduled minutes');
print('📱 Retry interval: $reminderRetryInterval minutes'); print('SupplementsLog: 📱 Retry interval: $reminderRetryInterval minutes');
print('📱 Should retry: $shouldRetry'); print('SupplementsLog: 📱 Should retry: $shouldRetry');
print('📱 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('📱 Notification ${notification['notificationId']} exceeded max attempts ($maxRetryAttempts)'); print('SupplementsLog: 📱 Notification ${notification['notificationId']} exceeded max attempts ($maxRetryAttempts)');
continue; continue;
} }
@@ -344,20 +360,20 @@ class NotificationService {
if (lastRetryTime != null) { if (lastRetryTime != null) {
final timeSinceLastRetry = now.difference(lastRetryTime).inMinutes; final timeSinceLastRetry = now.difference(lastRetryTime).inMinutes;
if (timeSinceLastRetry < reminderRetryInterval) { if (timeSinceLastRetry < reminderRetryInterval) {
print('📱 Notification ${notification['notificationId']} not ready for retry yet'); print('SupplementsLog: 📱 Notification ${notification['notificationId']} not ready for retry yet');
continue; continue;
} }
} }
if (shouldRetry) { if (shouldRetry) {
print('📱 ⚡ SCHEDULING RETRY for notification ${notification['notificationId']}'); print('SupplementsLog: 📱 ⚡ SCHEDULING RETRY for notification ${notification['notificationId']}');
await _scheduleRetryNotification(notification, retryCount + 1); await _scheduleRetryNotification(notification, retryCount + 1);
} else { } else {
print('📱 ⏸️ NOT READY FOR RETRY: ${notification['notificationId']}'); print('SupplementsLog: 📱 ⏸️ NOT READY FOR RETRY: ${notification['notificationId']}');
} }
} }
} catch (e) { } catch (e) {
print('📱 Error scheduling persistent reminders: $e'); print('SupplementsLog: 📱 Error scheduling persistent reminders: $e');
} }
} }
@@ -369,7 +385,7 @@ class NotificationService {
// 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('📱 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();
@@ -408,16 +424,16 @@ class NotificationService {
// Update the retry count in database // Update the retry count in database
await DatabaseHelper.instance.incrementRetryCount(notificationId); await DatabaseHelper.instance.incrementRetryCount(notificationId);
print('📱 Retry notification scheduled successfully'); print('SupplementsLog: 📱 Retry notification scheduled successfully');
} catch (e) { } catch (e) {
print('📱 Error scheduling retry notification: $e'); print('SupplementsLog: 📱 Error scheduling retry notification: $e');
} }
} }
Future<bool> requestPermissions() async { Future<bool> requestPermissions() async {
print('📱 Requesting notification permissions...'); print('SupplementsLog: 📱 Requesting notification permissions...');
if (_permissionsRequested) { if (_permissionsRequested) {
print('📱 Permissions already requested'); print('SupplementsLog: 📱 Permissions already requested');
return true; return true;
} }
@@ -426,9 +442,9 @@ class NotificationService {
final androidPlugin = _notifications.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>(); final androidPlugin = _notifications.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
if (androidPlugin != null) { if (androidPlugin != null) {
print('📱 Requesting Android permissions...'); print('SupplementsLog: 📱 Requesting Android permissions...');
final granted = await androidPlugin.requestNotificationsPermission(); final granted = await androidPlugin.requestNotificationsPermission();
print('📱 Android permissions granted: $granted'); print('SupplementsLog: 📱 Android permissions granted: $granted');
if (granted != true) { if (granted != true) {
_permissionsRequested = false; _permissionsRequested = false;
return false; return false;
@@ -437,31 +453,31 @@ class NotificationService {
final iosPlugin = _notifications.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>(); final iosPlugin = _notifications.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
if (iosPlugin != null) { if (iosPlugin != null) {
print('📱 Requesting iOS permissions...'); print('SupplementsLog: 📱 Requesting iOS permissions...');
final granted = await iosPlugin.requestPermissions( final granted = await iosPlugin.requestPermissions(
alert: true, alert: true,
badge: true, badge: true,
sound: true, sound: true,
); );
print('📱 iOS permissions granted: $granted'); print('SupplementsLog: 📱 iOS permissions granted: $granted');
if (granted != true) { if (granted != true) {
_permissionsRequested = false; _permissionsRequested = false;
return false; return false;
} }
} }
print('📱 All permissions granted successfully'); print('SupplementsLog: 📱 All permissions granted successfully');
return true; return true;
} catch (e) { } catch (e) {
_permissionsRequested = false; _permissionsRequested = false;
print('📱 Error requesting permissions: $e'); print('SupplementsLog: 📱 Error requesting permissions: $e');
return false; return false;
} }
} }
Future<void> scheduleSupplementReminders(Supplement supplement) async { Future<void> scheduleSupplementReminders(Supplement supplement) async {
print('📱 Scheduling reminders for ${supplement.name}'); print('SupplementsLog: 📱 Scheduling reminders for ${supplement.name}');
print('📱 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!);
@@ -475,7 +491,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('📱 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
await DatabaseHelper.instance.trackNotification( await DatabaseHelper.instance.trackNotification(
@@ -518,14 +534,14 @@ class NotificationService {
payload: '${supplement.id}|${supplement.name}|${supplement.numberOfUnits}|${supplement.unitType}', payload: '${supplement.id}|${supplement.name}|${supplement.numberOfUnits}|${supplement.unitType}',
); );
print('📱 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('📱 Total pending notifications: ${pendingNotifications.length}'); print('SupplementsLog: 📱 Total pending notifications: ${pendingNotifications.length}');
for (final notification in pendingNotifications) { for (final notification in pendingNotifications) {
print('📱 Pending: ID=${notification.id}, Title=${notification.title}'); print('SupplementsLog: 📱 Pending: ID=${notification.id}, Title=${notification.title}');
} }
} }
@@ -548,22 +564,22 @@ class NotificationService {
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('📱 Current time: $now (${now.timeZoneName})'); print('SupplementsLog: 📱 Current time: $now (${now.timeZoneName})');
print('📱 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('📱 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('📱 Time has passed, scheduling for tomorrow: $scheduledDate (${scheduledDate.timeZoneName})'); print('SupplementsLog: 📱 Time has passed, scheduling for tomorrow: $scheduledDate (${scheduledDate.timeZoneName})');
} else { } else {
print('📱 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;
} }
Future<void> showInstantNotification(String title, String body) async { Future<void> showInstantNotification(String title, String body) async {
print('📱 Showing instant notification: $title - $body'); print('SupplementsLog: 📱 Showing instant notification: $title - $body');
const NotificationDetails notificationDetails = NotificationDetails( const NotificationDetails notificationDetails = NotificationDetails(
android: AndroidNotificationDetails( android: AndroidNotificationDetails(
'instant_notifications', 'instant_notifications',
@@ -581,22 +597,22 @@ class NotificationService {
body, body,
notificationDetails, notificationDetails,
); );
print('📱 Instant notification sent'); print('SupplementsLog: 📱 Instant notification sent');
} }
// Debug function to test notifications // Debug function to test notifications
Future<void> testNotification() async { Future<void> testNotification() async {
print('📱 Testing notification system...'); print('SupplementsLog: 📱 Testing notification system...');
await showInstantNotification('Test Notification', 'This is a test notification to verify the system is working.'); await showInstantNotification('Test Notification', 'This is a test notification to verify the system is working.');
} }
// Debug function to schedule a test notification 1 minute from now // Debug function to schedule a test notification 1 minute from now
Future<void> testScheduledNotification() async { Future<void> testScheduledNotification() async {
print('📱 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('📱 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
@@ -616,7 +632,7 @@ class NotificationService {
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
); );
print('📱 Test notification scheduled successfully'); print('SupplementsLog: 📱 Test notification scheduled successfully');
} }
// Debug function to get all pending notifications // Debug function to get all pending notifications
@@ -626,7 +642,7 @@ class NotificationService {
// Debug function to test notification actions // Debug function to test notification actions
Future<void> testNotificationWithActions() async { Future<void> testNotificationWithActions() async {
print('📱 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
@@ -659,12 +675,12 @@ class NotificationService {
payload: '999|Test Supplement|1.0|capsule', payload: '999|Test Supplement|1.0|capsule',
); );
print('📱 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('📱 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
@@ -683,6 +699,6 @@ class NotificationService {
payload: 'basic_test', payload: 'basic_test',
); );
print('📱 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

@@ -1,928 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:webdav_client/webdav_client.dart';
import '../models/supplement.dart';
import '../models/supplement_intake.dart';
import '../models/sync_data.dart';
import '../models/sync_enums.dart';
import 'database_helper.dart';
/// Service for handling WebDAV synchronization operations
class WebDAVSyncService {
static const String _baseUrlKey = 'webdav_base_url';
static const String _usernameKey = 'webdav_username';
static const String _passwordKey = 'webdav_password';
static const String _deviceNameKey = 'webdav_device_name';
static const String _deviceIdKey = 'webdav_device_id';
static const String _lastSyncTimeKey = 'webdav_last_sync_time';
static const String _lastWorkingUrlKey = 'webdav_last_working_url';
static const String _syncFolderNameKey = 'webdav_sync_folder_name';
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(
aOptions: AndroidOptions(
encryptedSharedPreferences: true,
),
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
),
);
final DatabaseHelper _databaseHelper = DatabaseHelper.instance;
final Connectivity _connectivity = Connectivity();
Client? _webdavClient;
String? _currentDeviceId;
String? _currentDeviceName;
String? _syncFolderName;
/// Initialize the service and generate device ID if needed
Future<void> initialize() async {
await _ensureDeviceInfo();
_syncFolderName = await getSyncFolderName();
}
/// Check if WebDAV is configured
Future<bool> isConfigured() async {
final baseUrl = await _secureStorage.read(key: _baseUrlKey);
final username = await _secureStorage.read(key: _usernameKey);
final password = await _secureStorage.read(key: _passwordKey);
return baseUrl != null &&
baseUrl.isNotEmpty &&
username != null &&
username.isNotEmpty &&
password != null &&
password.isNotEmpty;
}
/// Configure WebDAV connection
Future<bool> configure({
required String baseUrl,
required String username,
String? password,
String? deviceName,
String? syncFolderName,
}) async {
try {
// For updates without password, get existing password
String actualPassword = password ?? '';
if (password == null) {
actualPassword = await _secureStorage.read(key: _passwordKey) ?? '';
if (actualPassword.isEmpty) {
throw Exception('No existing password found and no new password provided');
}
}
// Validate and normalize base URL
final normalizedUrl = _normalizeUrl(baseUrl);
// Build the full WebDAV URL with username for Nextcloud/ownCloud
final fullWebdavUrl = _buildFullWebDAVUrl(normalizedUrl, username);
// Test connection with smart fallback
final testResult = await _testConnectionWithFallback(
fullWebdavUrl,
username,
actualPassword,
baseUrl, // Original URL for fallback attempts
);
if (!testResult.success) {
throw Exception(testResult.errorMessage);
}
final finalUrl = testResult.workingUrl!;
// Save configuration with the working URL
await _secureStorage.write(key: _baseUrlKey, value: finalUrl);
await _secureStorage.write(key: _lastWorkingUrlKey, value: finalUrl);
await _secureStorage.write(key: _usernameKey, value: username);
await _secureStorage.write(key: _passwordKey, value: actualPassword);
if (deviceName != null && deviceName.isNotEmpty) {
await _secureStorage.write(key: _deviceNameKey, value: deviceName);
_currentDeviceName = deviceName;
}
// Store sync folder name
final folderName = syncFolderName ?? 'Supplements';
await _secureStorage.write(key: _syncFolderNameKey, value: folderName);
_syncFolderName = folderName;
// Initialize client with the working URL
_webdavClient = newClient(
finalUrl,
user: username,
password: actualPassword,
debug: kDebugMode,
);
// Ensure sync directory exists
await _ensureSyncDirectoryExists();
if (kDebugMode) {
print('WebDAV configured successfully: $finalUrl');
print('Original URL: $baseUrl -> Detected: $finalUrl');
}
return true;
} catch (e) {
if (kDebugMode) {
print('WebDAV configuration failed: $e');
}
return false;
}
}
/// Remove WebDAV configuration
Future<void> clearConfiguration() async {
await _secureStorage.delete(key: _baseUrlKey);
await _secureStorage.delete(key: _usernameKey);
await _secureStorage.delete(key: _passwordKey);
await _secureStorage.delete(key: _deviceNameKey);
await _secureStorage.delete(key: _lastSyncTimeKey);
await _secureStorage.delete(key: _lastWorkingUrlKey);
await _secureStorage.delete(key: _syncFolderNameKey);
_webdavClient = null;
_syncFolderName = null;
}
/// Test WebDAV connection
Future<bool> testConnection() async {
try {
await _ensureClient();
if (_webdavClient == null) return false;
await _webdavClient!.ping();
return true;
} catch (e) {
if (kDebugMode) {
print('WebDAV connection test failed: $e');
}
return false;
}
}
/// Check if device has internet connectivity
Future<bool> hasConnectivity() async {
final connectivityResult = await _connectivity.checkConnectivity();
return connectivityResult.any((result) =>
result == ConnectivityResult.mobile ||
result == ConnectivityResult.wifi ||
result == ConnectivityResult.ethernet);
}
/// Perform full sync operation
Future<SyncResult> performSync() async {
final syncStartTime = DateTime.now();
try {
// Check connectivity
if (!await hasConnectivity()) {
return SyncResult.failure(
error: 'No internet connection',
status: SyncOperationStatus.networkError,
);
}
// Ensure client is initialized
await _ensureClient();
if (_webdavClient == null) {
return SyncResult.failure(
error: 'WebDAV client not configured',
status: SyncOperationStatus.authenticationError,
);
}
// Download remote data
final remoteSyncData = await _downloadSyncData();
// Get local data that needs syncing
final localSyncData = await _getLocalSyncData();
// Merge data and detect conflicts
final mergeResult = await _mergeData(localSyncData, remoteSyncData);
// Upload merged data back to server
if (mergeResult.hasChanges) {
await _uploadSyncData(mergeResult.mergedData);
}
// Update local sync status
await _updateLocalSyncStatus(mergeResult.mergedData);
// Update last sync time
await _updateLastSyncTime();
final syncDuration = DateTime.now().difference(syncStartTime);
return SyncResult.success(
conflicts: mergeResult.conflicts,
statistics: SyncStatistics(
supplementsUploaded: mergeResult.statistics.supplementsUploaded,
supplementsDownloaded: mergeResult.statistics.supplementsDownloaded,
intakesUploaded: mergeResult.statistics.intakesUploaded,
intakesDownloaded: mergeResult.statistics.intakesDownloaded,
conflictsResolved: mergeResult.statistics.conflictsResolved,
syncDuration: syncDuration,
),
);
} catch (e) {
if (kDebugMode) {
print('Sync operation failed: $e');
}
SyncOperationStatus status = SyncOperationStatus.serverError;
if (e is SocketException || e.toString().contains('network')) {
status = SyncOperationStatus.networkError;
} else if (e.toString().contains('401') || e.toString().contains('403')) {
status = SyncOperationStatus.authenticationError;
}
return SyncResult.failure(
error: e.toString(),
status: status,
);
}
}
/// Get last sync time
Future<DateTime?> getLastSyncTime() async {
final lastSyncStr = await _secureStorage.read(key: _lastSyncTimeKey);
if (lastSyncStr != null) {
return DateTime.tryParse(lastSyncStr);
}
return null;
}
/// Get current device info
Future<Map<String, String?>> getDeviceInfo() async {
await _ensureDeviceInfo();
return {
'deviceId': _currentDeviceId,
'deviceName': _currentDeviceName,
};
}
/// Get the last working WebDAV URL
Future<String?> getLastWorkingUrl() async {
return await _secureStorage.read(key: _lastWorkingUrlKey);
}
/// Get stored server URL
Future<String?> getServerUrl() async {
return await _secureStorage.read(key: _baseUrlKey);
}
/// Get stored username
Future<String?> getUsername() async {
return await _secureStorage.read(key: _usernameKey);
}
/// Get stored sync folder name
Future<String?> getSyncFolderName() async {
final folderName = await _secureStorage.read(key: _syncFolderNameKey);
return folderName ?? 'Supplements';
}
// Private methods
Future<void> _ensureDeviceInfo() async {
_currentDeviceId = await _secureStorage.read(key: _deviceIdKey);
if (_currentDeviceId == null) {
_currentDeviceId = _generateDeviceId();
await _secureStorage.write(key: _deviceIdKey, value: _currentDeviceId!);
}
_currentDeviceName = await _secureStorage.read(key: _deviceNameKey);
if (_currentDeviceName == null) {
_currentDeviceName = _getDefaultDeviceName();
await _secureStorage.write(key: _deviceNameKey, value: _currentDeviceName!);
}
}
String _generateDeviceId() {
final timestamp = DateTime.now().millisecondsSinceEpoch;
final random = DateTime.now().microsecond;
return 'device_${timestamp}_$random';
}
String _getDefaultDeviceName() {
if (Platform.isAndroid) return 'Android Device';
if (Platform.isIOS) return 'iOS Device';
if (Platform.isWindows) return 'Windows PC';
if (Platform.isMacOS) return 'Mac';
if (Platform.isLinux) return 'Linux PC';
return 'Unknown Device';
}
String _normalizeUrl(String url) {
// Clean and trim the URL
url = url.trim();
if (url.endsWith('/')) {
url = url.substring(0, url.length - 1);
}
// Add protocol if missing - try HTTPS first, fallback to HTTP if needed
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://$url';
}
return _detectAndBuildWebDAVUrl(url);
}
/// Advanced server detection and WebDAV URL building
String _detectAndBuildWebDAVUrl(String baseUrl) {
final uri = Uri.tryParse(baseUrl);
if (uri == null) return baseUrl;
final host = uri.host.toLowerCase();
final path = uri.path.toLowerCase();
// If already contains WebDAV path, return as-is
if (path.contains('/remote.php/dav') || path.contains('/remote.php/webdav')) {
return baseUrl;
}
// Nextcloud detection patterns
if (_isNextcloudServer(host, path)) {
return _buildNextcloudUrl(baseUrl, uri);
}
// ownCloud detection patterns
if (_isOwnCloudServer(host, path)) {
return _buildOwnCloudUrl(baseUrl, uri);
}
// Generic WebDAV detection
if (_isGenericWebDAVServer(host, path)) {
return _buildGenericWebDAVUrl(baseUrl, uri);
}
// Default: assume it's a base URL and try Nextcloud first (most common)
return _buildNextcloudUrl(baseUrl, uri);
}
/// Detect if server is likely Nextcloud
bool _isNextcloudServer(String host, String path) {
// Common Nextcloud hosting patterns
final nextcloudPatterns = [
'nextcloud',
'cloud',
'nc.',
'.cloud.',
'files.',
'drive.',
];
// Check hostname patterns
for (final pattern in nextcloudPatterns) {
if (host.contains(pattern)) return true;
}
// Check path patterns
if (path.contains('nextcloud') || path.contains('index.php/apps/files')) {
return true;
}
return false;
}
/// Detect if server is likely ownCloud
bool _isOwnCloudServer(String host, String path) {
final owncloudPatterns = [
'owncloud',
'oc.',
'.owncloud.',
];
// Check hostname patterns
for (final pattern in owncloudPatterns) {
if (host.contains(pattern)) return true;
}
// Check path patterns
if (path.contains('owncloud') || path.contains('index.php/apps/files')) {
return true;
}
return false;
}
/// Detect if server might be generic WebDAV
bool _isGenericWebDAVServer(String host, String path) {
final webdavPatterns = [
'webdav',
'dav',
'caldav',
'carddav',
];
// Check hostname and path for WebDAV indicators
for (final pattern in webdavPatterns) {
if (host.contains(pattern) || path.contains(pattern)) return true;
}
return false;
}
/// Build Nextcloud WebDAV URL with username detection
String _buildNextcloudUrl(String baseUrl, Uri uri) {
String webdavUrl = '${uri.scheme}://${uri.host}';
if (uri.hasPort && uri.port != 80 && uri.port != 443) {
webdavUrl += ':${uri.port}';
}
// Handle subdirectory installations
String basePath = uri.path;
if (basePath.isNotEmpty && basePath != '/') {
// Remove common Nextcloud paths that shouldn't be in base
basePath = basePath.replaceAll(RegExp(r'/index\.php.*$'), '');
basePath = basePath.replaceAll(RegExp(r'/apps/files.*$'), '');
webdavUrl += basePath;
}
// Add Nextcloud WebDAV path - we'll need username later
webdavUrl += '/remote.php/dav/files/';
return webdavUrl;
}
/// Build ownCloud WebDAV URL
String _buildOwnCloudUrl(String baseUrl, Uri uri) {
String webdavUrl = '${uri.scheme}://${uri.host}';
if (uri.hasPort && uri.port != 80 && uri.port != 443) {
webdavUrl += ':${uri.port}';
}
// Handle subdirectory installations
String basePath = uri.path;
if (basePath.isNotEmpty && basePath != '/') {
basePath = basePath.replaceAll(RegExp(r'/index\.php.*$'), '');
basePath = basePath.replaceAll(RegExp(r'/apps/files.*$'), '');
webdavUrl += basePath;
}
webdavUrl += '/remote.php/webdav/';
return webdavUrl;
}
/// Build generic WebDAV URL
String _buildGenericWebDAVUrl(String baseUrl, Uri uri) {
// For generic WebDAV, we assume the URL provided is correct
// but we might need to add common WebDAV paths if missing
if (!uri.path.endsWith('/')) {
return '$baseUrl/';
}
return baseUrl;
}
Future<void> _ensureClient() async {
if (_webdavClient == null) {
final baseUrl = await _secureStorage.read(key: _baseUrlKey);
final username = await _secureStorage.read(key: _usernameKey);
final password = await _secureStorage.read(key: _passwordKey);
if (baseUrl != null && username != null && password != null) {
_webdavClient = newClient(
baseUrl,
user: username,
password: password,
debug: kDebugMode,
);
}
}
}
Future<void> _ensureSyncDirectoryExists() async {
if (_webdavClient == null) return;
// Get the configured folder name, default to 'Supplements'
final folderName = _syncFolderName ?? await getSyncFolderName() ?? 'Supplements';
try {
await _webdavClient!.mkdir(folderName);
} catch (e) {
// Directory might already exist, ignore error
if (kDebugMode && !e.toString().contains('409')) {
print('Failed to create sync directory ($folderName): $e');
}
}
}
Future<SyncData?> _downloadSyncData() async {
if (_webdavClient == null) return null;
try {
final folderName = _syncFolderName ?? await getSyncFolderName() ?? 'Supplements';
final syncFilePath = '$folderName/${SyncConstants.syncFileName}';
final fileData = await _webdavClient!.read(syncFilePath);
final jsonString = utf8.decode(fileData);
return SyncData.fromJsonString(jsonString);
} catch (e) {
if (kDebugMode) {
print('Failed to download sync data: $e');
}
return null;
}
}
Future<SyncData> _getLocalSyncData() async {
await _ensureDeviceInfo();
final supplements = await _databaseHelper.getAllSupplements();
final archivedSupplements = await _databaseHelper.getArchivedSupplements();
final allSupplements = [...supplements, ...archivedSupplements];
// Get all intakes (we'll need to implement a method to get all intakes)
final intakes = await _getAllIntakes();
return SyncData(
version: SyncConstants.currentSyncVersion,
deviceId: _currentDeviceId!,
deviceName: _currentDeviceName!,
syncTimestamp: DateTime.now(),
supplements: allSupplements,
intakes: intakes,
metadata: {
'totalSupplements': allSupplements.length,
'totalIntakes': intakes.length,
},
);
}
Future<List<SupplementIntake>> _getAllIntakes() async {
// This is a simplified version - in practice, you might want to limit
// to a certain date range or implement pagination for large datasets
final now = DateTime.now();
final oneYearAgo = now.subtract(const Duration(days: 365));
List<SupplementIntake> allIntakes = [];
DateTime current = oneYearAgo;
while (current.isBefore(now)) {
final monthIntakes = await _databaseHelper.getIntakesForMonth(
current.year,
current.month,
);
allIntakes.addAll(monthIntakes);
current = DateTime(current.year, current.month + 1);
}
return allIntakes;
}
Future<_MergeResult> _mergeData(SyncData local, SyncData? remote) async {
if (remote == null) {
// No remote data, just mark local data as pending sync
return _MergeResult(
mergedData: local,
conflicts: [],
hasChanges: true,
statistics: SyncStatistics(
supplementsUploaded: local.supplements.length,
intakesUploaded: local.intakes.length,
),
);
}
final conflicts = <SyncConflict>[];
final mergedSupplements = <String, Supplement>{};
final mergedIntakes = <String, SupplementIntake>{};
// Merge supplements
for (final localSupplement in local.supplements) {
mergedSupplements[localSupplement.syncId] = localSupplement;
}
int supplementsDownloaded = 0;
int supplementsUploaded = local.supplements.length;
for (final remoteSupplement in remote.supplements) {
final localSupplement = mergedSupplements[remoteSupplement.syncId];
if (localSupplement == null) {
// New remote supplement
mergedSupplements[remoteSupplement.syncId] = remoteSupplement;
supplementsDownloaded++;
} else {
// Check for conflicts
if (localSupplement.lastModified.isAfter(remoteSupplement.lastModified)) {
// Local is newer, keep local
continue;
} else if (remoteSupplement.lastModified.isAfter(localSupplement.lastModified)) {
// Remote is newer, use remote
mergedSupplements[remoteSupplement.syncId] = remoteSupplement;
supplementsDownloaded++;
} else {
// Same timestamp - potential conflict if data differs
if (!_supplementsEqual(localSupplement, remoteSupplement)) {
conflicts.add(SyncConflict(
syncId: localSupplement.syncId,
type: ConflictType.modification,
localTimestamp: localSupplement.lastModified,
remoteTimestamp: remoteSupplement.lastModified,
localData: localSupplement.toMap(),
remoteData: remoteSupplement.toMap(),
suggestedResolution: ConflictResolutionStrategy.preferNewer,
));
// For now, keep local version
continue;
}
}
}
}
// Merge intakes (intakes are usually append-only, so fewer conflicts)
for (final localIntake in local.intakes) {
mergedIntakes[localIntake.syncId] = localIntake;
}
int intakesDownloaded = 0;
int intakesUploaded = local.intakes.length;
for (final remoteIntake in remote.intakes) {
if (!mergedIntakes.containsKey(remoteIntake.syncId)) {
mergedIntakes[remoteIntake.syncId] = remoteIntake;
intakesDownloaded++;
}
}
final mergedData = SyncData(
version: SyncConstants.currentSyncVersion,
deviceId: local.deviceId,
deviceName: local.deviceName,
syncTimestamp: DateTime.now(),
supplements: mergedSupplements.values.toList(),
intakes: mergedIntakes.values.toList(),
metadata: {
'mergedAt': DateTime.now().toIso8601String(),
'sourceDevices': [local.deviceId, remote.deviceId],
'conflicts': conflicts.length,
},
);
return _MergeResult(
mergedData: mergedData,
conflicts: conflicts,
hasChanges: supplementsDownloaded > 0 || intakesDownloaded > 0 || conflicts.isNotEmpty,
statistics: SyncStatistics(
supplementsUploaded: supplementsUploaded,
supplementsDownloaded: supplementsDownloaded,
intakesUploaded: intakesUploaded,
intakesDownloaded: intakesDownloaded,
conflictsResolved: 0, // Conflicts are not auto-resolved yet
),
);
}
bool _supplementsEqual(Supplement a, Supplement b) {
return a.name == b.name &&
a.brand == b.brand &&
a.numberOfUnits == b.numberOfUnits &&
a.unitType == b.unitType &&
a.frequencyPerDay == b.frequencyPerDay &&
a.reminderTimes.join(',') == b.reminderTimes.join(',') &&
a.notes == b.notes &&
a.isActive == b.isActive &&
a.isDeleted == b.isDeleted &&
_ingredientsEqual(a.ingredients, b.ingredients);
}
bool _ingredientsEqual(List ingredients1, List ingredients2) {
if (ingredients1.length != ingredients2.length) return false;
for (int i = 0; i < ingredients1.length; i++) {
final ing1 = ingredients1[i];
final ing2 = ingredients2[i];
if (ing1.name != ing2.name ||
ing1.amount != ing2.amount ||
ing1.unit != ing2.unit) {
return false;
}
}
return true;
}
Future<void> _uploadSyncData(SyncData syncData) async {
if (_webdavClient == null) return;
final folderName = _syncFolderName ?? await getSyncFolderName() ?? 'Supplements';
final syncFilePath = '$folderName/${SyncConstants.syncFileName}';
final jsonString = syncData.toJsonString();
final jsonBytes = utf8.encode(jsonString);
// Create backup of existing file first
try {
final backupPath = '$folderName/${SyncConstants.syncFileBackupName}';
final existingData = await _webdavClient!.read(syncFilePath);
await _webdavClient!.write(backupPath, Uint8List.fromList(existingData));
} catch (e) {
// Backup failed, continue anyway
if (kDebugMode) {
print('Failed to create backup: $e');
}
}
// Upload new sync data
await _webdavClient!.write(syncFilePath, jsonBytes);
if (kDebugMode) {
print('Sync data uploaded successfully to $folderName');
}
}
Future<void> _updateLocalSyncStatus(SyncData mergedData) async {
// Mark all synced items as synced in local database
for (final supplement in mergedData.supplements) {
if (supplement.syncStatus != SyncStatus.synced) {
await _databaseHelper.markSupplementAsSynced(supplement.syncId);
}
}
for (final intake in mergedData.intakes) {
if (intake.syncStatus != SyncStatus.synced) {
await _databaseHelper.markIntakeAsSynced(intake.syncId);
}
}
}
Future<void> _updateLastSyncTime() async {
await _secureStorage.write(
key: _lastSyncTimeKey,
value: DateTime.now().toIso8601String(),
);
}
/// Build full WebDAV URL including username for Nextcloud/ownCloud
String _buildFullWebDAVUrl(String baseUrl, String username) {
// If URL ends with /files/ (Nextcloud), add username
if (baseUrl.endsWith('/remote.php/dav/files/')) {
return '$baseUrl$username/';
}
// If URL ends with /webdav/ (ownCloud), it's ready to use
if (baseUrl.endsWith('/remote.php/webdav/')) {
return baseUrl;
}
// For other cases, return as-is
return baseUrl;
}
/// Test connection with smart fallback logic
Future<_ConnectionTestResult> _testConnectionWithFallback(
String primaryUrl,
String username,
String password,
String originalUrl,
) async {
// Try the primary detected URL first
try {
final client = newClient(primaryUrl, user: username, password: password, debug: kDebugMode);
await client.ping();
return _ConnectionTestResult.success(primaryUrl);
} catch (e) {
if (kDebugMode) {
print('Primary URL failed: $primaryUrl - $e');
}
}
// Generate fallback URLs to try
final fallbackUrls = _generateFallbackUrls(originalUrl, username);
for (final fallbackUrl in fallbackUrls) {
try {
final client = newClient(fallbackUrl, user: username, password: password, debug: kDebugMode);
await client.ping();
if (kDebugMode) {
print('Fallback URL succeeded: $fallbackUrl');
}
return _ConnectionTestResult.success(fallbackUrl);
} catch (e) {
if (kDebugMode) {
print('Fallback URL failed: $fallbackUrl - $e');
}
continue;
}
}
return _ConnectionTestResult.failure('Could not connect to WebDAV server. Please check your server URL, username, and password.');
}
/// Generate fallback URLs to try if primary detection fails
List<String> _generateFallbackUrls(String originalUrl, String username) {
final fallbackUrls = <String>[];
final uri = Uri.tryParse(originalUrl.startsWith('http') ? originalUrl : 'https://$originalUrl');
if (uri == null) return fallbackUrls;
String baseUrl = '${uri.scheme}://${uri.host}';
if (uri.hasPort && uri.port != 80 && uri.port != 443) {
baseUrl += ':${uri.port}';
}
// Add path if exists (for subdirectory installations)
String installPath = uri.path;
if (installPath.isNotEmpty && installPath != '/') {
// Clean common paths
installPath = installPath.replaceAll(RegExp(r'/index\.php.*$'), '');
installPath = installPath.replaceAll(RegExp(r'/apps/files.*$'), '');
installPath = installPath.replaceAll(RegExp(r'/remote\.php.*$'), '');
baseUrl += installPath;
}
// Try different combinations
fallbackUrls.addAll([
// Nextcloud variants
'$baseUrl/remote.php/dav/files/$username/',
'$baseUrl/nextcloud/remote.php/dav/files/$username/',
'$baseUrl/cloud/remote.php/dav/files/$username/',
// ownCloud variants
'$baseUrl/remote.php/webdav/',
'$baseUrl/owncloud/remote.php/webdav/',
// Generic WebDAV variants
'$baseUrl/webdav/',
'$baseUrl/dav/',
// Try with different protocols if HTTPS failed
..._generateProtocolVariants(baseUrl, username),
]);
// Remove duplicates and return
return fallbackUrls.toSet().toList();
}
/// Generate protocol variants (HTTP/HTTPS)
List<String> _generateProtocolVariants(String baseUrl, String username) {
if (baseUrl.startsWith('https://')) {
// Try HTTP variants
final httpBase = baseUrl.replaceFirst('https://', 'http://');
return [
'$httpBase/remote.php/dav/files/$username/',
'$httpBase/remote.php/webdav/',
'$httpBase/webdav/',
];
} else if (baseUrl.startsWith('http://')) {
// Try HTTPS variants
final httpsBase = baseUrl.replaceFirst('http://', 'https://');
return [
'$httpsBase/remote.php/dav/files/$username/',
'$httpsBase/remote.php/webdav/',
'$httpsBase/webdav/',
];
}
return [];
}
}
/// Internal class for merge operation results
class _MergeResult {
final SyncData mergedData;
final List<SyncConflict> conflicts;
final bool hasChanges;
final SyncStatistics statistics;
const _MergeResult({
required this.mergedData,
required this.conflicts,
required this.hasChanges,
required this.statistics,
});
}
/// Internal class for connection test results
class _ConnectionTestResult {
final bool success;
final String? workingUrl;
final String? errorMessage;
const _ConnectionTestResult._({
required this.success,
this.workingUrl,
this.errorMessage,
});
factory _ConnectionTestResult.success(String url) {
return _ConnectionTestResult._(success: true, workingUrl: url);
}
factory _ConnectionTestResult.failure(String error) {
return _ConnectionTestResult._(success: false, errorMessage: error);
}
}

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
@@ -42,7 +44,7 @@ class _SupplementCardState extends State<SupplementCard> {
final unitsTaken = intake['unitsTaken'] ?? 1.0; final unitsTaken = intake['unitsTaken'] ?? 1.0;
return { return {
'time': '${takenAt.hour.toString().padLeft(2, '0')}:${takenAt.minute.toString().padLeft(2, '0')}', 'time': '${takenAt.hour.toString().padLeft(2, '0')}:${takenAt.minute.toString().padLeft(2, '0')}',
'units': unitsTaken, 'units': unitsTaken is int ? unitsTaken.toDouble() : unitsTaken as double,
}; };
}).toList(); }).toList();
@@ -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(
@@ -434,7 +461,7 @@ class _SupplementCardState extends State<SupplementCard> {
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.2+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

@@ -1,30 +0,0 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:supplements/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}

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