From e95dcf3322816338f201a3b018cc139cd7393298 Mon Sep 17 00:00:00 2001 From: Menno van Leeuwen Date: Wed, 27 Aug 2025 21:47:24 +0200 Subject: [PATCH] feat: adds auto sync feature and fixes UI a bit up --- lib/main.dart | 11 + lib/providers/settings_provider.dart | 79 ++- lib/providers/simple_sync_provider.dart | 78 ++- lib/screens/add_supplement_screen.dart | 19 +- lib/screens/history_screen.dart | 265 ++++++--- lib/screens/simple_sync_settings_screen.dart | 539 ++++++++++++++++--- lib/services/auto_sync_service.dart | 470 ++++++++++++++++ test/widget_test.dart | 30 -- 8 files changed, 1268 insertions(+), 223 deletions(-) create mode 100644 lib/services/auto_sync_service.dart delete mode 100644 test/widget_test.dart diff --git a/lib/main.dart b/lib/main.dart index 13b0fc0..e707fcf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -31,8 +31,11 @@ class MyApp extends StatelessWidget { child: Consumer2( builder: (context, settingsProvider, syncProvider, child) { // Set up the sync completion callback to refresh supplement data + // and initialize auto-sync integration WidgetsBinding.instance.addPostFrameCallback((_) { final supplementProvider = context.read(); + + // Set up sync completion callback syncProvider.setOnSyncCompleteCallback(() async { if (kDebugMode) { print('SupplementsLog: Sync completed, refreshing UI data...'); @@ -43,6 +46,14 @@ class MyApp extends StatelessWidget { print('SupplementsLog: UI data refreshed after sync'); } }); + + // Initialize auto-sync service + syncProvider.initializeAutoSync(settingsProvider); + + // Set up auto-sync callback for data changes + supplementProvider.setOnDataChangedCallback(() { + syncProvider.triggerAutoSyncIfEnabled(); + }); }); return MaterialApp( diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index f0016ca..e862cf8 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -9,7 +9,7 @@ enum ThemeOption { class SettingsProvider extends ChangeNotifier { ThemeOption _themeOption = ThemeOption.system; - + // Time range settings (stored as hours, 0-23) int _morningStart = 5; int _morningEnd = 10; @@ -19,14 +19,18 @@ class SettingsProvider extends ChangeNotifier { int _eveningEnd = 22; int _nightStart = 23; int _nightEnd = 4; - + // Persistent reminder settings bool _persistentReminders = true; int _reminderRetryInterval = 5; // minutes int _maxRetryAttempts = 3; - + + // Auto-sync settings + bool _autoSyncEnabled = false; + int _autoSyncDebounceSeconds = 5; + ThemeOption get themeOption => _themeOption; - + // Time range getters int get morningStart => _morningStart; int get morningEnd => _morningEnd; @@ -36,22 +40,26 @@ class SettingsProvider extends ChangeNotifier { int get eveningEnd => _eveningEnd; int get nightStart => _nightStart; int get nightEnd => _nightEnd; - + // Persistent reminder getters bool get persistentReminders => _persistentReminders; int get reminderRetryInterval => _reminderRetryInterval; int get maxRetryAttempts => _maxRetryAttempts; - + + // Auto-sync getters + bool get autoSyncEnabled => _autoSyncEnabled; + int get autoSyncDebounceSeconds => _autoSyncDebounceSeconds; + // Helper method to get formatted time ranges for display String get morningRange => '${_formatHour(_morningStart)} - ${_formatHour((_morningEnd + 1) % 24)}'; String get afternoonRange => '${_formatHour(_afternoonStart)} - ${_formatHour((_afternoonEnd + 1) % 24)}'; String get eveningRange => '${_formatHour(_eveningStart)} - ${_formatHour((_eveningEnd + 1) % 24)}'; String get nightRange => '${_formatHour(_nightStart)} - ${_formatHour((_nightEnd + 1) % 24)}'; - + String _formatHour(int hour) { return '${hour.toString().padLeft(2, '0')}:00'; } - + ThemeMode get themeMode { switch (_themeOption) { case ThemeOption.light: @@ -67,7 +75,7 @@ class SettingsProvider extends ChangeNotifier { final prefs = await SharedPreferences.getInstance(); final themeIndex = prefs.getInt('theme_option') ?? 0; _themeOption = ThemeOption.values[themeIndex]; - + // Load time range settings _morningStart = prefs.getInt('morning_start') ?? 5; _morningEnd = prefs.getInt('morning_end') ?? 10; @@ -77,19 +85,23 @@ class SettingsProvider extends ChangeNotifier { _eveningEnd = prefs.getInt('evening_end') ?? 22; _nightStart = prefs.getInt('night_start') ?? 23; _nightEnd = prefs.getInt('night_end') ?? 4; - + // Load persistent reminder settings _persistentReminders = prefs.getBool('persistent_reminders') ?? true; _reminderRetryInterval = prefs.getInt('reminder_retry_interval') ?? 5; _maxRetryAttempts = prefs.getInt('max_retry_attempts') ?? 3; - + + // Load auto-sync settings + _autoSyncEnabled = prefs.getBool('auto_sync_enabled') ?? false; + _autoSyncDebounceSeconds = prefs.getInt('auto_sync_debounce_seconds') ?? 30; + notifyListeners(); } Future setThemeOption(ThemeOption option) async { _themeOption = option; notifyListeners(); - + final prefs = await SharedPreferences.getInstance(); await prefs.setInt('theme_option', option.index); } @@ -146,14 +158,14 @@ class SettingsProvider extends ChangeNotifier { if (morningStart > morningEnd) return false; if (afternoonStart > afternoonEnd) return false; if (eveningStart > eveningEnd) return false; - + // Night can wrap around midnight, so we allow nightStart > nightEnd - + // Check for overlaps in sequential periods if (morningEnd >= afternoonStart) return false; if (afternoonEnd >= eveningStart) return false; if (eveningEnd >= nightStart) return false; - + return true; } @@ -174,7 +186,7 @@ class SettingsProvider extends ChangeNotifier { int afternoonCount = 0; int eveningCount = 0; int nightCount = 0; - + for (final hour in hours) { if (hour >= _morningStart && hour <= _morningEnd) { morningCount++; @@ -186,13 +198,13 @@ class SettingsProvider extends ChangeNotifier { nightCount++; } } - + // If supplement is taken throughout the day (has times in multiple periods) - final periodsCount = (morningCount > 0 ? 1 : 0) + - (afternoonCount > 0 ? 1 : 0) + - (eveningCount > 0 ? 1 : 0) + + final periodsCount = (morningCount > 0 ? 1 : 0) + + (afternoonCount > 0 ? 1 : 0) + + (eveningCount > 0 ? 1 : 0) + (nightCount > 0 ? 1 : 0); - + if (periodsCount >= 2) { // Categorize based on the earliest reminder time for consistency final earliestHour = hours.reduce((a, b) => a < b ? a : b); @@ -206,7 +218,7 @@ class SettingsProvider extends ChangeNotifier { return 'night'; } } - + // If all times are in one period, categorize accordingly if (morningCount > 0) { return 'morning'; @@ -236,7 +248,7 @@ class SettingsProvider extends ChangeNotifier { Future setPersistentReminders(bool enabled) async { _persistentReminders = enabled; notifyListeners(); - + final prefs = await SharedPreferences.getInstance(); await prefs.setBool('persistent_reminders', enabled); } @@ -244,7 +256,7 @@ class SettingsProvider extends ChangeNotifier { Future setReminderRetryInterval(int minutes) async { _reminderRetryInterval = minutes; notifyListeners(); - + final prefs = await SharedPreferences.getInstance(); await prefs.setInt('reminder_retry_interval', minutes); } @@ -252,8 +264,25 @@ class SettingsProvider extends ChangeNotifier { Future setMaxRetryAttempts(int attempts) async { _maxRetryAttempts = attempts; notifyListeners(); - + final prefs = await SharedPreferences.getInstance(); await prefs.setInt('max_retry_attempts', attempts); } + + // Auto-sync setters + Future setAutoSyncEnabled(bool enabled) async { + _autoSyncEnabled = enabled; + notifyListeners(); + + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('auto_sync_enabled', enabled); + } + + Future setAutoSyncDebounceSeconds(int seconds) async { + _autoSyncDebounceSeconds = seconds; + notifyListeners(); + + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('auto_sync_debounce_seconds', seconds); + } } diff --git a/lib/providers/simple_sync_provider.dart b/lib/providers/simple_sync_provider.dart index 029f4d8..052a39e 100644 --- a/lib/providers/simple_sync_provider.dart +++ b/lib/providers/simple_sync_provider.dart @@ -1,7 +1,8 @@ import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import '../services/database_sync_service.dart'; +import '../services/auto_sync_service.dart'; +import 'settings_provider.dart'; class SimpleSyncProvider with ChangeNotifier { final DatabaseSyncService _syncService = DatabaseSyncService(); @@ -9,6 +10,12 @@ class SimpleSyncProvider with ChangeNotifier { // Callback for UI refresh after sync VoidCallback? _onSyncCompleteCallback; + // Auto-sync service + AutoSyncService? _autoSyncService; + + // Track if current sync is auto-triggered + bool _isAutoSync = false; + // Getters SyncStatus get status => _syncService.status; String? get lastError => _syncService.lastError; @@ -17,6 +24,14 @@ class SimpleSyncProvider with ChangeNotifier { bool get isSyncing => status == SyncStatus.downloading || status == SyncStatus.merging || status == SyncStatus.uploading; + bool get isAutoSync => _isAutoSync; + AutoSyncService? get autoSyncService => _autoSyncService; + + // Auto-sync error handling getters + bool get isAutoSyncDisabledDueToErrors => _autoSyncService?.isAutoDisabledDueToErrors ?? false; + int get autoSyncConsecutiveFailures => _autoSyncService?.consecutiveFailures ?? 0; + String? get autoSyncLastError => _autoSyncService?.lastErrorMessage; + bool get hasAutoSyncScheduledRetry => _autoSyncService?.hasScheduledRetry ?? false; // Configuration getters String? get serverUrl => _syncService.serverUrl; @@ -43,6 +58,23 @@ class SimpleSyncProvider with ChangeNotifier { _onSyncCompleteCallback = callback; } + /// Initialize auto-sync service with settings provider + void initializeAutoSync(SettingsProvider settingsProvider) { + _autoSyncService = AutoSyncService( + syncProvider: this, + settingsProvider: settingsProvider, + ); + + if (kDebugMode) { + print('SimpleSyncProvider: Auto-sync service initialized'); + } + } + + /// Triggers auto-sync if enabled and configured + void triggerAutoSyncIfEnabled() { + _autoSyncService?.triggerAutoSync(); + } + Future _loadConfiguration() async { await _syncService.loadSavedConfiguration(); notifyListeners(); // Notify UI that configuration might be available @@ -67,11 +99,14 @@ class SimpleSyncProvider with ChangeNotifier { return await _syncService.testConnection(); } - Future syncDatabase() async { + Future syncDatabase({bool isAutoSync = false}) async { if (!isConfigured) { throw Exception('Sync not configured'); } + _isAutoSync = isAutoSync; + notifyListeners(); + try { await _syncService.syncDatabase(); } catch (e) { @@ -79,6 +114,9 @@ class SimpleSyncProvider with ChangeNotifier { print('SupplementsLog: Sync failed in provider: $e'); } rethrow; + } finally { + _isAutoSync = false; + notifyListeners(); } } @@ -87,20 +125,46 @@ class SimpleSyncProvider with ChangeNotifier { notifyListeners(); } + /// Resets auto-sync error state and re-enables auto-sync if it was disabled + void resetAutoSyncErrors() { + _autoSyncService?.resetErrorState(); + notifyListeners(); + } + String getStatusText() { + final syncType = _isAutoSync ? 'Auto-sync' : 'Sync'; + + // Check for auto-sync specific errors first + if (isAutoSyncDisabledDueToErrors) { + return 'Auto-sync disabled due to repeated failures. ${autoSyncLastError ?? 'Check sync settings.'}'; + } + switch (status) { case SyncStatus.idle: + if (hasAutoSyncScheduledRetry) { + return 'Auto-sync will retry shortly...'; + } return 'Ready to sync'; case SyncStatus.downloading: - return 'Downloading remote database...'; + return '$syncType: Downloading remote database...'; case SyncStatus.merging: - return 'Merging databases...'; + return '$syncType: Merging databases...'; case SyncStatus.uploading: - return 'Uploading database...'; + return '$syncType: Uploading database...'; case SyncStatus.completed: - return 'Sync completed successfully'; + return '$syncType completed successfully'; case SyncStatus.error: - return 'Sync failed: ${lastError ?? 'Unknown error'}'; + // For auto-sync errors, show more specific messages + if (_isAutoSync && autoSyncLastError != null) { + return 'Auto-sync failed: $autoSyncLastError'; + } + return '$syncType failed: ${lastError ?? 'Unknown error'}'; } } + + @override + void dispose() { + _autoSyncService?.dispose(); + super.dispose(); + } } diff --git a/lib/screens/add_supplement_screen.dart b/lib/screens/add_supplement_screen.dart index a31d20f..6847afe 100644 --- a/lib/screens/add_supplement_screen.dart +++ b/lib/screens/add_supplement_screen.dart @@ -224,6 +224,13 @@ class _AddSupplementScreenState extends State { appBar: AppBar( title: Text(isEditing ? 'Edit Supplement' : 'Add Supplement'), backgroundColor: Theme.of(context).colorScheme.inversePrimary, + actions: [ + IconButton( + tooltip: isEditing ? 'Update Supplement' : 'Save Supplement', + onPressed: _saveSupplement, + icon: const Icon(Icons.save), + ), + ], ), body: Form( key: _formKey, @@ -482,17 +489,7 @@ class _AddSupplementScreenState extends State { ), const SizedBox(height: 24), - // Save button - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: _saveSupplement, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.all(16), - ), - child: Text(isEditing ? 'Update Supplement' : 'Add Supplement'), - ), - ), + // Save is now in the AppBar for consistency with app-wide pattern ], ), ), diff --git a/lib/screens/history_screen.dart b/lib/screens/history_screen.dart index d91f5ff..0029b4b 100644 --- a/lib/screens/history_screen.dart +++ b/lib/screens/history_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; +import '../providers/settings_provider.dart'; import '../providers/supplement_provider.dart'; class HistoryScreen extends StatefulWidget { @@ -125,27 +126,33 @@ class _HistoryScreenState extends State { Expanded( flex: 3, child: Container( - margin: const EdgeInsets.fromLTRB(0, 16, 16, 16), - child: _buildSelectedDayDetails(groupedIntakes), + // add a bit more horizontal spacing between calendar and card + margin: const EdgeInsets.fromLTRB(8, 16, 16, 16), + child: SingleChildScrollView( + child: _buildSelectedDayDetails(groupedIntakes), + ), ), ), ], ); } else { // Mobile layout: vertical stack - return Column( - children: [ - // Calendar - Container( - margin: const EdgeInsets.symmetric(horizontal: 16), - child: _buildCalendar(groupedIntakes), - ), - const SizedBox(height: 16), - // Selected day details - Expanded( - child: _buildSelectedDayDetails(groupedIntakes), - ), - ], + return SingleChildScrollView( + child: Column( + children: [ + // Calendar + Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + child: _buildCalendar(groupedIntakes), + ), + const SizedBox(height: 16), + // Selected day details + Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + child: _buildSelectedDayDetails(groupedIntakes), + ), + ], + ), ); } }, @@ -479,78 +486,208 @@ class _HistoryScreenState extends State { ], ), ), - Expanded( - child: ListView.builder( - padding: EdgeInsets.all(isWideScreen ? 20 : 16), - itemCount: dayIntakes.length, - itemBuilder: (context, index) { - final intake = dayIntakes[index]; - final takenAt = DateTime.parse(intake['takenAt']); - final units = (intake['unitsTaken'] as num?)?.toDouble() ?? 1.0; - - return Card( - margin: const EdgeInsets.only(bottom: 12), - elevation: 2, - child: Padding( - padding: EdgeInsets.all(isWideScreen ? 16 : 12), + Padding( + padding: EdgeInsets.all(isWideScreen ? 20 : 16), + child: Builder( + builder: (context) { + final settingsProvider = Provider.of(context, listen: false); + // Sort once per render + final sortedDayIntakes = List>.from(dayIntakes) + ..sort((a, b) => DateTime.parse(a['takenAt']).compareTo(DateTime.parse(b['takenAt']))); + // Helpers + String timeCategory(DateTime dt) { + final h = dt.hour; + if (h >= settingsProvider.morningStart && h <= settingsProvider.morningEnd) return 'morning'; + if (h >= settingsProvider.afternoonStart && h <= settingsProvider.afternoonEnd) return 'afternoon'; + if (h >= settingsProvider.eveningStart && h <= settingsProvider.eveningEnd) return 'evening'; + final ns = settingsProvider.nightStart; + final ne = settingsProvider.nightEnd; + final inNight = ns <= ne ? (h >= ns && h <= ne) : (h >= ns || h <= ne); + return inNight ? 'night' : 'anytime'; + } + String? sectionRange(String cat) { + switch (cat) { + case 'morning': + return settingsProvider.morningRange; + case 'afternoon': + return settingsProvider.afternoonRange; + case 'evening': + return settingsProvider.eveningRange; + case 'night': + return settingsProvider.nightRange; + default: + return null; + } + } + Widget headerFor(String cat) { + late final IconData icon; + late final Color color; + late final String title; + switch (cat) { + case 'morning': + icon = Icons.wb_sunny; + color = Colors.orange; + title = 'Morning'; + break; + case 'afternoon': + icon = Icons.light_mode; + color = Colors.blue; + title = 'Afternoon'; + break; + case 'evening': + icon = Icons.nightlight_round; + color = Colors.indigo; + title = 'Evening'; + break; + case 'night': + icon = Icons.bedtime; + color = Colors.purple; + title = 'Night'; + break; + default: + icon = Icons.schedule; + color = Colors.grey; + title = 'Anytime'; + } + final range = sectionRange(cat); + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: color.withOpacity(0.3), + width: 1, + ), + ), child: Row( children: [ - CircleAvatar( - backgroundColor: Theme.of(context).colorScheme.primary, - radius: isWideScreen ? 24 : 20, + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withOpacity(0.2), + shape: BoxShape.circle, + ), child: Icon( - Icons.medication, - color: Theme.of(context).colorScheme.onPrimary, - size: isWideScreen ? 24 : 20, + icon, + size: 20, + color: color, ), ), - SizedBox(width: isWideScreen ? 16 : 12), + const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - intake['supplementName'], + title, style: TextStyle( - fontWeight: FontWeight.w600, - fontSize: isWideScreen ? 16 : 14, + fontSize: 18, + fontWeight: FontWeight.bold, + color: color, ), ), - const SizedBox(height: 4), - Text( - '${units.toStringAsFixed(units % 1 == 0 ? 0 : 1)} ${intake['supplementUnitType'] ?? 'units'} at ${DateFormat('HH:mm').format(takenAt)}', - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w500, - fontSize: isWideScreen ? 14 : 12, - ), - ), - if (intake['notes'] != null && intake['notes'].toString().isNotEmpty) ...[ - const SizedBox(height: 4), + if (range != null) ...[ Text( - intake['notes'], + '($range)', style: TextStyle( - fontSize: isWideScreen ? 13 : 12, - fontStyle: FontStyle.italic, - color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 12, + fontWeight: FontWeight.w500, + color: color.withOpacity(0.8), ), ), ], ], ), ), - IconButton( - icon: Icon( - Icons.delete_outline, - color: Colors.red.shade400, - size: isWideScreen ? 24 : 20, - ), - onPressed: () => _deleteIntake(context, intake['id'], intake['supplementName']), - tooltip: 'Delete intake', - ), ], ), - ), + ); + } + // Build a non-scrollable list so the card auto-expands to fit content + final List children = []; + for (int index = 0; index < sortedDayIntakes.length; index++) { + final intake = sortedDayIntakes[index]; + final takenAt = DateTime.parse(intake['takenAt']); + final units = (intake['unitsTaken'] as num?)?.toDouble() ?? 1.0; + final currentCategory = timeCategory(takenAt); + final needsHeader = index == 0 + ? true + : currentCategory != timeCategory(DateTime.parse(sortedDayIntakes[index - 1]['takenAt'])); + if (needsHeader) { + children.add(headerFor(currentCategory)); + } + children.add( + Card( + margin: const EdgeInsets.only(bottom: 12), + elevation: 2, + child: Padding( + padding: EdgeInsets.all(isWideScreen ? 16 : 12), + child: Row( + children: [ + CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.primary, + radius: isWideScreen ? 24 : 20, + child: Icon( + Icons.medication, + color: Theme.of(context).colorScheme.onPrimary, + size: isWideScreen ? 24 : 20, + ), + ), + SizedBox(width: isWideScreen ? 16 : 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + intake['supplementName'], + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: isWideScreen ? 16 : 14, + ), + ), + const SizedBox(height: 4), + Text( + '${units.toStringAsFixed(units % 1 == 0 ? 0 : 1)} ${intake['supplementUnitType'] ?? 'units'} at ${DateFormat('HH:mm').format(takenAt)}', + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, + fontSize: isWideScreen ? 14 : 12, + ), + ), + if (intake['notes'] != null && intake['notes'].toString().isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + intake['notes'], + style: TextStyle( + fontSize: isWideScreen ? 13 : 12, + fontStyle: FontStyle.italic, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + IconButton( + icon: Icon( + Icons.delete_outline, + color: Colors.red.shade400, + size: isWideScreen ? 24 : 20, + ), + onPressed: () => _deleteIntake(context, intake['id'], intake['supplementName']), + tooltip: 'Delete intake', + ), + ], + ), + ), + ), + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, ); }, ), diff --git a/lib/screens/simple_sync_settings_screen.dart b/lib/screens/simple_sync_settings_screen.dart index a767c6a..d9e0a5b 100644 --- a/lib/screens/simple_sync_settings_screen.dart +++ b/lib/screens/simple_sync_settings_screen.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../services/database_sync_service.dart'; +import '../providers/settings_provider.dart'; import '../providers/simple_sync_provider.dart'; +import '../services/database_sync_service.dart'; class SimpleSyncSettingsScreen extends StatefulWidget { const SimpleSyncSettingsScreen({super.key}); @@ -17,7 +18,7 @@ class _SimpleSyncSettingsScreenState extends State { final _usernameController = TextEditingController(); final _passwordController = TextEditingController(); final _remotePathController = TextEditingController(); - + String _previewUrl = ''; @override @@ -27,11 +28,11 @@ class _SimpleSyncSettingsScreenState extends State { _usernameController.addListener(_updatePreviewUrl); _loadSavedConfiguration(); } - + void _loadSavedConfiguration() { WidgetsBinding.instance.addPostFrameCallback((_) { final syncProvider = context.read(); - + if (syncProvider.serverUrl != null) { _serverUrlController.text = _extractHostnameFromUrl(syncProvider.serverUrl!); } @@ -44,11 +45,11 @@ class _SimpleSyncSettingsScreenState extends State { if (syncProvider.remotePath != null) { _remotePathController.text = syncProvider.remotePath!; } - + _updatePreviewUrl(); }); } - + String _extractHostnameFromUrl(String fullUrl) { try { final uri = Uri.parse(fullUrl); @@ -81,30 +82,35 @@ class _SimpleSyncSettingsScreenState extends State { @override Widget build(BuildContext context) { + final syncProvider = context.watch(); + return Scaffold( appBar: AppBar( title: const Text('Database Sync Settings'), backgroundColor: Theme.of(context).colorScheme.inversePrimary, + actions: [ + IconButton( + tooltip: 'Save Configuration', + onPressed: syncProvider.isSyncing ? null : _configureSync, + icon: const Icon(Icons.save), + ), + ], ), - 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), - ], - ), - ), - ); - }, + body: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildStatusCard(syncProvider), + const SizedBox(height: 20), + _buildConfigurationSection(syncProvider), + const SizedBox(height: 20), + _buildActionButtons(), + ], + ), + ), ), ); } @@ -116,17 +122,17 @@ class _SimpleSyncSettingsScreenState extends State { switch (syncProvider.status) { case SyncStatus.idle: - icon = Icons.sync; + icon = syncProvider.isAutoSync ? Icons.sync_alt : Icons.sync; color = Colors.blue; break; case SyncStatus.downloading: case SyncStatus.merging: case SyncStatus.uploading: - icon = Icons.sync; - color = Colors.orange; + icon = syncProvider.isAutoSync ? Icons.sync_alt : Icons.sync; + color = syncProvider.isAutoSync ? Colors.deepOrange : Colors.orange; break; case SyncStatus.completed: - icon = Icons.check_circle; + icon = syncProvider.isAutoSync ? Icons.check_circle_outline : Icons.check_circle; color = Colors.green; break; case SyncStatus.error: @@ -152,8 +158,28 @@ class _SimpleSyncSettingsScreenState extends State { ), ), ), + // Sync action inside the status card + if (syncProvider.isSyncing) ...[ + const SizedBox(width: 12), + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + valueColor: AlwaysStoppedAnimation(color), + ), + ), + ] else ...[ + IconButton( + tooltip: 'Sync Database', + onPressed: (!syncProvider.isConfigured || syncProvider.isSyncing) ? null : _syncDatabase, + icon: const Icon(Icons.sync), + color: Theme.of(context).colorScheme.primary, + ), + ], ], ), + _buildAutoSyncStatusIndicator(syncProvider), if (syncProvider.lastSyncTime != null) ...[ const SizedBox(height: 8), Text( @@ -161,19 +187,73 @@ class _SimpleSyncSettingsScreenState extends State { style: Theme.of(context).textTheme.bodySmall, ), ], - if (syncProvider.lastError != null) ...[ + // Show auto-sync specific errors + if (syncProvider.isAutoSyncDisabledDueToErrors) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.withValues(alpha: 0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.warning, color: Colors.red, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Auto-sync disabled due to repeated failures', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Colors.red, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + if (syncProvider.autoSyncLastError != null) ...[ + const SizedBox(height: 8), + Text( + syncProvider.autoSyncLastError!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.red[700], + ), + ), + ], + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + onPressed: () => syncProvider.resetAutoSyncErrors(), + icon: const Icon(Icons.refresh, size: 16), + label: const Text('Reset & Re-enable'), + style: TextButton.styleFrom( + foregroundColor: Colors.red, + ), + ), + ], + ), + ], + ), + ), + ] else if (syncProvider.lastError != null) ...[ const SizedBox(height: 8), Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: Colors.red.withOpacity(0.1), + color: Colors.red.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4), ), child: Row( children: [ Expanded( child: Text( - syncProvider.lastError!, + _getErrorMessage(syncProvider), style: const TextStyle(color: Colors.red), ), ), @@ -191,7 +271,7 @@ class _SimpleSyncSettingsScreenState extends State { ); } - Widget _buildConfigurationSection() { + Widget _buildConfigurationSection(SimpleSyncProvider syncProvider) { return Card( child: Padding( padding: const EdgeInsets.all(16.0), @@ -199,10 +279,17 @@ class _SimpleSyncSettingsScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'WebDAV Configuration', + 'Sync Configuration', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 16), + _buildAutoSyncSection(), + const SizedBox(height: 24), + Text( + 'WebDAV Settings', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), TextFormField( controller: _serverUrlController, decoration: const InputDecoration( @@ -237,10 +324,10 @@ class _SimpleSyncSettingsScreenState extends State { Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.3), + color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(8), border: Border.all( - color: Theme.of(context).colorScheme.outline.withOpacity(0.5), + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), ), ), child: Column( @@ -258,6 +345,21 @@ class _SimpleSyncSettingsScreenState extends State { color: Theme.of(context).colorScheme.primary, ), ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ElevatedButton.icon( + onPressed: syncProvider.isSyncing ? null : _testConnection, + icon: const Icon(Icons.link), + label: const Text('Test'), + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + elevation: 0, + ), + ), + ], + ), ], ), ), @@ -292,40 +394,122 @@ class _SimpleSyncSettingsScreenState extends State { ); } - 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, + Widget _buildActionButtons() { + // Buttons have been moved into the AppBar / cards. Keep a small spacer here for layout. + return const SizedBox.shrink(); + } + + Widget _buildAutoSyncSection() { + return Consumer( + builder: (context, settingsProvider, child) { + return Consumer( + builder: (context, syncProvider, child) { + return Container( + padding: const EdgeInsets.all(16.0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), ), - child: syncProvider.isSyncing - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile( + title: Row( + children: [ + const Text( + 'Auto-sync', + style: TextStyle(fontWeight: FontWeight.w600), + ), + const SizedBox(width: 8), + _buildAutoSyncStatusBadge(settingsProvider, syncProvider), + ], + ), + subtitle: Text( + settingsProvider.autoSyncEnabled + ? 'Automatically sync when you make changes' + : 'Sync manually using the sync button', + style: Theme.of(context).textTheme.bodySmall, + ), + value: settingsProvider.autoSyncEnabled, + onChanged: (bool value) async { + await settingsProvider.setAutoSyncEnabled(value); + }, + contentPadding: EdgeInsets.zero, + ), + if (settingsProvider.autoSyncEnabled) ...[ + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.only(left: 16.0), + child: Text( + 'Changes are debounced for ${settingsProvider.autoSyncDebounceSeconds} seconds to prevent excessive syncing.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), ), - ) - : const Text('Sync Database'), - ), - ], + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.only(left: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Debounce timeout', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment(value: 1, label: Text('1s')), + ButtonSegment(value: 5, label: Text('5s')), + ButtonSegment(value: 15, label: Text('15s')), + ButtonSegment(value: 30, label: Text('30s')), + ], + selected: {settingsProvider.autoSyncDebounceSeconds}, + onSelectionChanged: (values) { + settingsProvider.setAutoSyncDebounceSeconds(values.first); + }, + ), + ], + ), + ), + ], + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + Icons.info_outline, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Auto-sync triggers when you add, update, or delete supplements and intakes. Configure your WebDAV settings below to enable syncing.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ), + ], + ), + ), + ], + ), + ); + }, + ); + }, ); } @@ -333,31 +517,31 @@ class _SimpleSyncSettingsScreenState extends State { 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' + 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!' + content: Text(success + ? 'Connection successful!' : 'Connection failed. Check your settings.'), backgroundColor: success ? Colors.green : Colors.red, ), @@ -379,20 +563,20 @@ class _SimpleSyncSettingsScreenState extends State { 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' + remotePath: _remotePathController.text.trim().isEmpty + ? 'Supplements' : _remotePathController.text.trim(), ); @@ -418,14 +602,14 @@ class _SimpleSyncSettingsScreenState extends State { Future _syncDatabase() async { final syncProvider = context.read(); - + try { - await syncProvider.syncDatabase(); - + await syncProvider.syncDatabase(isAutoSync: false); + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Database sync completed!'), + content: Text('Manual sync completed!'), backgroundColor: Colors.green, ), ); @@ -434,7 +618,7 @@ class _SimpleSyncSettingsScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Sync failed: $e'), + content: Text('Manual sync failed: $e'), backgroundColor: Colors.red, ), ); @@ -450,17 +634,200 @@ class _SimpleSyncSettingsScreenState extends State { } 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/'; } + Widget _buildAutoSyncStatusIndicator(SimpleSyncProvider syncProvider) { + return Consumer( + builder: (context, settingsProvider, child) { + // Only show auto-sync status if auto-sync is enabled + if (!settingsProvider.autoSyncEnabled) { + return const SizedBox.shrink(); + } + + // Check if auto-sync service has pending sync + final autoSyncService = syncProvider.autoSyncService; + if (autoSyncService == null) { + return const SizedBox.shrink(); + } + + // Show pending auto-sync indicator + if (autoSyncService.hasPendingSync && !syncProvider.isSyncing) { + return Container( + margin: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 1.5, + valueColor: const AlwaysStoppedAnimation(Colors.blue), + ), + ), + const SizedBox(width: 8), + Text( + 'Auto-sync pending...', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.blue, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } + + // Show auto-sync active indicator (when sync is running and it's auto-triggered) + if (syncProvider.isSyncing && syncProvider.isAutoSync) { + return Container( + margin: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.deepOrange.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.deepOrange.withValues(alpha: 0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.sync_alt, + size: 12, + color: Colors.deepOrange, + ), + const SizedBox(width: 8), + Text( + 'Auto-sync active', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.deepOrange, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } + + return const SizedBox.shrink(); + }, + ); + } + + Widget _buildAutoSyncStatusBadge(SettingsProvider settingsProvider, SimpleSyncProvider syncProvider) { + if (!settingsProvider.autoSyncEnabled) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.grey.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'OFF', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.grey[600], + fontWeight: FontWeight.w600, + ), + ), + ); + } + + // Check if auto-sync is disabled due to errors + if (syncProvider.isAutoSyncDisabledDueToErrors) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'ERROR', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.red[700], + fontWeight: FontWeight.w600, + ), + ), + ); + } + + // Check if sync is configured + if (!syncProvider.isConfigured) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'NOT CONFIGURED', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.orange[700], + fontWeight: FontWeight.w600, + ), + ), + ); + } + + // Check if there are recent failures but not disabled yet + if (syncProvider.autoSyncConsecutiveFailures > 0) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'RETRYING', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.orange[700], + fontWeight: FontWeight.w600, + ), + ), + ); + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'ACTIVE', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.green[700], + fontWeight: FontWeight.w600, + ), + ), + ); + } + + String _getErrorMessage(SimpleSyncProvider syncProvider) { + final error = syncProvider.lastError ?? 'Unknown error'; + + // Add context for auto-sync errors + if (syncProvider.isAutoSync) { + return 'Auto-sync error: $error'; + } + + return error; + } + String _formatDateTime(DateTime dateTime) { return '${dateTime.day}/${dateTime.month}/${dateTime.year} ${dateTime.hour}:${dateTime.minute.toString().padLeft(2, '0')}'; } diff --git a/lib/services/auto_sync_service.dart b/lib/services/auto_sync_service.dart new file mode 100644 index 0000000..5dfb1bf --- /dev/null +++ b/lib/services/auto_sync_service.dart @@ -0,0 +1,470 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; +import 'package:flutter/foundation.dart'; + +import '../providers/settings_provider.dart'; +import '../providers/simple_sync_provider.dart'; + +/// Error types for auto-sync operations +enum AutoSyncErrorType { + network, + configuration, + authentication, + server, + unknown, +} + +/// Represents an auto-sync error with context +class AutoSyncError { + final AutoSyncErrorType type; + final String message; + final DateTime timestamp; + final dynamic originalError; + + AutoSyncError({ + required this.type, + required this.message, + required this.timestamp, + this.originalError, + }); + + @override + String toString() => 'AutoSyncError($type): $message'; +} + +/// Service that handles automatic synchronization with debouncing logic +/// to prevent excessive sync requests when multiple data changes occur rapidly. +class AutoSyncService { + Timer? _debounceTimer; + bool _syncInProgress = false; + bool _hasPendingSync = false; + + // Error handling and retry logic + final List _recentErrors = []; + int _consecutiveFailures = 0; + DateTime? _lastFailureTime; + Timer? _retryTimer; + bool _autoDisabledDueToErrors = false; + + // Exponential backoff configuration + static const int _maxRetryAttempts = 5; + static const int _baseRetryDelaySeconds = 30; + static const int _maxRetryDelaySeconds = 300; // 5 minutes + static const int _errorHistoryMaxSize = 10; + static const int _autoDisableThreshold = 3; // Consecutive failures before auto-disable + + final SimpleSyncProvider _syncProvider; + final SettingsProvider _settingsProvider; + + AutoSyncService({ + required SimpleSyncProvider syncProvider, + required SettingsProvider settingsProvider, + }) : _syncProvider = syncProvider, + _settingsProvider = settingsProvider; + + /// Triggers an auto-sync if enabled in settings. + /// Uses debouncing to prevent excessive sync requests. + void triggerAutoSync() { + // Check if auto-sync is enabled + if (!_settingsProvider.autoSyncEnabled) { + if (kDebugMode) { + print('AutoSyncService: Auto-sync is disabled, skipping trigger'); + } + return; + } + + // Check if auto-sync was disabled due to persistent errors + if (_autoDisabledDueToErrors) { + if (kDebugMode) { + print('AutoSyncService: Auto-sync disabled due to persistent errors, skipping trigger'); + } + return; + } + + // Check if sync is configured + if (!_syncProvider.isConfigured) { + _recordError(AutoSyncError( + type: AutoSyncErrorType.configuration, + message: 'Sync not configured. Please configure cloud sync settings.', + timestamp: DateTime.now(), + )); + if (kDebugMode) { + print('AutoSyncService: Sync not configured, skipping auto-sync'); + } + return; + } + + // If sync is already in progress, mark that we have a pending sync + if (_syncInProgress || _syncProvider.isSyncing) { + _hasPendingSync = true; + if (kDebugMode) { + print('AutoSyncService: Sync in progress, marking pending sync'); + } + return; + } + + // Cancel existing timer if one is running + _cancelPendingSync(); + + // Check if we should apply exponential backoff + final backoffDelay = _calculateBackoffDelay(); + if (backoffDelay > 0) { + if (kDebugMode) { + print('AutoSyncService: Applying backoff delay of ${backoffDelay}s due to recent failures'); + } + _debounceTimer = Timer(Duration(seconds: backoffDelay), () { + _executePendingSync(); + }); + return; + } + + // Start new debounce timer + final debounceSeconds = _settingsProvider.autoSyncDebounceSeconds; + _debounceTimer = Timer(Duration(seconds: debounceSeconds), () { + _executePendingSync(); + }); + + if (kDebugMode) { + print('AutoSyncService: Auto-sync scheduled in ${debounceSeconds}s'); + } + } + + /// Executes the pending sync operation + Future _executePendingSync() async { + // Double-check conditions before executing + if (!_settingsProvider.autoSyncEnabled) { + if (kDebugMode) { + print('AutoSyncService: Auto-sync disabled during execution, aborting'); + } + return; + } + + if (_autoDisabledDueToErrors) { + if (kDebugMode) { + print('AutoSyncService: Auto-sync disabled due to errors during execution, aborting'); + } + return; + } + + if (!_syncProvider.isConfigured) { + _recordError(AutoSyncError( + type: AutoSyncErrorType.configuration, + message: 'Sync not configured during execution', + timestamp: DateTime.now(), + )); + if (kDebugMode) { + print('AutoSyncService: Sync not configured during execution, aborting'); + } + return; + } + + if (_syncInProgress || _syncProvider.isSyncing) { + if (kDebugMode) { + print('AutoSyncService: Sync already in progress during execution, aborting'); + } + return; + } + + _syncInProgress = true; + _hasPendingSync = false; + + try { + if (kDebugMode) { + print('AutoSyncService: Executing auto-sync (attempt ${_consecutiveFailures + 1})'); + } + + // Check network connectivity before attempting sync + if (!await _isNetworkAvailable()) { + throw AutoSyncError( + type: AutoSyncErrorType.network, + message: 'Network is not available', + timestamp: DateTime.now(), + ); + } + + await _syncProvider.syncDatabase(isAutoSync: true); + + // Reset failure count on successful sync + _consecutiveFailures = 0; + _lastFailureTime = null; + _autoDisabledDueToErrors = false; + + if (kDebugMode) { + print('AutoSyncService: Auto-sync completed successfully'); + } + + } catch (e) { + if (kDebugMode) { + print('AutoSyncService: Auto-sync failed: $e'); + } + + // Handle specific error types + _handleSyncError(e); + + } finally { + _syncInProgress = false; + + // If there was a pending sync request while we were syncing, trigger it + if (_hasPendingSync && !_autoDisabledDueToErrors) { + if (kDebugMode) { + print('AutoSyncService: Processing queued sync request'); + } + _hasPendingSync = false; + // Use a small delay to avoid immediate re-triggering + Timer(const Duration(milliseconds: 500), () { + triggerAutoSync(); + }); + } + } + } + + /// Handles sync errors with appropriate recovery strategies + void _handleSyncError(dynamic error) { + _consecutiveFailures++; + _lastFailureTime = DateTime.now(); + + final autoSyncError = _categorizeError(error); + _recordError(autoSyncError); + + // Check if we should disable auto-sync due to persistent errors + if (_consecutiveFailures >= _autoDisableThreshold) { + _autoDisabledDueToErrors = true; + if (kDebugMode) { + print('AutoSyncService: Auto-sync disabled due to ${_consecutiveFailures} consecutive failures'); + } + + // For configuration errors, disable immediately + if (autoSyncError.type == AutoSyncErrorType.configuration || + autoSyncError.type == AutoSyncErrorType.authentication) { + if (kDebugMode) { + print('AutoSyncService: Auto-sync disabled due to configuration/authentication error'); + } + } + } + + // Schedule retry for recoverable errors (unless auto-disabled) + if (!_autoDisabledDueToErrors && _shouldRetry(autoSyncError.type)) { + _scheduleRetry(); + } + } + + /// Categorizes an error into a specific AutoSyncError type + AutoSyncError _categorizeError(dynamic error) { + final errorString = error.toString().toLowerCase(); + + // Network-related errors + if (error is SocketException || + errorString.contains('network') || + errorString.contains('connection') || + errorString.contains('timeout') || + errorString.contains('unreachable') || + errorString.contains('host lookup failed') || + errorString.contains('no route to host')) { + return AutoSyncError( + type: AutoSyncErrorType.network, + message: 'Network connection failed. Check your internet connection.', + timestamp: DateTime.now(), + originalError: error, + ); + } + + // Configuration-related errors + if (errorString.contains('not configured') || + errorString.contains('invalid url') || + errorString.contains('malformed url')) { + return AutoSyncError( + type: AutoSyncErrorType.configuration, + message: 'Sync configuration is invalid. Please check your sync settings.', + timestamp: DateTime.now(), + originalError: error, + ); + } + + // Authentication errors + if (errorString.contains('authentication') || + errorString.contains('unauthorized') || + errorString.contains('401') || + errorString.contains('403') || + errorString.contains('invalid credentials')) { + return AutoSyncError( + type: AutoSyncErrorType.authentication, + message: 'Authentication failed. Please check your username and password.', + timestamp: DateTime.now(), + originalError: error, + ); + } + + // Server errors + if (errorString.contains('500') || + errorString.contains('502') || + errorString.contains('503') || + errorString.contains('504') || + errorString.contains('server error')) { + return AutoSyncError( + type: AutoSyncErrorType.server, + message: 'Server error occurred. The sync server may be temporarily unavailable.', + timestamp: DateTime.now(), + originalError: error, + ); + } + + // Unknown errors + return AutoSyncError( + type: AutoSyncErrorType.unknown, + message: 'An unexpected error occurred during sync: ${error.toString()}', + timestamp: DateTime.now(), + originalError: error, + ); + } + + /// Records an error in the recent errors list + void _recordError(AutoSyncError error) { + _recentErrors.add(error); + + // Keep only recent errors + if (_recentErrors.length > _errorHistoryMaxSize) { + _recentErrors.removeAt(0); + } + + if (kDebugMode) { + print('AutoSyncService: Recorded error: $error'); + } + } + + /// Determines if we should retry for a given error type + bool _shouldRetry(AutoSyncErrorType errorType) { + switch (errorType) { + case AutoSyncErrorType.network: + case AutoSyncErrorType.server: + return _consecutiveFailures < _maxRetryAttempts; + case AutoSyncErrorType.configuration: + case AutoSyncErrorType.authentication: + return false; // Don't retry config/auth errors + case AutoSyncErrorType.unknown: + return _consecutiveFailures < _maxRetryAttempts; + } + } + + /// Calculates the backoff delay based on consecutive failures + int _calculateBackoffDelay() { + if (_consecutiveFailures == 0 || _lastFailureTime == null) { + return 0; + } + + // Calculate exponential backoff: base * (2^failures) + final backoffSeconds = min( + _baseRetryDelaySeconds * pow(2, _consecutiveFailures - 1).toInt(), + _maxRetryDelaySeconds, + ); + + // Check if enough time has passed since last failure + final timeSinceLastFailure = DateTime.now().difference(_lastFailureTime!).inSeconds; + if (timeSinceLastFailure >= backoffSeconds) { + return 0; // No additional delay needed + } + + return backoffSeconds - timeSinceLastFailure; + } + + /// Schedules a retry attempt with exponential backoff + void _scheduleRetry() { + final retryDelay = _calculateBackoffDelay(); + if (retryDelay <= 0) return; + + _retryTimer?.cancel(); + _retryTimer = Timer(Duration(seconds: retryDelay), () { + if (kDebugMode) { + print('AutoSyncService: Retrying auto-sync after backoff delay'); + } + triggerAutoSync(); + }); + + if (kDebugMode) { + print('AutoSyncService: Scheduled retry in ${retryDelay}s'); + } + } + + /// Checks if network is available + Future _isNetworkAvailable() async { + try { + final result = await InternetAddress.lookup('google.com'); + return result.isNotEmpty && result[0].rawAddress.isNotEmpty; + } catch (e) { + if (kDebugMode) { + print('AutoSyncService: Network check failed: $e'); + } + return false; + } + } + + /// Cancels any pending sync operation + void cancelPendingSync() { + _cancelPendingSync(); + _retryTimer?.cancel(); + _retryTimer = null; + _hasPendingSync = false; + + if (kDebugMode) { + print('AutoSyncService: Cancelled pending sync and retry timer'); + } + } + + /// Internal method to cancel the debounce timer + void _cancelPendingSync() { + _debounceTimer?.cancel(); + _debounceTimer = null; + } + + /// Resets error state and re-enables auto-sync if it was disabled + void resetErrorState() { + _consecutiveFailures = 0; + _lastFailureTime = null; + _autoDisabledDueToErrors = false; + _recentErrors.clear(); + _retryTimer?.cancel(); + _retryTimer = null; + + if (kDebugMode) { + print('AutoSyncService: Error state reset, auto-sync re-enabled'); + } + } + + /// Disposes of the service and cleans up resources + void dispose() { + _cancelPendingSync(); + _retryTimer?.cancel(); + _retryTimer = null; + _hasPendingSync = false; + _syncInProgress = false; + _recentErrors.clear(); + + if (kDebugMode) { + print('AutoSyncService: Disposed'); + } + } + + /// Returns true if there is a pending sync operation + bool get hasPendingSync => _hasPendingSync || _debounceTimer != null; + + /// Returns true if a sync is currently in progress + bool get isSyncInProgress => _syncInProgress; + + /// Returns true if auto-sync was disabled due to persistent errors + bool get isAutoDisabledDueToErrors => _autoDisabledDueToErrors; + + /// Returns the number of consecutive failures + int get consecutiveFailures => _consecutiveFailures; + + /// Returns a copy of recent errors + List get recentErrors => List.unmodifiable(_recentErrors); + + /// Returns the last error message suitable for display to users + String? get lastErrorMessage { + if (_recentErrors.isEmpty) return null; + return _recentErrors.last.message; + } + + /// Returns true if a retry is currently scheduled + bool get hasScheduledRetry => _retryTimer != null; +} \ No newline at end of file diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index 8b031dd..0000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:supplements/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -}