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