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
+332
View File
@@ -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
View File
@@ -7,39 +7,70 @@ import 'package:path_provider/path_provider.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')
class Notes extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get uuid => text().unique()();
TextColumn get title => text()();
TextColumn get body => text()();
DateTimeColumn get createdAt => dateTime()();
DateTimeColumn get updatedAt => dateTime()();
DateTimeColumn get createdAt => dateTime().named('created_at')();
DateTimeColumn get updatedAt => dateTime().named('updated_at')();
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 {
AppDatabase({required String encryptionKey}) : super(_openConnection(encryptionKey));
@override
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() {
return (select(notes)..orderBy([
(note) => OrderingTerm(expression: note.sortIndex),
])).get();
return (select(notes)
..orderBy([(note) => OrderingTerm(expression: note.sortIndex)])
..where((n) => n.isDeleted.equals(false)))
.get();
}
Future<int> insertNoteAtTop(NotesCompanion note) {
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)));
});
}
Future<void> replaceAllNotes(List<NotesCompanion> noteList) {
return transaction(() async {
await delete(notes).go();
await (delete(notes)..where((n) => n.isDeleted.equals(false))).go();
for (final NotesCompanion note in noteList) {
await into(notes).insert(note);
@@ -51,17 +82,20 @@ class AppDatabase extends _$AppDatabase {
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({
required int id,
required int removedIndex,
}) {
return transaction(() async {
await (delete(notes)..where((note) => note.id.equals(id))).go();
await customStatement(
'UPDATE notes SET sort_index = sort_index - 1 WHERE sort_index > ?',
[removedIndex],
);
});
return deleteNote(id, removedIndex);
}
Future<void> moveNote({
@@ -76,12 +110,12 @@ class AppDatabase extends _$AppDatabase {
return transaction(() async {
if (oldIndex < newIndex) {
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],
);
} else {
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],
);
}
@@ -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) {
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -74,6 +74,14 @@ class LocalVaultService {
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 {
return (await readStoredEncryptionKeyRaw()) != null;
}
@@ -117,10 +125,7 @@ class LocalVaultService {
Future<String> createEncryptionKey({bool protectWithBiometrics = false}) async {
final String encryptionKey = _generateEncryptionKey();
await _secureStorage.write(
key: _encryptionKeyStorageKey,
value: encryptionKey,
);
await storeEncryptionKey(encryptionKey);
// If requested, try to enable biometric protection. Only enable on mobile
// platforms and only if the authentication succeeds.
+86
View File
@@ -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));
}
}
+184 -6
View File
@@ -1,10 +1,23 @@
import 'package:drift/drift.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/category.dart';
import 'package:notas/data/note_encryption.dart';
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 AuthApi _authApi;
final String _masterKey;
Future<List<Note>> loadNotes() async {
return _loadNotesFromDatabase();
@@ -13,11 +26,15 @@ class NoteRepository {
Future<Note> createNote(Note note) async {
final int id = await _database.insertNoteAtTop(
NotesCompanion.insert(
uuid: note.uuid,
title: note.title,
body: note.body,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
sortIndex: 0,
serverVersion: const Value(0),
isDeleted: const Value(false),
categoryId: const Value(null),
),
);
@@ -30,11 +47,15 @@ class NoteRepository {
await _database.updateNoteRow(
DbNote(
id: noteId,
uuid: note.uuid,
title: note.title,
body: note.body,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
sortIndex: note.index,
serverVersion: note.serverVersion,
isDeleted: note.isDeleted,
categoryId: note.categoryId,
),
);
@@ -60,19 +81,176 @@ class NoteRepository {
);
}
Future<List<Note>> _loadNotesFromDatabase() async {
final List<DbNote> rows = await _database.getAllNotes();
return rows.map(_fromRow).toList();
// ========== Sync logic ==========
/// 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(
id: row.id,
uuid: row.uuid,
title: row.title,
body: row.body,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
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,
);
}
}
+258
View File
@@ -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,
);
}
}