feat adds proper syncing feature

Signed-off-by: Menno van Leeuwen <menno@vleeuwen.me>
This commit is contained in:
2025-08-27 20:51:29 +02:00
parent b0d5130cbf
commit 2017fd097d
22 changed files with 1518 additions and 3258 deletions

View File

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

View File

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

View File

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

View File

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

View File

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