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

@@ -224,6 +224,13 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
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<AddSupplementScreen> {
),
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
],
),
),

View File

@@ -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<HistoryScreen> {
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<HistoryScreen> {
],
),
),
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<SettingsProvider>(context, listen: false);
// Sort once per render
final sortedDayIntakes = List<Map<String, dynamic>>.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<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: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<SimpleSyncSettingsScreen> {
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _remotePathController = TextEditingController();
String _previewUrl = '';
@override
@@ -27,11 +28,11 @@ class _SimpleSyncSettingsScreenState extends State<SimpleSyncSettingsScreen> {
_usernameController.addListener(_updatePreviewUrl);
_loadSavedConfiguration();
}
void _loadSavedConfiguration() {
WidgetsBinding.instance.addPostFrameCallback((_) {
final syncProvider = context.read<SimpleSyncProvider>();
if (syncProvider.serverUrl != null) {
_serverUrlController.text = _extractHostnameFromUrl(syncProvider.serverUrl!);
}
@@ -44,11 +45,11 @@ class _SimpleSyncSettingsScreenState extends State<SimpleSyncSettingsScreen> {
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<SimpleSyncSettingsScreen> {
@override
Widget build(BuildContext context) {
final syncProvider = context.watch<SimpleSyncProvider>();
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<SimpleSyncProvider>(
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<SimpleSyncSettingsScreen> {
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<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) ...[
const SizedBox(height: 8),
Text(
@@ -161,19 +187,73 @@ class _SimpleSyncSettingsScreenState extends State<SimpleSyncSettingsScreen> {
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<SimpleSyncSettingsScreen> {
);
}
Widget _buildConfigurationSection() {
Widget _buildConfigurationSection(SimpleSyncProvider syncProvider) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
@@ -199,10 +279,17 @@ class _SimpleSyncSettingsScreenState extends State<SimpleSyncSettingsScreen> {
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<SimpleSyncSettingsScreen> {
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<SimpleSyncSettingsScreen> {
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) {
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<SettingsProvider>(
builder: (context, settingsProvider, child) {
return Consumer<SimpleSyncProvider>(
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<Color>(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<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;
final syncProvider = context.read<SimpleSyncProvider>();
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<SimpleSyncSettingsScreen> {
if (!_formKey.currentState!.validate()) return;
final syncProvider = context.read<SimpleSyncProvider>();
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<SimpleSyncSettingsScreen> {
Future<void> _syncDatabase() async {
final syncProvider = context.read<SimpleSyncProvider>();
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<SimpleSyncSettingsScreen> {
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<SimpleSyncSettingsScreen> {
} 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<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) {
return '${dateTime.day}/${dateTime.month}/${dateTime.year} ${dateTime.hour}:${dateTime.minute.toString().padLeft(2, '0')}';
}