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