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:
+196
-6
@@ -2,7 +2,9 @@ import 'dart:async';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:notas/data/app_database.dart';
|
import 'package:notas/data/app_database.dart';
|
||||||
|
import 'package:notas/data/api_client.dart';
|
||||||
import 'package:notas/data/local_vault_service.dart';
|
import 'package:notas/data/local_vault_service.dart';
|
||||||
import 'package:notas/data/note_repository.dart';
|
import 'package:notas/data/note_repository.dart';
|
||||||
import 'package:notas/platform/app_platform.dart';
|
import 'package:notas/platform/app_platform.dart';
|
||||||
@@ -14,6 +16,7 @@ import 'package:notas/screens/settings_screen.dart';
|
|||||||
import 'package:notas/screens/vault_access_screen.dart';
|
import 'package:notas/screens/vault_access_screen.dart';
|
||||||
import 'package:notas/theme/app_theme.dart';
|
import 'package:notas/theme/app_theme.dart';
|
||||||
import 'package:notas/widgets/app_title_bar.dart';
|
import 'package:notas/widgets/app_title_bar.dart';
|
||||||
|
import 'package:notas/widgets/sync_status_indicator.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
@@ -31,6 +34,10 @@ enum _AppPhase {
|
|||||||
notes,
|
notes,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class PerformSyncIntent extends Intent {
|
||||||
|
const PerformSyncIntent();
|
||||||
|
}
|
||||||
|
|
||||||
class NotesApp extends StatefulWidget {
|
class NotesApp extends StatefulWidget {
|
||||||
const NotesApp({super.key});
|
const NotesApp({super.key});
|
||||||
|
|
||||||
@@ -42,6 +49,7 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
with WindowListener, WidgetsBindingObserver {
|
with WindowListener, WidgetsBindingObserver {
|
||||||
static const Duration _screenTransitionDuration = Duration(milliseconds: 280);
|
static const Duration _screenTransitionDuration = Duration(milliseconds: 280);
|
||||||
static const Duration _biometricInactivityTimeout = Duration(minutes: 5);
|
static const Duration _biometricInactivityTimeout = Duration(minutes: 5);
|
||||||
|
static const Duration _syncInterval = Duration(minutes: 5);
|
||||||
|
|
||||||
final LocalVaultService _vaultService = LocalVaultService.instance;
|
final LocalVaultService _vaultService = LocalVaultService.instance;
|
||||||
final GlobalKey<ScaffoldMessengerState> _scaffoldMessengerKey =
|
final GlobalKey<ScaffoldMessengerState> _scaffoldMessengerKey =
|
||||||
@@ -56,9 +64,12 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
bool _biometricGateEnabled = false;
|
bool _biometricGateEnabled = false;
|
||||||
int _biometricGateSession = 0;
|
int _biometricGateSession = 0;
|
||||||
Timer? _biometricLockTimer;
|
Timer? _biometricLockTimer;
|
||||||
|
Timer? _syncTimer;
|
||||||
bool _isHandlingWindowClose = false;
|
bool _isHandlingWindowClose = false;
|
||||||
_AppPhase _phase = _AppPhase.loading;
|
_AppPhase _phase = _AppPhase.loading;
|
||||||
_AppSection _currentSection = _AppSection.home;
|
_AppSection _currentSection = _AppSection.home;
|
||||||
|
SyncStatus _syncStatus = SyncStatus.idle;
|
||||||
|
String? _syncErrorMessage;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -79,6 +90,7 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
windowManager.setPreventClose(false);
|
windowManager.setPreventClose(false);
|
||||||
}
|
}
|
||||||
_biometricLockTimer?.cancel();
|
_biometricLockTimer?.cancel();
|
||||||
|
_syncTimer?.cancel();
|
||||||
_database?.close();
|
_database?.close();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -179,9 +191,18 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_database = database;
|
_database = database;
|
||||||
_repository = NoteRepository(database: database);
|
_repository = NoteRepository(
|
||||||
|
database: database,
|
||||||
|
authApi: AuthApi.instance,
|
||||||
|
masterKey: encryptionKey,
|
||||||
|
);
|
||||||
_phase = _AppPhase.notes;
|
_phase = _AppPhase.notes;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Start periodic sync
|
||||||
|
_startPeriodicSync();
|
||||||
|
// Run an initial full sync immediately to pull server changes
|
||||||
|
unawaited(_performSync(forceFull: true));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If the database file is not a valid SQLite DB (e.g., wrong key or corruption),
|
// 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
|
// reset the local vault so the app doesn't crash. The reset will delete DB files
|
||||||
@@ -238,6 +259,86 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _beginRemoteVaultFlow({
|
||||||
|
required String username,
|
||||||
|
required String password,
|
||||||
|
required bool isRegister,
|
||||||
|
}) async {
|
||||||
|
if (_isUnlocking) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isUnlocking = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isRegister) {
|
||||||
|
final String encryptionKey = _vaultService.generateEncryptionKey();
|
||||||
|
final String encryptedMasterKey =
|
||||||
|
await AuthApi.instance.encryptWithPassword(encryptionKey, password);
|
||||||
|
|
||||||
|
final Map<String, dynamic> response = await AuthApi.instance.register(
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
encryptedMasterKey: encryptedMasterKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response['error'] == true) {
|
||||||
|
throw StateError('No se pudo registrar el usuario.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await _vaultService.storeEncryptionKey(encryptionKey);
|
||||||
|
_pendingEncryptionKey = encryptionKey;
|
||||||
|
} else {
|
||||||
|
final Map<String, dynamic> response = await AuthApi.instance.login(
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response['error'] == true) {
|
||||||
|
throw StateError('No se pudo iniciar sesión.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final String? encryptedMasterKey =
|
||||||
|
(response['encrypted_master_key'] as String?) ??
|
||||||
|
(response['encryptedMasterKey'] as String?);
|
||||||
|
|
||||||
|
if (encryptedMasterKey == null || encryptedMasterKey.isEmpty) {
|
||||||
|
throw StateError('La API no devolvió la clave de encriptación.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final String encryptionKey =
|
||||||
|
await AuthApi.instance.decryptWithPassword(encryptedMasterKey, password);
|
||||||
|
|
||||||
|
await _vaultService.storeEncryptionKey(encryptionKey);
|
||||||
|
_pendingEncryptionKey = encryptionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _vaultService.setVaultAccessCompleted(true);
|
||||||
|
await _vaultService.setBiometricChoicePending(true);
|
||||||
|
await _vaultService.setBiometricGateEnabled(false);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_phase = _AppPhase.biometricChoice;
|
||||||
|
_biometricGateEnabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
_scaffoldMessengerKey.currentState?.showSnackBar(
|
||||||
|
SnackBar(content: Text('No se pudo completar la autenticación: $error')),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isUnlocking = false;
|
||||||
|
_isBootstrapping = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _enterWithoutAccount() {
|
Future<void> _enterWithoutAccount() {
|
||||||
return _beginInitialVaultFlow();
|
return _beginInitialVaultFlow();
|
||||||
}
|
}
|
||||||
@@ -496,6 +597,66 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
await WindowStateStore.instance.saveWindowSize(currentSize);
|
await WindowStateStore.instance.saveWindowSize(currentSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _startPeriodicSync() {
|
||||||
|
_syncTimer?.cancel();
|
||||||
|
_syncTimer = Timer.periodic(_syncInterval, (_) {
|
||||||
|
_performSync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _performSync({bool forceFull = false}) async {
|
||||||
|
if (_repository == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_syncStatus = SyncStatus.syncing;
|
||||||
|
_syncErrorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final Map<String, dynamic> result = await _repository!.performSync(forceFull: forceFull);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result['error'] == true) {
|
||||||
|
setState(() {
|
||||||
|
_syncStatus = SyncStatus.error;
|
||||||
|
_syncErrorMessage = result['message'] as String?;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_syncStatus = SyncStatus.synced;
|
||||||
|
_syncErrorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset to idle after 3 seconds
|
||||||
|
Future<void>.delayed(const Duration(seconds: 3), () {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_syncStatus = SyncStatus.idle;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_syncStatus = SyncStatus.error;
|
||||||
|
_syncErrorMessage = e.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildLoadingScreen() {
|
Widget _buildLoadingScreen() {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
navigatorKey: _navigatorKey,
|
navigatorKey: _navigatorKey,
|
||||||
@@ -557,8 +718,23 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
scaffoldMessengerKey: _scaffoldMessengerKey,
|
scaffoldMessengerKey: _scaffoldMessengerKey,
|
||||||
theme: AppTheme.theme,
|
theme: AppTheme.theme,
|
||||||
home: Scaffold(
|
home: Shortcuts(
|
||||||
body: Container(
|
shortcuts: <LogicalKeySet, Intent>{
|
||||||
|
LogicalKeySet(LogicalKeyboardKey.f5): const PerformSyncIntent(),
|
||||||
|
},
|
||||||
|
child: Actions(
|
||||||
|
actions: <Type, Action<Intent>>{
|
||||||
|
PerformSyncIntent: CallbackAction<PerformSyncIntent>(
|
||||||
|
onInvoke: (PerformSyncIntent intent) {
|
||||||
|
_performSync();
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
child: Focus(
|
||||||
|
autofocus: true,
|
||||||
|
child: Scaffold(
|
||||||
|
body: Container(
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
@@ -573,7 +749,10 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const AppTitleBar(),
|
AppTitleBar(
|
||||||
|
syncStatus: _syncStatus,
|
||||||
|
syncErrorMessage: _syncErrorMessage,
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: AnimatedSwitcher(
|
child: AnimatedSwitcher(
|
||||||
duration: _screenTransitionDuration,
|
duration: _screenTransitionDuration,
|
||||||
@@ -598,6 +777,9 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,10 +851,18 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
home: VaultAccessScreen(
|
home: VaultAccessScreen(
|
||||||
isBusy: _isUnlocking,
|
isBusy: _isUnlocking,
|
||||||
onCreateAccountPressed: (String email, String password) async {
|
onCreateAccountPressed: (String email, String password) async {
|
||||||
await _beginInitialVaultFlow(actionLabel: 'Crear cuenta');
|
await _beginRemoteVaultFlow(
|
||||||
|
username: email,
|
||||||
|
password: password,
|
||||||
|
isRegister: true,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
onSignInPressed: (String email, String password) async {
|
onSignInPressed: (String email, String password) async {
|
||||||
await _beginInitialVaultFlow(actionLabel: 'Iniciar sesión');
|
await _beginRemoteVaultFlow(
|
||||||
|
username: email,
|
||||||
|
password: password,
|
||||||
|
isRegister: false,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
onContinueWithoutAccount: _enterWithoutAccount,
|
onContinueWithoutAccount: _enterWithoutAccount,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:crypto/crypto.dart' as crypto;
|
||||||
|
import 'package:cryptography/cryptography.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:notas/data/sync_models.dart';
|
||||||
|
|
||||||
|
class ApiConfig {
|
||||||
|
ApiConfig._();
|
||||||
|
|
||||||
|
static const String _endpointKey = 'api_endpoint_v1';
|
||||||
|
|
||||||
|
/// Default endpoint for local development. Can be overridden by user.
|
||||||
|
static const String defaultEndpoint = 'http://localhost:3000/api';
|
||||||
|
|
||||||
|
static Future<String> getEndpoint() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_endpointKey) ?? defaultEndpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setEndpoint(String endpoint) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_endpointKey, endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> clearEndpoint() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_endpointKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthApi {
|
||||||
|
AuthApi._();
|
||||||
|
|
||||||
|
static final AuthApi instance = AuthApi._();
|
||||||
|
|
||||||
|
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
||||||
|
|
||||||
|
static const String _accessTokenKey = 'api_access_token_v1';
|
||||||
|
static const String _refreshTokenKey = 'api_refresh_token_v1';
|
||||||
|
static const int _passwordHashVersion = 1;
|
||||||
|
static const int _kdfIterations = 100000;
|
||||||
|
static final Pbkdf2 _kdf = Pbkdf2(
|
||||||
|
macAlgorithm: Hmac.sha256(),
|
||||||
|
iterations: _kdfIterations,
|
||||||
|
bits: 256,
|
||||||
|
);
|
||||||
|
static final AesGcm _aes = AesGcm.with256bits();
|
||||||
|
|
||||||
|
Future<String?> get accessToken async =>
|
||||||
|
await _secureStorage.read(key: _accessTokenKey);
|
||||||
|
|
||||||
|
Future<String?> get refreshToken async =>
|
||||||
|
await _secureStorage.read(key: _refreshTokenKey);
|
||||||
|
|
||||||
|
Future<void> clearTokens() async {
|
||||||
|
await _secureStorage.delete(key: _accessTokenKey);
|
||||||
|
await _secureStorage.delete(key: _refreshTokenKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
String hashPassword(String password) {
|
||||||
|
return crypto.sha256.convert(utf8.encode(password)).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> encryptWithPassword(
|
||||||
|
String plaintext,
|
||||||
|
String password,
|
||||||
|
) async {
|
||||||
|
final List<int> salt = _randomBytes(16);
|
||||||
|
final SecretKey secretKey = await _kdf.deriveKey(
|
||||||
|
secretKey: SecretKey(utf8.encode(password)),
|
||||||
|
nonce: salt,
|
||||||
|
);
|
||||||
|
|
||||||
|
final List<int> nonce = _randomBytes(12);
|
||||||
|
final SecretBox box = await _aes.encrypt(
|
||||||
|
utf8.encode(plaintext),
|
||||||
|
secretKey: secretKey,
|
||||||
|
nonce: nonce,
|
||||||
|
);
|
||||||
|
|
||||||
|
return jsonEncode(<String, dynamic>{
|
||||||
|
'v': _passwordHashVersion,
|
||||||
|
'salt': base64Encode(salt),
|
||||||
|
'nonce': base64Encode(box.nonce),
|
||||||
|
'cipherText': base64Encode(box.cipherText),
|
||||||
|
'mac': base64Encode(box.mac.bytes),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> decryptWithPassword(
|
||||||
|
String encodedBox,
|
||||||
|
String password,
|
||||||
|
) async {
|
||||||
|
final Map<String, dynamic> payload = jsonDecode(encodedBox) as Map<String, dynamic>;
|
||||||
|
final List<int> salt = base64Decode(payload['salt'] as String);
|
||||||
|
final List<int> nonce = base64Decode(payload['nonce'] as String);
|
||||||
|
final List<int> cipherText = base64Decode(payload['cipherText'] as String);
|
||||||
|
final List<int> macBytes = base64Decode(payload['mac'] as String);
|
||||||
|
|
||||||
|
final SecretKey secretKey = await _kdf.deriveKey(
|
||||||
|
secretKey: SecretKey(utf8.encode(password)),
|
||||||
|
nonce: salt,
|
||||||
|
);
|
||||||
|
|
||||||
|
final SecretBox box = SecretBox(
|
||||||
|
cipherText,
|
||||||
|
nonce: nonce,
|
||||||
|
mac: Mac(macBytes),
|
||||||
|
);
|
||||||
|
|
||||||
|
final List<int> clearText = await _aes.decrypt(
|
||||||
|
box,
|
||||||
|
secretKey: secretKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
return utf8.decode(clearText);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> login(
|
||||||
|
String username,
|
||||||
|
String password, {
|
||||||
|
String? deviceName,
|
||||||
|
String? endpoint,
|
||||||
|
}) async {
|
||||||
|
final String base = endpoint ?? await ApiConfig.getEndpoint();
|
||||||
|
final Uri url = Uri.parse('$base/auth/login');
|
||||||
|
|
||||||
|
final Map<String, dynamic> body = {
|
||||||
|
'username': username,
|
||||||
|
'password': hashPassword(password),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (deviceName != null) body['deviceName'] = deviceName;
|
||||||
|
|
||||||
|
final http.Response res = await http.post(
|
||||||
|
url,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
final Map<String, dynamic> json =
|
||||||
|
jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
|
||||||
|
final String? access = json['accessToken'] as String?;
|
||||||
|
final String? refresh = json['refreshToken'] as String?;
|
||||||
|
|
||||||
|
if (access != null) {
|
||||||
|
await _secureStorage.write(key: _accessTokenKey, value: access);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refresh != null) {
|
||||||
|
await _secureStorage.write(key: _refreshTokenKey, value: refresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to decode error body for better diagnostics.
|
||||||
|
try {
|
||||||
|
final dynamic decoded = jsonDecode(res.body);
|
||||||
|
return {'error': true, 'status': res.statusCode, 'body': decoded};
|
||||||
|
} catch (_) {
|
||||||
|
return {'error': true, 'status': res.statusCode, 'body': res.body};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> register(
|
||||||
|
String username,
|
||||||
|
String password, {
|
||||||
|
String? encryptedMasterKey,
|
||||||
|
String? endpoint,
|
||||||
|
}) async {
|
||||||
|
final String base = endpoint ?? await ApiConfig.getEndpoint();
|
||||||
|
final Uri url = Uri.parse('$base/auth/register');
|
||||||
|
|
||||||
|
final Map<String, dynamic> body = {
|
||||||
|
'username': username,
|
||||||
|
'password': hashPassword(password),
|
||||||
|
'encrypted_master_key': encryptedMasterKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
final http.Response res = await http.post(
|
||||||
|
url,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
final Map<String, dynamic> json =
|
||||||
|
jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
|
||||||
|
final String? access = json['accessToken'] as String?;
|
||||||
|
final String? refresh = json['refreshToken'] as String?;
|
||||||
|
|
||||||
|
if (access != null) {
|
||||||
|
await _secureStorage.write(key: _accessTokenKey, value: access);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refresh != null) {
|
||||||
|
await _secureStorage.write(key: _refreshTokenKey, value: refresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final dynamic decoded = jsonDecode(res.body);
|
||||||
|
return {'error': true, 'status': res.statusCode, 'body': decoded};
|
||||||
|
} catch (_) {
|
||||||
|
return {'error': true, 'status': res.statusCode, 'body': res.body};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<int> _randomBytes(int length) {
|
||||||
|
final Random random = Random.secure();
|
||||||
|
return List<int>.generate(length, (_) => random.nextInt(256));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Sync API ==========
|
||||||
|
|
||||||
|
static const String _lastSyncAtKey = 'api_last_sync_at_v1';
|
||||||
|
|
||||||
|
Future<DateTime?> getLastSyncAt() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final String? timestamp = prefs.getString(_lastSyncAtKey);
|
||||||
|
if (timestamp == null) return null;
|
||||||
|
return DateTime.tryParse(timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setLastSyncAt(DateTime timestamp) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_lastSyncAtKey, timestamp.toIso8601String());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> sync(
|
||||||
|
SyncRequest syncRequest, {
|
||||||
|
String? endpoint,
|
||||||
|
}) async {
|
||||||
|
final String? token = await accessToken;
|
||||||
|
if (token == null) {
|
||||||
|
return {'error': true, 'message': 'No access token available'};
|
||||||
|
}
|
||||||
|
|
||||||
|
final String base = endpoint ?? await ApiConfig.getEndpoint();
|
||||||
|
final Uri url = Uri.parse('$base/sync');
|
||||||
|
|
||||||
|
final Map<String, String> headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
};
|
||||||
|
|
||||||
|
final String bodyJson = jsonEncode(syncRequest.toJson());
|
||||||
|
|
||||||
|
// Log request (mask authorization header)
|
||||||
|
final Map<String, String> logHeaders = Map.from(headers);
|
||||||
|
if (logHeaders.containsKey('Authorization')) {
|
||||||
|
logHeaders['Authorization'] = 'REDACTED';
|
||||||
|
}
|
||||||
|
debugPrint('SYNC REQUEST -> POST $url');
|
||||||
|
debugPrint('Headers: $logHeaders');
|
||||||
|
debugPrint('Body: $bodyJson');
|
||||||
|
|
||||||
|
final http.Response res = await http.post(
|
||||||
|
url,
|
||||||
|
headers: headers,
|
||||||
|
body: bodyJson,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log response
|
||||||
|
debugPrint('SYNC RESPONSE <- ${res.statusCode}');
|
||||||
|
debugPrint('Response body: ${res.body}');
|
||||||
|
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
final Map<String, dynamic> json =
|
||||||
|
jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
return {'error': false, 'data': SyncResponse.fromJson(json)};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If token expired (401), try to refresh
|
||||||
|
if (res.statusCode == 401) {
|
||||||
|
final String? refreshTok = await refreshToken;
|
||||||
|
if (refreshTok != null) {
|
||||||
|
final bool refreshed = await _refreshAccessToken(refreshTok, endpoint: endpoint);
|
||||||
|
if (refreshed) {
|
||||||
|
// Retry sync with new token
|
||||||
|
return sync(syncRequest, endpoint: endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to decode error body for better diagnostics.
|
||||||
|
try {
|
||||||
|
final dynamic decoded = jsonDecode(res.body);
|
||||||
|
return {'error': true, 'status': res.statusCode, 'body': decoded};
|
||||||
|
} catch (_) {
|
||||||
|
return {'error': true, 'status': res.statusCode, 'body': res.body};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _refreshAccessToken(
|
||||||
|
String refreshToken, {
|
||||||
|
String? endpoint,
|
||||||
|
}) async {
|
||||||
|
final String base = endpoint ?? await ApiConfig.getEndpoint();
|
||||||
|
final Uri url = Uri.parse('$base/auth/refresh');
|
||||||
|
|
||||||
|
final http.Response res = await http.post(
|
||||||
|
url,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({'refreshToken': refreshToken}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
final Map<String, dynamic> json =
|
||||||
|
jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
final String? newAccess = json['accessToken'] as String?;
|
||||||
|
|
||||||
|
if (newAccess != null) {
|
||||||
|
await _secureStorage.write(key: _accessTokenKey, value: newAccess);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
-19
@@ -7,39 +7,70 @@ import 'package:path_provider/path_provider.dart';
|
|||||||
|
|
||||||
part 'app_database.g.dart';
|
part 'app_database.g.dart';
|
||||||
|
|
||||||
|
@DataClassName('DbCategory')
|
||||||
|
class Categories extends Table {
|
||||||
|
TextColumn get uuid => text().unique()();
|
||||||
|
TextColumn get encryptedName => text().named('encrypted_name')();
|
||||||
|
IntColumn get serverVersion => integer().named('server_version').withDefault(const Constant(0))();
|
||||||
|
BoolColumn get isDeleted => boolean().named('is_deleted').withDefault(const Constant(false))();
|
||||||
|
DateTimeColumn get updatedAt => dateTime().named('updated_at')();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {uuid};
|
||||||
|
}
|
||||||
|
|
||||||
@DataClassName('DbNote')
|
@DataClassName('DbNote')
|
||||||
class Notes extends Table {
|
class Notes extends Table {
|
||||||
IntColumn get id => integer().autoIncrement()();
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
TextColumn get uuid => text().unique()();
|
||||||
TextColumn get title => text()();
|
TextColumn get title => text()();
|
||||||
TextColumn get body => text()();
|
TextColumn get body => text()();
|
||||||
DateTimeColumn get createdAt => dateTime()();
|
DateTimeColumn get createdAt => dateTime().named('created_at')();
|
||||||
DateTimeColumn get updatedAt => dateTime()();
|
DateTimeColumn get updatedAt => dateTime().named('updated_at')();
|
||||||
IntColumn get sortIndex => integer().named('sort_index')();
|
IntColumn get sortIndex => integer().named('sort_index')();
|
||||||
|
IntColumn get serverVersion => integer().named('server_version').withDefault(const Constant(0))();
|
||||||
|
BoolColumn get isDeleted => boolean().named('is_deleted').withDefault(const Constant(false))();
|
||||||
|
TextColumn get categoryId => text().nullable().named('category_id')();
|
||||||
}
|
}
|
||||||
|
|
||||||
@DriftDatabase(tables: [Notes])
|
@DriftDatabase(tables: [Notes, Categories])
|
||||||
class AppDatabase extends _$AppDatabase {
|
class AppDatabase extends _$AppDatabase {
|
||||||
AppDatabase({required String encryptionKey}) : super(_openConnection(encryptionKey));
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 1;
|
int get schemaVersion => 1;
|
||||||
|
AppDatabase({required String encryptionKey}) : super(_openConnection(encryptionKey));
|
||||||
|
|
||||||
|
// ========== Categories ==========
|
||||||
|
Future<List<DbCategory>> getAllCategories() {
|
||||||
|
return (select(categories)..where((c) => c.isDeleted.equals(false))).get();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> upsertCategory(CategoriesCompanion category) {
|
||||||
|
return into(categories).insertOnConflictUpdate(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteCategory(String uuid) {
|
||||||
|
return (update(categories)..where((c) => c.uuid.equals(uuid)))
|
||||||
|
.write(CategoriesCompanion(isDeleted: Value(true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Notes ==========
|
||||||
Future<List<DbNote>> getAllNotes() {
|
Future<List<DbNote>> getAllNotes() {
|
||||||
return (select(notes)..orderBy([
|
return (select(notes)
|
||||||
(note) => OrderingTerm(expression: note.sortIndex),
|
..orderBy([(note) => OrderingTerm(expression: note.sortIndex)])
|
||||||
])).get();
|
..where((n) => n.isDeleted.equals(false)))
|
||||||
|
.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> insertNoteAtTop(NotesCompanion note) {
|
Future<int> insertNoteAtTop(NotesCompanion note) {
|
||||||
return transaction(() async {
|
return transaction(() async {
|
||||||
await customStatement('UPDATE notes SET sort_index = sort_index + 1');
|
await customStatement('UPDATE notes SET sort_index = sort_index + 1 WHERE is_deleted = 0');
|
||||||
return into(notes).insert(note.copyWith(sortIndex: const Value<int>(0)));
|
return into(notes).insert(note.copyWith(sortIndex: const Value<int>(0)));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> replaceAllNotes(List<NotesCompanion> noteList) {
|
Future<void> replaceAllNotes(List<NotesCompanion> noteList) {
|
||||||
return transaction(() async {
|
return transaction(() async {
|
||||||
await delete(notes).go();
|
await (delete(notes)..where((n) => n.isDeleted.equals(false))).go();
|
||||||
|
|
||||||
for (final NotesCompanion note in noteList) {
|
for (final NotesCompanion note in noteList) {
|
||||||
await into(notes).insert(note);
|
await into(notes).insert(note);
|
||||||
@@ -51,17 +82,20 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
return update(notes).replace(note);
|
return update(notes).replace(note);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> deleteNote(int id, int removedIndex) async {
|
||||||
|
await (update(notes)..where((n) => n.id.equals(id))).write(NotesCompanion(isDeleted: Value(true)));
|
||||||
|
|
||||||
|
await customStatement(
|
||||||
|
'UPDATE notes SET sort_index = sort_index - 1 WHERE sort_index > ? AND is_deleted = 0',
|
||||||
|
[removedIndex],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> deleteNoteAndShift({
|
Future<void> deleteNoteAndShift({
|
||||||
required int id,
|
required int id,
|
||||||
required int removedIndex,
|
required int removedIndex,
|
||||||
}) {
|
}) {
|
||||||
return transaction(() async {
|
return deleteNote(id, removedIndex);
|
||||||
await (delete(notes)..where((note) => note.id.equals(id))).go();
|
|
||||||
await customStatement(
|
|
||||||
'UPDATE notes SET sort_index = sort_index - 1 WHERE sort_index > ?',
|
|
||||||
[removedIndex],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> moveNote({
|
Future<void> moveNote({
|
||||||
@@ -76,12 +110,12 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
return transaction(() async {
|
return transaction(() async {
|
||||||
if (oldIndex < newIndex) {
|
if (oldIndex < newIndex) {
|
||||||
await customStatement(
|
await customStatement(
|
||||||
'UPDATE notes SET sort_index = sort_index - 1 WHERE sort_index > ? AND sort_index <= ?',
|
'UPDATE notes SET sort_index = sort_index - 1 WHERE sort_index > ? AND sort_index <= ? AND is_deleted = 0',
|
||||||
[oldIndex, newIndex],
|
[oldIndex, newIndex],
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await customStatement(
|
await customStatement(
|
||||||
'UPDATE notes SET sort_index = sort_index + 1 WHERE sort_index >= ? AND sort_index < ?',
|
'UPDATE notes SET sort_index = sort_index + 1 WHERE sort_index >= ? AND sort_index < ? AND is_deleted = 0',
|
||||||
[newIndex, oldIndex],
|
[newIndex, oldIndex],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -92,6 +126,15 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== Sync helpers ==========
|
||||||
|
Future<List<DbNote>> getUnsyncedNotes() {
|
||||||
|
return (select(notes)..where((n) => n.isDeleted.equals(true) | n.serverVersion.equals(0))).get();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<DbCategory>> getUnsyncedCategories() {
|
||||||
|
return (select(categories)..where((c) => c.isDeleted.equals(true) | c.serverVersion.equals(0))).get();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyDatabase _openConnection(String encryptionKey) {
|
LazyDatabase _openConnection(String encryptionKey) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -74,6 +74,14 @@ class LocalVaultService {
|
|||||||
return _secureStorage.read(key: _encryptionKeyStorageKey);
|
return _secureStorage.read(key: _encryptionKeyStorageKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> storeEncryptionKey(String encryptionKey) async {
|
||||||
|
await _secureStorage.write(key: _encryptionKeyStorageKey, value: encryptionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
String generateEncryptionKey() {
|
||||||
|
return _generateEncryptionKey();
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> hasEncryptionKey() async {
|
Future<bool> hasEncryptionKey() async {
|
||||||
return (await readStoredEncryptionKeyRaw()) != null;
|
return (await readStoredEncryptionKeyRaw()) != null;
|
||||||
}
|
}
|
||||||
@@ -117,10 +125,7 @@ class LocalVaultService {
|
|||||||
Future<String> createEncryptionKey({bool protectWithBiometrics = false}) async {
|
Future<String> createEncryptionKey({bool protectWithBiometrics = false}) async {
|
||||||
final String encryptionKey = _generateEncryptionKey();
|
final String encryptionKey = _generateEncryptionKey();
|
||||||
|
|
||||||
await _secureStorage.write(
|
await storeEncryptionKey(encryptionKey);
|
||||||
key: _encryptionKeyStorageKey,
|
|
||||||
value: encryptionKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
// If requested, try to enable biometric protection. Only enable on mobile
|
// If requested, try to enable biometric protection. Only enable on mobile
|
||||||
// platforms and only if the authentication succeeds.
|
// platforms and only if the authentication succeeds.
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:cryptography/cryptography.dart';
|
||||||
|
|
||||||
|
/// Encriptación de contenido de notas usando AES-GCM
|
||||||
|
/// Usa una clave derivada del master key del usuario
|
||||||
|
class NoteEncryption {
|
||||||
|
static final AesGcm _aes = AesGcm.with256bits();
|
||||||
|
static const int _passwordHashVersion = 1;
|
||||||
|
static const int _kdfIterations = 100000;
|
||||||
|
static final Pbkdf2 _kdf = Pbkdf2(
|
||||||
|
macAlgorithm: Hmac.sha256(),
|
||||||
|
iterations: _kdfIterations,
|
||||||
|
bits: 256,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Encripta el contenido de una nota usando el master key
|
||||||
|
/// Retorna el contenido encriptado en formato JSON base64
|
||||||
|
static Future<String> encryptNote(
|
||||||
|
String plaintext,
|
||||||
|
String masterKey,
|
||||||
|
) async {
|
||||||
|
final List<int> salt = _randomBytes(16);
|
||||||
|
final SecretKey secretKey = await _kdf.deriveKey(
|
||||||
|
secretKey: SecretKey(utf8.encode(masterKey)),
|
||||||
|
nonce: salt,
|
||||||
|
);
|
||||||
|
|
||||||
|
final List<int> nonce = _randomBytes(12);
|
||||||
|
final SecretBox box = await _aes.encrypt(
|
||||||
|
utf8.encode(plaintext),
|
||||||
|
secretKey: secretKey,
|
||||||
|
nonce: nonce,
|
||||||
|
);
|
||||||
|
|
||||||
|
return jsonEncode(<String, dynamic>{
|
||||||
|
'v': _passwordHashVersion,
|
||||||
|
'salt': base64Encode(salt),
|
||||||
|
'nonce': base64Encode(box.nonce),
|
||||||
|
'cipherText': base64Encode(box.cipherText),
|
||||||
|
'mac': base64Encode(box.mac.bytes),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Desencripta el contenido de una nota usando el master key
|
||||||
|
static Future<String> decryptNote(
|
||||||
|
String encodedBox,
|
||||||
|
String masterKey,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final Map<String, dynamic> payload =
|
||||||
|
jsonDecode(encodedBox) as Map<String, dynamic>;
|
||||||
|
final List<int> salt = base64Decode(payload['salt'] as String);
|
||||||
|
final List<int> nonce = base64Decode(payload['nonce'] as String);
|
||||||
|
final List<int> cipherText =
|
||||||
|
base64Decode(payload['cipherText'] as String);
|
||||||
|
final List<int> macBytes = base64Decode(payload['mac'] as String);
|
||||||
|
|
||||||
|
final SecretKey secretKey = await _kdf.deriveKey(
|
||||||
|
secretKey: SecretKey(utf8.encode(masterKey)),
|
||||||
|
nonce: salt,
|
||||||
|
);
|
||||||
|
|
||||||
|
final SecretBox box = SecretBox(
|
||||||
|
cipherText,
|
||||||
|
nonce: nonce,
|
||||||
|
mac: Mac(macBytes),
|
||||||
|
);
|
||||||
|
|
||||||
|
final List<int> clearText = await _aes.decrypt(
|
||||||
|
box,
|
||||||
|
secretKey: secretKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
return utf8.decode(clearText);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to decrypt note: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<int> _randomBytes(int length) {
|
||||||
|
final Random random = Random.secure();
|
||||||
|
return List<int>.generate(length, (_) => random.nextInt(256));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,23 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
import 'package:notas/data/app_database.dart';
|
import 'package:notas/data/app_database.dart';
|
||||||
|
import 'package:notas/data/api_client.dart';
|
||||||
|
import 'package:notas/data/sync_models.dart';
|
||||||
import 'package:notas/models/note.dart';
|
import 'package:notas/models/note.dart';
|
||||||
|
import 'package:notas/models/category.dart';
|
||||||
|
|
||||||
|
import 'package:notas/data/note_encryption.dart';
|
||||||
class NoteRepository {
|
class NoteRepository {
|
||||||
NoteRepository({required AppDatabase database}) : _database = database;
|
NoteRepository({
|
||||||
|
required AppDatabase database,
|
||||||
|
required AuthApi authApi,
|
||||||
|
required String masterKey,
|
||||||
|
}) : _database = database,
|
||||||
|
_authApi = authApi,
|
||||||
|
_masterKey = masterKey;
|
||||||
|
|
||||||
final AppDatabase _database;
|
final AppDatabase _database;
|
||||||
|
final AuthApi _authApi;
|
||||||
|
final String _masterKey;
|
||||||
|
|
||||||
Future<List<Note>> loadNotes() async {
|
Future<List<Note>> loadNotes() async {
|
||||||
return _loadNotesFromDatabase();
|
return _loadNotesFromDatabase();
|
||||||
@@ -13,11 +26,15 @@ class NoteRepository {
|
|||||||
Future<Note> createNote(Note note) async {
|
Future<Note> createNote(Note note) async {
|
||||||
final int id = await _database.insertNoteAtTop(
|
final int id = await _database.insertNoteAtTop(
|
||||||
NotesCompanion.insert(
|
NotesCompanion.insert(
|
||||||
|
uuid: note.uuid,
|
||||||
title: note.title,
|
title: note.title,
|
||||||
body: note.body,
|
body: note.body,
|
||||||
createdAt: note.createdAt,
|
createdAt: note.createdAt,
|
||||||
updatedAt: note.updatedAt,
|
updatedAt: note.updatedAt,
|
||||||
sortIndex: 0,
|
sortIndex: 0,
|
||||||
|
serverVersion: const Value(0),
|
||||||
|
isDeleted: const Value(false),
|
||||||
|
categoryId: const Value(null),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -30,11 +47,15 @@ class NoteRepository {
|
|||||||
await _database.updateNoteRow(
|
await _database.updateNoteRow(
|
||||||
DbNote(
|
DbNote(
|
||||||
id: noteId,
|
id: noteId,
|
||||||
|
uuid: note.uuid,
|
||||||
title: note.title,
|
title: note.title,
|
||||||
body: note.body,
|
body: note.body,
|
||||||
createdAt: note.createdAt,
|
createdAt: note.createdAt,
|
||||||
updatedAt: note.updatedAt,
|
updatedAt: note.updatedAt,
|
||||||
sortIndex: note.index,
|
sortIndex: note.index,
|
||||||
|
serverVersion: note.serverVersion,
|
||||||
|
isDeleted: note.isDeleted,
|
||||||
|
categoryId: note.categoryId,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -60,19 +81,176 @@ class NoteRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Note>> _loadNotesFromDatabase() async {
|
// ========== Sync logic ==========
|
||||||
final List<DbNote> rows = await _database.getAllNotes();
|
|
||||||
return rows.map(_fromRow).toList();
|
/// Sincroniza notas con el servidor.
|
||||||
|
/// Requiere que el usuario esté autenticado (token válido).
|
||||||
|
Future<Map<String, dynamic>> performSync({bool forceFull = false}) async {
|
||||||
|
try {
|
||||||
|
// Get last sync timestamp
|
||||||
|
final DateTime? lastSync = await _authApi.getLastSyncAt();
|
||||||
|
final DateTime? lastSyncForRequest = forceFull ? DateTime.utc(1970, 1, 1) : lastSync;
|
||||||
|
|
||||||
|
// Collect pending changes
|
||||||
|
final List<DbNote> unsyncedNotes = await _database.getUnsyncedNotes();
|
||||||
|
final List<DbCategory> unsyncedCategories = await _database.getUnsyncedCategories();
|
||||||
|
|
||||||
|
// Build sync request (note: we send encrypted data, but locally we have plaintext)
|
||||||
|
// Encrypt all notes before sending
|
||||||
|
final List<SyncNotePayload> encryptedNotesPayload = [];
|
||||||
|
for (final dbNote in unsyncedNotes) {
|
||||||
|
final note = _fromDbNote(dbNote);
|
||||||
|
final encryptedTitle = await NoteEncryption.encryptNote(note.title, _masterKey);
|
||||||
|
final encryptedBody = await NoteEncryption.encryptNote(note.body, _masterKey);
|
||||||
|
encryptedNotesPayload.add(
|
||||||
|
SyncNotePayload.fromNote(
|
||||||
|
note,
|
||||||
|
encryptedTitle: encryptedTitle,
|
||||||
|
encryptedBody: encryptedBody,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<SyncCategoryPayload> categoriesPayload = unsyncedCategories
|
||||||
|
.map((cat) => SyncCategoryPayload.fromCategory(
|
||||||
|
_fromDbCategory(cat),
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final SyncRequest syncRequest = SyncRequest(
|
||||||
|
lastSyncAt: lastSyncForRequest,
|
||||||
|
changes: SyncChanges(
|
||||||
|
categories: categoriesPayload,
|
||||||
|
notes: encryptedNotesPayload,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Call sync API
|
||||||
|
final Map<String, dynamic> syncResult =
|
||||||
|
await _authApi.sync(syncRequest);
|
||||||
|
|
||||||
|
if (syncResult['error'] == true) {
|
||||||
|
return {'error': true, 'message': syncResult['body']};
|
||||||
|
}
|
||||||
|
|
||||||
|
final SyncResponse response = syncResult['data'] as SyncResponse;
|
||||||
|
|
||||||
|
// Apply server changes to local database
|
||||||
|
await _applySyncResponse(response);
|
||||||
|
|
||||||
|
// Update lastSyncAt
|
||||||
|
await _authApi.setLastSyncAt(response.serverTimestamp);
|
||||||
|
|
||||||
|
return {
|
||||||
|
'error': false,
|
||||||
|
'synced': response.synced,
|
||||||
|
'notesCount': response.changes.notes.length,
|
||||||
|
'categoriesCount': response.changes.categories.length,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return {'error': true, 'message': e.toString()};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Note _fromRow(DbNote row) {
|
Future<void> _applySyncResponse(SyncResponse response) async {
|
||||||
|
// Apply categories from server
|
||||||
|
for (final SyncCategoryResponse catResponse in response.changes.categories) {
|
||||||
|
await _database.upsertCategory(
|
||||||
|
CategoriesCompanion(
|
||||||
|
uuid: Value(catResponse.id),
|
||||||
|
encryptedName: Value(catResponse.encryptedName),
|
||||||
|
serverVersion: Value(catResponse.serverVersion),
|
||||||
|
isDeleted: Value(catResponse.isDeleted),
|
||||||
|
updatedAt: Value(catResponse.updatedAt),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply notes from server
|
||||||
|
for (final SyncNoteResponse noteResponse in response.changes.notes) {
|
||||||
|
final existingNote = await (_database.select(_database.notes)
|
||||||
|
..where((n) => n.uuid.equals(noteResponse.id)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
|
||||||
|
// Decrypt note content
|
||||||
|
String decryptedTitle = 'Encrypted';
|
||||||
|
String decryptedBody = 'Encrypted';
|
||||||
|
try {
|
||||||
|
decryptedTitle = await NoteEncryption.decryptNote(
|
||||||
|
noteResponse.encryptedTitle,
|
||||||
|
_masterKey,
|
||||||
|
);
|
||||||
|
decryptedBody = await NoteEncryption.decryptNote(
|
||||||
|
noteResponse.encryptedBody,
|
||||||
|
_masterKey,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// If decryption fails, keep default encrypted placeholders
|
||||||
|
print('Failed to decrypt note ${noteResponse.id}: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingNote != null) {
|
||||||
|
// Update existing note
|
||||||
|
await _database.updateNoteRow(
|
||||||
|
DbNote(
|
||||||
|
id: existingNote.id,
|
||||||
|
uuid: noteResponse.id,
|
||||||
|
title: decryptedTitle,
|
||||||
|
body: decryptedBody,
|
||||||
|
createdAt: existingNote.createdAt,
|
||||||
|
updatedAt: noteResponse.updatedAt,
|
||||||
|
sortIndex: noteResponse.position,
|
||||||
|
serverVersion: noteResponse.serverVersion,
|
||||||
|
isDeleted: noteResponse.isDeleted,
|
||||||
|
categoryId: noteResponse.categoryId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Insert new note
|
||||||
|
await _database.into(_database.notes).insert(
|
||||||
|
NotesCompanion(
|
||||||
|
uuid: Value(noteResponse.id),
|
||||||
|
title: Value(decryptedTitle),
|
||||||
|
body: Value(decryptedBody),
|
||||||
|
createdAt: Value(noteResponse.updatedAt),
|
||||||
|
updatedAt: Value(noteResponse.updatedAt),
|
||||||
|
sortIndex: Value(noteResponse.position),
|
||||||
|
serverVersion: Value(noteResponse.serverVersion),
|
||||||
|
isDeleted: Value(noteResponse.isDeleted),
|
||||||
|
categoryId: Value(noteResponse.categoryId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Note>> _loadNotesFromDatabase() async {
|
||||||
|
final List<DbNote> rows = await _database.getAllNotes();
|
||||||
|
return rows.map(_fromDbNote).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Note _fromDbNote(DbNote row) {
|
||||||
return Note(
|
return Note(
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
uuid: row.uuid,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
body: row.body,
|
body: row.body,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
index: row.sortIndex,
|
index: row.sortIndex,
|
||||||
|
serverVersion: row.serverVersion,
|
||||||
|
isDeleted: row.isDeleted,
|
||||||
|
categoryId: row.categoryId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
Category _fromDbCategory(DbCategory row) {
|
||||||
|
return Category(
|
||||||
|
uuid: row.uuid,
|
||||||
|
encryptedName: row.encryptedName,
|
||||||
|
serverVersion: row.serverVersion,
|
||||||
|
isDeleted: row.isDeleted,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import 'package:notas/models/note.dart';
|
||||||
|
import 'package:notas/models/category.dart';
|
||||||
|
|
||||||
|
// DTOs para sincronización con el servidor
|
||||||
|
|
||||||
|
class SyncRequest {
|
||||||
|
SyncRequest({
|
||||||
|
DateTime? lastSyncAt,
|
||||||
|
required this.changes,
|
||||||
|
}) : lastSyncAt = lastSyncAt ?? DateTime.utc(1970, 1, 1);
|
||||||
|
|
||||||
|
final DateTime lastSyncAt;
|
||||||
|
final SyncChanges changes;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'lastSyncAt': lastSyncAt.toIso8601String(),
|
||||||
|
'changes': changes.toJson(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SyncChanges {
|
||||||
|
const SyncChanges({
|
||||||
|
this.categories = const [],
|
||||||
|
this.notes = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<SyncCategoryPayload> categories;
|
||||||
|
final List<SyncNotePayload> notes;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
if (categories.isNotEmpty)
|
||||||
|
'categories': categories.map((c) => c.toJson()).toList(),
|
||||||
|
if (notes.isNotEmpty)
|
||||||
|
'notes': notes.map((n) => n.toJson()).toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SyncChangesResponse {
|
||||||
|
const SyncChangesResponse({
|
||||||
|
this.categories = const [],
|
||||||
|
this.notes = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<SyncCategoryResponse> categories;
|
||||||
|
final List<SyncNoteResponse> notes;
|
||||||
|
|
||||||
|
factory SyncChangesResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
final List<dynamic> categoriesJson = json['categories'] as List<dynamic>? ?? [];
|
||||||
|
final List<dynamic> notesJson = json['notes'] as List<dynamic>? ?? [];
|
||||||
|
|
||||||
|
return SyncChangesResponse(
|
||||||
|
categories: categoriesJson
|
||||||
|
.map((c) => SyncCategoryResponse.fromJson(c as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
notes: notesJson
|
||||||
|
.map((n) => SyncNoteResponse.fromJson(n as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class SyncCategoryPayload {
|
||||||
|
const SyncCategoryPayload({
|
||||||
|
required this.id,
|
||||||
|
required this.encryptedName,
|
||||||
|
required this.serverVersion,
|
||||||
|
this.isDeleted = false,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id; // uuid
|
||||||
|
final String encryptedName;
|
||||||
|
final int serverVersion;
|
||||||
|
final bool isDeleted;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
|
||||||
|
factory SyncCategoryPayload.fromCategory(Category category) {
|
||||||
|
return SyncCategoryPayload(
|
||||||
|
id: category.uuid,
|
||||||
|
encryptedName: category.encryptedName,
|
||||||
|
serverVersion: category.serverVersion,
|
||||||
|
isDeleted: category.isDeleted,
|
||||||
|
updatedAt: category.updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'encrypted_name': encryptedName,
|
||||||
|
'serverVersion': serverVersion,
|
||||||
|
'isDeleted': isDeleted,
|
||||||
|
'updatedAt': updatedAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SyncNotePayload {
|
||||||
|
const SyncNotePayload({
|
||||||
|
required this.id,
|
||||||
|
this.categoryId,
|
||||||
|
required this.encryptedTitle,
|
||||||
|
required this.encryptedBody,
|
||||||
|
required this.serverVersion,
|
||||||
|
this.position = 0,
|
||||||
|
this.isDeleted = false,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id; // uuid
|
||||||
|
final String? categoryId;
|
||||||
|
final String encryptedTitle;
|
||||||
|
final String encryptedBody;
|
||||||
|
final int serverVersion;
|
||||||
|
final int position;
|
||||||
|
final bool isDeleted;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
|
||||||
|
factory SyncNotePayload.fromNote(
|
||||||
|
Note note, {
|
||||||
|
required String encryptedTitle,
|
||||||
|
required String encryptedBody,
|
||||||
|
}) {
|
||||||
|
return SyncNotePayload(
|
||||||
|
id: note.uuid,
|
||||||
|
categoryId: note.categoryId,
|
||||||
|
encryptedTitle: encryptedTitle,
|
||||||
|
encryptedBody: encryptedBody,
|
||||||
|
serverVersion: note.serverVersion,
|
||||||
|
position: note.index,
|
||||||
|
isDeleted: note.isDeleted,
|
||||||
|
updatedAt: note.updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
if (categoryId != null) 'categoryId': categoryId,
|
||||||
|
'encrypted_title': encryptedTitle,
|
||||||
|
'encrypted_body': encryptedBody,
|
||||||
|
'serverVersion': serverVersion,
|
||||||
|
if (position != 0) 'position': position,
|
||||||
|
if (isDeleted) 'isDeleted': isDeleted,
|
||||||
|
'updatedAt': updatedAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SyncResponse {
|
||||||
|
const SyncResponse({
|
||||||
|
required this.serverTimestamp,
|
||||||
|
required this.synced,
|
||||||
|
required this.changes,
|
||||||
|
});
|
||||||
|
|
||||||
|
final DateTime serverTimestamp;
|
||||||
|
final bool synced;
|
||||||
|
final SyncChangesResponse changes;
|
||||||
|
|
||||||
|
factory SyncResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SyncResponse(
|
||||||
|
serverTimestamp:
|
||||||
|
DateTime.parse(json['serverTimestamp'] as String),
|
||||||
|
synced: json['synced'] as bool? ?? false,
|
||||||
|
changes: SyncChangesResponse.fromJson(
|
||||||
|
json['changes'] as Map<String, dynamic>? ?? {}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SyncCategoryResponse {
|
||||||
|
const SyncCategoryResponse({
|
||||||
|
required this.id,
|
||||||
|
required this.encryptedName,
|
||||||
|
required this.serverVersion,
|
||||||
|
this.isDeleted = false,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id; // uuid
|
||||||
|
final String encryptedName;
|
||||||
|
final int serverVersion;
|
||||||
|
final bool isDeleted;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
|
||||||
|
factory SyncCategoryResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SyncCategoryResponse(
|
||||||
|
id: json['id'] as String,
|
||||||
|
encryptedName: json['encrypted_name'] as String,
|
||||||
|
serverVersion: json['serverVersion'] as int,
|
||||||
|
isDeleted: json['isDeleted'] as bool? ?? false,
|
||||||
|
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Category toCategory() {
|
||||||
|
return Category(
|
||||||
|
uuid: id,
|
||||||
|
encryptedName: encryptedName,
|
||||||
|
serverVersion: serverVersion,
|
||||||
|
isDeleted: isDeleted,
|
||||||
|
updatedAt: updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SyncNoteResponse {
|
||||||
|
const SyncNoteResponse({
|
||||||
|
required this.id,
|
||||||
|
this.categoryId,
|
||||||
|
required this.encryptedTitle,
|
||||||
|
required this.encryptedBody,
|
||||||
|
required this.serverVersion,
|
||||||
|
this.position = 0,
|
||||||
|
this.isDeleted = false,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id; // uuid
|
||||||
|
final String? categoryId;
|
||||||
|
final String encryptedTitle;
|
||||||
|
final String encryptedBody;
|
||||||
|
final int serverVersion;
|
||||||
|
final int position;
|
||||||
|
final bool isDeleted;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
|
||||||
|
factory SyncNoteResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SyncNoteResponse(
|
||||||
|
id: json['id'] as String,
|
||||||
|
categoryId: json['categoryId'] as String?,
|
||||||
|
encryptedTitle: json['encrypted_title'] as String,
|
||||||
|
encryptedBody: json['encrypted_body'] as String,
|
||||||
|
serverVersion: json['serverVersion'] as int,
|
||||||
|
position: json['position'] as int? ?? 0,
|
||||||
|
isDeleted: json['isDeleted'] as bool? ?? false,
|
||||||
|
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Note toNote() {
|
||||||
|
return Note(
|
||||||
|
uuid: id,
|
||||||
|
title: 'Encrypted', // placeholder, será descifrado por la app
|
||||||
|
body: 'Encrypted', // placeholder, será descifrado por la app
|
||||||
|
createdAt: updatedAt,
|
||||||
|
updatedAt: updatedAt,
|
||||||
|
index: position,
|
||||||
|
serverVersion: serverVersion,
|
||||||
|
isDeleted: isDeleted,
|
||||||
|
categoryId: categoryId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
class Category {
|
||||||
|
Category({
|
||||||
|
String? uuid,
|
||||||
|
required this.encryptedName,
|
||||||
|
this.serverVersion = 0,
|
||||||
|
this.isDeleted = false,
|
||||||
|
required this.updatedAt,
|
||||||
|
}) : uuid = uuid ?? Uuid().v4();
|
||||||
|
|
||||||
|
final String uuid;
|
||||||
|
final String encryptedName;
|
||||||
|
final int serverVersion;
|
||||||
|
final bool isDeleted;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
|
||||||
|
Category copyWith({
|
||||||
|
String? uuid,
|
||||||
|
String? encryptedName,
|
||||||
|
int? serverVersion,
|
||||||
|
bool? isDeleted,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
}) {
|
||||||
|
return Category(
|
||||||
|
uuid: uuid ?? this.uuid,
|
||||||
|
encryptedName: encryptedName ?? this.encryptedName,
|
||||||
|
serverVersion: serverVersion ?? this.serverVersion,
|
||||||
|
isDeleted: isDeleted ?? this.isDeleted,
|
||||||
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return other is Category && uuid == other.uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => uuid.hashCode;
|
||||||
|
}
|
||||||
+26
-5
@@ -1,39 +1,60 @@
|
|||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
// Model: Note
|
// Model: Note
|
||||||
// - Representa una nota guardada en la app.
|
// - Representa una nota guardada en la app.
|
||||||
// - `id` viene de SQLite y sirve como identificador estable.
|
// - `id` es el identificador local de SQLite (autoincrement).
|
||||||
|
// - `uuid` es el identificador global sincronizado con el servidor.
|
||||||
// - `index` representa el orden visual dentro de la lista.
|
// - `index` representa el orden visual dentro de la lista.
|
||||||
|
// - `serverVersion` se usa para resolver conflictos en sync.
|
||||||
|
// - `isDeleted` marca eliminaciones blandas.
|
||||||
class Note {
|
class Note {
|
||||||
const Note({
|
Note({
|
||||||
this.id,
|
this.id,
|
||||||
|
String? uuid,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.body,
|
required this.body,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
required this.index,
|
required this.index,
|
||||||
});
|
this.serverVersion = 0,
|
||||||
|
this.isDeleted = false,
|
||||||
|
this.categoryId,
|
||||||
|
}) : uuid = uuid ?? Uuid().v4();
|
||||||
|
|
||||||
final int? id;
|
final int? id;
|
||||||
|
final String uuid;
|
||||||
final String title;
|
final String title;
|
||||||
final String body;
|
final String body;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
final DateTime updatedAt;
|
final DateTime updatedAt;
|
||||||
final int index;
|
final int index;
|
||||||
|
final int serverVersion;
|
||||||
|
final bool isDeleted;
|
||||||
|
final String? categoryId;
|
||||||
|
|
||||||
Note copyWith({
|
Note copyWith({
|
||||||
int? id,
|
int? id,
|
||||||
|
String? uuid,
|
||||||
String? title,
|
String? title,
|
||||||
String? body,
|
String? body,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
int? index,
|
int? index,
|
||||||
|
int? serverVersion,
|
||||||
|
bool? isDeleted,
|
||||||
|
String? categoryId,
|
||||||
}) {
|
}) {
|
||||||
return Note(
|
return Note(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
|
uuid: uuid ?? this.uuid,
|
||||||
title: title ?? this.title,
|
title: title ?? this.title,
|
||||||
body: body ?? this.body,
|
body: body ?? this.body,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
updatedAt: updatedAt ?? this.updatedAt,
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
index: index ?? this.index,
|
index: index ?? this.index,
|
||||||
|
serverVersion: serverVersion ?? this.serverVersion,
|
||||||
|
isDeleted: isDeleted ?? this.isDeleted,
|
||||||
|
categoryId: categoryId ?? this.categoryId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,9 +64,9 @@ class Note {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return other is Note && id != null && other.id == id;
|
return other is Note && uuid == other.uuid;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => id?.hashCode ?? Object.hash(title, body, createdAt, updatedAt, index);
|
int get hashCode => uuid.hashCode;
|
||||||
}
|
}
|
||||||
@@ -75,6 +75,12 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_notes = updatedNotes;
|
_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(() {
|
setState(() {
|
||||||
_notes = updatedNotes;
|
_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 {
|
Future<void> _reorderNote(int oldIndex, int newIndex) async {
|
||||||
@@ -140,6 +152,11 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_notes = _normalizeNotes(updatedNotes);
|
_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())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _notes.isEmpty
|
: _notes.isEmpty
|
||||||
? const _EmptyState()
|
? const _EmptyState()
|
||||||
: MouseRegion(
|
: RefreshIndicator(
|
||||||
cursor: _isDragging ? SystemMouseCursors.grabbing : SystemMouseCursors.basic,
|
onRefresh: () async {
|
||||||
child: MasonryGridView.count(
|
try {
|
||||||
|
await widget.repository.performSync();
|
||||||
|
} catch (_) {}
|
||||||
|
await _loadNotes();
|
||||||
|
},
|
||||||
|
child: MouseRegion(
|
||||||
|
cursor: _isDragging ? SystemMouseCursors.grabbing : SystemMouseCursors.basic,
|
||||||
|
child: MasonryGridView.count(
|
||||||
crossAxisCount: crossAxisCount,
|
crossAxisCount: crossAxisCount,
|
||||||
mainAxisSpacing: 10,
|
mainAxisSpacing: 10,
|
||||||
crossAxisSpacing: 10,
|
crossAxisSpacing: 10,
|
||||||
@@ -296,7 +320,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Container(
|
body: Container(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:notas/data/local_vault_service.dart';
|
||||||
import 'package:notas/widgets/search_app_bar.dart';
|
import 'package:notas/widgets/search_app_bar.dart';
|
||||||
|
import 'package:notas/data/api_client.dart';
|
||||||
|
|
||||||
class SettingsScreen extends StatefulWidget {
|
class SettingsScreen extends StatefulWidget {
|
||||||
const SettingsScreen({
|
const SettingsScreen({
|
||||||
@@ -17,6 +19,11 @@ class SettingsScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _SettingsScreenState extends State<SettingsScreen> {
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
bool _isBusy = false;
|
bool _isBusy = false;
|
||||||
|
final TextEditingController _endpointController = TextEditingController();
|
||||||
|
final TextEditingController _encryptionKeyController = TextEditingController();
|
||||||
|
bool _endpointLoading = true;
|
||||||
|
bool _encryptionKeyLoading = false;
|
||||||
|
bool _encryptionKeyVisible = false;
|
||||||
|
|
||||||
Future<void> _confirmAndDeleteAll() async {
|
Future<void> _confirmAndDeleteAll() async {
|
||||||
final bool? confirmed = await showDialog<bool>(
|
final bool? confirmed = await showDialog<bool>(
|
||||||
@@ -59,6 +66,91 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Container(
|
body: Container(
|
||||||
@@ -97,6 +189,113 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
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 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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:notas/widgets/app_title_bar.dart';
|
import 'package:notas/widgets/app_title_bar.dart';
|
||||||
|
import 'package:notas/data/api_client.dart';
|
||||||
|
|
||||||
class VaultAccessScreen extends StatefulWidget {
|
class VaultAccessScreen extends StatefulWidget {
|
||||||
const VaultAccessScreen({
|
const VaultAccessScreen({
|
||||||
@@ -22,6 +23,29 @@ class VaultAccessScreen extends StatefulWidget {
|
|||||||
class _VaultAccessScreenState extends State<VaultAccessScreen> {
|
class _VaultAccessScreenState extends State<VaultAccessScreen> {
|
||||||
final TextEditingController _emailController = TextEditingController();
|
final TextEditingController _emailController = TextEditingController();
|
||||||
final TextEditingController _passwordController = 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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -31,6 +55,8 @@ class _VaultAccessScreenState extends State<VaultAccessScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleCreateAccount() async {
|
Future<void> _handleCreateAccount() async {
|
||||||
|
await _persistEndpointFromField();
|
||||||
|
|
||||||
await widget.onCreateAccountPressed(
|
await widget.onCreateAccountPressed(
|
||||||
_emailController.text.trim(),
|
_emailController.text.trim(),
|
||||||
_passwordController.text,
|
_passwordController.text,
|
||||||
@@ -38,6 +64,8 @@ class _VaultAccessScreenState extends State<VaultAccessScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleSignIn() async {
|
Future<void> _handleSignIn() async {
|
||||||
|
await _persistEndpointFromField();
|
||||||
|
|
||||||
await widget.onSignInPressed(
|
await widget.onSignInPressed(
|
||||||
_emailController.text.trim(),
|
_emailController.text.trim(),
|
||||||
_passwordController.text,
|
_passwordController.text,
|
||||||
@@ -112,13 +140,43 @@ class _VaultAccessScreenState extends State<VaultAccessScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
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(
|
TextField(
|
||||||
controller: _emailController,
|
controller: _emailController,
|
||||||
enabled: !widget.isBusy,
|
enabled: !widget.isBusy,
|
||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.text,
|
||||||
style: const TextStyle(color: Colors.white),
|
style: const TextStyle(color: Colors.white),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Email',
|
labelText: 'Usuario',
|
||||||
labelStyle: TextStyle(color: Colors.white.withValues(alpha: 0.7)),
|
labelStyle: TextStyle(color: Colors.white.withValues(alpha: 0.7)),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: Colors.white.withValues(alpha: 0.05),
|
fillColor: Colors.white.withValues(alpha: 0.05),
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:notas/platform/app_platform.dart';
|
import 'package:notas/platform/app_platform.dart';
|
||||||
|
import 'package:notas/widgets/sync_status_indicator.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
class AppTitleBar extends StatelessWidget {
|
class AppTitleBar extends StatelessWidget {
|
||||||
const AppTitleBar({super.key});
|
const AppTitleBar({
|
||||||
|
this.syncStatus = SyncStatus.idle,
|
||||||
|
this.syncErrorMessage,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final SyncStatus syncStatus;
|
||||||
|
final String? syncErrorMessage;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -12,33 +20,62 @@ class AppTitleBar extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isMacOS) {
|
if (isMacOS) {
|
||||||
return const SizedBox(
|
return SizedBox(
|
||||||
height: 28,
|
height: 28,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Row(
|
||||||
'Mis Notas',
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
style: TextStyle(color: Colors.white70, fontSize: 13),
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Mis Notas',
|
||||||
|
style: TextStyle(color: Colors.white70, fontSize: 13),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
SyncStatusIndicator(
|
||||||
|
status: syncStatus,
|
||||||
|
errorMessage: syncErrorMessage,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLinux) {
|
if (isLinux) {
|
||||||
return const _KdeTitleBar();
|
return _KdeTitleBar(
|
||||||
|
syncStatus: syncStatus,
|
||||||
|
syncErrorMessage: syncErrorMessage,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return const SizedBox(
|
return SizedBox(
|
||||||
height: 40,
|
height: 40,
|
||||||
child: WindowCaption(
|
child: WindowCaption(
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
title: Text('Mis Notas', style: TextStyle(color: Colors.white)),
|
title: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text('Mis Notas', style: TextStyle(color: Colors.white)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
SyncStatusIndicator(
|
||||||
|
status: syncStatus,
|
||||||
|
errorMessage: syncErrorMessage,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _KdeTitleBar extends StatefulWidget {
|
class _KdeTitleBar extends StatefulWidget {
|
||||||
const _KdeTitleBar();
|
const _KdeTitleBar({
|
||||||
|
this.syncStatus = SyncStatus.idle,
|
||||||
|
this.syncErrorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
final SyncStatus syncStatus;
|
||||||
|
final String? syncErrorMessage;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_KdeTitleBar> createState() => _KdeTitleBarState();
|
State<_KdeTitleBar> createState() => _KdeTitleBarState();
|
||||||
@@ -130,12 +167,33 @@ class _KdeTitleBarState extends State<_KdeTitleBar> with WindowListener {
|
|||||||
),
|
),
|
||||||
const IgnorePointer(
|
const IgnorePointer(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Row(
|
||||||
'Mis Notas',
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
style: TextStyle(
|
children: [
|
||||||
color: Color.fromARGB(255, 163, 163, 163),
|
Text(
|
||||||
fontSize: 14,
|
'Mis Notas',
|
||||||
fontWeight: FontWeight.w500,
|
style: TextStyle(
|
||||||
|
color: Color.fromARGB(255, 163, 163, 163),
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: Center(
|
||||||
|
child: SyncStatusIndicator(
|
||||||
|
status: widget.syncStatus,
|
||||||
|
errorMessage: widget.syncErrorMessage,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:notas/widgets/sync_status_indicator.dart';
|
||||||
|
|
||||||
class AppTitleBar extends StatelessWidget {
|
class AppTitleBar extends StatelessWidget {
|
||||||
const AppTitleBar({super.key});
|
const AppTitleBar({
|
||||||
|
this.syncStatus = SyncStatus.idle,
|
||||||
|
this.syncErrorMessage,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final SyncStatus syncStatus;
|
||||||
|
final String? syncErrorMessage;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
enum SyncStatus {
|
||||||
|
idle,
|
||||||
|
syncing,
|
||||||
|
synced,
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
|
||||||
|
class SyncStatusIndicator extends StatelessWidget {
|
||||||
|
const SyncStatusIndicator({
|
||||||
|
required this.status,
|
||||||
|
this.errorMessage,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final SyncStatus status;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
switch (status) {
|
||||||
|
case SyncStatus.idle:
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
|
||||||
|
case SyncStatus.syncing:
|
||||||
|
return const Tooltip(
|
||||||
|
message: 'Sincronizando...',
|
||||||
|
child: SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
Color.fromARGB(255, 150, 150, 150),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
case SyncStatus.synced:
|
||||||
|
return const Tooltip(
|
||||||
|
message: 'Sincronizado',
|
||||||
|
child: Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
case SyncStatus.error:
|
||||||
|
return Tooltip(
|
||||||
|
message: errorMessage ?? 'Error al sincronizar',
|
||||||
|
child: const Icon(
|
||||||
|
Icons.error,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-1
@@ -162,13 +162,21 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.2"
|
version: "3.1.2"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: crypto
|
name: crypto
|
||||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
version: "3.0.7"
|
||||||
|
cryptography:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: cryptography
|
||||||
|
sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.9.0"
|
||||||
cupertino_icons:
|
cupertino_icons:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -360,6 +368,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.3"
|
version: "1.0.3"
|
||||||
|
http:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: http
|
||||||
|
sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.13.6"
|
||||||
http_multi_server:
|
http_multi_server:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -893,6 +909,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.0"
|
||||||
|
uuid:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: uuid
|
||||||
|
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.5.3"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+5
-1
@@ -44,6 +44,10 @@ dependencies:
|
|||||||
flutter_secure_storage: ^10.2.0
|
flutter_secure_storage: ^10.2.0
|
||||||
local_auth: ^3.0.1
|
local_auth: ^3.0.1
|
||||||
sqlite3: ^3.3.1
|
sqlite3: ^3.3.1
|
||||||
|
http: ^0.13.6
|
||||||
|
crypto: ^3.0.6
|
||||||
|
cryptography: ^2.7.0
|
||||||
|
uuid: ^4.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -70,7 +74,7 @@ flutter:
|
|||||||
# the material Icons class.
|
# the material Icons class.
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
assets:
|
assets:
|
||||||
- assets/icon.png
|
- assets/icon.png
|
||||||
|
|
||||||
hooks:
|
hooks:
|
||||||
|
|||||||
Reference in New Issue
Block a user