9 Commits

32 changed files with 3776 additions and 873 deletions

View File

@@ -0,0 +1,287 @@
# 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.

View File

@@ -1,7 +1,10 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'providers/supplement_provider.dart';
import 'providers/settings_provider.dart';
import 'providers/supplement_provider.dart';
import 'providers/simple_sync_provider.dart';
import 'screens/home_screen.dart';
void main() {
@@ -21,9 +24,38 @@ class MyApp extends StatelessWidget {
ChangeNotifierProvider(
create: (context) => SettingsProvider()..initialize(),
),
ChangeNotifierProvider(
create: (context) => SimpleSyncProvider(),
),
],
child: Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
child: Consumer2<SettingsProvider, SimpleSyncProvider>(
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(
title: 'Supplements Tracker',
theme: ThemeData(

View File

@@ -1,14 +1,26 @@
import '../services/database_sync_service.dart';
class Ingredient {
final int? id;
final String name; // e.g., "Vitamin K2", "Vitamin D3"
final double amount; // e.g., 75, 20
final String unit; // e.g., "mcg", "mg", "IU"
// Sync metadata
final String syncId;
final DateTime lastModified;
final RecordSyncStatus syncStatus;
final bool isDeleted;
const Ingredient({
this.id,
required this.name,
required this.amount,
required this.unit,
required this.syncId,
required this.lastModified,
this.syncStatus = RecordSyncStatus.pending,
this.isDeleted = false,
});
Map<String, dynamic> toMap() {
@@ -17,6 +29,10 @@ class Ingredient {
'name': name,
'amount': amount,
'unit': unit,
'syncId': syncId,
'lastModified': lastModified.toIso8601String(),
'syncStatus': syncStatus.name,
'isDeleted': isDeleted ? 1 : 0,
};
}
@@ -26,6 +42,17 @@ class Ingredient {
name: map['name'],
amount: map['amount']?.toDouble() ?? 0.0,
unit: map['unit'],
syncId: map['syncId'] ?? '',
lastModified: map['lastModified'] != null
? DateTime.parse(map['lastModified'])
: DateTime.now(),
syncStatus: map['syncStatus'] != null
? RecordSyncStatus.values.firstWhere(
(e) => e.name == map['syncStatus'],
orElse: () => RecordSyncStatus.pending,
)
: RecordSyncStatus.pending,
isDeleted: (map['isDeleted'] ?? 0) == 1,
);
}
@@ -34,12 +61,20 @@ class Ingredient {
String? name,
double? amount,
String? unit,
String? syncId,
DateTime? lastModified,
RecordSyncStatus? syncStatus,
bool? isDeleted,
}) {
return Ingredient(
id: id ?? this.id,
name: name ?? this.name,
amount: amount ?? this.amount,
unit: unit ?? this.unit,
syncId: syncId ?? this.syncId,
lastModified: lastModified ?? this.lastModified,
syncStatus: syncStatus ?? this.syncStatus,
isDeleted: isDeleted ?? this.isDeleted,
);
}
@@ -54,11 +89,12 @@ class Ingredient {
return other is Ingredient &&
other.name == name &&
other.amount == amount &&
other.unit == unit;
other.unit == unit &&
other.syncId == syncId;
}
@override
int get hashCode {
return name.hashCode ^ amount.hashCode ^ unit.hashCode;
return name.hashCode ^ amount.hashCode ^ unit.hashCode ^ syncId.hashCode;
}
}

View File

@@ -1,6 +1,10 @@
import 'ingredient.dart';
import 'dart:convert';
import 'package:uuid/uuid.dart';
import 'ingredient.dart';
import '../services/database_sync_service.dart';
class Supplement {
final int? id;
final String name;
@@ -14,6 +18,12 @@ class Supplement {
final DateTime createdAt;
final bool isActive;
// Sync metadata
final String syncId;
final DateTime lastModified;
final RecordSyncStatus syncStatus;
final bool isDeleted;
Supplement({
this.id,
required this.name,
@@ -26,7 +36,12 @@ class Supplement {
this.notes,
required this.createdAt,
this.isActive = true,
});
String? syncId,
DateTime? lastModified,
this.syncStatus = RecordSyncStatus.pending,
this.isDeleted = false,
}) : syncId = syncId ?? const Uuid().v4(),
lastModified = lastModified ?? DateTime.now();
// Helper getters
double get totalDosagePerIntake {
@@ -40,7 +55,7 @@ class Supplement {
if (ingredients.isEmpty) {
return 'No ingredients specified';
}
return ingredients.map((ingredient) =>
return ingredients.map((ingredient) =>
'${ingredient.amount * numberOfUnits}${ingredient.unit} ${ingredient.name}'
).join(', ');
}
@@ -66,12 +81,16 @@ class Supplement {
'notes': notes,
'createdAt': createdAt.toIso8601String(),
'isActive': isActive ? 1 : 0,
'syncId': syncId,
'lastModified': lastModified.toIso8601String(),
'syncStatus': syncStatus.name,
'isDeleted': isDeleted ? 1 : 0,
};
}
factory Supplement.fromMap(Map<String, dynamic> map) {
List<Ingredient> ingredients = [];
// Try to parse ingredients if they exist
if (map['ingredients'] != null && map['ingredients'].isNotEmpty) {
try {
@@ -98,6 +117,17 @@ class Supplement {
notes: map['notes'],
createdAt: DateTime.parse(map['createdAt']),
isActive: map['isActive'] == 1,
syncId: map['syncId'] ?? const Uuid().v4(),
lastModified: map['lastModified'] != null
? DateTime.parse(map['lastModified'])
: DateTime.now(),
syncStatus: map['syncStatus'] != null
? RecordSyncStatus.values.firstWhere(
(e) => e.name == map['syncStatus'],
orElse: () => RecordSyncStatus.pending,
)
: RecordSyncStatus.pending,
isDeleted: (map['isDeleted'] ?? 0) == 1,
);
}
@@ -113,6 +143,10 @@ class Supplement {
String? notes,
DateTime? createdAt,
bool? isActive,
String? syncId,
DateTime? lastModified,
RecordSyncStatus? syncStatus,
bool? isDeleted,
}) {
return Supplement(
id: id ?? this.id,
@@ -126,6 +160,34 @@ class Supplement {
notes: notes ?? this.notes,
createdAt: createdAt ?? this.createdAt,
isActive: isActive ?? this.isActive,
syncId: syncId ?? this.syncId,
lastModified: lastModified ?? this.lastModified,
syncStatus: syncStatus ?? this.syncStatus,
isDeleted: isDeleted ?? this.isDeleted,
);
}
/// Create a new supplement with updated sync status and timestamp
Supplement markAsModified() {
return copyWith(
lastModified: DateTime.now(),
syncStatus: RecordSyncStatus.modified,
);
}
/// Create a new supplement marked as synced
Supplement markAsSynced() {
return copyWith(
syncStatus: RecordSyncStatus.synced,
);
}
/// Create a new supplement marked for deletion
Supplement markAsDeleted() {
return copyWith(
isDeleted: true,
lastModified: DateTime.now(),
syncStatus: RecordSyncStatus.modified,
);
}
}

View File

@@ -1,3 +1,7 @@
import 'package:uuid/uuid.dart';
import '../services/database_sync_service.dart';
class SupplementIntake {
final int? id;
final int supplementId;
@@ -6,6 +10,12 @@ class SupplementIntake {
final double unitsTaken; // Number of units taken (can be fractional)
final String? notes;
// Sync metadata
final String syncId;
final DateTime lastModified;
final RecordSyncStatus syncStatus;
final bool isDeleted;
SupplementIntake({
this.id,
required this.supplementId,
@@ -13,7 +23,12 @@ class SupplementIntake {
required this.dosageTaken,
required this.unitsTaken,
this.notes,
});
String? syncId,
DateTime? lastModified,
this.syncStatus = RecordSyncStatus.pending,
this.isDeleted = false,
}) : syncId = syncId ?? const Uuid().v4(),
lastModified = lastModified ?? DateTime.now();
Map<String, dynamic> toMap() {
return {
@@ -23,6 +38,10 @@ class SupplementIntake {
'dosageTaken': dosageTaken,
'unitsTaken': unitsTaken,
'notes': notes,
'syncId': syncId,
'lastModified': lastModified.toIso8601String(),
'syncStatus': syncStatus.name,
'isDeleted': isDeleted ? 1 : 0,
};
}
@@ -34,6 +53,17 @@ class SupplementIntake {
dosageTaken: map['dosageTaken'],
unitsTaken: (map['unitsTaken'] ?? 1).toDouble(), // Default for backwards compatibility
notes: map['notes'],
syncId: map['syncId'] ?? const Uuid().v4(),
lastModified: map['lastModified'] != null
? DateTime.parse(map['lastModified'])
: DateTime.now(),
syncStatus: map['syncStatus'] != null
? RecordSyncStatus.values.firstWhere(
(e) => e.name == map['syncStatus'],
orElse: () => RecordSyncStatus.pending,
)
: RecordSyncStatus.pending,
isDeleted: (map['isDeleted'] ?? 0) == 1,
);
}
@@ -44,6 +74,10 @@ class SupplementIntake {
double? dosageTaken,
double? unitsTaken,
String? notes,
String? syncId,
DateTime? lastModified,
RecordSyncStatus? syncStatus,
bool? isDeleted,
}) {
return SupplementIntake(
id: id ?? this.id,
@@ -52,6 +86,26 @@ class SupplementIntake {
dosageTaken: dosageTaken ?? this.dosageTaken,
unitsTaken: unitsTaken ?? this.unitsTaken,
notes: notes ?? this.notes,
syncId: syncId ?? this.syncId,
lastModified: lastModified ?? this.lastModified,
syncStatus: syncStatus ?? this.syncStatus,
isDeleted: isDeleted ?? this.isDeleted,
);
}
/// Create a new intake marked as synced
SupplementIntake markAsSynced() {
return copyWith(
syncStatus: RecordSyncStatus.synced,
);
}
/// Create a new intake marked for deletion
SupplementIntake markAsDeleted() {
return copyWith(
isDeleted: true,
lastModified: DateTime.now(),
syncStatus: RecordSyncStatus.modified,
);
}
}

View File

@@ -9,7 +9,7 @@ enum ThemeOption {
class SettingsProvider extends ChangeNotifier {
ThemeOption _themeOption = ThemeOption.system;
// Time range settings (stored as hours, 0-23)
int _morningStart = 5;
int _morningEnd = 10;
@@ -19,14 +19,18 @@ class SettingsProvider extends ChangeNotifier {
int _eveningEnd = 22;
int _nightStart = 23;
int _nightEnd = 4;
// Persistent reminder settings
bool _persistentReminders = true;
int _reminderRetryInterval = 5; // minutes
int _maxRetryAttempts = 3;
// Auto-sync settings
bool _autoSyncEnabled = false;
int _autoSyncDebounceSeconds = 5;
ThemeOption get themeOption => _themeOption;
// Time range getters
int get morningStart => _morningStart;
int get morningEnd => _morningEnd;
@@ -36,22 +40,26 @@ class SettingsProvider extends ChangeNotifier {
int get eveningEnd => _eveningEnd;
int get nightStart => _nightStart;
int get nightEnd => _nightEnd;
// Persistent reminder getters
bool get persistentReminders => _persistentReminders;
int get reminderRetryInterval => _reminderRetryInterval;
int get maxRetryAttempts => _maxRetryAttempts;
// Auto-sync getters
bool get autoSyncEnabled => _autoSyncEnabled;
int get autoSyncDebounceSeconds => _autoSyncDebounceSeconds;
// Helper method to get formatted time ranges for display
String get morningRange => '${_formatHour(_morningStart)} - ${_formatHour((_morningEnd + 1) % 24)}';
String get afternoonRange => '${_formatHour(_afternoonStart)} - ${_formatHour((_afternoonEnd + 1) % 24)}';
String get eveningRange => '${_formatHour(_eveningStart)} - ${_formatHour((_eveningEnd + 1) % 24)}';
String get nightRange => '${_formatHour(_nightStart)} - ${_formatHour((_nightEnd + 1) % 24)}';
String _formatHour(int hour) {
return '${hour.toString().padLeft(2, '0')}:00';
}
ThemeMode get themeMode {
switch (_themeOption) {
case ThemeOption.light:
@@ -67,7 +75,7 @@ class SettingsProvider extends ChangeNotifier {
final prefs = await SharedPreferences.getInstance();
final themeIndex = prefs.getInt('theme_option') ?? 0;
_themeOption = ThemeOption.values[themeIndex];
// Load time range settings
_morningStart = prefs.getInt('morning_start') ?? 5;
_morningEnd = prefs.getInt('morning_end') ?? 10;
@@ -77,19 +85,23 @@ class SettingsProvider extends ChangeNotifier {
_eveningEnd = prefs.getInt('evening_end') ?? 22;
_nightStart = prefs.getInt('night_start') ?? 23;
_nightEnd = prefs.getInt('night_end') ?? 4;
// Load persistent reminder settings
_persistentReminders = prefs.getBool('persistent_reminders') ?? true;
_reminderRetryInterval = prefs.getInt('reminder_retry_interval') ?? 5;
_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();
}
Future<void> setThemeOption(ThemeOption option) async {
_themeOption = option;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('theme_option', option.index);
}
@@ -146,14 +158,14 @@ class SettingsProvider extends ChangeNotifier {
if (morningStart > morningEnd) return false;
if (afternoonStart > afternoonEnd) return false;
if (eveningStart > eveningEnd) return false;
// Night can wrap around midnight, so we allow nightStart > nightEnd
// Check for overlaps in sequential periods
if (morningEnd >= afternoonStart) return false;
if (afternoonEnd >= eveningStart) return false;
if (eveningEnd >= nightStart) return false;
return true;
}
@@ -174,7 +186,7 @@ class SettingsProvider extends ChangeNotifier {
int afternoonCount = 0;
int eveningCount = 0;
int nightCount = 0;
for (final hour in hours) {
if (hour >= _morningStart && hour <= _morningEnd) {
morningCount++;
@@ -186,13 +198,13 @@ class SettingsProvider extends ChangeNotifier {
nightCount++;
}
}
// If supplement is taken throughout the day (has times in multiple periods)
final periodsCount = (morningCount > 0 ? 1 : 0) +
(afternoonCount > 0 ? 1 : 0) +
(eveningCount > 0 ? 1 : 0) +
final periodsCount = (morningCount > 0 ? 1 : 0) +
(afternoonCount > 0 ? 1 : 0) +
(eveningCount > 0 ? 1 : 0) +
(nightCount > 0 ? 1 : 0);
if (periodsCount >= 2) {
// Categorize based on the earliest reminder time for consistency
final earliestHour = hours.reduce((a, b) => a < b ? a : b);
@@ -206,7 +218,7 @@ class SettingsProvider extends ChangeNotifier {
return 'night';
}
}
// If all times are in one period, categorize accordingly
if (morningCount > 0) {
return 'morning';
@@ -236,7 +248,7 @@ class SettingsProvider extends ChangeNotifier {
Future<void> setPersistentReminders(bool enabled) async {
_persistentReminders = enabled;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('persistent_reminders', enabled);
}
@@ -244,7 +256,7 @@ class SettingsProvider extends ChangeNotifier {
Future<void> setReminderRetryInterval(int minutes) async {
_reminderRetryInterval = minutes;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('reminder_retry_interval', minutes);
}
@@ -252,8 +264,25 @@ class SettingsProvider extends ChangeNotifier {
Future<void> setMaxRetryAttempts(int attempts) async {
_maxRetryAttempts = attempts;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
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

@@ -1,7 +1,9 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../models/supplement.dart';
import '../models/supplement_intake.dart';
import '../services/database_helper.dart';
@@ -19,56 +21,69 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
Timer? _dateChangeTimer;
DateTime _lastDateCheck = DateTime.now();
// Callback for triggering sync when data changes
VoidCallback? _onDataChanged;
List<Supplement> get supplements => _supplements;
List<Map<String, dynamic>> get todayIntakes => _todayIntakes;
List<Map<String, dynamic>> get monthlyIntakes => _monthlyIntakes;
bool get isLoading => _isLoading;
/// Set callback for triggering sync when data changes
void setOnDataChangedCallback(VoidCallback? callback) {
_onDataChanged = callback;
}
/// Trigger sync if callback is set
void _triggerSyncIfEnabled() {
_onDataChanged?.call();
}
Future<void> initialize() async {
// Add this provider as an observer for app lifecycle changes
WidgetsBinding.instance.addObserver(this);
await _notificationService.initialize();
// 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) {
print('📱 === NOTIFICATION CALLBACK TRIGGERED ===');
print('📱 Supplement ID: $supplementId');
print('📱 Supplement Name: $supplementName');
print('📱 Units: $units');
print('📱 Unit Type: $unitType');
print('SupplementsLog: 📱 === NOTIFICATION CALLBACK TRIGGERED ===');
print('SupplementsLog: 📱 Supplement ID: $supplementId');
print('SupplementsLog: 📱 Supplement Name: $supplementName');
print('SupplementsLog: 📱 Units: $units');
print('SupplementsLog: 📱 Unit Type: $unitType');
// Record the intake when user taps "Take" on notification
recordIntake(supplementId, 0.0, unitsTaken: units);
print('📱 Intake recorded successfully');
print('📱 === CALLBACK COMPLETE ===');
print('SupplementsLog: 📱 Intake recorded successfully');
print('SupplementsLog: 📱 === CALLBACK COMPLETE ===');
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
try {
await _notificationService.requestPermissions();
} catch (e) {
if (kDebugMode) {
print('Error requesting notification permissions: $e');
print('SupplementsLog: Error requesting notification permissions: $e');
}
// Continue without notifications rather than crashing
}
await loadSupplements();
await loadTodayIntakes();
// Reschedule notifications for all active supplements to ensure persistence
await _rescheduleAllNotifications();
// Start periodic checking for persistent reminders (every 5 minutes)
_startPersistentReminderCheck();
// Start date change monitoring to reset daily intake status
_startDateChangeMonitoring();
}
@@ -76,7 +91,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
void _startPersistentReminderCheck() {
// Cancel any existing timer
_persistentReminderTimer?.cancel();
// Check every 5 minutes for persistent reminders
_persistentReminderTimer = Timer.periodic(const Duration(minutes: 5), (timer) async {
try {
@@ -84,11 +99,11 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
await _checkPersistentReminders();
} catch (e) {
if (kDebugMode) {
print('Error checking persistent reminders: $e');
print('SupplementsLog: Error checking persistent reminders: $e');
}
}
});
// Also check immediately
_checkPersistentReminders();
}
@@ -96,25 +111,25 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
void _startDateChangeMonitoring() {
// Cancel any existing timer
_dateChangeTimer?.cancel();
// Check every minute if the date has changed
_dateChangeTimer = Timer.periodic(const Duration(minutes: 1), (timer) async {
final now = DateTime.now();
final currentDate = DateTime(now.year, now.month, now.day);
final lastCheckDate = DateTime(_lastDateCheck.year, _lastDateCheck.month, _lastDateCheck.day);
if (currentDate != lastCheckDate) {
if (kDebugMode) {
print('Date changed detected: ${lastCheckDate} -> ${currentDate}');
print('Refreshing today\'s intakes for new day...');
print('SupplementsLog: Date changed detected: ${lastCheckDate} -> ${currentDate}');
print('SupplementsLog: Refreshing today\'s intakes for new day...');
}
// Date has changed, refresh today's intakes
_lastDateCheck = now;
await loadTodayIntakes();
if (kDebugMode) {
print('Today\'s intakes refreshed for new day');
print('SupplementsLog: Today\'s intakes refreshed for new day');
}
}
});
@@ -125,7 +140,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
// For now, we'll check with default settings
// In practice, the UI should call checkPersistentRemindersWithSettings
if (kDebugMode) {
print('📱 Checking persistent reminders with default settings');
print('SupplementsLog: 📱 Checking persistent reminders with default settings');
}
}
@@ -135,7 +150,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
required int reminderRetryInterval,
required int maxRetryAttempts,
}) async {
print('📱 🔄 MANUAL CHECK: Persistent reminders called from UI');
print('SupplementsLog: 📱 🔄 MANUAL CHECK: Persistent reminders called from UI');
await _notificationService.checkPersistentReminders(
persistentReminders,
reminderRetryInterval,
@@ -145,7 +160,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
// Add a manual trigger method for testing
Future<void> triggerRetryCheck() async {
print('📱 🚨 MANUAL TRIGGER: Forcing retry check...');
print('SupplementsLog: 📱 🚨 MANUAL TRIGGER: Forcing retry check...');
await checkPersistentRemindersWithSettings(
persistentReminders: true,
reminderRetryInterval: 5, // Force 5 minute interval for testing
@@ -164,11 +179,11 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (state == AppLifecycleState.resumed) {
// App came back to foreground, check if date changed
if (kDebugMode) {
print('App resumed, checking for date change...');
print('SupplementsLog: App resumed, checking for date change...');
}
forceCheckDateChange();
}
@@ -176,23 +191,23 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
Future<void> _rescheduleAllNotifications() async {
if (kDebugMode) {
print('📱 Rescheduling notifications for all active supplements...');
print('SupplementsLog: 📱 Rescheduling notifications for all active supplements...');
}
for (final supplement in _supplements) {
if (supplement.reminderTimes.isNotEmpty) {
try {
await _notificationService.scheduleSupplementReminders(supplement);
} catch (e) {
if (kDebugMode) {
print('📱 Error rescheduling notifications for ${supplement.name}: $e');
print('SupplementsLog: 📱 Error rescheduling notifications for ${supplement.name}: $e');
}
}
}
}
if (kDebugMode) {
print('📱 Finished rescheduling notifications');
print('SupplementsLog: 📱 Finished rescheduling notifications');
}
}
@@ -201,16 +216,16 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
notifyListeners();
try {
print('Loading supplements from database...');
print('SupplementsLog: Loading supplements from database...');
_supplements = await _databaseHelper.getAllSupplements();
print('Loaded ${_supplements.length} supplements');
print('SupplementsLog: Loaded ${_supplements.length} supplements');
for (var supplement in _supplements) {
print('Supplement: ${supplement.name}');
print('SupplementsLog: Supplement: ${supplement.name}');
}
} catch (e) {
print('Error loading supplements: $e');
print('SupplementsLog: Error loading supplements: $e');
if (kDebugMode) {
print('Error loading supplements: $e');
print('SupplementsLog: Error loading supplements: $e');
}
} finally {
_isLoading = false;
@@ -220,25 +235,28 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
Future<void> addSupplement(Supplement supplement) async {
try {
print('Adding supplement: ${supplement.name}');
print('SupplementsLog: Adding supplement: ${supplement.name}');
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);
// Schedule notifications (skip if there's an error)
try {
await _notificationService.scheduleSupplementReminders(newSupplement);
print('Notifications scheduled');
print('SupplementsLog: Notifications scheduled');
} catch (notificationError) {
print('Warning: Could not schedule notifications: $notificationError');
print('SupplementsLog: Warning: Could not schedule notifications: $notificationError');
}
await loadSupplements();
print('Supplements reloaded, count: ${_supplements.length}');
print('SupplementsLog: Supplements reloaded, count: ${_supplements.length}');
// Trigger sync after adding supplement
_triggerSyncIfEnabled();
} catch (e) {
print('Error adding supplement: $e');
print('SupplementsLog: Error adding supplement: $e');
if (kDebugMode) {
print('Error adding supplement: $e');
print('SupplementsLog: Error adding supplement: $e');
}
rethrow;
}
@@ -247,14 +265,17 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
Future<void> updateSupplement(Supplement supplement) async {
try {
await _databaseHelper.updateSupplement(supplement);
// Reschedule notifications
await _notificationService.scheduleSupplementReminders(supplement);
await loadSupplements();
// Trigger sync after updating supplement
_triggerSyncIfEnabled();
} catch (e) {
if (kDebugMode) {
print('Error updating supplement: $e');
print('SupplementsLog: Error updating supplement: $e');
}
}
}
@@ -262,14 +283,17 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
Future<void> deleteSupplement(int id) async {
try {
await _databaseHelper.deleteSupplement(id);
// Cancel notifications
await _notificationService.cancelSupplementReminders(id);
await loadSupplements();
// Trigger sync after deleting supplement
_triggerSyncIfEnabled();
} catch (e) {
if (kDebugMode) {
print('Error deleting supplement: $e');
print('SupplementsLog: Error deleting supplement: $e');
}
}
}
@@ -286,7 +310,10 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
await _databaseHelper.insertIntake(intake);
await loadTodayIntakes();
// Trigger sync after recording intake
_triggerSyncIfEnabled();
// Show confirmation notification
final supplement = _supplements.firstWhere((s) => s.id == supplementId);
final unitsText = unitsTaken != null && unitsTaken != 1 ? '${unitsTaken.toStringAsFixed(unitsTaken % 1 == 0 ? 0 : 1)} ${supplement.unitType}' : '';
@@ -296,7 +323,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
);
} catch (e) {
if (kDebugMode) {
print('Error recording intake: $e');
print('SupplementsLog: Error recording intake: $e');
}
}
}
@@ -305,22 +332,22 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
try {
final today = DateTime.now();
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);
if (kDebugMode) {
print('Loaded ${_todayIntakes.length} intakes for today');
print('SupplementsLog: Loaded ${_todayIntakes.length} intakes for today');
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();
} catch (e) {
if (kDebugMode) {
print('Error loading today\'s intakes: $e');
print('SupplementsLog: Error loading today\'s intakes: $e');
}
}
}
@@ -331,7 +358,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
notifyListeners();
} catch (e) {
if (kDebugMode) {
print('Error loading monthly intakes: $e');
print('SupplementsLog: Error loading monthly intakes: $e');
}
}
}
@@ -341,7 +368,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
return await _databaseHelper.getIntakesWithSupplementsForDate(date);
} catch (e) {
if (kDebugMode) {
print('Error loading intakes for date: $e');
print('SupplementsLog: Error loading intakes for date: $e');
}
return [];
}
@@ -356,9 +383,31 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
await loadMonthlyIntakes(DateTime.now().year, DateTime.now().month);
}
notifyListeners();
// Trigger sync after deleting intake
_triggerSyncIfEnabled();
} catch (e) {
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');
}
}
}
@@ -374,7 +423,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
// Method to manually refresh daily status (useful for testing or manual refresh)
Future<void> refreshDailyStatus() async {
if (kDebugMode) {
print('Manually refreshing daily status...');
print('SupplementsLog: Manually refreshing daily status...');
}
_lastDateCheck = DateTime.now();
await loadTodayIntakes();
@@ -385,22 +434,22 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
final now = DateTime.now();
final currentDate = DateTime(now.year, now.month, now.day);
final lastCheckDate = DateTime(_lastDateCheck.year, _lastDateCheck.month, _lastDateCheck.day);
if (kDebugMode) {
print('Force checking date change...');
print('Current date: $currentDate');
print('Last check date: $lastCheckDate');
print('SupplementsLog: Force checking date change...');
print('SupplementsLog: Current date: $currentDate');
print('SupplementsLog: Last check date: $lastCheckDate');
}
if (currentDate != lastCheckDate) {
if (kDebugMode) {
print('Date change detected, refreshing intakes...');
print('SupplementsLog: Date change detected, refreshing intakes...');
}
_lastDateCheck = now;
await loadTodayIntakes();
} else {
if (kDebugMode) {
print('No date change detected');
print('SupplementsLog: No date change detected');
}
}
}
@@ -415,7 +464,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
notifyListeners();
} catch (e) {
if (kDebugMode) {
print('Error loading archived supplements: $e');
print('SupplementsLog: Error loading archived supplements: $e');
}
}
}
@@ -425,9 +474,12 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
await _databaseHelper.archiveSupplement(supplementId);
await loadSupplements(); // Refresh active supplements
await loadArchivedSupplements(); // Refresh archived supplements
// Trigger sync after archiving supplement
_triggerSyncIfEnabled();
} catch (e) {
if (kDebugMode) {
print('Error archiving supplement: $e');
print('SupplementsLog: Error archiving supplement: $e');
}
}
}
@@ -437,20 +489,26 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
await _databaseHelper.unarchiveSupplement(supplementId);
await loadSupplements(); // Refresh active supplements
await loadArchivedSupplements(); // Refresh archived supplements
// Trigger sync after unarchiving supplement
_triggerSyncIfEnabled();
} catch (e) {
if (kDebugMode) {
print('Error unarchiving supplement: $e');
print('SupplementsLog: Error unarchiving supplement: $e');
}
}
}
Future<void> deleteArchivedSupplement(int supplementId) async {
try {
await _databaseHelper.deleteSupplement(supplementId);
await _databaseHelper.permanentlyDeleteSupplement(supplementId);
await loadArchivedSupplements(); // Refresh archived supplements
// Trigger sync after permanently deleting archived supplement
_triggerSyncIfEnabled();
} catch (e) {
if (kDebugMode) {
print('Error deleting archived supplement: $e');
print('SupplementsLog: Error permanently deleting archived supplement: $e');
}
}
}

View File

View File

@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/supplement.dart';
import 'package:uuid/uuid.dart';
import '../models/ingredient.dart';
import '../models/supplement.dart';
import '../providers/supplement_provider.dart';
// Helper class to manage ingredient text controllers
@@ -22,6 +24,8 @@ class IngredientController {
name: nameController.text.trim(),
amount: double.tryParse(amountController.text) ?? 0.0,
unit: selectedUnit,
syncId: const Uuid().v4(),
lastModified: DateTime.now(),
);
}
@@ -46,10 +50,10 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
final _brandController = TextEditingController();
final _numberOfUnitsController = TextEditingController();
final _notesController = TextEditingController();
// Multi-ingredient support with persistent controllers
List<IngredientController> _ingredientControllers = [];
String _selectedUnitType = 'capsules';
int _frequencyPerDay = 1;
List<String> _reminderTimes = ['08:00'];
@@ -195,7 +199,7 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
_selectedUnitType = supplement.unitType;
_frequencyPerDay = supplement.frequencyPerDay;
_reminderTimes = List.from(supplement.reminderTimes);
// Initialize ingredient controllers from existing ingredients
_ingredientControllers.clear();
if (supplement.ingredients.isEmpty) {
@@ -220,6 +224,13 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
appBar: AppBar(
title: Text(isEditing ? 'Edit Supplement' : 'Add Supplement'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: [
IconButton(
tooltip: isEditing ? 'Update Supplement' : 'Save Supplement',
onPressed: _saveSupplement,
icon: const Icon(Icons.save),
),
],
),
body: Form(
key: _formKey,
@@ -478,17 +489,7 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
),
const SizedBox(height: 24),
// Save button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _saveSupplement,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16),
),
child: Text(isEditing ? 'Update Supplement' : 'Add Supplement'),
),
),
// Save is now in the AppBar for consistency with app-wide pattern
],
),
),
@@ -556,13 +557,15 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
void _saveSupplement() async {
if (_formKey.currentState!.validate()) {
// Validate that we have at least one ingredient with name and amount
final validIngredients = _ingredientControllers.where((controller) =>
controller.nameController.text.trim().isNotEmpty &&
final validIngredients = _ingredientControllers.where((controller) =>
controller.nameController.text.trim().isNotEmpty &&
(double.tryParse(controller.amountController.text) ?? 0) > 0
).map((controller) => Ingredient(
name: controller.nameController.text.trim(),
amount: double.tryParse(controller.amountController.text) ?? 0,
amount: double.tryParse(controller.amountController.text) ?? 0.0,
unit: controller.selectedUnit,
syncId: const Uuid().v4(),
lastModified: DateTime.now(),
)).toList();
if (validIngredients.isEmpty) {
@@ -588,7 +591,7 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
);
final provider = context.read<SupplementProvider>();
try {
if (widget.supplement != null) {
await provider.updateSupplement(supplement);
@@ -598,10 +601,10 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
if (mounted) {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(widget.supplement != null
content: Text(widget.supplement != null
? 'Supplement updated successfully!'
: 'Supplement added successfully!'),
backgroundColor: Colors.green,
@@ -627,12 +630,12 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
_brandController.dispose();
_numberOfUnitsController.dispose();
_notesController.dispose();
// Dispose all ingredient controllers
for (final controller in _ingredientControllers) {
controller.dispose();
}
super.dispose();
}
}

View File

@@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/supplement_provider.dart';
import '../models/supplement.dart';
import '../providers/supplement_provider.dart';
class ArchivedSupplementsScreen extends StatefulWidget {
const ArchivedSupplementsScreen({super.key});
@@ -254,7 +255,7 @@ class _ArchivedSupplementCard extends StatelessWidget {
],
),
const SizedBox(height: 16),
// Supplement details in a muted style
if (supplement.ingredients.isNotEmpty) ...[
Container(
@@ -301,7 +302,7 @@ class _ArchivedSupplementCard extends StatelessWidget {
),
const SizedBox(height: 12),
],
// Dosage info
Row(
children: [
@@ -318,7 +319,7 @@ class _ArchivedSupplementCard extends StatelessWidget {
),
],
),
if (supplement.reminderTimes.isNotEmpty) ...[
const SizedBox(height: 8),
_InfoChip(

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../providers/settings_provider.dart';
import '../providers/supplement_provider.dart';
class HistoryScreen extends StatefulWidget {
@@ -106,7 +108,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
return LayoutBuilder(
builder: (context, constraints) {
final isWideScreen = constraints.maxWidth > 800;
if (isWideScreen) {
// Desktop/tablet layout: side-by-side
return Row(
@@ -124,27 +126,33 @@ class _HistoryScreenState extends State<HistoryScreen> {
Expanded(
flex: 3,
child: Container(
margin: const EdgeInsets.fromLTRB(0, 16, 16, 16),
child: _buildSelectedDayDetails(groupedIntakes),
// add a bit more horizontal spacing between calendar and card
margin: const EdgeInsets.fromLTRB(8, 16, 16, 16),
child: SingleChildScrollView(
child: _buildSelectedDayDetails(groupedIntakes),
),
),
),
],
);
} else {
// Mobile layout: vertical stack
return Column(
children: [
// Calendar
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: _buildCalendar(groupedIntakes),
),
const SizedBox(height: 16),
// Selected day details
Expanded(
child: _buildSelectedDayDetails(groupedIntakes),
),
],
return SingleChildScrollView(
child: Column(
children: [
// Calendar
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: _buildCalendar(groupedIntakes),
),
const SizedBox(height: 16),
// Selected day details
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: _buildSelectedDayDetails(groupedIntakes),
),
],
),
);
}
},
@@ -177,7 +185,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
);
},
);
if (picked != null) {
setState(() {
_selectedMonth = picked.month;
@@ -203,13 +211,13 @@ class _HistoryScreenState extends State<HistoryScreen> {
onPressed: () async {
await context.read<SupplementProvider>().deleteIntake(intakeId);
Navigator.of(context).pop();
// Force refresh of the UI
setState(() {});
// Force refresh of the current view data
context.read<SupplementProvider>().loadMonthlyIntakes(_selectedYear, _selectedMonth);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$supplementName intake deleted'),
@@ -230,11 +238,11 @@ class _HistoryScreenState extends State<HistoryScreen> {
final lastDayOfMonth = DateTime(_selectedYear, _selectedMonth + 1, 0);
final firstWeekday = firstDayOfMonth.weekday;
final daysInMonth = lastDayOfMonth.day;
// Calculate how many cells we need (including empty ones for alignment)
final totalCells = ((daysInMonth + firstWeekday - 1) / 7).ceil() * 7;
final weeks = (totalCells / 7).ceil();
return LayoutBuilder(
builder: (context, constraints) {
final isWideScreen = constraints.maxWidth > 800;
@@ -242,7 +250,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
final cellHeight = isWideScreen ? 56.0 : 48.0;
final calendarContentHeight = (weeks * cellHeight) + 60; // +60 for headers and padding
final calendarHeight = isWideScreen ? 400.0 : calendarContentHeight;
return Card(
child: Container(
height: calendarHeight,
@@ -283,11 +291,11 @@ class _HistoryScreenState extends State<HistoryScreen> {
itemCount: totalCells,
itemBuilder: (context, index) {
final dayNumber = index - firstWeekday + 2;
if (dayNumber < 1 || dayNumber > daysInMonth) {
return const SizedBox(); // Empty cell
}
final date = DateTime(_selectedYear, _selectedMonth, dayNumber);
final dateKey = DateFormat('yyyy-MM-dd').format(date);
final hasIntakes = groupedIntakes.containsKey(dateKey);
@@ -295,7 +303,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
final isSelected = _selectedDay != null &&
DateFormat('yyyy-MM-dd').format(_selectedDay!) == dateKey;
final isToday = DateFormat('yyyy-MM-dd').format(DateTime.now()) == dateKey;
return GestureDetector(
onTap: () {
setState(() {
@@ -305,12 +313,12 @@ class _HistoryScreenState extends State<HistoryScreen> {
child: Container(
margin: const EdgeInsets.all(1),
decoration: BoxDecoration(
color: isSelected
color: isSelected
? Theme.of(context).colorScheme.primary
: hasIntakes
: hasIntakes
? Theme.of(context).colorScheme.primaryContainer
: null,
border: isToday
border: isToday
? Border.all(color: Theme.of(context).colorScheme.secondary, width: 2)
: null,
borderRadius: BorderRadius.circular(8),
@@ -321,9 +329,9 @@ class _HistoryScreenState extends State<HistoryScreen> {
child: Text(
'$dayNumber',
style: TextStyle(
color: isSelected
color: isSelected
? Theme.of(context).colorScheme.onPrimary
: hasIntakes
: hasIntakes
? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(context).colorScheme.onSurface,
fontWeight: isToday ? FontWeight.bold : FontWeight.normal,
@@ -338,7 +346,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
child: Container(
padding: EdgeInsets.all(isWideScreen ? 3 : 2),
decoration: BoxDecoration(
color: isSelected
color: isSelected
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(8),
@@ -350,7 +358,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
child: Text(
'$intakeCount',
style: TextStyle(
color: isSelected
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onPrimary,
fontSize: isWideScreen ? 11 : 10,
@@ -380,7 +388,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
return LayoutBuilder(
builder: (context, constraints) {
final isWideScreen = constraints.maxWidth > 600;
if (_selectedDay == null) {
return Card(
child: Center(
@@ -478,78 +486,208 @@ class _HistoryScreenState extends State<HistoryScreen> {
],
),
),
Expanded(
child: ListView.builder(
padding: EdgeInsets.all(isWideScreen ? 20 : 16),
itemCount: dayIntakes.length,
itemBuilder: (context, index) {
final intake = dayIntakes[index];
final takenAt = DateTime.parse(intake['takenAt']);
final units = (intake['unitsTaken'] as num?)?.toDouble() ?? 1.0;
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 2,
child: Padding(
padding: EdgeInsets.all(isWideScreen ? 16 : 12),
Padding(
padding: EdgeInsets.all(isWideScreen ? 20 : 16),
child: Builder(
builder: (context) {
final settingsProvider = Provider.of<SettingsProvider>(context, listen: false);
// Sort once per render
final sortedDayIntakes = List<Map<String, dynamic>>.from(dayIntakes)
..sort((a, b) => DateTime.parse(a['takenAt']).compareTo(DateTime.parse(b['takenAt'])));
// Helpers
String timeCategory(DateTime dt) {
final h = dt.hour;
if (h >= settingsProvider.morningStart && h <= settingsProvider.morningEnd) return 'morning';
if (h >= settingsProvider.afternoonStart && h <= settingsProvider.afternoonEnd) return 'afternoon';
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(
children: [
CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.primary,
radius: isWideScreen ? 24 : 20,
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.2),
shape: BoxShape.circle,
),
child: Icon(
Icons.medication,
color: Theme.of(context).colorScheme.onPrimary,
size: isWideScreen ? 24 : 20,
icon,
size: 20,
color: color,
),
),
SizedBox(width: isWideScreen ? 16 : 12),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
intake['supplementName'],
title,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: isWideScreen ? 16 : 14,
fontSize: 18,
fontWeight: FontWeight.bold,
color: color,
),
),
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),
if (range != null) ...[
Text(
intake['notes'],
'($range)',
style: TextStyle(
fontSize: isWideScreen ? 13 : 12,
fontStyle: FontStyle.italic,
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 12,
fontWeight: FontWeight.w500,
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;
try {
print('📱 === HOME SCREEN: Checking persistent reminders ===');
print('SupplementsLog: 📱 === HOME SCREEN: Checking persistent reminders ===');
final supplementProvider = context.read<SupplementProvider>();
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(
persistentReminders: settingsProvider.persistentReminders,
reminderRetryInterval: settingsProvider.reminderRetryInterval,
maxRetryAttempts: settingsProvider.maxRetryAttempts,
);
print('📱 === HOME SCREEN: Persistent reminder check complete ===');
print('SupplementsLog: 📱 === HOME SCREEN: Persistent reminder check complete ===');
} 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;
});
} catch (e) {
print('Error loading notifications: $e');
print('SupplementsLog: Error loading notifications: $e');
setState(() {
_isLoading = false;
});

View File

@@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/settings_provider.dart';
import '../providers/supplement_provider.dart';
import '../services/notification_service.dart';
import 'pending_notifications_screen.dart';
import 'simple_sync_settings_screen.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@@ -19,6 +21,22 @@ class SettingsScreen extends StatelessWidget {
return ListView(
padding: const EdgeInsets.all(16.0),
children: [
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(
child: Padding(
padding: const EdgeInsets.all(16.0),
@@ -68,6 +86,102 @@ 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(
'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),
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
@@ -137,354 +251,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,8 +1,11 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/supplement_provider.dart';
import '../providers/settings_provider.dart';
import '../models/supplement.dart';
import '../providers/settings_provider.dart';
import '../providers/supplement_provider.dart';
import '../providers/simple_sync_provider.dart';
import '../services/database_sync_service.dart';
import '../widgets/supplement_card.dart';
import 'add_supplement_screen.dart';
import 'archived_supplements_screen.dart';
@@ -17,6 +20,31 @@ class SupplementsListScreen extends StatelessWidget {
title: const Text('My Supplements'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: [
Consumer<SimpleSyncProvider>(
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 == SyncStatus.completed &&
syncProvider.lastSyncTime != null &&
DateTime.now().difference(syncProvider.lastSyncTime!).inSeconds < 5
? const Icon(Icons.check, color: Colors.green)
: const Icon(Icons.sync),
onPressed: syncProvider.isSyncing ? null : () {
syncProvider.syncDatabase();
},
tooltip: syncProvider.isSyncing ? 'Syncing...' : 'Force Sync',
);
},
),
IconButton(
icon: const Icon(Icons.archive),
onPressed: () {
@@ -80,13 +108,13 @@ class SupplementsListScreen extends StatelessWidget {
Widget _buildGroupedSupplementsList(BuildContext context, List<Supplement> supplements, SettingsProvider settingsProvider) {
final groupedSupplements = _groupSupplementsByTimeOfDay(supplements, settingsProvider);
return ListView(
padding: const EdgeInsets.all(16),
children: [
if (groupedSupplements['morning']!.isNotEmpty) ...[
_buildSectionHeader('Morning (${settingsProvider.morningRange})', Icons.wb_sunny, Colors.orange, groupedSupplements['morning']!.length),
...groupedSupplements['morning']!.map((supplement) =>
...groupedSupplements['morning']!.map((supplement) =>
SupplementCard(
supplement: supplement,
onTake: () => _showTakeDialog(context, supplement),
@@ -97,10 +125,10 @@ class SupplementsListScreen extends StatelessWidget {
),
const SizedBox(height: 16),
],
if (groupedSupplements['afternoon']!.isNotEmpty) ...[
_buildSectionHeader('Afternoon (${settingsProvider.afternoonRange})', Icons.light_mode, Colors.blue, groupedSupplements['afternoon']!.length),
...groupedSupplements['afternoon']!.map((supplement) =>
...groupedSupplements['afternoon']!.map((supplement) =>
SupplementCard(
supplement: supplement,
onTake: () => _showTakeDialog(context, supplement),
@@ -111,10 +139,10 @@ class SupplementsListScreen extends StatelessWidget {
),
const SizedBox(height: 16),
],
if (groupedSupplements['evening']!.isNotEmpty) ...[
_buildSectionHeader('Evening (${settingsProvider.eveningRange})', Icons.nightlight_round, Colors.indigo, groupedSupplements['evening']!.length),
...groupedSupplements['evening']!.map((supplement) =>
...groupedSupplements['evening']!.map((supplement) =>
SupplementCard(
supplement: supplement,
onTake: () => _showTakeDialog(context, supplement),
@@ -125,10 +153,10 @@ class SupplementsListScreen extends StatelessWidget {
),
const SizedBox(height: 16),
],
if (groupedSupplements['night']!.isNotEmpty) ...[
_buildSectionHeader('Night (${settingsProvider.nightRange})', Icons.bedtime, Colors.purple, groupedSupplements['night']!.length),
...groupedSupplements['night']!.map((supplement) =>
...groupedSupplements['night']!.map((supplement) =>
SupplementCard(
supplement: supplement,
onTake: () => _showTakeDialog(context, supplement),
@@ -139,10 +167,10 @@ class SupplementsListScreen extends StatelessWidget {
),
const SizedBox(height: 16),
],
if (groupedSupplements['anytime']!.isNotEmpty) ...[
_buildSectionHeader('Anytime', Icons.schedule, Colors.grey, groupedSupplements['anytime']!.length),
...groupedSupplements['anytime']!.map((supplement) =>
...groupedSupplements['anytime']!.map((supplement) =>
SupplementCard(
supplement: supplement,
onTake: () => _showTakeDialog(context, supplement),
@@ -305,7 +333,7 @@ class SupplementsListScreen extends StatelessWidget {
),
),
const SizedBox(height: 16),
// Time selection section
Container(
padding: const EdgeInsets.all(12),

View File

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

@@ -1,18 +1,22 @@
import 'package:sqflite/sqflite.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:path/path.dart';
import 'dart:io';
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import '../models/supplement.dart';
import '../models/supplement_intake.dart';
import 'database_sync_service.dart';
class DatabaseHelper {
static const _databaseName = 'supplements.db';
static const _databaseVersion = 5; // Increment version for notification tracking
static const _databaseVersion = 6; // Increment version for sync support
static const supplementsTable = 'supplements';
static const intakesTable = 'supplement_intakes';
static const notificationTrackingTable = 'notification_tracking';
static const syncMetadataTable = 'sync_metadata';
static const deviceInfoTable = 'device_info';
DatabaseHelper._privateConstructor();
static final DatabaseHelper instance = DatabaseHelper._privateConstructor();
@@ -60,7 +64,11 @@ class DatabaseHelper {
reminderTimes TEXT NOT NULL,
notes TEXT,
createdAt TEXT NOT NULL,
isActive INTEGER NOT NULL DEFAULT 1
isActive INTEGER NOT NULL DEFAULT 1,
syncId TEXT NOT NULL UNIQUE,
lastModified TEXT NOT NULL,
syncStatus TEXT NOT NULL DEFAULT 'pending',
isDeleted INTEGER NOT NULL DEFAULT 0
)
''');
@@ -72,6 +80,10 @@ class DatabaseHelper {
dosageTaken REAL NOT NULL,
unitsTaken REAL NOT NULL DEFAULT 1,
notes TEXT,
syncId TEXT NOT NULL UNIQUE,
lastModified TEXT NOT NULL,
syncStatus TEXT NOT NULL DEFAULT 'pending',
isDeleted INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (supplementId) REFERENCES $supplementsTable (id)
)
''');
@@ -89,6 +101,27 @@ class DatabaseHelper {
FOREIGN KEY (supplementId) REFERENCES $supplementsTable (id)
)
''');
// Sync metadata table for tracking sync operations
await db.execute('''
CREATE TABLE $syncMetadataTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL,
lastUpdated TEXT NOT NULL
)
''');
// Device info table for conflict resolution
await db.execute('''
CREATE TABLE $deviceInfoTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
deviceId TEXT NOT NULL UNIQUE,
deviceName TEXT NOT NULL,
lastSyncTime TEXT,
createdAt TEXT NOT NULL
)
''');
}
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
@@ -98,16 +131,16 @@ class DatabaseHelper {
await db.execute('ALTER TABLE $supplementsTable ADD COLUMN numberOfUnits INTEGER DEFAULT 1');
await db.execute('ALTER TABLE $supplementsTable ADD COLUMN unitType TEXT DEFAULT "units"');
await db.execute('ALTER TABLE $intakesTable ADD COLUMN unitsTaken REAL DEFAULT 1');
// Migrate existing data from old dosage column to new dosageAmount column
await db.execute('''
UPDATE $supplementsTable
SET dosageAmount = COALESCE(dosage, 0),
UPDATE $supplementsTable
SET dosageAmount = COALESCE(dosage, 0),
numberOfUnits = 1,
unitType = 'units'
WHERE dosageAmount = 0
''');
// Create new table with correct schema
await db.execute('''
CREATE TABLE ${supplementsTable}_new (
@@ -125,37 +158,37 @@ class DatabaseHelper {
isActive INTEGER NOT NULL DEFAULT 1
)
''');
// Copy data to new table
await db.execute('''
INSERT INTO ${supplementsTable}_new
INSERT INTO ${supplementsTable}_new
(id, name, brand, dosageAmount, numberOfUnits, unit, unitType, frequencyPerDay, reminderTimes, notes, createdAt, isActive)
SELECT id, name, NULL as brand, dosageAmount, numberOfUnits, unit, unitType, frequencyPerDay, reminderTimes, notes, createdAt, isActive
FROM $supplementsTable
''');
// Drop old table and rename new table
await db.execute('DROP TABLE $supplementsTable');
await db.execute('ALTER TABLE ${supplementsTable}_new RENAME TO $supplementsTable');
}
if (oldVersion < 3) {
// Add brand column for version 3
await db.execute('ALTER TABLE $supplementsTable ADD COLUMN brand TEXT');
}
if (oldVersion < 4) {
// Complete migration to new ingredient-based schema
// Add ingredients column and migrate old data
await db.execute('ALTER TABLE $supplementsTable ADD COLUMN ingredients TEXT DEFAULT "[]"');
// Migrate existing supplements to use ingredients format
final supplements = await db.query(supplementsTable);
for (final supplement in supplements) {
final dosageAmount = supplement['dosageAmount'] as double?;
final unit = supplement['unit'] as String?;
final name = supplement['name'] as String;
if (dosageAmount != null && unit != null && dosageAmount > 0) {
// Create a single ingredient from the old dosage data
final ingredient = {
@@ -164,7 +197,7 @@ class DatabaseHelper {
'unit': unit,
};
final ingredientsJson = jsonEncode([ingredient]);
await db.update(
supplementsTable,
{'ingredients': ingredientsJson},
@@ -173,7 +206,7 @@ class DatabaseHelper {
);
}
}
// Remove old columns
await db.execute('''
CREATE TABLE ${supplementsTable}_new (
@@ -190,18 +223,18 @@ class DatabaseHelper {
isActive INTEGER NOT NULL DEFAULT 1
)
''');
await db.execute('''
INSERT INTO ${supplementsTable}_new
INSERT INTO ${supplementsTable}_new
(id, name, brand, ingredients, numberOfUnits, unitType, frequencyPerDay, reminderTimes, notes, createdAt, isActive)
SELECT id, name, brand, ingredients, numberOfUnits, unitType, frequencyPerDay, reminderTimes, notes, createdAt, isActive
FROM $supplementsTable
''');
await db.execute('DROP TABLE $supplementsTable');
await db.execute('ALTER TABLE ${supplementsTable}_new RENAME TO $supplementsTable');
}
if (oldVersion < 5) {
// Add notification tracking table
await db.execute('''
@@ -218,6 +251,77 @@ class DatabaseHelper {
)
''');
}
if (oldVersion < 6) {
// Add sync columns to existing tables
await db.execute('ALTER TABLE $supplementsTable ADD COLUMN syncId TEXT');
await db.execute('ALTER TABLE $supplementsTable ADD COLUMN lastModified TEXT');
await db.execute('ALTER TABLE $supplementsTable ADD COLUMN syncStatus TEXT DEFAULT "pending"');
await db.execute('ALTER TABLE $supplementsTable ADD COLUMN isDeleted INTEGER DEFAULT 0');
await db.execute('ALTER TABLE $intakesTable ADD COLUMN syncId TEXT');
await db.execute('ALTER TABLE $intakesTable ADD COLUMN lastModified TEXT');
await db.execute('ALTER TABLE $intakesTable ADD COLUMN syncStatus TEXT DEFAULT "pending"');
await db.execute('ALTER TABLE $intakesTable ADD COLUMN isDeleted INTEGER DEFAULT 0');
// Generate sync IDs and timestamps for existing records
final supplements = await db.query(supplementsTable);
for (final supplement in supplements) {
if (supplement['syncId'] == null) {
final now = DateTime.now().toIso8601String();
await db.update(
supplementsTable,
{
'syncId': 'sync-${supplement['id']}-${DateTime.now().millisecondsSinceEpoch}',
'lastModified': now,
'syncStatus': 'pending',
'isDeleted': 0,
},
where: 'id = ?',
whereArgs: [supplement['id']],
);
}
}
final intakes = await db.query(intakesTable);
for (final intake in intakes) {
if (intake['syncId'] == null) {
final now = DateTime.now().toIso8601String();
await db.update(
intakesTable,
{
'syncId': 'sync-${intake['id']}-${DateTime.now().millisecondsSinceEpoch}',
'lastModified': now,
'syncStatus': 'pending',
'isDeleted': 0,
},
where: 'id = ?',
whereArgs: [intake['id']],
);
}
}
// Create sync metadata table
await db.execute('''
CREATE TABLE $syncMetadataTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL,
lastUpdated TEXT NOT NULL
)
''');
// Create device info table
await db.execute('''
CREATE TABLE $deviceInfoTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
deviceId TEXT NOT NULL UNIQUE,
deviceName TEXT NOT NULL,
lastSyncTime TEXT,
createdAt TEXT NOT NULL
)
''');
}
}
// Supplement CRUD operations
@@ -230,8 +334,8 @@ class DatabaseHelper {
Database db = await database;
List<Map<String, dynamic>> maps = await db.query(
supplementsTable,
where: 'isActive = ?',
whereArgs: [1],
where: 'isActive = ? AND isDeleted = ?',
whereArgs: [1, 0],
orderBy: 'name ASC',
);
return List.generate(maps.length, (i) => Supplement.fromMap(maps[i]));
@@ -241,8 +345,8 @@ class DatabaseHelper {
Database db = await database;
List<Map<String, dynamic>> maps = await db.query(
supplementsTable,
where: 'isActive = ?',
whereArgs: [0],
where: 'isActive = ? AND isDeleted = ?',
whereArgs: [0, 0],
orderBy: 'name ASC',
);
return List.generate(maps.length, (i) => Supplement.fromMap(maps[i]));
@@ -295,7 +399,43 @@ class DatabaseHelper {
Database db = await database;
return await db.update(
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 = ?',
whereArgs: [id],
);
@@ -307,15 +447,45 @@ class DatabaseHelper {
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 {
Database db = await database;
String startDate = DateTime(date.year, date.month, date.day).toIso8601String();
String endDate = DateTime(date.year, date.month, date.day, 23, 59, 59).toIso8601String();
List<Map<String, dynamic>> maps = await db.query(
intakesTable,
where: 'takenAt >= ? AND takenAt <= ?',
whereArgs: [startDate, endDate],
where: 'takenAt >= ? AND takenAt <= ? AND isDeleted = ?',
whereArgs: [startDate, endDate, 0],
orderBy: 'takenAt DESC',
);
return List.generate(maps.length, (i) => SupplementIntake.fromMap(maps[i]));
@@ -325,11 +495,11 @@ class DatabaseHelper {
Database db = await database;
String startDate = DateTime(year, month, 1).toIso8601String();
String endDate = DateTime(year, month + 1, 0, 23, 59, 59).toIso8601String();
List<Map<String, dynamic>> maps = await db.query(
intakesTable,
where: 'takenAt >= ? AND takenAt <= ?',
whereArgs: [startDate, endDate],
where: 'takenAt >= ? AND takenAt <= ? AND isDeleted = ?',
whereArgs: [startDate, endDate, 0],
orderBy: 'takenAt DESC',
);
return List.generate(maps.length, (i) => SupplementIntake.fromMap(maps[i]));
@@ -339,18 +509,18 @@ class DatabaseHelper {
Database db = await database;
String startDate = DateTime(date.year, date.month, date.day).toIso8601String();
String endDate = DateTime(date.year, date.month, date.day, 23, 59, 59).toIso8601String();
List<Map<String, dynamic>> result = await db.rawQuery('''
SELECT i.*,
SELECT i.*,
i.supplementId as supplement_id,
s.name as supplementName,
s.name as supplementName,
s.unitType as supplementUnitType
FROM $intakesTable i
JOIN $supplementsTable s ON i.supplementId = s.id
WHERE i.takenAt >= ? AND i.takenAt <= ?
WHERE i.takenAt >= ? AND i.takenAt <= ? AND i.isDeleted = ?
ORDER BY i.takenAt DESC
''', [startDate, endDate]);
''', [startDate, endDate, 0]);
return result;
}
@@ -358,28 +528,19 @@ class DatabaseHelper {
Database db = await database;
String startDate = DateTime(year, month, 1).toIso8601String();
String endDate = DateTime(year, month + 1, 0, 23, 59, 59).toIso8601String();
List<Map<String, dynamic>> result = await db.rawQuery('''
SELECT i.*,
SELECT i.*,
i.supplementId as supplement_id,
s.name as supplementName,
s.name as supplementName,
s.unitType as supplementUnitType
FROM $intakesTable i
JOIN $supplementsTable s ON i.supplementId = s.id
WHERE i.takenAt >= ? AND i.takenAt <= ?
WHERE i.takenAt >= ? AND i.takenAt <= ? AND i.isDeleted = ?
ORDER BY i.takenAt DESC
''', [startDate, endDate]);
return result;
}
''', [startDate, endDate, 0]);
Future<int> deleteIntake(int id) async {
Database db = await database;
return await db.delete(
intakesTable,
where: 'id = ?',
whereArgs: [id],
);
return result;
}
// Notification tracking methods
@@ -389,11 +550,11 @@ class DatabaseHelper {
required DateTime scheduledTime,
}) async {
Database db = await database;
// Use INSERT OR REPLACE to handle both new and existing notifications
await db.rawInsert('''
INSERT OR REPLACE INTO $notificationTrackingTable
(notificationId, supplementId, scheduledTime, status, retryCount, lastRetryTime, createdAt)
INSERT OR REPLACE INTO $notificationTrackingTable
(notificationId, supplementId, scheduledTime, status, retryCount, lastRetryTime, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?)
''', [
notificationId,
@@ -404,7 +565,7 @@ class DatabaseHelper {
null,
DateTime.now().toIso8601String(),
]);
return notificationId;
}
@@ -421,8 +582,8 @@ class DatabaseHelper {
Future<void> incrementRetryCount(int notificationId) async {
Database db = await database;
await db.rawUpdate('''
UPDATE $notificationTrackingTable
SET retryCount = retryCount + 1,
UPDATE $notificationTrackingTable
SET retryCount = retryCount + 1,
lastRetryTime = ?,
status = 'retrying'
WHERE notificationId = ?
@@ -469,4 +630,130 @@ class DatabaseHelper {
whereArgs: [supplementId],
);
}
// Sync metadata operations
Future<void> setSyncMetadata(String key, String value) async {
Database db = await database;
await db.rawInsert('''
INSERT OR REPLACE INTO $syncMetadataTable (key, value, lastUpdated)
VALUES (?, ?, ?)
''', [key, value, DateTime.now().toIso8601String()]);
}
Future<String?> getSyncMetadata(String key) async {
Database db = await database;
List<Map<String, dynamic>> result = await db.query(
syncMetadataTable,
where: 'key = ?',
whereArgs: [key],
);
return result.isNotEmpty ? result.first['value'] : null;
}
Future<void> deleteSyncMetadata(String key) async {
Database db = await database;
await db.delete(
syncMetadataTable,
where: 'key = ?',
whereArgs: [key],
);
}
// Device info operations
Future<void> setDeviceInfo(String deviceId, String deviceName) async {
Database db = await database;
await db.rawInsert('''
INSERT OR REPLACE INTO $deviceInfoTable (deviceId, deviceName, lastSyncTime, createdAt)
VALUES (?, ?, ?, ?)
''', [deviceId, deviceName, null, DateTime.now().toIso8601String()]);
}
Future<void> updateLastSyncTime(String deviceId) async {
Database db = await database;
await db.update(
deviceInfoTable,
{'lastSyncTime': DateTime.now().toIso8601String()},
where: 'deviceId = ?',
whereArgs: [deviceId],
);
}
Future<Map<String, dynamic>?> getDeviceInfo(String deviceId) async {
Database db = await database;
List<Map<String, dynamic>> result = await db.query(
deviceInfoTable,
where: 'deviceId = ?',
whereArgs: [deviceId],
);
return result.isNotEmpty ? result.first : null;
}
// Sync-specific queries
Future<List<Supplement>> getModifiedSupplements() async {
Database db = await database;
List<Map<String, dynamic>> maps = await db.query(
supplementsTable,
where: 'syncStatus IN (?, ?)',
whereArgs: [RecordSyncStatus.pending.name, RecordSyncStatus.modified.name],
orderBy: 'lastModified ASC',
);
return List.generate(maps.length, (i) => Supplement.fromMap(maps[i]));
}
Future<List<SupplementIntake>> getModifiedIntakes() async {
Database db = await database;
List<Map<String, dynamic>> maps = await db.query(
intakesTable,
where: 'syncStatus IN (?, ?)',
whereArgs: [RecordSyncStatus.pending.name, RecordSyncStatus.modified.name],
orderBy: 'lastModified ASC',
);
return List.generate(maps.length, (i) => SupplementIntake.fromMap(maps[i]));
}
Future<void> markSupplementAsSynced(String syncId) async {
Database db = await database;
await db.update(
supplementsTable,
{'syncStatus': RecordSyncStatus.synced.name},
where: 'syncId = ?',
whereArgs: [syncId],
);
}
Future<void> markIntakeAsSynced(String syncId) async {
Database db = await database;
await db.update(
intakesTable,
{'syncStatus': RecordSyncStatus.synced.name},
where: 'syncId = ?',
whereArgs: [syncId],
);
}
Future<Supplement?> getSupplementBySyncId(String syncId) async {
Database db = await database;
List<Map<String, dynamic>> maps = await db.query(
supplementsTable,
where: 'syncId = ?',
whereArgs: [syncId],
);
if (maps.isNotEmpty) {
return Supplement.fromMap(maps.first);
}
return null;
}
Future<SupplementIntake?> getIntakeBySyncId(String syncId) async {
Database db = await database;
List<Map<String, dynamic>> maps = await db.query(
intakesTable,
where: 'syncId = ?',
whereArgs: [syncId],
);
if (maps.isNotEmpty) {
return SupplementIntake.fromMap(maps.first);
}
return null;
}
}

View File

@@ -0,0 +1,520 @@
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) {
final supplementToInsert = remoteSupplement.copyWith(id: null);
await localDb.insert('supplements', supplementToInsert.toMap());
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
@pragma('vm:entry-point')
void notificationTapBackground(NotificationResponse notificationResponse) {
print('📱 === BACKGROUND NOTIFICATION RESPONSE ===');
print('📱 Action ID: ${notificationResponse.actionId}');
print('📱 Payload: ${notificationResponse.payload}');
print('📱 Notification ID: ${notificationResponse.id}');
print('📱 ==========================================');
print('SupplementsLog: 📱 === BACKGROUND NOTIFICATION RESPONSE ===');
print('SupplementsLog: 📱 Action ID: ${notificationResponse.actionId}');
print('SupplementsLog: 📱 Payload: ${notificationResponse.payload}');
print('SupplementsLog: 📱 Notification ID: ${notificationResponse.id}');
print('SupplementsLog: 📱 ==========================================');
// For now, just log the action. The main app handler will process it.
if (notificationResponse.actionId == 'take_supplement') {
print('📱 BACKGROUND: Take action detected');
print('SupplementsLog: 📱 BACKGROUND: Take action detected');
} 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 {
print('📱 Initializing NotificationService...');
print('SupplementsLog: 📱 Initializing NotificationService...');
if (_isInitialized) {
print('📱 Already initialized');
print('SupplementsLog: 📱 Already initialized');
return;
}
try {
print('📱 Initializing timezones...');
print('📱 Engine initialized flag: $_engineInitialized');
print('SupplementsLog: 📱 Initializing timezones...');
print('SupplementsLog: 📱 Engine initialized flag: $_engineInitialized');
if (!_engineInitialized) {
tz.initializeTimeZones();
_engineInitialized = true;
print('📱 Timezones initialized successfully');
print('SupplementsLog: 📱 Timezones initialized successfully');
} else {
print('📱 Timezones already initialized, skipping');
print('SupplementsLog: 📱 Timezones already initialized, skipping');
}
} 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
}
@@ -66,7 +66,7 @@ class NotificationService {
try {
// First try using the system timezone name
final String timeZoneName = DateTime.now().timeZoneName;
print('📱 System timezone name: $timeZoneName');
print('SupplementsLog: 📱 System timezone name: $timeZoneName');
tz.Location? location;
@@ -80,22 +80,22 @@ class NotificationService {
try {
location = tz.getLocation(timeZoneName);
} 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');
}
}
tz.setLocalLocation(location);
print('📱 Timezone set to: ${location.name}');
print('SupplementsLog: 📱 Timezone set to: ${location.name}');
} catch (e) {
print('📱 Error setting timezone: $e, using default');
print('SupplementsLog: 📱 Error setting timezone: $e, using default');
// Fallback to a reasonable default for Netherlands
tz.setLocalLocation(tz.getLocation('Europe/Amsterdam'));
}
print('📱 Current local time: ${tz.TZDateTime.now(tz.local)}');
print('📱 Current system time: ${DateTime.now()}');
print('SupplementsLog: 📱 Current local time: ${tz.TZDateTime.now(tz.local)}');
print('SupplementsLog: 📱 Current system time: ${DateTime.now()}');
const AndroidInitializationSettings androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const DarwinInitializationSettings iosSettings = DarwinInitializationSettings(
@@ -113,7 +113,7 @@ class NotificationService {
linux: linuxSettings,
);
print('📱 Initializing flutter_local_notifications...');
print('SupplementsLog: 📱 Initializing flutter_local_notifications...');
await _notifications.initialize(
initSettings,
onDidReceiveNotificationResponse: _onNotificationResponse,
@@ -121,42 +121,42 @@ class NotificationService {
);
// Test if notification response callback is working
print('📱 Callback function is set and ready');
print('SupplementsLog: 📱 Callback function is set and ready');
_isInitialized = true;
print('📱 NotificationService initialization complete');
print('SupplementsLog: 📱 NotificationService initialization complete');
}
// Handle notification responses (when user taps on notification or action)
void _onNotificationResponse(NotificationResponse response) {
print('📱 === NOTIFICATION RESPONSE ===');
print('📱 Action ID: ${response.actionId}');
print('📱 Payload: ${response.payload}');
print('📱 Notification ID: ${response.id}');
print('📱 Input: ${response.input}');
print('📱 ===============================');
print('SupplementsLog: 📱 === NOTIFICATION RESPONSE ===');
print('SupplementsLog: 📱 Action ID: ${response.actionId}');
print('SupplementsLog: 📱 Payload: ${response.payload}');
print('SupplementsLog: 📱 Notification ID: ${response.id}');
print('SupplementsLog: 📱 Input: ${response.input}');
print('SupplementsLog: 📱 ===============================');
if (response.actionId == 'take_supplement') {
print('📱 Processing TAKE action...');
print('SupplementsLog: 📱 Processing TAKE action...');
_handleTakeAction(response.payload, response.id);
} else if (response.actionId == 'snooze_10') {
print('📱 Processing SNOOZE action...');
print('SupplementsLog: 📱 Processing SNOOZE action...');
_handleSnoozeAction(response.payload, 10, response.id);
} else {
print('📱 Default notification tap (no specific action)');
print('SupplementsLog: 📱 Default notification tap (no specific action)');
// Default tap (no actionId) opens the app normally
}
}
Future<void> _handleTakeAction(String? payload, int? notificationId) async {
print('📱 === HANDLING TAKE ACTION ===');
print('📱 Payload received: $payload');
print('SupplementsLog: 📱 === HANDLING TAKE ACTION ===');
print('SupplementsLog: 📱 Payload received: $payload');
if (payload != null) {
try {
// Parse the payload to get supplement info
final parts = payload.split('|');
print('📱 Payload parts: $parts (length: ${parts.length})');
print('SupplementsLog: 📱 Payload parts: $parts (length: ${parts.length})');
if (parts.length >= 4) {
final supplementId = int.parse(parts[0]);
@@ -164,24 +164,24 @@ class NotificationService {
final units = double.parse(parts[2]);
final unitType = parts[3];
print('📱 Parsed data:');
print('📱 - ID: $supplementId');
print('📱 - Name: $supplementName');
print('📱 - Units: $units');
print('📱 - Type: $unitType');
print('SupplementsLog: 📱 Parsed data:');
print('SupplementsLog: 📱 - ID: $supplementId');
print('SupplementsLog: 📱 - Name: $supplementName');
print('SupplementsLog: 📱 - Units: $units');
print('SupplementsLog: 📱 - Type: $unitType');
// Call the callback to record the intake
if (_onTakeSupplementCallback != null) {
print('📱 Calling supplement callback...');
print('SupplementsLog: 📱 Calling supplement callback...');
_onTakeSupplementCallback!(supplementId, supplementName, units, unitType);
print('📱 Callback completed');
print('SupplementsLog: 📱 Callback completed');
} else {
print('📱 ERROR: No callback registered!');
print('SupplementsLog: 📱 ERROR: No callback registered!');
}
// Mark notification as taken in database (this will cancel any pending retries)
if (notificationId != null) {
print('📱 Marking notification $notificationId as taken');
print('SupplementsLog: 📱 Marking notification $notificationId as taken');
await DatabaseHelper.instance.markNotificationTaken(notificationId);
// Cancel any pending retry notifications for this notification
@@ -189,21 +189,21 @@ class NotificationService {
}
// Show a confirmation notification
print('📱 Showing confirmation notification...');
print('SupplementsLog: 📱 Showing confirmation notification...');
showInstantNotification(
'Supplement Taken!',
'$supplementName has been recorded at ${DateTime.now().hour.toString().padLeft(2, '0')}:${DateTime.now().minute.toString().padLeft(2, '0')}',
);
} else {
print('📱 ERROR: Invalid payload format - not enough parts');
print('SupplementsLog: 📱 ERROR: Invalid payload format - not enough parts');
}
} catch (e) {
print('📱 ERROR in _handleTakeAction: $e');
print('SupplementsLog: 📱 ERROR in _handleTakeAction: $e');
}
} 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) {
@@ -211,13 +211,13 @@ class NotificationService {
for (int i = 0; i < 10; i++) { // Cancel up to 10 potential retries
int retryId = 200000 + (notificationId * 10) + i;
_notifications.cancel(retryId);
print('📱 Cancelled retry notification ID: $retryId');
print('SupplementsLog: 📱 Cancelled retry notification ID: $retryId');
}
}
void _handleSnoozeAction(String? payload, int minutes, int? notificationId) {
print('📱 === HANDLING SNOOZE ACTION ===');
print('📱 Payload: $payload, Minutes: $minutes');
print('SupplementsLog: 📱 === HANDLING SNOOZE ACTION ===');
print('SupplementsLog: 📱 Payload: $payload, Minutes: $minutes');
if (payload != null) {
try {
@@ -226,17 +226,17 @@ class NotificationService {
final supplementId = int.parse(parts[0]);
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)
if (notificationId != null) {
print('📱 Incrementing retry count for notification $notificationId');
print('SupplementsLog: 📱 Incrementing retry count for notification $notificationId');
DatabaseHelper.instance.incrementRetryCount(notificationId);
}
// Schedule a new notification for the snooze time
final snoozeTime = tz.TZDateTime.now(tz.local).add(Duration(minutes: minutes));
print('📱 Snooze time: $snoozeTime');
print('SupplementsLog: 📱 Snooze time: $snoozeTime');
_notifications.zonedSchedule(
supplementId * 1000 + minutes, // Unique ID for snooze notifications
@@ -271,13 +271,13 @@ class NotificationService {
'Reminder Snoozed',
'$supplementName reminder snoozed for $minutes minutes',
);
print('📱 Snooze scheduled successfully');
print('SupplementsLog: 📱 Snooze scheduled successfully');
}
} 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
@@ -299,19 +299,19 @@ class NotificationService {
required int reminderRetryInterval,
required int maxRetryAttempts,
}) async {
print('📱 Checking for pending notifications to retry...');
print('SupplementsLog: 📱 Checking for pending notifications to retry...');
try {
if (!persistentReminders) {
print('📱 Persistent reminders disabled');
print('SupplementsLog: 📱 Persistent reminders disabled');
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
final pendingNotifications = await DatabaseHelper.instance.getPendingNotifications();
print('📱 Found ${pendingNotifications.length} pending notifications');
print('SupplementsLog: 📱 Found ${pendingNotifications.length} pending notifications');
final now = DateTime.now();
@@ -326,17 +326,17 @@ class NotificationService {
final timeSinceScheduled = now.difference(scheduledTime).inMinutes;
final shouldRetry = timeSinceScheduled >= reminderRetryInterval;
print('📱 Checking notification ${notification['notificationId']}:');
print('📱 Scheduled: $scheduledTime (local)');
print('📱 Now: $now');
print('📱 Time since scheduled: $timeSinceScheduled minutes');
print('📱 Retry interval: $reminderRetryInterval minutes');
print('📱 Should retry: $shouldRetry');
print('📱 Retry count: $retryCount / $maxRetryAttempts');
print('SupplementsLog: 📱 Checking notification ${notification['notificationId']}:');
print('SupplementsLog: 📱 Scheduled: $scheduledTime (local)');
print('SupplementsLog: 📱 Now: $now');
print('SupplementsLog: 📱 Time since scheduled: $timeSinceScheduled minutes');
print('SupplementsLog: 📱 Retry interval: $reminderRetryInterval minutes');
print('SupplementsLog: 📱 Should retry: $shouldRetry');
print('SupplementsLog: 📱 Retry count: $retryCount / $maxRetryAttempts');
// Check if we haven't exceeded max retry attempts
if (retryCount >= maxRetryAttempts) {
print('📱 Notification ${notification['notificationId']} exceeded max attempts ($maxRetryAttempts)');
print('SupplementsLog: 📱 Notification ${notification['notificationId']} exceeded max attempts ($maxRetryAttempts)');
continue;
}
@@ -344,20 +344,20 @@ class NotificationService {
if (lastRetryTime != null) {
final timeSinceLastRetry = now.difference(lastRetryTime).inMinutes;
if (timeSinceLastRetry < reminderRetryInterval) {
print('📱 Notification ${notification['notificationId']} not ready for retry yet');
print('SupplementsLog: 📱 Notification ${notification['notificationId']} not ready for retry yet');
continue;
}
}
if (shouldRetry) {
print('📱 ⚡ SCHEDULING RETRY for notification ${notification['notificationId']}');
print('SupplementsLog: 📱 ⚡ SCHEDULING RETRY for notification ${notification['notificationId']}');
await _scheduleRetryNotification(notification, retryCount + 1);
} else {
print('📱 ⏸️ NOT READY FOR RETRY: ${notification['notificationId']}');
print('SupplementsLog: 📱 ⏸️ NOT READY FOR RETRY: ${notification['notificationId']}');
}
}
} catch (e) {
print('📱 Error scheduling persistent reminders: $e');
print('SupplementsLog: 📱 Error scheduling persistent reminders: $e');
}
}
@@ -369,7 +369,7 @@ class NotificationService {
// Generate a unique ID for this retry (200000 + original_id * 10 + retry_attempt)
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
final supplements = await DatabaseHelper.instance.getAllSupplements();
@@ -408,16 +408,16 @@ class NotificationService {
// Update the retry count in database
await DatabaseHelper.instance.incrementRetryCount(notificationId);
print('📱 Retry notification scheduled successfully');
print('SupplementsLog: 📱 Retry notification scheduled successfully');
} catch (e) {
print('📱 Error scheduling retry notification: $e');
print('SupplementsLog: 📱 Error scheduling retry notification: $e');
}
}
Future<bool> requestPermissions() async {
print('📱 Requesting notification permissions...');
print('SupplementsLog: 📱 Requesting notification permissions...');
if (_permissionsRequested) {
print('📱 Permissions already requested');
print('SupplementsLog: 📱 Permissions already requested');
return true;
}
@@ -426,9 +426,9 @@ class NotificationService {
final androidPlugin = _notifications.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
if (androidPlugin != null) {
print('📱 Requesting Android permissions...');
print('SupplementsLog: 📱 Requesting Android permissions...');
final granted = await androidPlugin.requestNotificationsPermission();
print('📱 Android permissions granted: $granted');
print('SupplementsLog: 📱 Android permissions granted: $granted');
if (granted != true) {
_permissionsRequested = false;
return false;
@@ -437,31 +437,31 @@ class NotificationService {
final iosPlugin = _notifications.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
if (iosPlugin != null) {
print('📱 Requesting iOS permissions...');
print('SupplementsLog: 📱 Requesting iOS permissions...');
final granted = await iosPlugin.requestPermissions(
alert: true,
badge: true,
sound: true,
);
print('📱 iOS permissions granted: $granted');
print('SupplementsLog: 📱 iOS permissions granted: $granted');
if (granted != true) {
_permissionsRequested = false;
return false;
}
}
print('📱 All permissions granted successfully');
print('SupplementsLog: 📱 All permissions granted successfully');
return true;
} catch (e) {
_permissionsRequested = false;
print('📱 Error requesting permissions: $e');
print('SupplementsLog: 📱 Error requesting permissions: $e');
return false;
}
}
Future<void> scheduleSupplementReminders(Supplement supplement) async {
print('📱 Scheduling reminders for ${supplement.name}');
print('📱 Reminder times: ${supplement.reminderTimes}');
print('SupplementsLog: 📱 Scheduling reminders for ${supplement.name}');
print('SupplementsLog: 📱 Reminder times: ${supplement.reminderTimes}');
// Cancel existing notifications for this supplement
await cancelSupplementReminders(supplement.id!);
@@ -475,7 +475,7 @@ class NotificationService {
final notificationId = supplement.id! * 100 + i; // Unique ID for each reminder
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
await DatabaseHelper.instance.trackNotification(
@@ -518,14 +518,14 @@ class NotificationService {
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
final pendingNotifications = await _notifications.pendingNotificationRequests();
print('📱 Total pending notifications: ${pendingNotifications.length}');
print('SupplementsLog: 📱 Total pending notifications: ${pendingNotifications.length}');
for (final notification in pendingNotifications) {
print('📱 Pending: ID=${notification.id}, Title=${notification.title}');
print('SupplementsLog: 📱 Pending: ID=${notification.id}, Title=${notification.title}');
}
}
@@ -548,22 +548,22 @@ class NotificationService {
final tz.TZDateTime now = tz.TZDateTime.now(tz.local);
tz.TZDateTime scheduledDate = tz.TZDateTime(tz.local, now.year, now.month, now.day, hour, minute);
print('📱 Current time: $now (${now.timeZoneName})');
print('📱 Target time: ${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}');
print('📱 Initial scheduled date: $scheduledDate (${scheduledDate.timeZoneName})');
print('SupplementsLog: 📱 Current time: $now (${now.timeZoneName})');
print('SupplementsLog: 📱 Target time: ${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}');
print('SupplementsLog: 📱 Initial scheduled date: $scheduledDate (${scheduledDate.timeZoneName})');
if (scheduledDate.isBefore(now)) {
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 {
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;
}
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(
android: AndroidNotificationDetails(
'instant_notifications',
@@ -581,22 +581,22 @@ class NotificationService {
body,
notificationDetails,
);
print('📱 Instant notification sent');
print('SupplementsLog: 📱 Instant notification sent');
}
// Debug function to test notifications
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.');
}
// Debug function to schedule a test notification 1 minute from now
Future<void> testScheduledNotification() async {
print('📱 Testing scheduled notification...');
print('SupplementsLog: 📱 Testing scheduled notification...');
final now = tz.TZDateTime.now(tz.local);
final testTime = now.add(const Duration(minutes: 1));
print('📱 Scheduling test notification for: $testTime');
print('SupplementsLog: 📱 Scheduling test notification for: $testTime');
await _notifications.zonedSchedule(
99999, // Special ID for test notifications
@@ -616,7 +616,7 @@ class NotificationService {
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
);
print('📱 Test notification scheduled successfully');
print('SupplementsLog: 📱 Test notification scheduled successfully');
}
// Debug function to get all pending notifications
@@ -626,7 +626,7 @@ class NotificationService {
// Debug function to test notification actions
Future<void> testNotificationWithActions() async {
print('📱 Creating test notification with actions...');
print('SupplementsLog: 📱 Creating test notification with actions...');
await _notifications.show(
88888, // Special test ID
@@ -659,12 +659,12 @@ class NotificationService {
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
Future<void> testBasicNotification() async {
print('📱 Creating basic test notification...');
print('SupplementsLog: 📱 Creating basic test notification...');
await _notifications.show(
77777, // Special test ID for basic notification
@@ -683,6 +683,6 @@ class NotificationService {
payload: 'basic_test',
);
print('📱 Basic test notification created');
print('SupplementsLog: 📱 Basic test notification created');
}
}

View File

View File

@@ -42,7 +42,7 @@ class _SupplementCardState extends State<SupplementCard> {
final unitsTaken = intake['unitsTaken'] ?? 1.0;
return {
'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();

View File

@@ -6,6 +6,10 @@
#include "generated_plugin_registrant.h"
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
}

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@@ -5,12 +5,18 @@
import FlutterMacOS
import Foundation
import connectivity_plus
import flutter_local_notifications
import flutter_secure_storage_darwin
import path_provider_foundation
import shared_preferences_foundation
import sqflite_darwin
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
}

View File

@@ -49,6 +49,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
connectivity_plus:
dependency: "direct main"
description:
name: connectivity_plus
sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec
url: "https://pub.dev"
source: hosted
version: "6.1.5"
connectivity_plus_platform_interface:
dependency: transitive
description:
name: connectivity_plus_platform_interface
sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
crypto:
dependency: "direct main"
description:
name: crypto
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
url: "https://pub.dev"
source: hosted
version: "3.0.6"
cupertino_icons:
dependency: "direct main"
description:
@@ -65,6 +97,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.11"
dio:
dependency: transitive
description:
name: dio
sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9
url: "https://pub.dev"
source: hosted
version: "5.9.0"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
fake_async:
dependency: transitive
description:
@@ -89,6 +137,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
@@ -134,6 +190,55 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.2"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: f7eceb0bc6f4fd0441e29d43cab9ac2a1c5ffd7ea7b64075136b718c46954874
url: "https://pub.dev"
source: hosted
version: "10.0.0-beta.4"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: f226f2a572bed96bc6542198ebaec227150786e34311d455a7e2d3d06d951845
url: "https://pub.dev"
source: hosted
version: "0.1.0"
flutter_secure_storage_linux:
dependency: "direct overridden"
description:
path: flutter_secure_storage_linux
ref: patch-2
resolved-ref: f076cbb65b075afd6e3b648122987a67306dc298
url: "https://github.com/m-berto/flutter_secure_storage.git"
source: git
version: "2.0.1"
flutter_secure_storage_platform_interface:
dependency: "direct overridden"
description:
name: flutter_secure_storage_platform_interface
sha256: b8337d3d52e429e6c0a7710e38cf9742a3bb05844bd927450eb94f80c11ef85d
url: "https://pub.dev"
source: hosted
version: "2.0.0"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: "4c3f233e739545c6cb09286eeec1cc4744138372b985113acc904f7263bef517"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: ff32af20f70a8d0e59b2938fc92de35b54a74671041c814275afd80e27df9f21
url: "https://pub.dev"
source: hosted
version: "4.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -224,6 +329,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.16.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
nested:
dependency: transitive
description:
@@ -232,6 +345,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
nm:
dependency: transitive
description:
name: nm
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
path:
dependency: "direct main"
description:
@@ -240,6 +361,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db"
url: "https://pub.dev"
source: hosted
version: "2.2.18"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
path_provider_linux:
dependency: transitive
description:
@@ -308,10 +453,10 @@ packages:
dependency: transitive
description:
name: shared_preferences_android
sha256: "5bcf0772a761b04f8c6bf814721713de6f3e5d9d89caf8d3fe031b02a342379e"
sha256: a2608114b1ffdcbc9c120eb71a0e207c71da56202852d4aab8a5e30a82269e74
url: "https://pub.dev"
source: hosted
version: "2.4.11"
version: "2.4.12"
shared_preferences_foundation:
dependency: transitive
description:
@@ -365,6 +510,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.10.1"
sprintf:
dependency: transitive
description:
name: sprintf
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
sqflite:
dependency: "direct main"
description:
@@ -485,6 +638,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
uuid:
dependency: "direct main"
description:
name: uuid
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff
url: "https://pub.dev"
source: hosted
version: "4.5.1"
vector_math:
dependency: transitive
description:
@@ -509,6 +670,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
webdav_client:
dependency: "direct main"
description:
name: webdav_client
sha256: "682fffc50b61dc0e8f46717171db03bf9caaa17347be41c0c91e297553bf86b2"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
win32:
dependency: transitive
description:
name: win32
sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03"
url: "https://pub.dev"
source: hosted
version: "5.14.0"
xdg_directories:
dependency: transitive
description:
@@ -527,4 +704,4 @@ packages:
version: "6.6.1"
sdks:
dart: ">=3.9.0 <4.0.0"
flutter: ">=3.27.0"
flutter: ">=3.29.0"

View File

@@ -1,107 +1,54 @@
name: supplements
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
description: "A supplement tracking app for managing your daily supplements"
publish_to: "none"
version: 1.0.5+27082025
environment:
sdk: ^3.9.0
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
# Local database for storing supplements data
sqflite: ^2.3.0
sqflite_common_ffi: ^2.3.0
path: ^1.8.3
# State management
provider: ^6.1.1
# Settings persistence
shared_preferences: ^2.2.2
# Local notifications
flutter_local_notifications: ^19.4.1
timezone: ^0.10.1
# Date time handling
intl: ^0.20.2
# WebDAV sync functionality
webdav_client: ^1.2.2
connectivity_plus: ^6.1.5
flutter_secure_storage: ^10.0.0-beta.4
uuid: ^4.5.1
crypto: ^3.0.6
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
dependency_overrides:
flutter_secure_storage_linux:
git:
url: https://github.com/m-berto/flutter_secure_storage.git
ref: patch-2
path: flutter_secure_storage_linux
flutter_secure_storage_platform_interface: 2.0.0
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package

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

@@ -6,6 +6,12 @@
#include "generated_plugin_registrant.h"
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
}

View File

@@ -3,6 +3,8 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus
flutter_secure_storage_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST