adds syncing

This commit is contained in:
2025-08-27 16:17:21 +02:00
parent 1191d06e53
commit 709cf2cbd9
24 changed files with 3809 additions and 226 deletions

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,17 +21,30 @@ 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...');
_notificationService.setTakeSupplementCallback((supplementId, supplementName, units, unitType) {
@@ -38,18 +53,18 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
print('📱 Supplement Name: $supplementName');
print('📱 Units: $units');
print('📱 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 ===');
if (kDebugMode) {
print('📱 Recorded intake from notification: $supplementName ($units $unitType)');
}
});
print('📱 Notification callback setup complete');
// Request permissions with error handling
try {
await _notificationService.requestPermissions();
@@ -59,16 +74,16 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
}
// 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 {
@@ -88,7 +103,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
}
}
});
// Also check immediately
_checkPersistentReminders();
}
@@ -96,23 +111,23 @@ 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...');
}
// Date has changed, refresh today's intakes
_lastDateCheck = now;
await loadTodayIntakes();
if (kDebugMode) {
print('Today\'s intakes refreshed for new day');
}
@@ -164,7 +179,7 @@ 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) {
@@ -178,7 +193,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
if (kDebugMode) {
print('📱 Rescheduling notifications for all active supplements...');
}
for (final supplement in _supplements) {
if (supplement.reminderTimes.isNotEmpty) {
try {
@@ -190,7 +205,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
}
}
}
if (kDebugMode) {
print('📱 Finished rescheduling notifications');
}
@@ -224,7 +239,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
final id = await _databaseHelper.insertSupplement(supplement);
print('Supplement inserted with ID: $id');
final newSupplement = supplement.copyWith(id: id);
// Schedule notifications (skip if there's an error)
try {
await _notificationService.scheduleSupplementReminders(newSupplement);
@@ -232,9 +247,12 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
} catch (notificationError) {
print('Warning: Could not schedule notifications: $notificationError');
}
await loadSupplements();
print('Supplements reloaded, count: ${_supplements.length}');
// Trigger sync after adding supplement
_triggerSyncIfEnabled();
} catch (e) {
print('Error adding supplement: $e');
if (kDebugMode) {
@@ -247,11 +265,14 @@ 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');
@@ -262,11 +283,14 @@ 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');
@@ -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}' : '';
@@ -307,16 +334,16 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
if (kDebugMode) {
print('Loading intakes for date: ${today.year}-${today.month}-${today.day}');
}
_todayIntakes = await _databaseHelper.getIntakesWithSupplementsForDate(today);
if (kDebugMode) {
print('Loaded ${_todayIntakes.length} intakes for today');
for (var intake in _todayIntakes) {
print(' - Supplement ID: ${intake['supplement_id']}, taken at: ${intake['takenAt']}');
}
}
notifyListeners();
} catch (e) {
if (kDebugMode) {
@@ -356,6 +383,9 @@ 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');
@@ -385,13 +415,13 @@ 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');
}
if (currentDate != lastCheckDate) {
if (kDebugMode) {
print('Date change detected, refreshing intakes...');
@@ -425,6 +455,9 @@ 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');
@@ -437,6 +470,9 @@ 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');
@@ -448,6 +484,9 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
try {
await _databaseHelper.deleteSupplement(supplementId);
await loadArchivedSupplements(); // Refresh archived supplements
// Trigger sync after deleting archived supplement
_triggerSyncIfEnabled();
} catch (e) {
if (kDebugMode) {
print('Error deleting archived supplement: $e');

View File

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