feat: Implement note encryption and synchronization features

- Added NoteEncryption class for encrypting and decrypting note content using AES-GCM.
- Updated NoteRepository to handle synchronization of notes and categories with the server, including encryption of note data before sending.
- Introduced SyncRequest and SyncResponse models for managing synchronization data.
- Enhanced LocalVaultService to store and retrieve the encryption key.
- Modified HomeScreen and SettingsScreen to trigger synchronization after note operations and manage API endpoint settings.
- Added SyncStatusIndicator to provide visual feedback on synchronization status in the app title bar.
- Created Category model to manage note categories with encryption support.
- Updated note model to include UUID, server version, deletion status, and category ID.
- Added necessary UI elements for displaying and managing the encryption key in SettingsScreen.
- Updated dependencies in pubspec.yaml for cryptography and HTTP handling.
This commit is contained in:
2026-05-18 16:11:19 +02:00
parent 516b3b9aa3
commit efe602a5da
18 changed files with 2531 additions and 71 deletions
+29 -4
View File
@@ -75,6 +75,12 @@ class _HomeScreenState extends State<HomeScreen> {
setState(() {
_notes = updatedNotes;
});
// Trigger sync after creating a note and refresh local list
try {
await widget.repository.performSync();
await _loadNotes();
} catch (_) {}
}
}
@@ -92,6 +98,12 @@ class _HomeScreenState extends State<HomeScreen> {
setState(() {
_notes = updatedNotes;
});
// Trigger sync after deleting a note and refresh local list
try {
await widget.repository.performSync();
await _loadNotes();
} catch (_) {}
}
Future<void> _reorderNote(int oldIndex, int newIndex) async {
@@ -140,6 +152,11 @@ class _HomeScreenState extends State<HomeScreen> {
setState(() {
_notes = _normalizeNotes(updatedNotes);
});
// Trigger sync after editing a note and refresh local list
try {
await widget.repository.performSync();
await _loadNotes();
} catch (_) {}
}
}
}
@@ -172,9 +189,16 @@ class _HomeScreenState extends State<HomeScreen> {
? const Center(child: CircularProgressIndicator())
: _notes.isEmpty
? const _EmptyState()
: MouseRegion(
cursor: _isDragging ? SystemMouseCursors.grabbing : SystemMouseCursors.basic,
child: MasonryGridView.count(
: RefreshIndicator(
onRefresh: () async {
try {
await widget.repository.performSync();
} catch (_) {}
await _loadNotes();
},
child: MouseRegion(
cursor: _isDragging ? SystemMouseCursors.grabbing : SystemMouseCursors.basic,
child: MasonryGridView.count(
crossAxisCount: crossAxisCount,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
@@ -296,7 +320,8 @@ class _HomeScreenState extends State<HomeScreen> {
);
},
),
);
),
);
return Scaffold(
body: Container(
+199
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:notas/data/local_vault_service.dart';
import 'package:notas/widgets/search_app_bar.dart';
import 'package:notas/data/api_client.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({
@@ -17,6 +19,11 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> {
bool _isBusy = false;
final TextEditingController _endpointController = TextEditingController();
final TextEditingController _encryptionKeyController = TextEditingController();
bool _endpointLoading = true;
bool _encryptionKeyLoading = false;
bool _encryptionKeyVisible = false;
Future<void> _confirmAndDeleteAll() async {
final bool? confirmed = await showDialog<bool>(
@@ -59,6 +66,91 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
@override
void initState() {
super.initState();
_loadEndpoint();
}
Future<void> _loadEndpoint() async {
final String endpoint = await ApiConfig.getEndpoint();
if (!mounted) return;
_endpointController.text = endpoint;
setState(() {
_endpointLoading = false;
});
}
Future<void> _loadEncryptionKey() async {
setState(() {
_encryptionKeyLoading = true;
});
try {
final String? encryptionKey = await LocalVaultService.instance.readEncryptionKey();
if (!mounted) return;
if (encryptionKey == null || encryptionKey.isEmpty) {
_encryptionKeyController.text = '';
_encryptionKeyVisible = false;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No se pudo leer la clave de cifrado.')),
);
return;
}
_encryptionKeyController.text = encryptionKey;
_encryptionKeyVisible = true;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Clave de cifrado mostrada.')),
);
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error al leer la clave de cifrado: $error')),
);
} finally {
if (mounted) {
setState(() {
_encryptionKeyLoading = false;
});
}
}
}
void _hideEncryptionKey() {
setState(() {
_encryptionKeyVisible = false;
_encryptionKeyController.clear();
});
}
@override
void dispose() {
_endpointController.dispose();
_encryptionKeyController.dispose();
super.dispose();
}
Future<void> _saveEndpoint() async {
final String value = _endpointController.text.trim();
try {
await ApiConfig.setEndpoint(value);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Endpoint guardado')));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error guardando endpoint: $e')));
}
}
Future<void> _resetEndpoint() async {
await ApiConfig.clearEndpoint();
final String endpoint = await ApiConfig.getEndpoint();
if (!mounted) return;
_endpointController.text = endpoint;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Endpoint restaurado al valor por defecto')));
}
Widget build(BuildContext context) {
return Scaffold(
body: Container(
@@ -97,6 +189,113 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
const SizedBox(height: 16),
const Text('Esto cerrará el vault actual y eliminará la base de datos local junto con la clave de cifrado.'),
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),
),
),
),
),
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'),
),
],
),
],
),
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),
),
),
),
),
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'),
),
],
),
],
),
],
),
),
+60 -2
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:notas/widgets/app_title_bar.dart';
import 'package:notas/data/api_client.dart';
class VaultAccessScreen extends StatefulWidget {
const VaultAccessScreen({
@@ -22,6 +23,29 @@ class VaultAccessScreen extends StatefulWidget {
class _VaultAccessScreenState extends State<VaultAccessScreen> {
final TextEditingController _emailController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
final TextEditingController _endpointController = TextEditingController();
bool _endpointLoading = true;
@override
void initState() {
super.initState();
_loadEndpoint();
}
Future<void> _loadEndpoint() async {
final String endpoint = await ApiConfig.getEndpoint();
if (!mounted) return;
_endpointController.text = endpoint;
setState(() {
_endpointLoading = false;
});
}
Future<void> _persistEndpointFromField() async {
final String value = _endpointController.text.trim();
if (value.isEmpty) return;
await ApiConfig.setEndpoint(value);
}
@override
void dispose() {
@@ -31,6 +55,8 @@ class _VaultAccessScreenState extends State<VaultAccessScreen> {
}
Future<void> _handleCreateAccount() async {
await _persistEndpointFromField();
await widget.onCreateAccountPressed(
_emailController.text.trim(),
_passwordController.text,
@@ -38,6 +64,8 @@ class _VaultAccessScreenState extends State<VaultAccessScreen> {
}
Future<void> _handleSignIn() async {
await _persistEndpointFromField();
await widget.onSignInPressed(
_emailController.text.trim(),
_passwordController.text,
@@ -112,13 +140,43 @@ class _VaultAccessScreenState extends State<VaultAccessScreen> {
),
),
const SizedBox(height: 28),
_endpointLoading
? const SizedBox(
height: 48,
child: Center(child: CircularProgressIndicator()),
)
: TextField(
controller: _endpointController,
enabled: !widget.isBusy,
keyboardType: TextInputType.url,
style: const TextStyle(color: Colors.white),
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),
),
),
),
const SizedBox(height: 16),
TextField(
controller: _emailController,
enabled: !widget.isBusy,
keyboardType: TextInputType.emailAddress,
keyboardType: TextInputType.text,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: 'Email',
labelText: 'Usuario',
labelStyle: TextStyle(color: Colors.white.withValues(alpha: 0.7)),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.05),