From 2017fd097daa288952019f7c2b41a11650fa99d1 Mon Sep 17 00:00:00 2001 From: Menno van Leeuwen Date: Wed, 27 Aug 2025 20:51:29 +0200 Subject: [PATCH] feat adds proper syncing feature Signed-off-by: Menno van Leeuwen --- lib/main.dart | 28 +- lib/models/ingredient.dart | 14 +- lib/models/supplement.dart | 20 +- lib/models/supplement_intake.dart | 18 +- lib/models/sync_data.dart | 314 ------ lib/models/sync_enums.dart | 178 ---- lib/providers/simple_sync_provider.dart | 106 ++ lib/providers/supplement_provider.dart | 131 +-- lib/providers/sync_provider.dart | 417 -------- lib/screens/archived_supplements_screen.dart | 31 +- lib/screens/history_screen.dart | 27 - lib/screens/home_screen.dart | 8 +- lib/screens/pending_notifications_screen.dart | 2 +- lib/screens/settings_screen.dart | 468 ++------- lib/screens/simple_sync_settings_screen.dart | 467 +++++++++ lib/screens/supplements_list_screen.dart | 16 +- lib/screens/sync_settings_screen.dart | 782 --------------- lib/services/database_helper.dart | 87 +- lib/services/database_sync_service.dart | 520 ++++++++++ lib/services/notification_service.dart | 212 ++-- lib/services/webdav_sync_service.dart | 928 ------------------ lib/widgets/supplement_card.dart | 2 +- 22 files changed, 1518 insertions(+), 3258 deletions(-) delete mode 100644 lib/models/sync_data.dart delete mode 100644 lib/models/sync_enums.dart create mode 100644 lib/providers/simple_sync_provider.dart create mode 100644 lib/screens/simple_sync_settings_screen.dart create mode 100644 lib/services/database_sync_service.dart diff --git a/lib/main.dart b/lib/main.dart index c91cf71..13b0fc0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,10 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'providers/settings_provider.dart'; import 'providers/supplement_provider.dart'; -import 'providers/sync_provider.dart'; +import 'providers/simple_sync_provider.dart'; import 'screens/home_screen.dart'; void main() { @@ -23,14 +24,27 @@ class MyApp extends StatelessWidget { ChangeNotifierProvider( create: (context) => SettingsProvider()..initialize(), ), - ChangeNotifierProxyProvider( - create: (context) => SyncProvider(context.read())..initialize(), - update: (context, supplementProvider, syncProvider) => - syncProvider ?? SyncProvider(supplementProvider)..initialize(), + ChangeNotifierProvider( + create: (context) => SimpleSyncProvider(), ), ], - child: Consumer( - builder: (context, settingsProvider, child) { + child: Consumer2( + builder: (context, settingsProvider, syncProvider, child) { + // Set up the sync completion callback to refresh supplement data + WidgetsBinding.instance.addPostFrameCallback((_) { + final supplementProvider = context.read(); + syncProvider.setOnSyncCompleteCallback(() async { + if (kDebugMode) { + print('SupplementsLog: Sync completed, refreshing UI data...'); + } + await supplementProvider.loadSupplements(); + await supplementProvider.loadTodayIntakes(); + if (kDebugMode) { + print('SupplementsLog: UI data refreshed after sync'); + } + }); + }); + return MaterialApp( title: 'Supplements Tracker', theme: ThemeData( diff --git a/lib/models/ingredient.dart b/lib/models/ingredient.dart index cefa2c3..bbce41b 100644 --- a/lib/models/ingredient.dart +++ b/lib/models/ingredient.dart @@ -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( diff --git a/lib/models/supplement.dart b/lib/models/supplement.dart index 44545c1..8514318 100644 --- a/lib/models/supplement.dart +++ b/lib/models/supplement.dart @@ -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, ); } } diff --git a/lib/models/supplement_intake.dart b/lib/models/supplement_intake.dart index 00ed664..0cb057d 100644 --- a/lib/models/supplement_intake.dart +++ b/lib/models/supplement_intake.dart @@ -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, ); } } diff --git a/lib/models/sync_data.dart b/lib/models/sync_data.dart deleted file mode 100644 index c42f5e4..0000000 --- a/lib/models/sync_data.dart +++ /dev/null @@ -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 supplements; - final List intakes; - final Map 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 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 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? ?? []) - .map((s) => Supplement.fromMap(s as Map)) - .toList(), - intakes: (json['intakes'] as List? ?? []) - .map((i) => SupplementIntake.fromMap(i as Map)) - .toList(), - metadata: json['metadata'] as Map? ?? {}, - ); - } - - /// 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; - return SyncData.fromJson(json); - } - - /// Create a copy with updated values - SyncData copyWith({ - int? version, - String? deviceId, - String? deviceName, - DateTime? syncTimestamp, - List? supplements, - List? intakes, - Map? 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 localData; - final Map 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 toJson() { - return { - 'syncId': syncId, - 'type': type.name, - 'localTimestamp': localTimestamp.toIso8601String(), - 'remoteTimestamp': remoteTimestamp.toIso8601String(), - 'localData': localData, - 'remoteData': remoteData, - 'suggestedResolution': suggestedResolution?.name, - }; - } - - factory SyncConflict.fromJson(Map 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, - remoteData: json['remoteData'] as Map, - 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 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 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 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? ?? []) - .map((c) => SyncConflict.fromJson(c as Map)) - .toList(), - statistics: json['statistics'] != null - ? SyncStatistics.fromJson(json['statistics'] as Map) - : const SyncStatistics(), - ); - } - - /// Create a failed sync result - factory SyncResult.failure({ - required String error, - required SyncOperationStatus status, - List conflicts = const [], - }) { - return SyncResult( - success: false, - timestamp: DateTime.now(), - status: status, - error: error, - conflicts: conflicts, - ); - } - - /// Create a successful sync result - factory SyncResult.success({ - List 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 toJson() { - return { - 'supplementsUploaded': supplementsUploaded, - 'supplementsDownloaded': supplementsDownloaded, - 'intakesUploaded': intakesUploaded, - 'intakesDownloaded': intakesDownloaded, - 'conflictsResolved': conflictsResolved, - 'syncDurationMs': syncDuration.inMilliseconds, - }; - } - - factory SyncStatistics.fromJson(Map 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)'; - } -} diff --git a/lib/models/sync_enums.dart b/lib/models/sync_enums.dart deleted file mode 100644 index cb81c37..0000000 --- a/lib/models/sync_enums.dart +++ /dev/null @@ -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 supportedProviders = [ - 'Nextcloud', - 'ownCloud', - 'Generic WebDAV', - ]; -} diff --git a/lib/providers/simple_sync_provider.dart b/lib/providers/simple_sync_provider.dart new file mode 100644 index 0000000..029f4d8 --- /dev/null +++ b/lib/providers/simple_sync_provider.dart @@ -0,0 +1,106 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../services/database_sync_service.dart'; + +class SimpleSyncProvider with ChangeNotifier { + final DatabaseSyncService _syncService = DatabaseSyncService(); + + // Callback for UI refresh after sync + VoidCallback? _onSyncCompleteCallback; + + // Getters + SyncStatus get status => _syncService.status; + String? get lastError => _syncService.lastError; + DateTime? get lastSyncTime => _syncService.lastSyncTime; + bool get isConfigured => _syncService.isConfigured; + bool get isSyncing => status == SyncStatus.downloading || + status == SyncStatus.merging || + status == SyncStatus.uploading; + + // Configuration getters + String? get serverUrl => _syncService.serverUrl; + String? get username => _syncService.username; + String? get password => _syncService.password; + String? get remotePath => _syncService.remotePath; + + SimpleSyncProvider() { + // Set up callbacks to notify listeners + _syncService.onStatusChanged = (_) => notifyListeners(); + _syncService.onError = (_) => notifyListeners(); + _syncService.onSyncCompleted = () { + notifyListeners(); + // Trigger UI refresh callback if set + _onSyncCompleteCallback?.call(); + }; + + // Load saved configuration and notify listeners when done + _loadConfiguration(); + } + + /// Set callback to refresh UI data after sync completes + void setOnSyncCompleteCallback(VoidCallback? callback) { + _onSyncCompleteCallback = callback; + } + + Future _loadConfiguration() async { + await _syncService.loadSavedConfiguration(); + notifyListeners(); // Notify UI that configuration might be available + } + + Future configure({ + required String serverUrl, + required String username, + required String password, + required String remotePath, + }) async { + _syncService.configure( + serverUrl: serverUrl, + username: username, + password: password, + remotePath: remotePath, + ); + notifyListeners(); + } + + Future testConnection() async { + return await _syncService.testConnection(); + } + + Future syncDatabase() async { + if (!isConfigured) { + throw Exception('Sync not configured'); + } + + try { + await _syncService.syncDatabase(); + } catch (e) { + if (kDebugMode) { + print('SupplementsLog: Sync failed in provider: $e'); + } + rethrow; + } + } + + void clearError() { + _syncService.clearError(); + notifyListeners(); + } + + String getStatusText() { + switch (status) { + case SyncStatus.idle: + return 'Ready to sync'; + case SyncStatus.downloading: + return 'Downloading remote database...'; + case SyncStatus.merging: + return 'Merging databases...'; + case SyncStatus.uploading: + return 'Uploading database...'; + case SyncStatus.completed: + return 'Sync completed successfully'; + case SyncStatus.error: + return 'Sync failed: ${lastError ?? 'Unknown error'}'; + } + } +} diff --git a/lib/providers/supplement_provider.dart b/lib/providers/supplement_provider.dart index 6b27f33..60410b8 100644 --- a/lib/providers/supplement_provider.dart +++ b/lib/providers/supplement_provider.dart @@ -46,31 +46,31 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { await _notificationService.initialize(); // Set up the callback for handling supplement intake from notifications - print('πŸ“± Setting up notification callback...'); + print('SupplementsLog: πŸ“± Setting up notification callback...'); _notificationService.setTakeSupplementCallback((supplementId, supplementName, units, unitType) { - print('πŸ“± === NOTIFICATION CALLBACK TRIGGERED ==='); - print('πŸ“± Supplement ID: $supplementId'); - print('πŸ“± Supplement Name: $supplementName'); - print('πŸ“± Units: $units'); - print('πŸ“± Unit Type: $unitType'); + print('SupplementsLog: πŸ“± === NOTIFICATION CALLBACK TRIGGERED ==='); + print('SupplementsLog: πŸ“± Supplement ID: $supplementId'); + print('SupplementsLog: πŸ“± Supplement Name: $supplementName'); + print('SupplementsLog: πŸ“± Units: $units'); + print('SupplementsLog: πŸ“± Unit Type: $unitType'); // Record the intake when user taps "Take" on notification recordIntake(supplementId, 0.0, unitsTaken: units); - print('πŸ“± Intake recorded successfully'); - print('πŸ“± === CALLBACK COMPLETE ==='); + print('SupplementsLog: πŸ“± Intake recorded successfully'); + print('SupplementsLog: πŸ“± === CALLBACK COMPLETE ==='); if (kDebugMode) { - print('πŸ“± Recorded intake from notification: $supplementName ($units $unitType)'); + print('SupplementsLog: πŸ“± Recorded intake from notification: $supplementName ($units $unitType)'); } }); - print('πŸ“± Notification callback setup complete'); + print('SupplementsLog: πŸ“± Notification callback setup complete'); // Request permissions with error handling try { await _notificationService.requestPermissions(); } catch (e) { if (kDebugMode) { - print('Error requesting notification permissions: $e'); + print('SupplementsLog: Error requesting notification permissions: $e'); } // Continue without notifications rather than crashing } @@ -99,7 +99,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { await _checkPersistentReminders(); } catch (e) { if (kDebugMode) { - print('Error checking persistent reminders: $e'); + print('SupplementsLog: Error checking persistent reminders: $e'); } } }); @@ -120,8 +120,8 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { if (currentDate != lastCheckDate) { if (kDebugMode) { - print('Date changed detected: ${lastCheckDate} -> ${currentDate}'); - print('Refreshing today\'s intakes for new day...'); + print('SupplementsLog: Date changed detected: ${lastCheckDate} -> ${currentDate}'); + print('SupplementsLog: Refreshing today\'s intakes for new day...'); } // Date has changed, refresh today's intakes @@ -129,7 +129,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { await loadTodayIntakes(); if (kDebugMode) { - print('Today\'s intakes refreshed for new day'); + print('SupplementsLog: Today\'s intakes refreshed for new day'); } } }); @@ -140,7 +140,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { // For now, we'll check with default settings // In practice, the UI should call checkPersistentRemindersWithSettings if (kDebugMode) { - print('πŸ“± Checking persistent reminders with default settings'); + print('SupplementsLog: πŸ“± Checking persistent reminders with default settings'); } } @@ -150,7 +150,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { required int reminderRetryInterval, required int maxRetryAttempts, }) async { - print('πŸ“± πŸ”„ MANUAL CHECK: Persistent reminders called from UI'); + print('SupplementsLog: πŸ“± πŸ”„ MANUAL CHECK: Persistent reminders called from UI'); await _notificationService.checkPersistentReminders( persistentReminders, reminderRetryInterval, @@ -160,7 +160,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { // Add a manual trigger method for testing Future triggerRetryCheck() async { - print('πŸ“± 🚨 MANUAL TRIGGER: Forcing retry check...'); + print('SupplementsLog: πŸ“± 🚨 MANUAL TRIGGER: Forcing retry check...'); await checkPersistentRemindersWithSettings( persistentReminders: true, reminderRetryInterval: 5, // Force 5 minute interval for testing @@ -183,7 +183,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { if (state == AppLifecycleState.resumed) { // App came back to foreground, check if date changed if (kDebugMode) { - print('App resumed, checking for date change...'); + print('SupplementsLog: App resumed, checking for date change...'); } forceCheckDateChange(); } @@ -191,7 +191,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { Future _rescheduleAllNotifications() async { if (kDebugMode) { - print('πŸ“± Rescheduling notifications for all active supplements...'); + print('SupplementsLog: πŸ“± Rescheduling notifications for all active supplements...'); } for (final supplement in _supplements) { @@ -200,14 +200,14 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { await _notificationService.scheduleSupplementReminders(supplement); } catch (e) { if (kDebugMode) { - print('πŸ“± Error rescheduling notifications for ${supplement.name}: $e'); + print('SupplementsLog: πŸ“± Error rescheduling notifications for ${supplement.name}: $e'); } } } } if (kDebugMode) { - print('πŸ“± Finished rescheduling notifications'); + print('SupplementsLog: πŸ“± Finished rescheduling notifications'); } } @@ -216,16 +216,16 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { notifyListeners(); try { - print('Loading supplements from database...'); + print('SupplementsLog: Loading supplements from database...'); _supplements = await _databaseHelper.getAllSupplements(); - print('Loaded ${_supplements.length} supplements'); + print('SupplementsLog: Loaded ${_supplements.length} supplements'); for (var supplement in _supplements) { - print('Supplement: ${supplement.name}'); + print('SupplementsLog: Supplement: ${supplement.name}'); } } catch (e) { - print('Error loading supplements: $e'); + print('SupplementsLog: Error loading supplements: $e'); if (kDebugMode) { - print('Error loading supplements: $e'); + print('SupplementsLog: Error loading supplements: $e'); } } finally { _isLoading = false; @@ -235,28 +235,28 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { Future addSupplement(Supplement supplement) async { try { - print('Adding supplement: ${supplement.name}'); + print('SupplementsLog: Adding supplement: ${supplement.name}'); final id = await _databaseHelper.insertSupplement(supplement); - print('Supplement inserted with ID: $id'); + print('SupplementsLog: Supplement inserted with ID: $id'); final newSupplement = supplement.copyWith(id: id); // Schedule notifications (skip if there's an error) try { await _notificationService.scheduleSupplementReminders(newSupplement); - print('Notifications scheduled'); + print('SupplementsLog: Notifications scheduled'); } catch (notificationError) { - print('Warning: Could not schedule notifications: $notificationError'); + print('SupplementsLog: Warning: Could not schedule notifications: $notificationError'); } await loadSupplements(); - print('Supplements reloaded, count: ${_supplements.length}'); + print('SupplementsLog: Supplements reloaded, count: ${_supplements.length}'); // Trigger sync after adding supplement _triggerSyncIfEnabled(); } catch (e) { - print('Error adding supplement: $e'); + print('SupplementsLog: Error adding supplement: $e'); if (kDebugMode) { - print('Error adding supplement: $e'); + print('SupplementsLog: Error adding supplement: $e'); } rethrow; } @@ -275,7 +275,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { _triggerSyncIfEnabled(); } catch (e) { if (kDebugMode) { - print('Error updating supplement: $e'); + print('SupplementsLog: Error updating supplement: $e'); } } } @@ -293,7 +293,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { _triggerSyncIfEnabled(); } catch (e) { if (kDebugMode) { - print('Error deleting supplement: $e'); + print('SupplementsLog: Error deleting supplement: $e'); } } } @@ -323,7 +323,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { ); } catch (e) { if (kDebugMode) { - print('Error recording intake: $e'); + print('SupplementsLog: Error recording intake: $e'); } } } @@ -332,22 +332,22 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { try { final today = DateTime.now(); if (kDebugMode) { - print('Loading intakes for date: ${today.year}-${today.month}-${today.day}'); + print('SupplementsLog: Loading intakes for date: ${today.year}-${today.month}-${today.day}'); } _todayIntakes = await _databaseHelper.getIntakesWithSupplementsForDate(today); if (kDebugMode) { - print('Loaded ${_todayIntakes.length} intakes for today'); + print('SupplementsLog: Loaded ${_todayIntakes.length} intakes for today'); for (var intake in _todayIntakes) { - print(' - Supplement ID: ${intake['supplement_id']}, taken at: ${intake['takenAt']}'); + print('SupplementsLog: - Supplement ID: ${intake['supplement_id']}, taken at: ${intake['takenAt']}'); } } notifyListeners(); } catch (e) { if (kDebugMode) { - print('Error loading today\'s intakes: $e'); + print('SupplementsLog: Error loading today\'s intakes: $e'); } } } @@ -358,7 +358,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { notifyListeners(); } catch (e) { if (kDebugMode) { - print('Error loading monthly intakes: $e'); + print('SupplementsLog: Error loading monthly intakes: $e'); } } } @@ -368,7 +368,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { return await _databaseHelper.getIntakesWithSupplementsForDate(date); } catch (e) { if (kDebugMode) { - print('Error loading intakes for date: $e'); + print('SupplementsLog: Error loading intakes for date: $e'); } return []; } @@ -388,7 +388,26 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { _triggerSyncIfEnabled(); } catch (e) { if (kDebugMode) { - print('Error deleting intake: $e'); + print('SupplementsLog: Error deleting intake: $e'); + } + } + } + + Future permanentlyDeleteIntake(int intakeId) async { + try { + await _databaseHelper.permanentlyDeleteIntake(intakeId); + await loadTodayIntakes(); + // Also refresh monthly intakes if they're loaded + if (_monthlyIntakes.isNotEmpty) { + await loadMonthlyIntakes(DateTime.now().year, DateTime.now().month); + } + notifyListeners(); + + // Trigger sync after permanently deleting intake + _triggerSyncIfEnabled(); + } catch (e) { + if (kDebugMode) { + print('SupplementsLog: Error permanently deleting intake: $e'); } } } @@ -404,7 +423,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { // Method to manually refresh daily status (useful for testing or manual refresh) Future refreshDailyStatus() async { if (kDebugMode) { - print('Manually refreshing daily status...'); + print('SupplementsLog: Manually refreshing daily status...'); } _lastDateCheck = DateTime.now(); await loadTodayIntakes(); @@ -417,20 +436,20 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { final lastCheckDate = DateTime(_lastDateCheck.year, _lastDateCheck.month, _lastDateCheck.day); if (kDebugMode) { - print('Force checking date change...'); - print('Current date: $currentDate'); - print('Last check date: $lastCheckDate'); + print('SupplementsLog: Force checking date change...'); + print('SupplementsLog: Current date: $currentDate'); + print('SupplementsLog: Last check date: $lastCheckDate'); } if (currentDate != lastCheckDate) { if (kDebugMode) { - print('Date change detected, refreshing intakes...'); + print('SupplementsLog: Date change detected, refreshing intakes...'); } _lastDateCheck = now; await loadTodayIntakes(); } else { if (kDebugMode) { - print('No date change detected'); + print('SupplementsLog: No date change detected'); } } } @@ -445,7 +464,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { notifyListeners(); } catch (e) { if (kDebugMode) { - print('Error loading archived supplements: $e'); + print('SupplementsLog: Error loading archived supplements: $e'); } } } @@ -460,7 +479,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { _triggerSyncIfEnabled(); } catch (e) { if (kDebugMode) { - print('Error archiving supplement: $e'); + print('SupplementsLog: Error archiving supplement: $e'); } } } @@ -475,21 +494,21 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { _triggerSyncIfEnabled(); } catch (e) { if (kDebugMode) { - print('Error unarchiving supplement: $e'); + print('SupplementsLog: Error unarchiving supplement: $e'); } } } Future deleteArchivedSupplement(int supplementId) async { try { - await _databaseHelper.deleteSupplement(supplementId); + await _databaseHelper.permanentlyDeleteSupplement(supplementId); await loadArchivedSupplements(); // Refresh archived supplements - // Trigger sync after deleting archived supplement + // Trigger sync after permanently deleting archived supplement _triggerSyncIfEnabled(); } catch (e) { if (kDebugMode) { - print('Error deleting archived supplement: $e'); + print('SupplementsLog: Error permanently deleting archived supplement: $e'); } } } diff --git a/lib/providers/sync_provider.dart b/lib/providers/sync_provider.dart index 52d36c1..e69de29 100644 --- a/lib/providers/sync_provider.dart +++ b/lib/providers/sync_provider.dart @@ -1,417 +0,0 @@ -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 _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 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 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 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 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 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 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 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 resolveAllConflicts() async { - if (_conflictStrategy == ConflictResolutionStrategy.manual) { - return; // Manual conflicts need individual resolution - } - - final resolvedConflicts = []; - - 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 setAutoSyncEnabled(bool enabled) async { - _isAutoSyncEnabled = enabled; - notifyListeners(); - - if (enabled) { - _scheduleNextAutoSync(); - } - // TODO: Cancel existing timers when disabled - } - - /// Set sync frequency - Future setSyncFrequency(SyncFrequency frequency) async { - _syncFrequency = frequency; - notifyListeners(); - - if (_isAutoSyncEnabled) { - _scheduleNextAutoSync(); - } - } - - /// Set conflict resolution strategy - Future setConflictResolutionStrategy(ConflictResolutionStrategy strategy) async { - _conflictStrategy = strategy; - notifyListeners(); - } - - /// Enable or disable auto sync on data changes - Future setAutoSyncOnDataChanges(bool enabled) async { - _autoSyncOnDataChanges = enabled; - notifyListeners(); - } - - /// Trigger sync automatically when data changes (if enabled) - Future 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> 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'; - } - } -} diff --git a/lib/screens/archived_supplements_screen.dart b/lib/screens/archived_supplements_screen.dart index ec5f0e7..e8e12e0 100644 --- a/lib/screens/archived_supplements_screen.dart +++ b/lib/screens/archived_supplements_screen.dart @@ -3,7 +3,6 @@ import 'package:provider/provider.dart'; import '../models/supplement.dart'; import '../providers/supplement_provider.dart'; -import '../providers/sync_provider.dart'; class ArchivedSupplementsScreen extends StatefulWidget { const ArchivedSupplementsScreen({super.key}); @@ -27,35 +26,9 @@ class _ArchivedSupplementsScreenState extends State { appBar: AppBar( title: const Text('Archived Supplements'), backgroundColor: Theme.of(context).colorScheme.inversePrimary, - actions: [ - Consumer( - builder: (context, syncProvider, child) { - if (!syncProvider.isConfigured) { - return const SizedBox.shrink(); - } - - return IconButton( - icon: syncProvider.isSyncing - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : syncProvider.status.name == 'success' && - DateTime.now().difference(syncProvider.lastSyncTime ?? DateTime.now()).inSeconds < 5 - ? const Icon(Icons.check, color: Colors.green) - : const Icon(Icons.sync), - onPressed: syncProvider.isSyncing ? null : () { - syncProvider.performManualSync(); - }, - tooltip: syncProvider.isSyncing ? 'Syncing...' : 'Force Sync', - ); - }, - ), - ], ), - body: Consumer2( - builder: (context, provider, syncProvider, child) { + body: Consumer( + builder: (context, provider, child) { if (provider.archivedSupplements.isEmpty) { return Center( child: Column( diff --git a/lib/screens/history_screen.dart b/lib/screens/history_screen.dart index 938dbd4..d91f5ff 100644 --- a/lib/screens/history_screen.dart +++ b/lib/screens/history_screen.dart @@ -3,7 +3,6 @@ import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import '../providers/supplement_provider.dart'; -import '../providers/sync_provider.dart'; class HistoryScreen extends StatefulWidget { const HistoryScreen({super.key}); @@ -32,32 +31,6 @@ class _HistoryScreenState extends State { appBar: AppBar( title: const Text('Intake History'), backgroundColor: Theme.of(context).colorScheme.inversePrimary, - actions: [ - Consumer( - builder: (context, syncProvider, child) { - if (!syncProvider.isConfigured) { - return const SizedBox.shrink(); - } - - return IconButton( - icon: syncProvider.isSyncing - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : syncProvider.status.name == 'success' && - DateTime.now().difference(syncProvider.lastSyncTime ?? DateTime.now()).inSeconds < 5 - ? const Icon(Icons.check, color: Colors.green) - : const Icon(Icons.sync), - onPressed: syncProvider.isSyncing ? null : () { - syncProvider.performManualSync(); - }, - tooltip: syncProvider.isSyncing ? 'Syncing...' : 'Force Sync', - ); - }, - ), - ], ), body: _buildCalendarView(), ); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 14273cb..5e7b945 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -51,20 +51,20 @@ class _HomeScreenState extends State { if (!mounted) return; try { - print('πŸ“± === HOME SCREEN: Checking persistent reminders ==='); + print('SupplementsLog: πŸ“± === HOME SCREEN: Checking persistent reminders ==='); final supplementProvider = context.read(); final settingsProvider = context.read(); - print('πŸ“± Settings: persistent=${settingsProvider.persistentReminders}, interval=${settingsProvider.reminderRetryInterval}, max=${settingsProvider.maxRetryAttempts}'); + print('SupplementsLog: πŸ“± Settings: persistent=${settingsProvider.persistentReminders}, interval=${settingsProvider.reminderRetryInterval}, max=${settingsProvider.maxRetryAttempts}'); await supplementProvider.checkPersistentRemindersWithSettings( persistentReminders: settingsProvider.persistentReminders, reminderRetryInterval: settingsProvider.reminderRetryInterval, maxRetryAttempts: settingsProvider.maxRetryAttempts, ); - print('πŸ“± === HOME SCREEN: Persistent reminder check complete ==='); + print('SupplementsLog: πŸ“± === HOME SCREEN: Persistent reminder check complete ==='); } catch (e) { - print('Error checking persistent reminders: $e'); + print('SupplementsLog: Error checking persistent reminders: $e'); } } diff --git a/lib/screens/pending_notifications_screen.dart b/lib/screens/pending_notifications_screen.dart index 73508e3..61e3999 100644 --- a/lib/screens/pending_notifications_screen.dart +++ b/lib/screens/pending_notifications_screen.dart @@ -132,7 +132,7 @@ class _PendingNotificationsScreenState extends State _isLoading = false; }); } catch (e) { - print('Error loading notifications: $e'); + print('SupplementsLog: Error loading notifications: $e'); setState(() { _isLoading = false; }); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index a96fe50..43913c6 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -5,7 +5,7 @@ import '../providers/settings_provider.dart'; import '../providers/supplement_provider.dart'; import '../services/notification_service.dart'; import 'pending_notifications_screen.dart'; -import 'sync_settings_screen.dart'; +import 'simple_sync_settings_screen.dart'; class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @@ -21,6 +21,22 @@ class SettingsScreen extends StatelessWidget { return ListView( padding: const EdgeInsets.all(16.0), children: [ + Card( + child: ListTile( + leading: const Icon(Icons.cloud_sync), + title: const Text('Cloud Sync'), + subtitle: const Text('Configure WebDAV sync settings'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const SimpleSyncSettingsScreen(), + ), + ); + }, + ), + ), + const SizedBox(height: 16), Card( child: Padding( padding: const EdgeInsets.all(16.0), @@ -71,18 +87,98 @@ class SettingsScreen extends StatelessWidget { ), const SizedBox(height: 16), Card( - child: ListTile( - leading: const Icon(Icons.cloud_sync), - title: const Text('Cloud Sync'), - subtitle: const Text('Configure WebDAV sync settings'), - trailing: const Icon(Icons.chevron_right), - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => const SyncSettingsScreen(), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.notifications_active, color: Colors.blue), + const SizedBox(width: 8), + Text( + 'Reminders', + style: Theme.of(context).textTheme.titleMedium, + ), + ], ), - ); - }, + const SizedBox(height: 8), + Text( + 'Configure reminders and how often they are retried when ignored', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + SwitchListTile( + title: const Text('Enable Persistent Reminders'), + subtitle: const Text('Resend notifications if ignored after a specific time'), + value: settingsProvider.persistentReminders, + onChanged: (value) { + settingsProvider.setPersistentReminders(value); + }, + ), + if (settingsProvider.persistentReminders) ...[ + const SizedBox(height: 16), + Text( + 'Retry Interval', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment(value: 5, label: Text('5 min')), + ButtonSegment(value: 10, label: Text('10 min')), + ButtonSegment(value: 15, label: Text('15 min')), + ButtonSegment(value: 30, label: Text('30 min')), + ], + selected: {settingsProvider.reminderRetryInterval}, + onSelectionChanged: (values) { + settingsProvider.setReminderRetryInterval(values.first); + }, + ), + const SizedBox(height: 16), + Text( + 'Maximum Retry Attempts', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment(value: 1, label: Text('1')), + ButtonSegment(value: 2, label: Text('2')), + ButtonSegment(value: 3, label: Text('3')), + ButtonSegment(value: 4, label: Text('4')), + ButtonSegment(value: 5, label: Text('5')), + ], + selected: {settingsProvider.maxRetryAttempts}, + onSelectionChanged: (values) { + settingsProvider.setMaxRetryAttempts(values.first); + }, + ), + const SizedBox(height: 16), + Text( + 'Notification Actions', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + SizedBox( + width: 320, + child: ElevatedButton.icon( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const PendingNotificationsScreen(), + ), + ); + }, + icon: const Icon(Icons.list), + label: const Text('View Pending Notifications'), + ), + ), + ], + ], + ), ), ), const SizedBox(height: 16), @@ -155,354 +251,6 @@ class SettingsScreen extends StatelessWidget { ), ), ), - const SizedBox(height: 16), - Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.notifications_active, color: Colors.blue), - const SizedBox(width: 8), - Text( - 'Persistent Reminders', - style: Theme.of(context).textTheme.titleMedium, - ), - ], - ), - const SizedBox(height: 8), - Text( - 'Configure automatic reminder retries for ignored notifications', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 16), - SwitchListTile( - title: const Text('Enable Persistent Reminders'), - subtitle: const Text('Resend notifications if ignored'), - value: settingsProvider.persistentReminders, - onChanged: (value) { - settingsProvider.setPersistentReminders(value); - }, - ), - if (settingsProvider.persistentReminders) ...[ - const SizedBox(height: 16), - Text( - 'Retry Interval', - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: 8), - SegmentedButton( - segments: const [ - ButtonSegment(value: 5, label: Text('5 min')), - ButtonSegment(value: 10, label: Text('10 min')), - ButtonSegment(value: 15, label: Text('15 min')), - ButtonSegment(value: 30, label: Text('30 min')), - ], - selected: {settingsProvider.reminderRetryInterval}, - onSelectionChanged: (values) { - settingsProvider.setReminderRetryInterval(values.first); - }, - ), - const SizedBox(height: 16), - Text( - 'Maximum Retry Attempts', - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: 8), - SegmentedButton( - segments: const [ - ButtonSegment(value: 1, label: Text('1')), - ButtonSegment(value: 2, label: Text('2')), - ButtonSegment(value: 3, label: Text('3')), - ButtonSegment(value: 4, label: Text('4')), - ButtonSegment(value: 5, label: Text('5')), - ], - selected: {settingsProvider.maxRetryAttempts}, - onSelectionChanged: (values) { - settingsProvider.setMaxRetryAttempts(values.first); - }, - ), - ], - ], - ), - ), - ), - const SizedBox(height: 16), - Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.notifications_outlined), - const SizedBox(width: 8), - Text( - 'Notifications', - style: Theme.of(context).textTheme.titleMedium, - ), - ], - ), - const SizedBox(height: 8), - Text( - 'View and manage pending notifications', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => const PendingNotificationsScreen(), - ), - ); - }, - icon: const Icon(Icons.list), - label: const Text('View Pending Notifications'), - ), - ), - ], - ), - ), - ), - const SizedBox(height: 16), - if (Theme.of(context).brightness == Brightness.dark) // Only show in debug mode for now - Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.bug_report, color: Colors.orange), - const SizedBox(width: 8), - Text( - 'Debug - Notifications', - style: Theme.of(context).textTheme.titleMedium, - ), - ], - ), - const SizedBox(height: 16), - Consumer( - builder: (context, supplementProvider, child) { - return Column( - children: [ - ElevatedButton.icon( - onPressed: () async { - await supplementProvider.testNotifications(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Test notification sent!')), - ); - } - }, - icon: const Icon(Icons.notifications_active), - label: const Text('Test Instant'), - ), - const SizedBox(height: 8), - ElevatedButton.icon( - onPressed: () async { - await supplementProvider.testScheduledNotification(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Scheduled test notification for 1 minute from now!')), - ); - } - }, - icon: const Icon(Icons.schedule), - label: const Text('Test Scheduled (1min)'), - ), - const SizedBox(height: 8), - ElevatedButton.icon( - onPressed: () async { - await supplementProvider.testNotificationActions(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Test notification with actions sent! Try the Take/Snooze buttons.')), - ); - } - }, - icon: const Icon(Icons.touch_app), - label: const Text('Test Actions'), - ), - const SizedBox(height: 8), - ElevatedButton.icon( - onPressed: () async { - await NotificationService().testBasicNotification(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Basic test notification sent! Tap it to test callback.')), - ); - } - }, - icon: const Icon(Icons.tap_and_play), - label: const Text('Test Basic Tap'), - ), - const SizedBox(height: 8), - ElevatedButton.icon( - onPressed: () async { - await supplementProvider.rescheduleAllNotifications(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('All notifications rescheduled!')), - ); - } - }, - icon: const Icon(Icons.refresh), - label: const Text('Reschedule All'), - ), - const SizedBox(height: 8), - ElevatedButton.icon( - onPressed: () async { - await supplementProvider.cancelAllNotifications(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('All notifications cancelled!')), - ); - } - }, - icon: const Icon(Icons.cancel), - label: const Text('Cancel All'), - ), - const SizedBox(height: 8), - ElevatedButton.icon( - onPressed: () async { - final pending = await supplementProvider.getPendingNotifications(); - if (context.mounted) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Pending Notifications'), - content: pending.isEmpty - ? const Text('No pending notifications') - : SizedBox( - width: double.maxFinite, - child: Consumer( - builder: (context, provider, child) { - return ListView.builder( - shrinkWrap: true, - itemCount: pending.length, - itemBuilder: (context, index) { - final notification = pending[index]; - - // Calculate scheduled time inline - String scheduledTime = ''; - try { - final notificationId = notification.id; - if (notificationId == 99999) { - scheduledTime = 'Test notification'; - } else if (notificationId > 1000) { - final snoozeMinutes = notificationId % 1000; - scheduledTime = 'Snoozed ($snoozeMinutes min)'; - } else { - final supplementId = notificationId ~/ 100; - final reminderIndex = notificationId % 100; - - final supplement = provider.supplements.firstWhere( - (s) => s.id == supplementId, - orElse: () => provider.supplements.first, - ); - - if (reminderIndex < supplement.reminderTimes.length) { - final reminderTime = supplement.reminderTimes[reminderIndex]; - final now = DateTime.now(); - final timeParts = reminderTime.split(':'); - final hour = int.parse(timeParts[0]); - final minute = int.parse(timeParts[1]); - - final today = DateTime(now.year, now.month, now.day, hour, minute); - final isToday = today.isAfter(now); - - scheduledTime = '${isToday ? 'Today' : 'Tomorrow'} at $reminderTime'; - } else { - scheduledTime = 'Unknown time'; - } - } - } catch (e) { - scheduledTime = 'ID: ${notification.id}'; - } - - return Card( - margin: const EdgeInsets.symmetric(vertical: 4), - child: ListTile( - leading: CircleAvatar( - backgroundColor: Theme.of(context).colorScheme.primary, - child: Text( - '${index + 1}', - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimary, - fontWeight: FontWeight.bold, - ), - ), - ), - title: Text( - notification.title ?? 'No title', - style: const TextStyle(fontWeight: FontWeight.w600), - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('ID: ${notification.id}'), - Text(notification.body ?? 'No body'), - if (scheduledTime.isNotEmpty) ...[ - const SizedBox(height: 4), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Text( - '⏰ $scheduledTime', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onPrimaryContainer, - ), - ), - ), - ], - ], - ), - isThreeLine: true, - ), - ); - }, - ); - }, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Close'), - ), - ], - ), - ); - } - }, - icon: const Icon(Icons.list), - label: const Text('Show Pending'), - ), - ], - ); - }, - ), - ], - ), - ), - ), ], ); }, diff --git a/lib/screens/simple_sync_settings_screen.dart b/lib/screens/simple_sync_settings_screen.dart new file mode 100644 index 0000000..a767c6a --- /dev/null +++ b/lib/screens/simple_sync_settings_screen.dart @@ -0,0 +1,467 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../services/database_sync_service.dart'; +import '../providers/simple_sync_provider.dart'; + +class SimpleSyncSettingsScreen extends StatefulWidget { + const SimpleSyncSettingsScreen({super.key}); + + @override + State createState() => _SimpleSyncSettingsScreenState(); +} + +class _SimpleSyncSettingsScreenState extends State { + final _formKey = GlobalKey(); + final _serverUrlController = TextEditingController(); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + final _remotePathController = TextEditingController(); + + String _previewUrl = ''; + + @override + void initState() { + super.initState(); + _serverUrlController.addListener(_updatePreviewUrl); + _usernameController.addListener(_updatePreviewUrl); + _loadSavedConfiguration(); + } + + void _loadSavedConfiguration() { + WidgetsBinding.instance.addPostFrameCallback((_) { + final syncProvider = context.read(); + + if (syncProvider.serverUrl != null) { + _serverUrlController.text = _extractHostnameFromUrl(syncProvider.serverUrl!); + } + if (syncProvider.username != null) { + _usernameController.text = syncProvider.username!; + } + if (syncProvider.password != null) { + _passwordController.text = syncProvider.password!; + } + if (syncProvider.remotePath != null) { + _remotePathController.text = syncProvider.remotePath!; + } + + _updatePreviewUrl(); + }); + } + + String _extractHostnameFromUrl(String fullUrl) { + try { + final uri = Uri.parse(fullUrl); + return uri.host; + } catch (e) { + return fullUrl; // Return as-is if parsing fails + } + } + + void _updatePreviewUrl() { + setState(() { + if (_serverUrlController.text.isNotEmpty && _usernameController.text.isNotEmpty) { + _previewUrl = _constructWebDAVUrl(_serverUrlController.text, _usernameController.text); + } else { + _previewUrl = ''; + } + }); + } + + @override + void dispose() { + _serverUrlController.removeListener(_updatePreviewUrl); + _usernameController.removeListener(_updatePreviewUrl); + _serverUrlController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _remotePathController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Database Sync Settings'), + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + ), + body: Consumer( + builder: (context, syncProvider, child) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildStatusCard(syncProvider), + const SizedBox(height: 20), + _buildConfigurationSection(), + const SizedBox(height: 20), + _buildActionButtons(syncProvider), + ], + ), + ), + ); + }, + ), + ); + } + + Widget _buildStatusCard(SimpleSyncProvider syncProvider) { + IconData icon; + Color color; + String statusText = syncProvider.getStatusText(); + + switch (syncProvider.status) { + case SyncStatus.idle: + icon = Icons.sync; + color = Colors.blue; + break; + case SyncStatus.downloading: + case SyncStatus.merging: + case SyncStatus.uploading: + icon = Icons.sync; + color = Colors.orange; + break; + case SyncStatus.completed: + icon = Icons.check_circle; + color = Colors.green; + break; + case SyncStatus.error: + icon = Icons.error; + color = Colors.red; + break; + } + + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + Row( + children: [ + Icon(icon, color: color, size: 24), + const SizedBox(width: 12), + Expanded( + child: Text( + statusText, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: color, + ), + ), + ), + ], + ), + if (syncProvider.lastSyncTime != null) ...[ + const SizedBox(height: 8), + Text( + 'Last sync: ${_formatDateTime(syncProvider.lastSyncTime!)}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + if (syncProvider.lastError != null) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + children: [ + Expanded( + child: Text( + syncProvider.lastError!, + style: const TextStyle(color: Colors.red), + ), + ), + IconButton( + icon: const Icon(Icons.close, color: Colors.red), + onPressed: () => syncProvider.clearError(), + ), + ], + ), + ), + ], + ], + ), + ), + ); + } + + Widget _buildConfigurationSection() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'WebDAV Configuration', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + TextFormField( + controller: _serverUrlController, + decoration: const InputDecoration( + labelText: 'Server URL', + hintText: 'your-nextcloud.com', + helperText: 'Enter just the hostname. We\'ll auto-detect the full WebDAV path.', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a server URL'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _usernameController, + decoration: const InputDecoration( + labelText: 'Username', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a username'; + } + return null; + }, + ), + if (_previewUrl.isNotEmpty) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.3), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withOpacity(0.5), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'WebDAV URL Preview:', + style: Theme.of(context).textTheme.labelMedium, + ), + const SizedBox(height: 4), + SelectableText( + _previewUrl, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + ), + ], + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + decoration: const InputDecoration( + labelText: 'Password', + border: OutlineInputBorder(), + ), + obscureText: true, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a password'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _remotePathController, + decoration: const InputDecoration( + labelText: 'Remote Path (optional)', + hintText: 'Supplements/', + border: OutlineInputBorder(), + ), + ), + ], + ), + ), + ); + } + + Widget _buildActionButtons(SimpleSyncProvider syncProvider) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ElevatedButton( + onPressed: syncProvider.isSyncing ? null : _testConnection, + child: const Text('Test Connection'), + ), + const SizedBox(height: 12), + ElevatedButton( + onPressed: syncProvider.isSyncing ? null : _configureSync, + child: const Text('Save Configuration'), + ), + const SizedBox(height: 12), + ElevatedButton( + onPressed: (!syncProvider.isConfigured || syncProvider.isSyncing) + ? null + : _syncDatabase, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).primaryColor, + foregroundColor: Colors.white, + ), + child: syncProvider.isSyncing + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Text('Sync Database'), + ), + ], + ); + } + + Future _testConnection() async { + if (!_formKey.currentState!.validate()) return; + + final syncProvider = context.read(); + + try { + // Construct the full WebDAV URL from the simple hostname + final fullWebDAVUrl = _constructWebDAVUrl( + _serverUrlController.text.trim(), + _usernameController.text.trim(), + ); + + // Configure temporarily for testing + await syncProvider.configure( + serverUrl: fullWebDAVUrl, + username: _usernameController.text.trim(), + password: _passwordController.text.trim(), + remotePath: _remotePathController.text.trim().isEmpty + ? 'Supplements' + : _remotePathController.text.trim(), + ); + + final success = await syncProvider.testConnection(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(success + ? 'Connection successful!' + : 'Connection failed. Check your settings.'), + backgroundColor: success ? Colors.green : Colors.red, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Connection test failed: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _configureSync() async { + if (!_formKey.currentState!.validate()) return; + + final syncProvider = context.read(); + + try { + // Construct the full WebDAV URL from the simple hostname + final fullWebDAVUrl = _constructWebDAVUrl( + _serverUrlController.text.trim(), + _usernameController.text.trim(), + ); + + await syncProvider.configure( + serverUrl: fullWebDAVUrl, + username: _usernameController.text.trim(), + password: _passwordController.text.trim(), + remotePath: _remotePathController.text.trim().isEmpty + ? 'Supplements' + : _remotePathController.text.trim(), + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Configuration saved successfully!'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to save configuration: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _syncDatabase() async { + final syncProvider = context.read(); + + try { + await syncProvider.syncDatabase(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Database sync completed!'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Sync failed: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + String _constructWebDAVUrl(String serverUrl, String username) { + // Remove any protocol prefix if present + String cleanUrl = serverUrl.trim(); + if (cleanUrl.startsWith('http://')) { + cleanUrl = cleanUrl.substring(7); + } else if (cleanUrl.startsWith('https://')) { + cleanUrl = cleanUrl.substring(8); + } + + // Remove trailing slash if present + if (cleanUrl.endsWith('/')) { + cleanUrl = cleanUrl.substring(0, cleanUrl.length - 1); + } + + // For Nextcloud instances, construct the standard WebDAV path + // Default to HTTPS for security + return 'https://$cleanUrl/remote.php/dav/files/$username/'; + } + + String _formatDateTime(DateTime dateTime) { + return '${dateTime.day}/${dateTime.month}/${dateTime.year} ${dateTime.hour}:${dateTime.minute.toString().padLeft(2, '0')}'; + } +} diff --git a/lib/screens/supplements_list_screen.dart b/lib/screens/supplements_list_screen.dart index d980b46..ebeb8a4 100644 --- a/lib/screens/supplements_list_screen.dart +++ b/lib/screens/supplements_list_screen.dart @@ -4,7 +4,8 @@ import 'package:provider/provider.dart'; import '../models/supplement.dart'; import '../providers/settings_provider.dart'; import '../providers/supplement_provider.dart'; -import '../providers/sync_provider.dart'; +import '../providers/simple_sync_provider.dart'; +import '../services/database_sync_service.dart'; import '../widgets/supplement_card.dart'; import 'add_supplement_screen.dart'; import 'archived_supplements_screen.dart'; @@ -19,7 +20,7 @@ class SupplementsListScreen extends StatelessWidget { title: const Text('My Supplements'), backgroundColor: Theme.of(context).colorScheme.inversePrimary, actions: [ - Consumer( + Consumer( builder: (context, syncProvider, child) { if (!syncProvider.isConfigured) { return const SizedBox.shrink(); @@ -32,12 +33,13 @@ class SupplementsListScreen extends StatelessWidget { height: 20, child: CircularProgressIndicator(strokeWidth: 2), ) - : syncProvider.status.name == 'success' && - DateTime.now().difference(syncProvider.lastSyncTime ?? DateTime.now()).inSeconds < 5 + : syncProvider.status == SyncStatus.completed && + syncProvider.lastSyncTime != null && + DateTime.now().difference(syncProvider.lastSyncTime!).inSeconds < 5 ? const Icon(Icons.check, color: Colors.green) : const Icon(Icons.sync), onPressed: syncProvider.isSyncing ? null : () { - syncProvider.performManualSync(); + syncProvider.syncDatabase(); }, tooltip: syncProvider.isSyncing ? 'Syncing...' : 'Force Sync', ); @@ -56,8 +58,8 @@ class SupplementsListScreen extends StatelessWidget { ), ], ), - body: Consumer3( - builder: (context, provider, settingsProvider, syncProvider, child) { + body: Consumer2( + builder: (context, provider, settingsProvider, child) { if (provider.isLoading) { return const Center(child: CircularProgressIndicator()); } diff --git a/lib/screens/sync_settings_screen.dart b/lib/screens/sync_settings_screen.dart index f900460..e69de29 100644 --- a/lib/screens/sync_settings_screen.dart +++ b/lib/screens/sync_settings_screen.dart @@ -1,782 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../models/sync_enums.dart'; -import '../providers/sync_provider.dart'; - -/// Screen for configuring WebDAV sync settings -class SyncSettingsScreen extends StatefulWidget { - const SyncSettingsScreen({super.key}); - - @override - State createState() => _SyncSettingsScreenState(); -} - -class _SyncSettingsScreenState extends State { - final _formKey = GlobalKey(); - final _serverUrlController = TextEditingController(); - final _usernameController = TextEditingController(); - final _passwordController = TextEditingController(); - final _deviceNameController = TextEditingController(); - final _syncFolderController = TextEditingController(); - - bool _isPasswordVisible = false; - bool _isTestingConnection = false; - bool _isConfiguring = false; - - @override - void initState() { - super.initState(); - _loadCurrentSettings(); - } - - void _loadCurrentSettings() { - final syncProvider = context.read(); - _serverUrlController.text = syncProvider.serverUrl ?? ''; - _usernameController.text = syncProvider.username ?? ''; - _syncFolderController.text = syncProvider.syncFolderName ?? 'Supplements'; - // Note: We don't load the password for security reasons - } - - @override - void dispose() { - _serverUrlController.dispose(); - _usernameController.dispose(); - _passwordController.dispose(); - _deviceNameController.dispose(); - _syncFolderController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Cloud Sync Settings'), - actions: [ - Consumer( - builder: (context, syncProvider, child) { - if (!syncProvider.isConfigured) return const SizedBox.shrink(); - - return PopupMenuButton( - onSelected: (value) { - switch (value) { - case 'test': - _testConnection(); - break; - case 'sync': - _performSync(); - break; - case 'clear': - _showClearConfigDialog(); - break; - } - }, - itemBuilder: (context) => [ - const PopupMenuItem( - value: 'test', - child: ListTile( - leading: Icon(Icons.wifi_protected_setup), - title: Text('Test Connection'), - ), - ), - const PopupMenuItem( - value: 'sync', - child: ListTile( - leading: Icon(Icons.sync), - title: Text('Sync Now'), - ), - ), - const PopupMenuItem( - value: 'clear', - child: ListTile( - leading: Icon(Icons.clear), - title: Text('Clear Configuration'), - ), - ), - ], - ); - }, - ), - ], - ), - body: Consumer( - builder: (context, syncProvider, child) { - return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildStatusCard(syncProvider), - const SizedBox(height: 24), - _buildDeviceInfoSection(syncProvider), - const SizedBox(height: 24), - _buildConfigurationSection(syncProvider), - const SizedBox(height: 24), - _buildSyncSettingsSection(syncProvider), - if (syncProvider.hasPendingConflicts) ...[ - const SizedBox(height: 24), - _buildConflictsSection(syncProvider), - ], - ], - ), - ), - ); - }, - ), - ); - } - - Widget _buildStatusCard(SyncProvider syncProvider) { - Color statusColor; - IconData statusIcon; - - switch (syncProvider.status) { - case SyncOperationStatus.success: - statusColor = Colors.green; - statusIcon = Icons.check_circle; - break; - case SyncOperationStatus.syncing: - statusColor = Colors.blue; - statusIcon = Icons.sync; - break; - case SyncOperationStatus.networkError: - case SyncOperationStatus.authenticationError: - case SyncOperationStatus.serverError: - statusColor = Colors.red; - statusIcon = Icons.error; - break; - case SyncOperationStatus.conflictsDetected: - statusColor = Colors.orange; - statusIcon = Icons.warning; - break; - default: - statusColor = Colors.grey; - statusIcon = Icons.cloud_off; - } - - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(statusIcon, color: statusColor, size: 24), - const SizedBox(width: 12), - Expanded( - child: Text( - syncProvider.statusMessage, - style: Theme.of(context).textTheme.titleMedium, - ), - ), - ], - ), - if (syncProvider.lastSyncTime != null) ...[ - const SizedBox(height: 8), - Text( - 'Last sync: ${syncProvider.formattedLastSyncTime}', - style: Theme.of(context).textTheme.bodySmall, - ), - ], - if (syncProvider.isConfigured && syncProvider.detectedServerType != null) ...[ - const SizedBox(height: 8), - Row( - children: [ - Icon(Icons.check_circle, size: 16, color: Colors.green), - const SizedBox(width: 4), - Text( - 'Detected: ${syncProvider.detectedServerType}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.green[700], - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ], - if (syncProvider.hasError) ...[ - const SizedBox(height: 8), - Text( - syncProvider.currentError!, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.red, - ), - ), - ], - if (syncProvider.isSyncing) ...[ - const SizedBox(height: 12), - LinearProgressIndicator( - value: syncProvider.syncProgress, - ), - ], - ], - ), - ), - ); - } - - Widget _buildConfigurationSection(SyncProvider syncProvider) { - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'WebDAV Configuration', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 16), - TextFormField( - controller: _serverUrlController, - decoration: const InputDecoration( - labelText: 'Server URL', - hintText: 'cloud.example.com or drive.mydomain.com', - prefixIcon: Icon(Icons.cloud), - helperText: 'Just enter your server domain - we\'ll auto-detect the rest!', - helperMaxLines: 2, - ), - validator: (value) { - if (value?.isEmpty ?? true) { - return 'Please enter server URL'; - } - return null; - }, - ), - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.blue.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.blue.withOpacity(0.3)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.lightbulb_outline, size: 16, color: Colors.blue[700]), - const SizedBox(width: 8), - Text( - 'Smart URL Detection', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Colors.blue[700], - fontWeight: FontWeight.bold, - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - 'You can enter simple URLs like:', - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 4), - Text( - 'β€’ cloud.example.com\n' - 'β€’ drive.mydomain.com\n' - 'β€’ nextcloud.company.org\n' - 'β€’ my-server.duckdns.org:8080', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - ), - ), - const SizedBox(height: 8), - Text( - 'We\'ll automatically detect if it\'s Nextcloud, ownCloud, or generic WebDAV and build the correct URL for you!', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontStyle: FontStyle.italic, - ), - ), - ], - ), - ), - const SizedBox(height: 16), - TextFormField( - controller: _usernameController, - decoration: const InputDecoration( - labelText: 'Username', - prefixIcon: Icon(Icons.person), - ), - validator: (value) { - if (value?.isEmpty ?? true) { - return 'Please enter username'; - } - return null; - }, - ), - const SizedBox(height: 16), - TextFormField( - controller: _passwordController, - obscureText: !_isPasswordVisible, - decoration: InputDecoration( - labelText: 'Password / App Password', - prefixIcon: const Icon(Icons.lock), - suffixIcon: IconButton( - icon: Icon( - _isPasswordVisible ? Icons.visibility : Icons.visibility_off, - ), - onPressed: () { - setState(() { - _isPasswordVisible = !_isPasswordVisible; - }); - }, - ), - helperText: 'Use app passwords for better security', - ), - validator: (value) { - if (value?.isEmpty ?? true) { - return 'Please enter password'; - } - return null; - }, - ), - const SizedBox(height: 16), - TextFormField( - controller: _syncFolderController, - decoration: const InputDecoration( - labelText: 'Sync Folder Name', - prefixIcon: Icon(Icons.folder), - hintText: 'Supplements', - helperText: 'Folder name on your cloud server for syncing data', - ), - validator: (value) { - if (value?.isEmpty ?? true) { - return 'Please enter folder name'; - } - if (value!.contains('/') || value.contains('\\')) { - return 'Folder name cannot contain slashes'; - } - return null; - }, - ), - const SizedBox(height: 16), - TextFormField( - controller: _deviceNameController, - decoration: const InputDecoration( - labelText: 'Device Name (Optional)', - prefixIcon: Icon(Icons.phone_android), - hintText: 'My Phone', - ), - ), - const SizedBox(height: 24), - Row( - children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: _isConfiguring || syncProvider.isSyncing - ? null - : () => _configureWebDAV(syncProvider), - icon: _isConfiguring - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.save), - label: Text(syncProvider.isConfigured ? 'Update' : 'Configure'), - ), - ), - if (syncProvider.isConfigured) ...[ - const SizedBox(width: 12), - ElevatedButton.icon( - onPressed: _isTestingConnection || syncProvider.isSyncing - ? null - : _testConnection, - icon: _isTestingConnection - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.wifi_protected_setup), - label: const Text('Test'), - ), - ], - ], - ), - ], - ), - ), - ); - } - - Widget _buildSyncSettingsSection(SyncProvider syncProvider) { - if (!syncProvider.isConfigured) return const SizedBox.shrink(); - - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Sync Settings', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 16), - SwitchListTile( - title: const Text('Auto Sync on Data Changes'), - subtitle: const Text('Automatically sync when you add, modify, or take supplements'), - value: syncProvider.autoSyncOnDataChanges, - onChanged: syncProvider.setAutoSyncOnDataChanges, - ), - const SizedBox(height: 8), - ListTile( - title: const Text('Conflict Resolution'), - subtitle: Text(_getConflictStrategyDescription(syncProvider.conflictStrategy)), - trailing: DropdownButton( - value: syncProvider.conflictStrategy, - onChanged: (strategy) { - if (strategy != null) { - syncProvider.setConflictResolutionStrategy(strategy); - } - }, - items: ConflictResolutionStrategy.values - .map((strategy) => DropdownMenuItem( - value: strategy, - child: Text(_getConflictStrategyName(strategy)), - )) - .toList(), - ), - ), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: syncProvider.isSyncing ? null : _performSync, - icon: syncProvider.isSyncing - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.sync), - label: const Text('Sync Now'), - ), - ), - ], - ), - ), - ); - } - - Widget _buildConflictsSection(SyncProvider syncProvider) { - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.warning, color: Colors.orange), - const SizedBox(width: 8), - Text( - 'Sync Conflicts', - style: Theme.of(context).textTheme.titleMedium, - ), - ], - ), - const SizedBox(height: 16), - Text( - '${syncProvider.pendingConflicts.length} conflicts need your attention', - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: ElevatedButton( - onPressed: () => _showConflictsDialog(syncProvider), - child: const Text('Review Conflicts'), - ), - ), - const SizedBox(width: 12), - ElevatedButton( - onPressed: syncProvider.resolveAllConflicts, - child: const Text('Auto Resolve'), - ), - ], - ), - ], - ), - ), - ); - } - - Widget _buildDeviceInfoSection(SyncProvider syncProvider) { - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Connection Information', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 16), - FutureBuilder>( - future: syncProvider.getDeviceInfo(), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const Center(child: CircularProgressIndicator()); - } - - final deviceInfo = snapshot.data!; - return Column( - children: [ - if (syncProvider.detectedServerType != null) - ListTile( - leading: const Icon(Icons.cloud_done), - title: const Text('Server Type'), - subtitle: Text(syncProvider.detectedServerType!), - ), - if (syncProvider.finalWebdavUrl != null) - ListTile( - leading: const Icon(Icons.link), - title: const Text('WebDAV URL'), - subtitle: Text( - syncProvider.finalWebdavUrl!, - style: const TextStyle(fontFamily: 'monospace', fontSize: 12), - ), - ), - ListTile( - leading: const Icon(Icons.fingerprint), - title: const Text('Device ID'), - subtitle: Text(deviceInfo['deviceId'] ?? 'Unknown'), - ), - ListTile( - leading: const Icon(Icons.devices), - title: const Text('Device Name'), - subtitle: Text(deviceInfo['deviceName'] ?? 'Unknown'), - ), - ], - ); - }, - ), - ], - ), - ), - ); - } - - String _getConflictStrategyName(ConflictResolutionStrategy strategy) { - switch (strategy) { - case ConflictResolutionStrategy.preferLocal: - return 'Prefer Local'; - case ConflictResolutionStrategy.preferRemote: - return 'Prefer Remote'; - case ConflictResolutionStrategy.preferNewer: - return 'Prefer Newer'; - case ConflictResolutionStrategy.manual: - return 'Manual'; - } - } - - String _getConflictStrategyDescription(ConflictResolutionStrategy strategy) { - switch (strategy) { - case ConflictResolutionStrategy.preferLocal: - return 'Always keep local changes'; - case ConflictResolutionStrategy.preferRemote: - return 'Always keep remote changes'; - case ConflictResolutionStrategy.preferNewer: - return 'Keep most recent changes'; - case ConflictResolutionStrategy.manual: - return 'Review each conflict manually'; - } - } - - Future _configureWebDAV(SyncProvider syncProvider) async { - // For updates, allow empty password to keep existing one - if (syncProvider.isConfigured && _passwordController.text.isEmpty) { - // Skip validation for password on updates - if (_serverUrlController.text.trim().isEmpty || _usernameController.text.trim().isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Please fill in server URL and username'), - backgroundColor: Colors.red, - ), - ); - return; - } - } else if (!_formKey.currentState!.validate()) { - return; - } - - setState(() => _isConfiguring = true); - - final success = await syncProvider.configure( - serverUrl: _serverUrlController.text.trim(), - username: _usernameController.text.trim(), - password: _passwordController.text.isEmpty ? null : _passwordController.text, - deviceName: _deviceNameController.text.trim().isEmpty - ? null - : _deviceNameController.text.trim(), - syncFolderName: _syncFolderController.text.trim().isEmpty - ? 'Supplements' - : _syncFolderController.text.trim(), - ); - - setState(() => _isConfiguring = false); - - if (mounted) { - final message = success - ? 'WebDAV configured successfully!' - : 'Failed to configure WebDAV'; - - final detectionInfo = success && syncProvider.detectedServerType != null - ? '\nDetected: ${syncProvider.detectedServerType}' - : ''; - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('$message$detectionInfo'), - backgroundColor: success ? Colors.green : Colors.red, - duration: Duration(seconds: success ? 4 : 3), - ), - ); - - if (success) { - _passwordController.clear(); // Clear password for security - } - } - } - - Future _testConnection() async { - setState(() => _isTestingConnection = true); - - final syncProvider = context.read(); - final success = await syncProvider.testConnection(); - - setState(() => _isTestingConnection = false); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(success - ? 'Connection test successful!' - : 'Connection test failed'), - backgroundColor: success ? Colors.green : Colors.red, - ), - ); - } - } - - Future _performSync() async { - final syncProvider = context.read(); - final success = await syncProvider.performManualSync(); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(success - ? 'Sync completed successfully!' - : 'Sync failed'), - backgroundColor: success ? Colors.green : Colors.red, - ), - ); - } - } - - Future _showClearConfigDialog() async { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Clear Configuration'), - content: const Text( - 'Are you sure you want to clear the WebDAV configuration? ' - 'This will disable cloud sync.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Clear'), - ), - ], - ), - ); - - if (confirmed == true) { - final syncProvider = context.read(); - await syncProvider.clearConfiguration(); - _serverUrlController.clear(); - _usernameController.clear(); - _passwordController.clear(); - _deviceNameController.clear(); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Configuration cleared'), - backgroundColor: Colors.orange, - ), - ); - } - } - } - - Future _showConflictsDialog(SyncProvider syncProvider) async { - await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Sync Conflicts'), - content: SizedBox( - width: double.maxFinite, - height: 300, - child: ListView.builder( - itemCount: syncProvider.pendingConflicts.length, - itemBuilder: (context, index) { - final conflict = syncProvider.pendingConflicts[index]; - return Card( - child: ListTile( - title: Text(conflict.type.name), - subtitle: Text(conflict.description), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - TextButton( - onPressed: () { - syncProvider.resolveConflict( - conflict.syncId, - ConflictResolution.useLocal, - ); - }, - child: const Text('Local'), - ), - TextButton( - onPressed: () { - syncProvider.resolveConflict( - conflict.syncId, - ConflictResolution.useRemote, - ); - }, - child: const Text('Remote'), - ), - ], - ), - ), - ); - }, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Close'), - ), - ], - ), - ); - } -} diff --git a/lib/services/database_helper.dart b/lib/services/database_helper.dart index 3a18d0a..042df2e 100644 --- a/lib/services/database_helper.dart +++ b/lib/services/database_helper.dart @@ -6,7 +6,7 @@ import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import '../models/supplement.dart'; import '../models/supplement_intake.dart'; -import '../models/sync_enums.dart'; +import 'database_sync_service.dart'; class DatabaseHelper { static const _databaseName = 'supplements.db'; @@ -399,7 +399,43 @@ class DatabaseHelper { Database db = await database; return await db.update( supplementsTable, - {'isActive': 0}, + { + 'isActive': 0, + 'isDeleted': 1, + 'lastModified': DateTime.now().toIso8601String(), + }, + where: 'id = ?', + whereArgs: [id], + ); + } + + Future permanentlyDeleteSupplement(int id) async { + Database db = await database; + + // For sync compatibility, we should mark as deleted rather than completely removing + // This prevents the supplement from reappearing during sync + + // First mark all related intakes as deleted + await db.update( + intakesTable, + { + 'isDeleted': 1, + 'lastModified': DateTime.now().toIso8601String(), + 'syncStatus': RecordSyncStatus.modified.name, + }, + where: 'supplementId = ? AND isDeleted = ?', + whereArgs: [id, 0], + ); + + // Then mark the supplement as deleted instead of removing it completely + return await db.update( + supplementsTable, + { + 'isDeleted': 1, + 'isActive': 0, // Also ensure it's archived + 'lastModified': DateTime.now().toIso8601String(), + 'syncStatus': RecordSyncStatus.modified.name, + }, where: 'id = ?', whereArgs: [id], ); @@ -411,6 +447,36 @@ class DatabaseHelper { return await db.insert(intakesTable, intake.toMap()); } + Future deleteIntake(int id) async { + Database db = await database; + return await db.update( + intakesTable, + { + 'isDeleted': 1, + 'lastModified': DateTime.now().toIso8601String(), + }, + where: 'id = ?', + whereArgs: [id], + ); + } + + Future permanentlyDeleteIntake(int id) async { + Database db = await database; + + // For sync compatibility, mark as deleted rather than completely removing + // This prevents the intake from reappearing during sync + return await db.update( + intakesTable, + { + 'isDeleted': 1, + 'lastModified': DateTime.now().toIso8601String(), + 'syncStatus': RecordSyncStatus.modified.name, + }, + where: 'id = ?', + whereArgs: [id], + ); + } + Future> getIntakesForDate(DateTime date) async { Database db = await database; String startDate = DateTime(date.year, date.month, date.day).toIso8601String(); @@ -477,15 +543,6 @@ class DatabaseHelper { return result; } - Future deleteIntake(int id) async { - Database db = await database; - return await db.delete( - intakesTable, - where: 'id = ?', - whereArgs: [id], - ); - } - // Notification tracking methods Future trackNotification({ required int notificationId, @@ -637,7 +694,7 @@ class DatabaseHelper { List> maps = await db.query( supplementsTable, where: 'syncStatus IN (?, ?)', - whereArgs: [SyncStatus.pending.name, SyncStatus.modified.name], + whereArgs: [RecordSyncStatus.pending.name, RecordSyncStatus.modified.name], orderBy: 'lastModified ASC', ); return List.generate(maps.length, (i) => Supplement.fromMap(maps[i])); @@ -648,7 +705,7 @@ class DatabaseHelper { List> maps = await db.query( intakesTable, where: 'syncStatus IN (?, ?)', - whereArgs: [SyncStatus.pending.name, SyncStatus.modified.name], + whereArgs: [RecordSyncStatus.pending.name, RecordSyncStatus.modified.name], orderBy: 'lastModified ASC', ); return List.generate(maps.length, (i) => SupplementIntake.fromMap(maps[i])); @@ -658,7 +715,7 @@ class DatabaseHelper { Database db = await database; await db.update( supplementsTable, - {'syncStatus': SyncStatus.synced.name}, + {'syncStatus': RecordSyncStatus.synced.name}, where: 'syncId = ?', whereArgs: [syncId], ); @@ -668,7 +725,7 @@ class DatabaseHelper { Database db = await database; await db.update( intakesTable, - {'syncStatus': SyncStatus.synced.name}, + {'syncStatus': RecordSyncStatus.synced.name}, where: 'syncId = ?', whereArgs: [syncId], ); diff --git a/lib/services/database_sync_service.dart b/lib/services/database_sync_service.dart new file mode 100644 index 0000000..9ba1504 --- /dev/null +++ b/lib/services/database_sync_service.dart @@ -0,0 +1,520 @@ +import 'dart:io' as io; +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:webdav_client/webdav_client.dart'; + +import '../models/supplement.dart'; +import '../models/supplement_intake.dart'; +import 'database_helper.dart'; + +enum SyncStatus { + idle, + downloading, + merging, + uploading, + completed, + error, +} + +// Legacy record-level sync status for models +enum RecordSyncStatus { + pending, + synced, + modified, +} + +class DatabaseSyncService { + static const String _remoteDbFileName = 'supplements.db'; + + // SharedPreferences keys for persistence + static const String _keyServerUrl = 'sync_server_url'; + static const String _keyUsername = 'sync_username'; + static const String _keyPassword = 'sync_password'; + static const String _keyRemotePath = 'sync_remote_path'; + + Client? _client; + String? _remotePath; + + // Store configuration values + String? _serverUrl; + String? _username; + String? _password; + String? _configuredRemotePath; + + final DatabaseHelper _databaseHelper = DatabaseHelper.instance; + + SyncStatus _status = SyncStatus.idle; + String? _lastError; + DateTime? _lastSyncTime; + + // Getters + SyncStatus get status => _status; + String? get lastError => _lastError; + DateTime? get lastSyncTime => _lastSyncTime; + bool get isConfigured => _client != null; + + // Configuration getters + String? get serverUrl => _serverUrl; + String? get username => _username; + String? get password => _password; + String? get remotePath => _configuredRemotePath; + + // Callbacks for UI updates + Function(SyncStatus)? onStatusChanged; + Function(String)? onError; + Function()? onSyncCompleted; + + DatabaseSyncService() { + loadSavedConfiguration(); + } + + // Load saved configuration from SharedPreferences + Future loadSavedConfiguration() async { + try { + final prefs = await SharedPreferences.getInstance(); + _serverUrl = prefs.getString(_keyServerUrl); + _username = prefs.getString(_keyUsername); + _password = prefs.getString(_keyPassword); + _configuredRemotePath = prefs.getString(_keyRemotePath); + + // If we have saved configuration, set up the client + if (_serverUrl != null && _username != null && _password != null && _configuredRemotePath != null) { + _remotePath = _configuredRemotePath!.endsWith('/') ? _configuredRemotePath : '$_configuredRemotePath/'; + + _client = newClient( + _serverUrl!, + user: _username!, + password: _password!, + debug: kDebugMode, + ); + } + } catch (e) { + if (kDebugMode) { + print('SupplementsLog: Error loading saved sync configuration: $e'); + } + } + } + + // Save configuration to SharedPreferences + Future _saveConfiguration() async { + try { + final prefs = await SharedPreferences.getInstance(); + if (_serverUrl != null) await prefs.setString(_keyServerUrl, _serverUrl!); + if (_username != null) await prefs.setString(_keyUsername, _username!); + if (_password != null) await prefs.setString(_keyPassword, _password!); + if (_configuredRemotePath != null) await prefs.setString(_keyRemotePath, _configuredRemotePath!); + } catch (e) { + if (kDebugMode) { + print('SupplementsLog: Error saving sync configuration: $e'); + } + } + } + + void configure({ + required String serverUrl, + required String username, + required String password, + required String remotePath, + }) { + // Store configuration values + _serverUrl = serverUrl; + _username = username; + _password = password; + _configuredRemotePath = remotePath; + + _remotePath = remotePath.endsWith('/') ? remotePath : '$remotePath/'; + + _client = newClient( + serverUrl, + user: username, + password: password, + debug: kDebugMode, + ); + + // Save configuration to persistent storage + _saveConfiguration(); + } + + Future testConnection() async { + if (_client == null) return false; + + try { + await _client!.ping(); + return true; + } catch (e) { + if (kDebugMode) { + print('SupplementsLog: Connection test failed: $e'); + } + return false; + } + } + + Future syncDatabase() async { + if (_client == null) { + throw Exception('Sync not configured'); + } + + _setStatus(SyncStatus.downloading); + + try { + // Step 1: Download remote database (if it exists) + final remoteDbPath = await _downloadRemoteDatabase(); + + // Step 2: Merge databases + _setStatus(SyncStatus.merging); + await _mergeDatabases(remoteDbPath); + + // Step 3: Upload merged database + _setStatus(SyncStatus.uploading); + await _uploadLocalDatabase(); + + // Step 4: Cleanup - for now we'll skip cleanup to avoid file issues + // TODO: Implement proper cleanup once file operations are working + + _lastSyncTime = DateTime.now(); + _setStatus(SyncStatus.completed); + onSyncCompleted?.call(); + + } catch (e) { + _lastError = e.toString(); + _setStatus(SyncStatus.error); + onError?.call(_lastError!); + if (kDebugMode) { + print('SupplementsLog: Sync failed: $e'); + } + rethrow; + } + } + + Future _downloadRemoteDatabase() async { + try { + // Check if remote database exists + final files = await _client!.readDir(_remotePath!); + final remoteDbExists = files.any((file) => file.name == _remoteDbFileName); + + if (!remoteDbExists) { + if (kDebugMode) { + print('SupplementsLog: No remote database found, will upload local database'); + } + return null; + } + + if (kDebugMode) { + print('SupplementsLog: Remote database found, downloading...'); + } + + // Download the remote database + final remoteDbBytes = await _client!.read('$_remotePath$_remoteDbFileName'); + + // Create a temporary file path for the downloaded database + final tempDir = await getDatabasesPath(); + final tempDbPath = join(tempDir, 'remote_supplements.db'); + + // Write the downloaded database to a temporary file + final tempFile = io.File(tempDbPath); + await tempFile.writeAsBytes(remoteDbBytes); + + if (kDebugMode) { + print('SupplementsLog: Downloaded remote database (${remoteDbBytes.length} bytes) to: $tempDbPath'); + } + + return tempDbPath; + + } catch (e) { + if (kDebugMode) { + print('SupplementsLog: Failed to download remote database: $e'); + } + return null; + } + } + + Future _mergeDatabases(String? remoteDbPath) async { + if (remoteDbPath == null) { + if (kDebugMode) { + print('SupplementsLog: No remote database to merge'); + } + return; + } + + if (kDebugMode) { + print('SupplementsLog: Starting database merge from: $remoteDbPath'); + } + + final localDb = await _databaseHelper.database; + final remoteDb = await openDatabase(remoteDbPath, readOnly: true); + + try { + // Check what tables exist in remote database + if (kDebugMode) { + final tables = await remoteDb.rawQuery("SELECT name FROM sqlite_master WHERE type='table'"); + print('SupplementsLog: Remote database tables: ${tables.map((t) => t['name']).toList()}'); + + // Count records in each table + try { + final supplementCount = await remoteDb.rawQuery('SELECT COUNT(*) as count FROM supplements'); + print('SupplementsLog: Remote supplements count: ${supplementCount.first['count']}'); + } catch (e) { + print('SupplementsLog: Error counting supplements: $e'); + } + + try { + final intakeCount = await remoteDb.rawQuery('SELECT COUNT(*) as count FROM supplement_intakes'); + print('SupplementsLog: Remote intakes count: ${intakeCount.first['count']}'); + } catch (e) { + print('SupplementsLog: Error counting intakes: $e'); + } + } + + // Merge supplements + await _mergeSupplements(localDb, remoteDb); + + // Merge intakes + await _mergeIntakes(localDb, remoteDb); + + if (kDebugMode) { + print('SupplementsLog: Database merge completed successfully'); + } + + } finally { + await remoteDb.close(); + } + } + + Future _mergeSupplements(Database localDb, Database remoteDb) async { + if (kDebugMode) { + print('SupplementsLog: Starting supplement merge...'); + } + + // Get all supplements from remote database + final remoteMaps = await remoteDb.query('supplements'); + final remoteSupplements = remoteMaps.map((map) => Supplement.fromMap(map)).toList(); + + if (kDebugMode) { + print('SupplementsLog: Found ${remoteSupplements.length} supplements in remote database'); + for (final supplement in remoteSupplements) { + print('SupplementsLog: Remote supplement: ${supplement.name} (syncId: ${supplement.syncId}, deleted: ${supplement.isDeleted})'); + } + } + + for (final remoteSupplement in remoteSupplements) { + if (remoteSupplement.syncId.isEmpty) { + if (kDebugMode) { + print('SupplementsLog: Skipping supplement ${remoteSupplement.name} - no syncId'); + } + continue; + } + + // Find existing supplement by syncId + final existingMaps = await localDb.query( + 'supplements', + where: 'syncId = ?', + whereArgs: [remoteSupplement.syncId], + ); + + if (existingMaps.isEmpty) { + // New supplement from remote - insert it + if (!remoteSupplement.isDeleted) { + final supplementToInsert = remoteSupplement.copyWith(id: null); + await localDb.insert('supplements', supplementToInsert.toMap()); + if (kDebugMode) { + print('SupplementsLog: βœ“ Inserted new supplement: ${remoteSupplement.name}'); + } + } else { + if (kDebugMode) { + print('SupplementsLog: Skipping deleted supplement: ${remoteSupplement.name}'); + } + } + } else { + // Existing supplement - update if remote is newer + final existingSupplement = Supplement.fromMap(existingMaps.first); + + if (remoteSupplement.lastModified.isAfter(existingSupplement.lastModified)) { + final supplementToUpdate = remoteSupplement.copyWith(id: existingSupplement.id); + await localDb.update( + 'supplements', + supplementToUpdate.toMap(), + where: 'id = ?', + whereArgs: [existingSupplement.id], + ); + if (kDebugMode) { + print('SupplementsLog: βœ“ Updated supplement: ${remoteSupplement.name}'); + } + } else { + if (kDebugMode) { + print('SupplementsLog: Local supplement ${remoteSupplement.name} is newer, keeping local version'); + } + } + } + } + + if (kDebugMode) { + print('SupplementsLog: Supplement merge completed'); + } + } + + Future _mergeIntakes(Database localDb, Database remoteDb) async { + if (kDebugMode) { + print('SupplementsLog: Starting intake merge...'); + } + + // Get all intakes from remote database + final remoteMaps = await remoteDb.query('supplement_intakes'); + final remoteIntakes = remoteMaps.map((map) => SupplementIntake.fromMap(map)).toList(); + + if (kDebugMode) { + print('SupplementsLog: Found ${remoteIntakes.length} intakes in remote database'); + } + + for (final remoteIntake in remoteIntakes) { + if (remoteIntake.syncId.isEmpty) { + if (kDebugMode) { + print('SupplementsLog: Skipping intake - no syncId'); + } + continue; + } + + // Find existing intake by syncId + final existingMaps = await localDb.query( + 'supplement_intakes', + where: 'syncId = ?', + whereArgs: [remoteIntake.syncId], + ); + + if (existingMaps.isEmpty) { + // New intake from remote - need to find local supplement ID + if (!remoteIntake.isDeleted) { + final localSupplementId = await _findLocalSupplementId(localDb, remoteIntake.supplementId, remoteDb); + if (localSupplementId != null) { + final intakeToInsert = remoteIntake.copyWith( + id: null, + supplementId: localSupplementId, + ); + await localDb.insert('supplement_intakes', intakeToInsert.toMap()); + if (kDebugMode) { + print('SupplementsLog: βœ“ Inserted new intake: ${remoteIntake.syncId}'); + } + } else { + if (kDebugMode) { + print('SupplementsLog: Could not find local supplement for intake ${remoteIntake.syncId}'); + } + } + } else { + if (kDebugMode) { + print('SupplementsLog: Skipping deleted intake: ${remoteIntake.syncId}'); + } + } + } else { + // Existing intake - update if remote is newer + final existingIntake = SupplementIntake.fromMap(existingMaps.first); + + if (remoteIntake.lastModified.isAfter(existingIntake.lastModified)) { + final intakeToUpdate = remoteIntake.copyWith(id: existingIntake.id); + await localDb.update( + 'supplement_intakes', + intakeToUpdate.toMap(), + where: 'id = ?', + whereArgs: [existingIntake.id], + ); + if (kDebugMode) { + print('SupplementsLog: βœ“ Updated intake: ${remoteIntake.syncId}'); + } + } else { + if (kDebugMode) { + print('SupplementsLog: Local intake ${remoteIntake.syncId} is newer, keeping local version'); + } + } + } + } + + if (kDebugMode) { + print('SupplementsLog: Intake merge completed'); + } + } + + Future _findLocalSupplementId(Database localDb, int remoteSupplementId, Database remoteDb) async { + // Get the remote supplement + final remoteSupplementMaps = await remoteDb.query( + 'supplements', + where: 'id = ?', + whereArgs: [remoteSupplementId], + ); + + if (remoteSupplementMaps.isEmpty) return null; + + final remoteSupplement = Supplement.fromMap(remoteSupplementMaps.first); + + // Find the local supplement with the same syncId + final localSupplementMaps = await localDb.query( + 'supplements', + where: 'syncId = ?', + whereArgs: [remoteSupplement.syncId], + ); + + if (localSupplementMaps.isEmpty) return null; + + return localSupplementMaps.first['id'] as int; + } + + Future _uploadLocalDatabase() async { + try { + // Get the local database path + final localDb = await _databaseHelper.database; + final dbPath = localDb.path; + + if (kDebugMode) { + print('SupplementsLog: Reading database from: $dbPath'); + } + + // Read the database file + final dbFile = io.File(dbPath); + if (!await dbFile.exists()) { + throw Exception('Database file not found at: $dbPath'); + } + + final dbBytes = await dbFile.readAsBytes(); + + if (kDebugMode) { + print('SupplementsLog: Database file size: ${dbBytes.length} bytes'); + } + + if (dbBytes.isEmpty) { + throw Exception('Database file is empty'); + } + + // Ensure remote directory exists + try { + await _client!.readDir(_remotePath!); + } catch (e) { + if (kDebugMode) { + print('SupplementsLog: Creating remote directory: $_remotePath'); + } + await _client!.mkdir(_remotePath!); + } + + // Upload the database file + final remoteUrl = '$_remotePath$_remoteDbFileName'; + await _client!.write(remoteUrl, dbBytes); + + if (kDebugMode) { + print('SupplementsLog: Successfully uploaded database (${dbBytes.length} bytes) to: $remoteUrl'); + } + + } catch (e) { + if (kDebugMode) { + print('SupplementsLog: Failed to upload database: $e'); + } + rethrow; + } + } + + void _setStatus(SyncStatus status) { + _status = status; + onStatusChanged?.call(status); + } + + void clearError() { + _lastError = null; + } +} diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index da48f7e..3105b00 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -7,17 +7,17 @@ import 'database_helper.dart'; // Top-level function to handle notification responses when app is running @pragma('vm:entry-point') void notificationTapBackground(NotificationResponse notificationResponse) { - print('πŸ“± === BACKGROUND NOTIFICATION RESPONSE ==='); - print('πŸ“± Action ID: ${notificationResponse.actionId}'); - print('πŸ“± Payload: ${notificationResponse.payload}'); - print('πŸ“± Notification ID: ${notificationResponse.id}'); - print('πŸ“± =========================================='); + print('SupplementsLog: πŸ“± === BACKGROUND NOTIFICATION RESPONSE ==='); + print('SupplementsLog: πŸ“± Action ID: ${notificationResponse.actionId}'); + print('SupplementsLog: πŸ“± Payload: ${notificationResponse.payload}'); + print('SupplementsLog: πŸ“± Notification ID: ${notificationResponse.id}'); + print('SupplementsLog: πŸ“± =========================================='); // For now, just log the action. The main app handler will process it. if (notificationResponse.actionId == 'take_supplement') { - print('πŸ“± BACKGROUND: Take action detected'); + print('SupplementsLog: πŸ“± BACKGROUND: Take action detected'); } else if (notificationResponse.actionId == 'snooze_10') { - print('πŸ“± BACKGROUND: Snooze action detected'); + print('SupplementsLog: πŸ“± BACKGROUND: Snooze action detected'); } } @@ -40,25 +40,25 @@ class NotificationService { } Future initialize() async { - print('πŸ“± Initializing NotificationService...'); + print('SupplementsLog: πŸ“± Initializing NotificationService...'); if (_isInitialized) { - print('πŸ“± Already initialized'); + print('SupplementsLog: πŸ“± Already initialized'); return; } try { - print('πŸ“± Initializing timezones...'); - print('πŸ“± Engine initialized flag: $_engineInitialized'); + print('SupplementsLog: πŸ“± Initializing timezones...'); + print('SupplementsLog: πŸ“± Engine initialized flag: $_engineInitialized'); if (!_engineInitialized) { tz.initializeTimeZones(); _engineInitialized = true; - print('πŸ“± Timezones initialized successfully'); + print('SupplementsLog: πŸ“± Timezones initialized successfully'); } else { - print('πŸ“± Timezones already initialized, skipping'); + print('SupplementsLog: πŸ“± Timezones already initialized, skipping'); } } catch (e) { - print('πŸ“± Warning: Timezone initialization issue (may already be initialized): $e'); + print('SupplementsLog: πŸ“± Warning: Timezone initialization issue (may already be initialized): $e'); _engineInitialized = true; // Mark as initialized to prevent retry } @@ -66,7 +66,7 @@ class NotificationService { try { // First try using the system timezone name final String timeZoneName = DateTime.now().timeZoneName; - print('πŸ“± System timezone name: $timeZoneName'); + print('SupplementsLog: πŸ“± System timezone name: $timeZoneName'); tz.Location? location; @@ -80,22 +80,22 @@ class NotificationService { try { location = tz.getLocation(timeZoneName); } catch (e) { - print('πŸ“± Could not find timezone $timeZoneName, using Europe/Amsterdam as default'); + print('SupplementsLog: πŸ“± Could not find timezone $timeZoneName, using Europe/Amsterdam as default'); location = tz.getLocation('Europe/Amsterdam'); } } tz.setLocalLocation(location); - print('πŸ“± Timezone set to: ${location.name}'); + print('SupplementsLog: πŸ“± Timezone set to: ${location.name}'); } catch (e) { - print('πŸ“± Error setting timezone: $e, using default'); + print('SupplementsLog: πŸ“± Error setting timezone: $e, using default'); // Fallback to a reasonable default for Netherlands tz.setLocalLocation(tz.getLocation('Europe/Amsterdam')); } - print('πŸ“± Current local time: ${tz.TZDateTime.now(tz.local)}'); - print('πŸ“± Current system time: ${DateTime.now()}'); + print('SupplementsLog: πŸ“± Current local time: ${tz.TZDateTime.now(tz.local)}'); + print('SupplementsLog: πŸ“± Current system time: ${DateTime.now()}'); const AndroidInitializationSettings androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); const DarwinInitializationSettings iosSettings = DarwinInitializationSettings( @@ -113,7 +113,7 @@ class NotificationService { linux: linuxSettings, ); - print('πŸ“± Initializing flutter_local_notifications...'); + print('SupplementsLog: πŸ“± Initializing flutter_local_notifications...'); await _notifications.initialize( initSettings, onDidReceiveNotificationResponse: _onNotificationResponse, @@ -121,42 +121,42 @@ class NotificationService { ); // Test if notification response callback is working - print('πŸ“± Callback function is set and ready'); + print('SupplementsLog: πŸ“± Callback function is set and ready'); _isInitialized = true; - print('πŸ“± NotificationService initialization complete'); + print('SupplementsLog: πŸ“± NotificationService initialization complete'); } // Handle notification responses (when user taps on notification or action) void _onNotificationResponse(NotificationResponse response) { - print('πŸ“± === NOTIFICATION RESPONSE ==='); - print('πŸ“± Action ID: ${response.actionId}'); - print('πŸ“± Payload: ${response.payload}'); - print('πŸ“± Notification ID: ${response.id}'); - print('πŸ“± Input: ${response.input}'); - print('πŸ“± ==============================='); + print('SupplementsLog: πŸ“± === NOTIFICATION RESPONSE ==='); + print('SupplementsLog: πŸ“± Action ID: ${response.actionId}'); + print('SupplementsLog: πŸ“± Payload: ${response.payload}'); + print('SupplementsLog: πŸ“± Notification ID: ${response.id}'); + print('SupplementsLog: πŸ“± Input: ${response.input}'); + print('SupplementsLog: πŸ“± ==============================='); if (response.actionId == 'take_supplement') { - print('πŸ“± Processing TAKE action...'); + print('SupplementsLog: πŸ“± Processing TAKE action...'); _handleTakeAction(response.payload, response.id); } else if (response.actionId == 'snooze_10') { - print('πŸ“± Processing SNOOZE action...'); + print('SupplementsLog: πŸ“± Processing SNOOZE action...'); _handleSnoozeAction(response.payload, 10, response.id); } else { - print('πŸ“± Default notification tap (no specific action)'); + print('SupplementsLog: πŸ“± Default notification tap (no specific action)'); // Default tap (no actionId) opens the app normally } } Future _handleTakeAction(String? payload, int? notificationId) async { - print('πŸ“± === HANDLING TAKE ACTION ==='); - print('πŸ“± Payload received: $payload'); + print('SupplementsLog: πŸ“± === HANDLING TAKE ACTION ==='); + print('SupplementsLog: πŸ“± Payload received: $payload'); if (payload != null) { try { // Parse the payload to get supplement info final parts = payload.split('|'); - print('πŸ“± Payload parts: $parts (length: ${parts.length})'); + print('SupplementsLog: πŸ“± Payload parts: $parts (length: ${parts.length})'); if (parts.length >= 4) { final supplementId = int.parse(parts[0]); @@ -164,24 +164,24 @@ class NotificationService { final units = double.parse(parts[2]); final unitType = parts[3]; - print('πŸ“± Parsed data:'); - print('πŸ“± - ID: $supplementId'); - print('πŸ“± - Name: $supplementName'); - print('πŸ“± - Units: $units'); - print('πŸ“± - Type: $unitType'); + print('SupplementsLog: πŸ“± Parsed data:'); + print('SupplementsLog: πŸ“± - ID: $supplementId'); + print('SupplementsLog: πŸ“± - Name: $supplementName'); + print('SupplementsLog: πŸ“± - Units: $units'); + print('SupplementsLog: πŸ“± - Type: $unitType'); // Call the callback to record the intake if (_onTakeSupplementCallback != null) { - print('πŸ“± Calling supplement callback...'); + print('SupplementsLog: πŸ“± Calling supplement callback...'); _onTakeSupplementCallback!(supplementId, supplementName, units, unitType); - print('πŸ“± Callback completed'); + print('SupplementsLog: πŸ“± Callback completed'); } else { - print('πŸ“± ERROR: No callback registered!'); + print('SupplementsLog: πŸ“± ERROR: No callback registered!'); } // Mark notification as taken in database (this will cancel any pending retries) if (notificationId != null) { - print('πŸ“± Marking notification $notificationId as taken'); + print('SupplementsLog: πŸ“± Marking notification $notificationId as taken'); await DatabaseHelper.instance.markNotificationTaken(notificationId); // Cancel any pending retry notifications for this notification @@ -189,21 +189,21 @@ class NotificationService { } // Show a confirmation notification - print('πŸ“± Showing confirmation notification...'); + print('SupplementsLog: πŸ“± Showing confirmation notification...'); showInstantNotification( 'Supplement Taken!', '$supplementName has been recorded at ${DateTime.now().hour.toString().padLeft(2, '0')}:${DateTime.now().minute.toString().padLeft(2, '0')}', ); } else { - print('πŸ“± ERROR: Invalid payload format - not enough parts'); + print('SupplementsLog: πŸ“± ERROR: Invalid payload format - not enough parts'); } } catch (e) { - print('πŸ“± ERROR in _handleTakeAction: $e'); + print('SupplementsLog: πŸ“± ERROR in _handleTakeAction: $e'); } } else { - print('πŸ“± ERROR: Payload is null'); + print('SupplementsLog: πŸ“± ERROR: Payload is null'); } - print('πŸ“± === TAKE ACTION COMPLETE ==='); + print('SupplementsLog: πŸ“± === TAKE ACTION COMPLETE ==='); } void _cancelRetryNotifications(int notificationId) { @@ -211,13 +211,13 @@ class NotificationService { for (int i = 0; i < 10; i++) { // Cancel up to 10 potential retries int retryId = 200000 + (notificationId * 10) + i; _notifications.cancel(retryId); - print('πŸ“± Cancelled retry notification ID: $retryId'); + print('SupplementsLog: πŸ“± Cancelled retry notification ID: $retryId'); } } void _handleSnoozeAction(String? payload, int minutes, int? notificationId) { - print('πŸ“± === HANDLING SNOOZE ACTION ==='); - print('πŸ“± Payload: $payload, Minutes: $minutes'); + print('SupplementsLog: πŸ“± === HANDLING SNOOZE ACTION ==='); + print('SupplementsLog: πŸ“± Payload: $payload, Minutes: $minutes'); if (payload != null) { try { @@ -226,17 +226,17 @@ class NotificationService { final supplementId = int.parse(parts[0]); final supplementName = parts[1]; - print('πŸ“± Snoozing supplement for $minutes minutes: $supplementName'); + print('SupplementsLog: πŸ“± Snoozing supplement for $minutes minutes: $supplementName'); // Mark notification as snoozed in database (increment retry count) if (notificationId != null) { - print('πŸ“± Incrementing retry count for notification $notificationId'); + print('SupplementsLog: πŸ“± Incrementing retry count for notification $notificationId'); DatabaseHelper.instance.incrementRetryCount(notificationId); } // Schedule a new notification for the snooze time final snoozeTime = tz.TZDateTime.now(tz.local).add(Duration(minutes: minutes)); - print('πŸ“± Snooze time: $snoozeTime'); + print('SupplementsLog: πŸ“± Snooze time: $snoozeTime'); _notifications.zonedSchedule( supplementId * 1000 + minutes, // Unique ID for snooze notifications @@ -271,13 +271,13 @@ class NotificationService { 'Reminder Snoozed', '$supplementName reminder snoozed for $minutes minutes', ); - print('πŸ“± Snooze scheduled successfully'); + print('SupplementsLog: πŸ“± Snooze scheduled successfully'); } } catch (e) { - print('πŸ“± Error handling snooze action: $e'); + print('SupplementsLog: πŸ“± Error handling snooze action: $e'); } } - print('πŸ“± === SNOOZE ACTION COMPLETE ==='); + print('SupplementsLog: πŸ“± === SNOOZE ACTION COMPLETE ==='); } /// Check for persistent reminders from app context with settings @@ -299,19 +299,19 @@ class NotificationService { required int reminderRetryInterval, required int maxRetryAttempts, }) async { - print('πŸ“± Checking for pending notifications to retry...'); + print('SupplementsLog: πŸ“± Checking for pending notifications to retry...'); try { if (!persistentReminders) { - print('πŸ“± Persistent reminders disabled'); + print('SupplementsLog: πŸ“± Persistent reminders disabled'); return; } - print('πŸ“± Retry settings: interval=$reminderRetryInterval min, max=$maxRetryAttempts attempts'); + print('SupplementsLog: πŸ“± Retry settings: interval=$reminderRetryInterval min, max=$maxRetryAttempts attempts'); // Get all pending notifications from database final pendingNotifications = await DatabaseHelper.instance.getPendingNotifications(); - print('πŸ“± Found ${pendingNotifications.length} pending notifications'); + print('SupplementsLog: πŸ“± Found ${pendingNotifications.length} pending notifications'); final now = DateTime.now(); @@ -326,17 +326,17 @@ class NotificationService { final timeSinceScheduled = now.difference(scheduledTime).inMinutes; final shouldRetry = timeSinceScheduled >= reminderRetryInterval; - print('πŸ“± Checking notification ${notification['notificationId']}:'); - print('πŸ“± Scheduled: $scheduledTime (local)'); - print('πŸ“± Now: $now'); - print('πŸ“± Time since scheduled: $timeSinceScheduled minutes'); - print('πŸ“± Retry interval: $reminderRetryInterval minutes'); - print('πŸ“± Should retry: $shouldRetry'); - print('πŸ“± Retry count: $retryCount / $maxRetryAttempts'); + print('SupplementsLog: πŸ“± Checking notification ${notification['notificationId']}:'); + print('SupplementsLog: πŸ“± Scheduled: $scheduledTime (local)'); + print('SupplementsLog: πŸ“± Now: $now'); + print('SupplementsLog: πŸ“± Time since scheduled: $timeSinceScheduled minutes'); + print('SupplementsLog: πŸ“± Retry interval: $reminderRetryInterval minutes'); + print('SupplementsLog: πŸ“± Should retry: $shouldRetry'); + print('SupplementsLog: πŸ“± Retry count: $retryCount / $maxRetryAttempts'); // Check if we haven't exceeded max retry attempts if (retryCount >= maxRetryAttempts) { - print('πŸ“± Notification ${notification['notificationId']} exceeded max attempts ($maxRetryAttempts)'); + print('SupplementsLog: πŸ“± Notification ${notification['notificationId']} exceeded max attempts ($maxRetryAttempts)'); continue; } @@ -344,20 +344,20 @@ class NotificationService { if (lastRetryTime != null) { final timeSinceLastRetry = now.difference(lastRetryTime).inMinutes; if (timeSinceLastRetry < reminderRetryInterval) { - print('πŸ“± Notification ${notification['notificationId']} not ready for retry yet'); + print('SupplementsLog: πŸ“± Notification ${notification['notificationId']} not ready for retry yet'); continue; } } if (shouldRetry) { - print('πŸ“± ⚑ SCHEDULING RETRY for notification ${notification['notificationId']}'); + print('SupplementsLog: πŸ“± ⚑ SCHEDULING RETRY for notification ${notification['notificationId']}'); await _scheduleRetryNotification(notification, retryCount + 1); } else { - print('πŸ“± ⏸️ NOT READY FOR RETRY: ${notification['notificationId']}'); + print('SupplementsLog: πŸ“± ⏸️ NOT READY FOR RETRY: ${notification['notificationId']}'); } } } catch (e) { - print('πŸ“± Error scheduling persistent reminders: $e'); + print('SupplementsLog: πŸ“± Error scheduling persistent reminders: $e'); } } @@ -369,7 +369,7 @@ class NotificationService { // Generate a unique ID for this retry (200000 + original_id * 10 + retry_attempt) final retryNotificationId = 200000 + (notificationId * 10) + retryAttempt; - print('πŸ“± Scheduling retry notification $retryNotificationId for supplement $supplementId (attempt $retryAttempt)'); + print('SupplementsLog: πŸ“± Scheduling retry notification $retryNotificationId for supplement $supplementId (attempt $retryAttempt)'); // Get supplement details from database final supplements = await DatabaseHelper.instance.getAllSupplements(); @@ -408,16 +408,16 @@ class NotificationService { // Update the retry count in database await DatabaseHelper.instance.incrementRetryCount(notificationId); - print('πŸ“± Retry notification scheduled successfully'); + print('SupplementsLog: πŸ“± Retry notification scheduled successfully'); } catch (e) { - print('πŸ“± Error scheduling retry notification: $e'); + print('SupplementsLog: πŸ“± Error scheduling retry notification: $e'); } } Future requestPermissions() async { - print('πŸ“± Requesting notification permissions...'); + print('SupplementsLog: πŸ“± Requesting notification permissions...'); if (_permissionsRequested) { - print('πŸ“± Permissions already requested'); + print('SupplementsLog: πŸ“± Permissions already requested'); return true; } @@ -426,9 +426,9 @@ class NotificationService { final androidPlugin = _notifications.resolvePlatformSpecificImplementation(); if (androidPlugin != null) { - print('πŸ“± Requesting Android permissions...'); + print('SupplementsLog: πŸ“± Requesting Android permissions...'); final granted = await androidPlugin.requestNotificationsPermission(); - print('πŸ“± Android permissions granted: $granted'); + print('SupplementsLog: πŸ“± Android permissions granted: $granted'); if (granted != true) { _permissionsRequested = false; return false; @@ -437,31 +437,31 @@ class NotificationService { final iosPlugin = _notifications.resolvePlatformSpecificImplementation(); if (iosPlugin != null) { - print('πŸ“± Requesting iOS permissions...'); + print('SupplementsLog: πŸ“± Requesting iOS permissions...'); final granted = await iosPlugin.requestPermissions( alert: true, badge: true, sound: true, ); - print('πŸ“± iOS permissions granted: $granted'); + print('SupplementsLog: πŸ“± iOS permissions granted: $granted'); if (granted != true) { _permissionsRequested = false; return false; } } - print('πŸ“± All permissions granted successfully'); + print('SupplementsLog: πŸ“± All permissions granted successfully'); return true; } catch (e) { _permissionsRequested = false; - print('πŸ“± Error requesting permissions: $e'); + print('SupplementsLog: πŸ“± Error requesting permissions: $e'); return false; } } Future scheduleSupplementReminders(Supplement supplement) async { - print('πŸ“± Scheduling reminders for ${supplement.name}'); - print('πŸ“± Reminder times: ${supplement.reminderTimes}'); + print('SupplementsLog: πŸ“± Scheduling reminders for ${supplement.name}'); + print('SupplementsLog: πŸ“± Reminder times: ${supplement.reminderTimes}'); // Cancel existing notifications for this supplement await cancelSupplementReminders(supplement.id!); @@ -475,7 +475,7 @@ class NotificationService { final notificationId = supplement.id! * 100 + i; // Unique ID for each reminder final scheduledTime = _nextInstanceOfTime(hour, minute); - print('πŸ“± Scheduling notification ID $notificationId for ${timeStr} -> ${scheduledTime}'); + print('SupplementsLog: πŸ“± Scheduling notification ID $notificationId for ${timeStr} -> ${scheduledTime}'); // Track this notification in the database await DatabaseHelper.instance.trackNotification( @@ -518,14 +518,14 @@ class NotificationService { payload: '${supplement.id}|${supplement.name}|${supplement.numberOfUnits}|${supplement.unitType}', ); - print('πŸ“± Successfully scheduled notification ID $notificationId'); + print('SupplementsLog: πŸ“± Successfully scheduled notification ID $notificationId'); } // Get all pending notifications to verify final pendingNotifications = await _notifications.pendingNotificationRequests(); - print('πŸ“± Total pending notifications: ${pendingNotifications.length}'); + print('SupplementsLog: πŸ“± Total pending notifications: ${pendingNotifications.length}'); for (final notification in pendingNotifications) { - print('πŸ“± Pending: ID=${notification.id}, Title=${notification.title}'); + print('SupplementsLog: πŸ“± Pending: ID=${notification.id}, Title=${notification.title}'); } } @@ -548,22 +548,22 @@ class NotificationService { final tz.TZDateTime now = tz.TZDateTime.now(tz.local); tz.TZDateTime scheduledDate = tz.TZDateTime(tz.local, now.year, now.month, now.day, hour, minute); - print('πŸ“± Current time: $now (${now.timeZoneName})'); - print('πŸ“± Target time: ${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}'); - print('πŸ“± Initial scheduled date: $scheduledDate (${scheduledDate.timeZoneName})'); + print('SupplementsLog: πŸ“± Current time: $now (${now.timeZoneName})'); + print('SupplementsLog: πŸ“± Target time: ${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}'); + print('SupplementsLog: πŸ“± Initial scheduled date: $scheduledDate (${scheduledDate.timeZoneName})'); if (scheduledDate.isBefore(now)) { scheduledDate = scheduledDate.add(const Duration(days: 1)); - print('πŸ“± Time has passed, scheduling for tomorrow: $scheduledDate (${scheduledDate.timeZoneName})'); + print('SupplementsLog: πŸ“± Time has passed, scheduling for tomorrow: $scheduledDate (${scheduledDate.timeZoneName})'); } else { - print('πŸ“± Time is in the future, scheduling for today: $scheduledDate (${scheduledDate.timeZoneName})'); + print('SupplementsLog: πŸ“± Time is in the future, scheduling for today: $scheduledDate (${scheduledDate.timeZoneName})'); } return scheduledDate; } Future showInstantNotification(String title, String body) async { - print('πŸ“± Showing instant notification: $title - $body'); + print('SupplementsLog: πŸ“± Showing instant notification: $title - $body'); const NotificationDetails notificationDetails = NotificationDetails( android: AndroidNotificationDetails( 'instant_notifications', @@ -581,22 +581,22 @@ class NotificationService { body, notificationDetails, ); - print('πŸ“± Instant notification sent'); + print('SupplementsLog: πŸ“± Instant notification sent'); } // Debug function to test notifications Future testNotification() async { - print('πŸ“± Testing notification system...'); + print('SupplementsLog: πŸ“± Testing notification system...'); await showInstantNotification('Test Notification', 'This is a test notification to verify the system is working.'); } // Debug function to schedule a test notification 1 minute from now Future testScheduledNotification() async { - print('πŸ“± Testing scheduled notification...'); + print('SupplementsLog: πŸ“± Testing scheduled notification...'); final now = tz.TZDateTime.now(tz.local); final testTime = now.add(const Duration(minutes: 1)); - print('πŸ“± Scheduling test notification for: $testTime'); + print('SupplementsLog: πŸ“± Scheduling test notification for: $testTime'); await _notifications.zonedSchedule( 99999, // Special ID for test notifications @@ -616,7 +616,7 @@ class NotificationService { androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, ); - print('πŸ“± Test notification scheduled successfully'); + print('SupplementsLog: πŸ“± Test notification scheduled successfully'); } // Debug function to get all pending notifications @@ -626,7 +626,7 @@ class NotificationService { // Debug function to test notification actions Future testNotificationWithActions() async { - print('πŸ“± Creating test notification with actions...'); + print('SupplementsLog: πŸ“± Creating test notification with actions...'); await _notifications.show( 88888, // Special test ID @@ -659,12 +659,12 @@ class NotificationService { payload: '999|Test Supplement|1.0|capsule', ); - print('πŸ“± Test notification with actions created'); + print('SupplementsLog: πŸ“± Test notification with actions created'); } // Debug function to test basic notification tap response Future testBasicNotification() async { - print('πŸ“± Creating basic test notification...'); + print('SupplementsLog: πŸ“± Creating basic test notification...'); await _notifications.show( 77777, // Special test ID for basic notification @@ -683,6 +683,6 @@ class NotificationService { payload: 'basic_test', ); - print('πŸ“± Basic test notification created'); + print('SupplementsLog: πŸ“± Basic test notification created'); } } diff --git a/lib/services/webdav_sync_service.dart b/lib/services/webdav_sync_service.dart index 8e99b2f..e69de29 100644 --- a/lib/services/webdav_sync_service.dart +++ b/lib/services/webdav_sync_service.dart @@ -1,928 +0,0 @@ -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); - } -} diff --git a/lib/widgets/supplement_card.dart b/lib/widgets/supplement_card.dart index 3332d01..978edc2 100644 --- a/lib/widgets/supplement_card.dart +++ b/lib/widgets/supplement_card.dart @@ -42,7 +42,7 @@ class _SupplementCardState extends State { final unitsTaken = intake['unitsTaken'] ?? 1.0; return { 'time': '${takenAt.hour.toString().padLeft(2, '0')}:${takenAt.minute.toString().padLeft(2, '0')}', - 'units': unitsTaken, + 'units': unitsTaken is int ? unitsTaken.toDouble() : unitsTaken as double, }; }).toList();