feat: adds auto sync feature and fixes UI a bit up

This commit is contained in:
2025-08-27 21:47:24 +02:00
parent 33dfd6e3e5
commit e95dcf3322
8 changed files with 1268 additions and 223 deletions

View File

@@ -31,8 +31,11 @@ class MyApp extends StatelessWidget {
child: Consumer2<SettingsProvider, SimpleSyncProvider>( child: Consumer2<SettingsProvider, SimpleSyncProvider>(
builder: (context, settingsProvider, syncProvider, child) { builder: (context, settingsProvider, syncProvider, child) {
// Set up the sync completion callback to refresh supplement data // Set up the sync completion callback to refresh supplement data
// and initialize auto-sync integration
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
final supplementProvider = context.read<SupplementProvider>(); final supplementProvider = context.read<SupplementProvider>();
// Set up sync completion callback
syncProvider.setOnSyncCompleteCallback(() async { syncProvider.setOnSyncCompleteCallback(() async {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Sync completed, refreshing UI data...'); print('SupplementsLog: Sync completed, refreshing UI data...');
@@ -43,6 +46,14 @@ class MyApp extends StatelessWidget {
print('SupplementsLog: UI data refreshed after sync'); 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( return MaterialApp(

View File

@@ -9,7 +9,7 @@ enum ThemeOption {
class SettingsProvider extends ChangeNotifier { class SettingsProvider extends ChangeNotifier {
ThemeOption _themeOption = ThemeOption.system; ThemeOption _themeOption = ThemeOption.system;
// Time range settings (stored as hours, 0-23) // Time range settings (stored as hours, 0-23)
int _morningStart = 5; int _morningStart = 5;
int _morningEnd = 10; int _morningEnd = 10;
@@ -19,14 +19,18 @@ class SettingsProvider extends ChangeNotifier {
int _eveningEnd = 22; int _eveningEnd = 22;
int _nightStart = 23; int _nightStart = 23;
int _nightEnd = 4; int _nightEnd = 4;
// Persistent reminder settings // Persistent reminder settings
bool _persistentReminders = true; bool _persistentReminders = true;
int _reminderRetryInterval = 5; // minutes int _reminderRetryInterval = 5; // minutes
int _maxRetryAttempts = 3; int _maxRetryAttempts = 3;
// Auto-sync settings
bool _autoSyncEnabled = false;
int _autoSyncDebounceSeconds = 5;
ThemeOption get themeOption => _themeOption; ThemeOption get themeOption => _themeOption;
// Time range getters // Time range getters
int get morningStart => _morningStart; int get morningStart => _morningStart;
int get morningEnd => _morningEnd; int get morningEnd => _morningEnd;
@@ -36,22 +40,26 @@ class SettingsProvider extends ChangeNotifier {
int get eveningEnd => _eveningEnd; int get eveningEnd => _eveningEnd;
int get nightStart => _nightStart; int get nightStart => _nightStart;
int get nightEnd => _nightEnd; int get nightEnd => _nightEnd;
// Persistent reminder getters // Persistent reminder getters
bool get persistentReminders => _persistentReminders; bool get persistentReminders => _persistentReminders;
int get reminderRetryInterval => _reminderRetryInterval; int get reminderRetryInterval => _reminderRetryInterval;
int get maxRetryAttempts => _maxRetryAttempts; int get maxRetryAttempts => _maxRetryAttempts;
// Auto-sync getters
bool get autoSyncEnabled => _autoSyncEnabled;
int get autoSyncDebounceSeconds => _autoSyncDebounceSeconds;
// Helper method to get formatted time ranges for display // Helper method to get formatted time ranges for display
String get morningRange => '${_formatHour(_morningStart)} - ${_formatHour((_morningEnd + 1) % 24)}'; String get morningRange => '${_formatHour(_morningStart)} - ${_formatHour((_morningEnd + 1) % 24)}';
String get afternoonRange => '${_formatHour(_afternoonStart)} - ${_formatHour((_afternoonEnd + 1) % 24)}'; String get afternoonRange => '${_formatHour(_afternoonStart)} - ${_formatHour((_afternoonEnd + 1) % 24)}';
String get eveningRange => '${_formatHour(_eveningStart)} - ${_formatHour((_eveningEnd + 1) % 24)}'; String get eveningRange => '${_formatHour(_eveningStart)} - ${_formatHour((_eveningEnd + 1) % 24)}';
String get nightRange => '${_formatHour(_nightStart)} - ${_formatHour((_nightEnd + 1) % 24)}'; String get nightRange => '${_formatHour(_nightStart)} - ${_formatHour((_nightEnd + 1) % 24)}';
String _formatHour(int hour) { String _formatHour(int hour) {
return '${hour.toString().padLeft(2, '0')}:00'; return '${hour.toString().padLeft(2, '0')}:00';
} }
ThemeMode get themeMode { ThemeMode get themeMode {
switch (_themeOption) { switch (_themeOption) {
case ThemeOption.light: case ThemeOption.light:
@@ -67,7 +75,7 @@ class SettingsProvider extends ChangeNotifier {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final themeIndex = prefs.getInt('theme_option') ?? 0; final themeIndex = prefs.getInt('theme_option') ?? 0;
_themeOption = ThemeOption.values[themeIndex]; _themeOption = ThemeOption.values[themeIndex];
// Load time range settings // Load time range settings
_morningStart = prefs.getInt('morning_start') ?? 5; _morningStart = prefs.getInt('morning_start') ?? 5;
_morningEnd = prefs.getInt('morning_end') ?? 10; _morningEnd = prefs.getInt('morning_end') ?? 10;
@@ -77,19 +85,23 @@ class SettingsProvider extends ChangeNotifier {
_eveningEnd = prefs.getInt('evening_end') ?? 22; _eveningEnd = prefs.getInt('evening_end') ?? 22;
_nightStart = prefs.getInt('night_start') ?? 23; _nightStart = prefs.getInt('night_start') ?? 23;
_nightEnd = prefs.getInt('night_end') ?? 4; _nightEnd = prefs.getInt('night_end') ?? 4;
// Load persistent reminder settings // Load persistent reminder settings
_persistentReminders = prefs.getBool('persistent_reminders') ?? true; _persistentReminders = prefs.getBool('persistent_reminders') ?? true;
_reminderRetryInterval = prefs.getInt('reminder_retry_interval') ?? 5; _reminderRetryInterval = prefs.getInt('reminder_retry_interval') ?? 5;
_maxRetryAttempts = prefs.getInt('max_retry_attempts') ?? 3; _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(); notifyListeners();
} }
Future<void> setThemeOption(ThemeOption option) async { Future<void> setThemeOption(ThemeOption option) async {
_themeOption = option; _themeOption = option;
notifyListeners(); notifyListeners();
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setInt('theme_option', option.index); await prefs.setInt('theme_option', option.index);
} }
@@ -146,14 +158,14 @@ class SettingsProvider extends ChangeNotifier {
if (morningStart > morningEnd) return false; if (morningStart > morningEnd) return false;
if (afternoonStart > afternoonEnd) return false; if (afternoonStart > afternoonEnd) return false;
if (eveningStart > eveningEnd) return false; if (eveningStart > eveningEnd) return false;
// Night can wrap around midnight, so we allow nightStart > nightEnd // Night can wrap around midnight, so we allow nightStart > nightEnd
// Check for overlaps in sequential periods // Check for overlaps in sequential periods
if (morningEnd >= afternoonStart) return false; if (morningEnd >= afternoonStart) return false;
if (afternoonEnd >= eveningStart) return false; if (afternoonEnd >= eveningStart) return false;
if (eveningEnd >= nightStart) return false; if (eveningEnd >= nightStart) return false;
return true; return true;
} }
@@ -174,7 +186,7 @@ class SettingsProvider extends ChangeNotifier {
int afternoonCount = 0; int afternoonCount = 0;
int eveningCount = 0; int eveningCount = 0;
int nightCount = 0; int nightCount = 0;
for (final hour in hours) { for (final hour in hours) {
if (hour >= _morningStart && hour <= _morningEnd) { if (hour >= _morningStart && hour <= _morningEnd) {
morningCount++; morningCount++;
@@ -186,13 +198,13 @@ class SettingsProvider extends ChangeNotifier {
nightCount++; nightCount++;
} }
} }
// If supplement is taken throughout the day (has times in multiple periods) // If supplement is taken throughout the day (has times in multiple periods)
final periodsCount = (morningCount > 0 ? 1 : 0) + final periodsCount = (morningCount > 0 ? 1 : 0) +
(afternoonCount > 0 ? 1 : 0) + (afternoonCount > 0 ? 1 : 0) +
(eveningCount > 0 ? 1 : 0) + (eveningCount > 0 ? 1 : 0) +
(nightCount > 0 ? 1 : 0); (nightCount > 0 ? 1 : 0);
if (periodsCount >= 2) { if (periodsCount >= 2) {
// Categorize based on the earliest reminder time for consistency // Categorize based on the earliest reminder time for consistency
final earliestHour = hours.reduce((a, b) => a < b ? a : b); final earliestHour = hours.reduce((a, b) => a < b ? a : b);
@@ -206,7 +218,7 @@ class SettingsProvider extends ChangeNotifier {
return 'night'; return 'night';
} }
} }
// If all times are in one period, categorize accordingly // If all times are in one period, categorize accordingly
if (morningCount > 0) { if (morningCount > 0) {
return 'morning'; return 'morning';
@@ -236,7 +248,7 @@ class SettingsProvider extends ChangeNotifier {
Future<void> setPersistentReminders(bool enabled) async { Future<void> setPersistentReminders(bool enabled) async {
_persistentReminders = enabled; _persistentReminders = enabled;
notifyListeners(); notifyListeners();
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool('persistent_reminders', enabled); await prefs.setBool('persistent_reminders', enabled);
} }
@@ -244,7 +256,7 @@ class SettingsProvider extends ChangeNotifier {
Future<void> setReminderRetryInterval(int minutes) async { Future<void> setReminderRetryInterval(int minutes) async {
_reminderRetryInterval = minutes; _reminderRetryInterval = minutes;
notifyListeners(); notifyListeners();
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setInt('reminder_retry_interval', minutes); await prefs.setInt('reminder_retry_interval', minutes);
} }
@@ -252,8 +264,25 @@ class SettingsProvider extends ChangeNotifier {
Future<void> setMaxRetryAttempts(int attempts) async { Future<void> setMaxRetryAttempts(int attempts) async {
_maxRetryAttempts = attempts; _maxRetryAttempts = attempts;
notifyListeners(); notifyListeners();
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setInt('max_retry_attempts', attempts); await prefs.setInt('max_retry_attempts', attempts);
} }
// Auto-sync setters
Future<void> setAutoSyncEnabled(bool enabled) async {
_autoSyncEnabled = enabled;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('auto_sync_enabled', enabled);
}
Future<void> setAutoSyncDebounceSeconds(int seconds) async {
_autoSyncDebounceSeconds = seconds;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('auto_sync_debounce_seconds', seconds);
}
} }

View File

@@ -1,7 +1,8 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../services/database_sync_service.dart'; import '../services/database_sync_service.dart';
import '../services/auto_sync_service.dart';
import 'settings_provider.dart';
class SimpleSyncProvider with ChangeNotifier { class SimpleSyncProvider with ChangeNotifier {
final DatabaseSyncService _syncService = DatabaseSyncService(); final DatabaseSyncService _syncService = DatabaseSyncService();
@@ -9,6 +10,12 @@ class SimpleSyncProvider with ChangeNotifier {
// Callback for UI refresh after sync // Callback for UI refresh after sync
VoidCallback? _onSyncCompleteCallback; VoidCallback? _onSyncCompleteCallback;
// Auto-sync service
AutoSyncService? _autoSyncService;
// Track if current sync is auto-triggered
bool _isAutoSync = false;
// Getters // Getters
SyncStatus get status => _syncService.status; SyncStatus get status => _syncService.status;
String? get lastError => _syncService.lastError; String? get lastError => _syncService.lastError;
@@ -17,6 +24,14 @@ class SimpleSyncProvider with ChangeNotifier {
bool get isSyncing => status == SyncStatus.downloading || bool get isSyncing => status == SyncStatus.downloading ||
status == SyncStatus.merging || status == SyncStatus.merging ||
status == SyncStatus.uploading; 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 // Configuration getters
String? get serverUrl => _syncService.serverUrl; String? get serverUrl => _syncService.serverUrl;
@@ -43,6 +58,23 @@ class SimpleSyncProvider with ChangeNotifier {
_onSyncCompleteCallback = callback; _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<void> _loadConfiguration() async { Future<void> _loadConfiguration() async {
await _syncService.loadSavedConfiguration(); await _syncService.loadSavedConfiguration();
notifyListeners(); // Notify UI that configuration might be available notifyListeners(); // Notify UI that configuration might be available
@@ -67,11 +99,14 @@ class SimpleSyncProvider with ChangeNotifier {
return await _syncService.testConnection(); return await _syncService.testConnection();
} }
Future<void> syncDatabase() async { Future<void> syncDatabase({bool isAutoSync = false}) async {
if (!isConfigured) { if (!isConfigured) {
throw Exception('Sync not configured'); throw Exception('Sync not configured');
} }
_isAutoSync = isAutoSync;
notifyListeners();
try { try {
await _syncService.syncDatabase(); await _syncService.syncDatabase();
} catch (e) { } catch (e) {
@@ -79,6 +114,9 @@ class SimpleSyncProvider with ChangeNotifier {
print('SupplementsLog: Sync failed in provider: $e'); print('SupplementsLog: Sync failed in provider: $e');
} }
rethrow; rethrow;
} finally {
_isAutoSync = false;
notifyListeners();
} }
} }
@@ -87,20 +125,46 @@ class SimpleSyncProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Resets auto-sync error state and re-enables auto-sync if it was disabled
void resetAutoSyncErrors() {
_autoSyncService?.resetErrorState();
notifyListeners();
}
String getStatusText() { 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) { switch (status) {
case SyncStatus.idle: case SyncStatus.idle:
if (hasAutoSyncScheduledRetry) {
return 'Auto-sync will retry shortly...';
}
return 'Ready to sync'; return 'Ready to sync';
case SyncStatus.downloading: case SyncStatus.downloading:
return 'Downloading remote database...'; return '$syncType: Downloading remote database...';
case SyncStatus.merging: case SyncStatus.merging:
return 'Merging databases...'; return '$syncType: Merging databases...';
case SyncStatus.uploading: case SyncStatus.uploading:
return 'Uploading database...'; return '$syncType: Uploading database...';
case SyncStatus.completed: case SyncStatus.completed:
return 'Sync completed successfully'; return '$syncType completed successfully';
case SyncStatus.error: 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();
}
} }

View File

@@ -224,6 +224,13 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
appBar: AppBar( appBar: AppBar(
title: Text(isEditing ? 'Edit Supplement' : 'Add Supplement'), title: Text(isEditing ? 'Edit Supplement' : 'Add Supplement'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary, backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: [
IconButton(
tooltip: isEditing ? 'Update Supplement' : 'Save Supplement',
onPressed: _saveSupplement,
icon: const Icon(Icons.save),
),
],
), ),
body: Form( body: Form(
key: _formKey, key: _formKey,
@@ -482,17 +489,7 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Save button // Save is now in the AppBar for consistency with app-wide pattern
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _saveSupplement,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16),
),
child: Text(isEditing ? 'Update Supplement' : 'Add Supplement'),
),
),
], ],
), ),
), ),

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/settings_provider.dart';
import '../providers/supplement_provider.dart'; import '../providers/supplement_provider.dart';
class HistoryScreen extends StatefulWidget { class HistoryScreen extends StatefulWidget {
@@ -125,27 +126,33 @@ class _HistoryScreenState extends State<HistoryScreen> {
Expanded( Expanded(
flex: 3, flex: 3,
child: Container( child: Container(
margin: const EdgeInsets.fromLTRB(0, 16, 16, 16), // add a bit more horizontal spacing between calendar and card
child: _buildSelectedDayDetails(groupedIntakes), margin: const EdgeInsets.fromLTRB(8, 16, 16, 16),
child: SingleChildScrollView(
child: _buildSelectedDayDetails(groupedIntakes),
),
), ),
), ),
], ],
); );
} else { } else {
// Mobile layout: vertical stack // Mobile layout: vertical stack
return Column( return SingleChildScrollView(
children: [ child: Column(
// Calendar children: [
Container( // Calendar
margin: const EdgeInsets.symmetric(horizontal: 16), Container(
child: _buildCalendar(groupedIntakes), margin: const EdgeInsets.symmetric(horizontal: 16),
), child: _buildCalendar(groupedIntakes),
const SizedBox(height: 16), ),
// Selected day details const SizedBox(height: 16),
Expanded( // Selected day details
child: _buildSelectedDayDetails(groupedIntakes), Container(
), margin: const EdgeInsets.symmetric(horizontal: 16),
], child: _buildSelectedDayDetails(groupedIntakes),
),
],
),
); );
} }
}, },
@@ -479,78 +486,208 @@ class _HistoryScreenState extends State<HistoryScreen> {
], ],
), ),
), ),
Expanded( Padding(
child: ListView.builder( padding: EdgeInsets.all(isWideScreen ? 20 : 16),
padding: EdgeInsets.all(isWideScreen ? 20 : 16), child: Builder(
itemCount: dayIntakes.length, builder: (context) {
itemBuilder: (context, index) { final settingsProvider = Provider.of<SettingsProvider>(context, listen: false);
final intake = dayIntakes[index]; // Sort once per render
final takenAt = DateTime.parse(intake['takenAt']); final sortedDayIntakes = List<Map<String, dynamic>>.from(dayIntakes)
final units = (intake['unitsTaken'] as num?)?.toDouble() ?? 1.0; ..sort((a, b) => DateTime.parse(a['takenAt']).compareTo(DateTime.parse(b['takenAt'])));
// Helpers
return Card( String timeCategory(DateTime dt) {
margin: const EdgeInsets.only(bottom: 12), final h = dt.hour;
elevation: 2, if (h >= settingsProvider.morningStart && h <= settingsProvider.morningEnd) return 'morning';
child: Padding( if (h >= settingsProvider.afternoonStart && h <= settingsProvider.afternoonEnd) return 'afternoon';
padding: EdgeInsets.all(isWideScreen ? 16 : 12), 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( child: Row(
children: [ children: [
CircleAvatar( Container(
backgroundColor: Theme.of(context).colorScheme.primary, padding: const EdgeInsets.all(8),
radius: isWideScreen ? 24 : 20, decoration: BoxDecoration(
color: color.withOpacity(0.2),
shape: BoxShape.circle,
),
child: Icon( child: Icon(
Icons.medication, icon,
color: Theme.of(context).colorScheme.onPrimary, size: 20,
size: isWideScreen ? 24 : 20, color: color,
), ),
), ),
SizedBox(width: isWideScreen ? 16 : 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
intake['supplementName'], title,
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.w600, fontSize: 18,
fontSize: isWideScreen ? 16 : 14, fontWeight: FontWeight.bold,
color: color,
), ),
), ),
const SizedBox(height: 4), if (range != null) ...[
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( Text(
intake['notes'], '($range)',
style: TextStyle( style: TextStyle(
fontSize: isWideScreen ? 13 : 12, fontSize: 12,
fontStyle: FontStyle.italic, fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurfaceVariant, 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<Widget> 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,
); );
}, },
), ),

View File

@@ -1,8 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../services/database_sync_service.dart'; import '../providers/settings_provider.dart';
import '../providers/simple_sync_provider.dart'; import '../providers/simple_sync_provider.dart';
import '../services/database_sync_service.dart';
class SimpleSyncSettingsScreen extends StatefulWidget { class SimpleSyncSettingsScreen extends StatefulWidget {
const SimpleSyncSettingsScreen({super.key}); const SimpleSyncSettingsScreen({super.key});
@@ -17,7 +18,7 @@ class _SimpleSyncSettingsScreenState extends State<SimpleSyncSettingsScreen> {
final _usernameController = TextEditingController(); final _usernameController = TextEditingController();
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
final _remotePathController = TextEditingController(); final _remotePathController = TextEditingController();
String _previewUrl = ''; String _previewUrl = '';
@override @override
@@ -27,11 +28,11 @@ class _SimpleSyncSettingsScreenState extends State<SimpleSyncSettingsScreen> {
_usernameController.addListener(_updatePreviewUrl); _usernameController.addListener(_updatePreviewUrl);
_loadSavedConfiguration(); _loadSavedConfiguration();
} }
void _loadSavedConfiguration() { void _loadSavedConfiguration() {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
final syncProvider = context.read<SimpleSyncProvider>(); final syncProvider = context.read<SimpleSyncProvider>();
if (syncProvider.serverUrl != null) { if (syncProvider.serverUrl != null) {
_serverUrlController.text = _extractHostnameFromUrl(syncProvider.serverUrl!); _serverUrlController.text = _extractHostnameFromUrl(syncProvider.serverUrl!);
} }
@@ -44,11 +45,11 @@ class _SimpleSyncSettingsScreenState extends State<SimpleSyncSettingsScreen> {
if (syncProvider.remotePath != null) { if (syncProvider.remotePath != null) {
_remotePathController.text = syncProvider.remotePath!; _remotePathController.text = syncProvider.remotePath!;
} }
_updatePreviewUrl(); _updatePreviewUrl();
}); });
} }
String _extractHostnameFromUrl(String fullUrl) { String _extractHostnameFromUrl(String fullUrl) {
try { try {
final uri = Uri.parse(fullUrl); final uri = Uri.parse(fullUrl);
@@ -81,30 +82,35 @@ class _SimpleSyncSettingsScreenState extends State<SimpleSyncSettingsScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final syncProvider = context.watch<SimpleSyncProvider>();
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Database Sync Settings'), title: const Text('Database Sync Settings'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary, backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: [
IconButton(
tooltip: 'Save Configuration',
onPressed: syncProvider.isSyncing ? null : _configureSync,
icon: const Icon(Icons.save),
),
],
), ),
body: Consumer<SimpleSyncProvider>( body: SingleChildScrollView(
builder: (context, syncProvider, child) { padding: const EdgeInsets.all(16.0),
return SingleChildScrollView( child: Form(
padding: const EdgeInsets.all(16.0), key: _formKey,
child: Form( child: Column(
key: _formKey, crossAxisAlignment: CrossAxisAlignment.stretch,
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.stretch, _buildStatusCard(syncProvider),
children: [ const SizedBox(height: 20),
_buildStatusCard(syncProvider), _buildConfigurationSection(syncProvider),
const SizedBox(height: 20), const SizedBox(height: 20),
_buildConfigurationSection(), _buildActionButtons(),
const SizedBox(height: 20), ],
_buildActionButtons(syncProvider), ),
], ),
),
),
);
},
), ),
); );
} }
@@ -116,17 +122,17 @@ class _SimpleSyncSettingsScreenState extends State<SimpleSyncSettingsScreen> {
switch (syncProvider.status) { switch (syncProvider.status) {
case SyncStatus.idle: case SyncStatus.idle:
icon = Icons.sync; icon = syncProvider.isAutoSync ? Icons.sync_alt : Icons.sync;
color = Colors.blue; color = Colors.blue;
break; break;
case SyncStatus.downloading: case SyncStatus.downloading:
case SyncStatus.merging: case SyncStatus.merging:
case SyncStatus.uploading: case SyncStatus.uploading:
icon = Icons.sync; icon = syncProvider.isAutoSync ? Icons.sync_alt : Icons.sync;
color = Colors.orange; color = syncProvider.isAutoSync ? Colors.deepOrange : Colors.orange;
break; break;
case SyncStatus.completed: case SyncStatus.completed:
icon = Icons.check_circle; icon = syncProvider.isAutoSync ? Icons.check_circle_outline : Icons.check_circle;
color = Colors.green; color = Colors.green;
break; break;
case SyncStatus.error: case SyncStatus.error:
@@ -152,8 +158,28 @@ class _SimpleSyncSettingsScreenState extends State<SimpleSyncSettingsScreen> {
), ),
), ),
), ),
// 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>(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) ...[ if (syncProvider.lastSyncTime != null) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
@@ -161,19 +187,73 @@ class _SimpleSyncSettingsScreenState extends State<SimpleSyncSettingsScreen> {
style: Theme.of(context).textTheme.bodySmall, 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), const SizedBox(height: 8),
Container( Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.red.withOpacity(0.1), color: Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
syncProvider.lastError!, _getErrorMessage(syncProvider),
style: const TextStyle(color: Colors.red), style: const TextStyle(color: Colors.red),
), ),
), ),
@@ -191,7 +271,7 @@ class _SimpleSyncSettingsScreenState extends State<SimpleSyncSettingsScreen> {
); );
} }
Widget _buildConfigurationSection() { Widget _buildConfigurationSection(SimpleSyncProvider syncProvider) {
return Card( return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
@@ -199,10 +279,17 @@ class _SimpleSyncSettingsScreenState extends State<SimpleSyncSettingsScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'WebDAV Configuration', 'Sync Configuration',
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildAutoSyncSection(),
const SizedBox(height: 24),
Text(
'WebDAV Settings',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
TextFormField( TextFormField(
controller: _serverUrlController, controller: _serverUrlController,
decoration: const InputDecoration( decoration: const InputDecoration(
@@ -237,10 +324,10 @@ class _SimpleSyncSettingsScreenState extends State<SimpleSyncSettingsScreen> {
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( 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), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.5), color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5),
), ),
), ),
child: Column( child: Column(
@@ -258,6 +345,21 @@ class _SimpleSyncSettingsScreenState extends State<SimpleSyncSettingsScreen> {
color: Theme.of(context).colorScheme.primary, 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<SimpleSyncSettingsScreen> {
); );
} }
Widget _buildActionButtons(SimpleSyncProvider syncProvider) { Widget _buildActionButtons() {
return Column( // Buttons have been moved into the AppBar / cards. Keep a small spacer here for layout.
crossAxisAlignment: CrossAxisAlignment.stretch, return const SizedBox.shrink();
children: [ }
ElevatedButton(
onPressed: syncProvider.isSyncing ? null : _testConnection, Widget _buildAutoSyncSection() {
child: const Text('Test Connection'), return Consumer<SettingsProvider>(
), builder: (context, settingsProvider, child) {
const SizedBox(height: 12), return Consumer<SimpleSyncProvider>(
ElevatedButton( builder: (context, syncProvider, child) {
onPressed: syncProvider.isSyncing ? null : _configureSync, return Container(
child: const Text('Save Configuration'), padding: const EdgeInsets.all(16.0),
), decoration: BoxDecoration(
const SizedBox(height: 12), color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
ElevatedButton( borderRadius: BorderRadius.circular(12),
onPressed: (!syncProvider.isConfigured || syncProvider.isSyncing) border: Border.all(
? null color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5),
: _syncDatabase, ),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white,
), ),
child: syncProvider.isSyncing child: Column(
? const SizedBox( crossAxisAlignment: CrossAxisAlignment.start,
width: 20, children: [
height: 20, SwitchListTile(
child: CircularProgressIndicator( title: Row(
strokeWidth: 2, children: [
valueColor: AlwaysStoppedAnimation<Color>(Colors.white), 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<int>(
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<SimpleSyncSettingsScreen> {
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
final syncProvider = context.read<SimpleSyncProvider>(); final syncProvider = context.read<SimpleSyncProvider>();
try { try {
// Construct the full WebDAV URL from the simple hostname // Construct the full WebDAV URL from the simple hostname
final fullWebDAVUrl = _constructWebDAVUrl( final fullWebDAVUrl = _constructWebDAVUrl(
_serverUrlController.text.trim(), _serverUrlController.text.trim(),
_usernameController.text.trim(), _usernameController.text.trim(),
); );
// Configure temporarily for testing // Configure temporarily for testing
await syncProvider.configure( await syncProvider.configure(
serverUrl: fullWebDAVUrl, serverUrl: fullWebDAVUrl,
username: _usernameController.text.trim(), username: _usernameController.text.trim(),
password: _passwordController.text.trim(), password: _passwordController.text.trim(),
remotePath: _remotePathController.text.trim().isEmpty remotePath: _remotePathController.text.trim().isEmpty
? 'Supplements' ? 'Supplements'
: _remotePathController.text.trim(), : _remotePathController.text.trim(),
); );
final success = await syncProvider.testConnection(); final success = await syncProvider.testConnection();
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(success content: Text(success
? 'Connection successful!' ? 'Connection successful!'
: 'Connection failed. Check your settings.'), : 'Connection failed. Check your settings.'),
backgroundColor: success ? Colors.green : Colors.red, backgroundColor: success ? Colors.green : Colors.red,
), ),
@@ -379,20 +563,20 @@ class _SimpleSyncSettingsScreenState extends State<SimpleSyncSettingsScreen> {
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
final syncProvider = context.read<SimpleSyncProvider>(); final syncProvider = context.read<SimpleSyncProvider>();
try { try {
// Construct the full WebDAV URL from the simple hostname // Construct the full WebDAV URL from the simple hostname
final fullWebDAVUrl = _constructWebDAVUrl( final fullWebDAVUrl = _constructWebDAVUrl(
_serverUrlController.text.trim(), _serverUrlController.text.trim(),
_usernameController.text.trim(), _usernameController.text.trim(),
); );
await syncProvider.configure( await syncProvider.configure(
serverUrl: fullWebDAVUrl, serverUrl: fullWebDAVUrl,
username: _usernameController.text.trim(), username: _usernameController.text.trim(),
password: _passwordController.text.trim(), password: _passwordController.text.trim(),
remotePath: _remotePathController.text.trim().isEmpty remotePath: _remotePathController.text.trim().isEmpty
? 'Supplements' ? 'Supplements'
: _remotePathController.text.trim(), : _remotePathController.text.trim(),
); );
@@ -418,14 +602,14 @@ class _SimpleSyncSettingsScreenState extends State<SimpleSyncSettingsScreen> {
Future<void> _syncDatabase() async { Future<void> _syncDatabase() async {
final syncProvider = context.read<SimpleSyncProvider>(); final syncProvider = context.read<SimpleSyncProvider>();
try { try {
await syncProvider.syncDatabase(); await syncProvider.syncDatabase(isAutoSync: false);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Database sync completed!'), content: Text('Manual sync completed!'),
backgroundColor: Colors.green, backgroundColor: Colors.green,
), ),
); );
@@ -434,7 +618,7 @@ class _SimpleSyncSettingsScreenState extends State<SimpleSyncSettingsScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Sync failed: $e'), content: Text('Manual sync failed: $e'),
backgroundColor: Colors.red, backgroundColor: Colors.red,
), ),
); );
@@ -450,17 +634,200 @@ class _SimpleSyncSettingsScreenState extends State<SimpleSyncSettingsScreen> {
} else if (cleanUrl.startsWith('https://')) { } else if (cleanUrl.startsWith('https://')) {
cleanUrl = cleanUrl.substring(8); cleanUrl = cleanUrl.substring(8);
} }
// Remove trailing slash if present // Remove trailing slash if present
if (cleanUrl.endsWith('/')) { if (cleanUrl.endsWith('/')) {
cleanUrl = cleanUrl.substring(0, cleanUrl.length - 1); cleanUrl = cleanUrl.substring(0, cleanUrl.length - 1);
} }
// For Nextcloud instances, construct the standard WebDAV path // For Nextcloud instances, construct the standard WebDAV path
// Default to HTTPS for security // Default to HTTPS for security
return 'https://$cleanUrl/remote.php/dav/files/$username/'; return 'https://$cleanUrl/remote.php/dav/files/$username/';
} }
Widget _buildAutoSyncStatusIndicator(SimpleSyncProvider syncProvider) {
return Consumer<SettingsProvider>(
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<Color>(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) { String _formatDateTime(DateTime dateTime) {
return '${dateTime.day}/${dateTime.month}/${dateTime.year} ${dateTime.hour}:${dateTime.minute.toString().padLeft(2, '0')}'; return '${dateTime.day}/${dateTime.month}/${dateTime.year} ${dateTime.hour}:${dateTime.minute.toString().padLeft(2, '0')}';
} }

View File

@@ -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<AutoSyncError> _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<void> _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<bool> _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<AutoSyncError> 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;
}

View File

@@ -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);
});
}