From beadf860e236b5251083a1290b725793124b843b Mon Sep 17 00:00:00 2001 From: Marcos Date: Mon, 18 May 2026 21:04:59 +0200 Subject: [PATCH] feat: Implement force sync functionality in settings screen and update initial sync logic --- lib/app.dart | 6 +- lib/screens/settings_screen.dart | 300 +++++++++++++++++++++---------- 2 files changed, 206 insertions(+), 100 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index e3cd2dd..08e7371 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -202,8 +202,9 @@ class _NotesAppState extends State // Start periodic sync _startPeriodicSync(); - // Run an initial full sync immediately to pull server changes - unawaited(_performSync(forceFull: true)); + // Run an initial sync immediately and let the repository use the + // stored lastSyncAt when it exists. + unawaited(_performSync()); } catch (e) { // If the database file is not a valid SQLite DB (e.g., wrong key or corruption), // reset the local vault so the app doesn't crash. The reset will delete DB files @@ -720,6 +721,7 @@ class _NotesAppState extends State key: const ValueKey('settings-screen'), onDeleteAllData: _resetLocalVaultData, onBackToHome: _openHome, + onForceSync: () => _performSync(forceFull: true), ); return MaterialApp( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 691b48b..4a6abb8 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -8,10 +8,12 @@ class SettingsScreen extends StatefulWidget { super.key, required this.onDeleteAllData, required this.onBackToHome, + required this.onForceSync, }); final Future Function() onDeleteAllData; final VoidCallback onBackToHome; + final Future Function() onForceSync; @override State createState() => _SettingsScreenState(); @@ -19,6 +21,7 @@ class SettingsScreen extends StatefulWidget { class _SettingsScreenState extends State { bool _isBusy = false; + bool _isSyncing = false; final TextEditingController _endpointController = TextEditingController(); final TextEditingController _encryptionKeyController = TextEditingController(); bool _endpointLoading = true; @@ -30,7 +33,7 @@ class _SettingsScreenState extends State { context: context, builder: (context) => AlertDialog( title: const Text('Borrar todos los datos'), - content: const Text('¿Estás seguro? Esta acción eliminará la base de datos local y la clave de cifrado. No se podrá recuperar.'), + content: const Text('¿Estás seguro? Esta acción eliminará la base de datos local y la clave de cifrado. Asegúrate de tener una copia de seguridad si es necesario o cuenta sincronizada.'), actions: [ TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancelar')), TextButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('Borrar', style: TextStyle(color: Colors.red))), @@ -65,6 +68,38 @@ class _SettingsScreenState extends State { } } + Future _forceSync() async { + if (_isBusy || _isSyncing) { + return; + } + + setState(() { + _isSyncing = true; + }); + + try { + await widget.onForceSync(); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Sincronización forzada completada.')), + ); + } catch (error) { + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error al forzar la sincronización: $error')), + ); + } finally { + if (!mounted) return; + + setState(() { + _isSyncing = false; + }); + } + } + @override void initState() { super.initState(); @@ -151,6 +186,56 @@ class _SettingsScreenState extends State { _endpointController.text = endpoint; ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Endpoint restaurado al valor por defecto'))); } + + Widget _buildResponsiveInputActionsRow({ + required Widget input, + required List actions, + }) { + return LayoutBuilder( + builder: (context, constraints) { + final bool isCompact = constraints.maxWidth < 600; + + if (isCompact) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(width: double.infinity, child: input), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: Wrap( + alignment: WrapAlignment.end, + spacing: 8, + runSpacing: 8, + children: actions, + ), + ), + ], + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded(child: input), + const SizedBox(width: 8), + Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (int index = 0; index < actions.length; index++) ...[ + if (index > 0) const SizedBox(width: 8), + actions[index], + ], + ], + ), + ), + ], + ); + }, + ); + } + Widget build(BuildContext context) { return Scaffold( body: Container( @@ -181,118 +266,137 @@ class _SettingsScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ElevatedButton.icon( - style: ElevatedButton.styleFrom(backgroundColor: Colors.redAccent), - onPressed: _isBusy ? null : _confirmAndDeleteAll, - icon: _isBusy ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.delete_forever), - label: const Text('Borrar todos los datos'), + Row( + children: [ + const Expanded( + child: Text('Borrar datos locales:'), + ), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.redAccent, + foregroundColor: Colors.white, + textStyle: const TextStyle(fontWeight: FontWeight.w600), + ), + onPressed: _isBusy ? null : _confirmAndDeleteAll, + icon: _isBusy + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.delete_forever), + label: const Text('Borrar'), + ), + ], ), const SizedBox(height: 16), - const Text('Esto cerrará el vault actual y eliminará la base de datos local junto con la clave de cifrado.'), + Row( + children: [ + const Expanded( + child: Text('Forzar sincronizacion total:'), + ), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.amber, + foregroundColor: Colors.black, + textStyle: const TextStyle(fontWeight: FontWeight.w700), + ), + onPressed: (_isBusy || _isSyncing) ? null : _forceSync, + icon: _isSyncing + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.sync), + label: const Text('Sincronizar'), + ), + ], + ), const SizedBox(height: 24), const Text('API endpoint (ej: http://localhost:3000/api)'), const SizedBox(height: 8), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: _endpointLoading - ? const SizedBox(height: 48, child: Center(child: CircularProgressIndicator())) - : TextField( - controller: _endpointController, - style: const TextStyle(color: Colors.white), - keyboardType: TextInputType.url, - decoration: InputDecoration( - labelText: 'API endpoint', - labelStyle: TextStyle(color: Colors.white.withValues(alpha: 0.7)), - filled: true, - fillColor: Colors.white.withValues(alpha: 0.05), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), - borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), - borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), - borderSide: const BorderSide(color: Colors.amber, width: 1.2), - ), - ), + _buildResponsiveInputActionsRow( + input: _endpointLoading + ? const SizedBox(height: 48, child: Center(child: CircularProgressIndicator())) + : TextField( + controller: _endpointController, + style: const TextStyle(color: Colors.white), + keyboardType: TextInputType.url, + decoration: InputDecoration( + labelText: 'API endpoint', + labelStyle: TextStyle(color: Colors.white.withValues(alpha: 0.7)), + filled: true, + fillColor: Colors.white.withValues(alpha: 0.05), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)), ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: Colors.amber, width: 1.2), + ), + ), + ), + actions: [ + ElevatedButton( + onPressed: _endpointLoading ? null : _saveEndpoint, + child: const Text('Guardar'), ), - const SizedBox(width: 8), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - ElevatedButton( - onPressed: _endpointLoading ? null : _saveEndpoint, - child: const Text('Guardar'), - ), - const SizedBox(height: 8), - OutlinedButton( - onPressed: _endpointLoading ? null : _resetEndpoint, - child: const Text('Restaurar'), - ), - ], + OutlinedButton( + onPressed: _endpointLoading ? null : _resetEndpoint, + child: const Text('Restaurar'), ), ], ), const SizedBox(height: 24), const Text('Clave de cifrado local'), const SizedBox(height: 8), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: TextField( - controller: _encryptionKeyController, - readOnly: true, - obscureText: !_encryptionKeyVisible, - enableSuggestions: false, - autocorrect: false, - style: const TextStyle(color: Colors.white), - decoration: InputDecoration( - labelText: _encryptionKeyVisible ? 'Clave de cifrado' : 'Oculta hasta pulsar mostrar', - labelStyle: TextStyle(color: Colors.white.withValues(alpha: 0.7)), - filled: true, - fillColor: Colors.white.withValues(alpha: 0.05), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), - borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), - borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), - borderSide: const BorderSide(color: Colors.amber, width: 1.2), - ), - ), + _buildResponsiveInputActionsRow( + input: TextField( + controller: _encryptionKeyController, + readOnly: true, + obscureText: !_encryptionKeyVisible, + enableSuggestions: false, + autocorrect: false, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + labelText: _encryptionKeyVisible ? 'Clave de cifrado' : 'Oculta hasta pulsar mostrar', + labelStyle: TextStyle(color: Colors.white.withValues(alpha: 0.7)), + filled: true, + fillColor: Colors.white.withValues(alpha: 0.05), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: Colors.amber, width: 1.2), ), ), - const SizedBox(width: 8), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - ElevatedButton( - onPressed: _encryptionKeyLoading ? null : _loadEncryptionKey, - child: _encryptionKeyLoading - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Mostrar'), - ), - const SizedBox(height: 8), - OutlinedButton( - onPressed: _encryptionKeyVisible ? _hideEncryptionKey : null, - child: const Text('Ocultar'), - ), - ], + ), + actions: [ + ElevatedButton( + onPressed: _encryptionKeyLoading ? null : _loadEncryptionKey, + child: _encryptionKeyLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Mostrar'), + ), + OutlinedButton( + onPressed: _encryptionKeyVisible ? _hideEncryptionKey : null, + child: const Text('Ocultar'), ), ], ),