From efe602a5dac15931bdf670fdc05ac86e998adfc5 Mon Sep 17 00:00:00 2001 From: Marcos Date: Mon, 18 May 2026 16:11:19 +0200 Subject: [PATCH] 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. --- lib/app.dart | 202 +++++- lib/data/api_client.dart | 332 ++++++++++ lib/data/app_database.dart | 81 ++- lib/data/app_database.g.dart | 878 ++++++++++++++++++++++++- lib/data/local_vault_service.dart | 13 +- lib/data/note_encryption.dart | 86 +++ lib/data/note_repository.dart | 190 +++++- lib/data/sync_models.dart | 258 ++++++++ lib/models/category.dart | 45 ++ lib/models/note.dart | 31 +- lib/screens/home_screen.dart | 33 +- lib/screens/settings_screen.dart | 199 ++++++ lib/screens/vault_access_screen.dart | 62 +- lib/widgets/app_title_bar_io.dart | 88 ++- lib/widgets/app_title_bar_stub.dart | 10 +- lib/widgets/sync_status_indicator.dart | 62 ++ pubspec.lock | 26 +- pubspec.yaml | 6 +- 18 files changed, 2531 insertions(+), 71 deletions(-) create mode 100644 lib/data/api_client.dart create mode 100644 lib/data/note_encryption.dart create mode 100644 lib/data/sync_models.dart create mode 100644 lib/models/category.dart create mode 100644 lib/widgets/sync_status_indicator.dart diff --git a/lib/app.dart b/lib/app.dart index ee23af1..1d10f83 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -2,7 +2,9 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/services.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/note_repository.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/theme/app_theme.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_provider/path_provider.dart'; import 'package:window_manager/window_manager.dart'; @@ -31,6 +34,10 @@ enum _AppPhase { notes, } +class PerformSyncIntent extends Intent { + const PerformSyncIntent(); +} + class NotesApp extends StatefulWidget { const NotesApp({super.key}); @@ -42,6 +49,7 @@ class _NotesAppState extends State with WindowListener, WidgetsBindingObserver { static const Duration _screenTransitionDuration = Duration(milliseconds: 280); static const Duration _biometricInactivityTimeout = Duration(minutes: 5); + static const Duration _syncInterval = Duration(minutes: 5); final LocalVaultService _vaultService = LocalVaultService.instance; final GlobalKey _scaffoldMessengerKey = @@ -56,9 +64,12 @@ class _NotesAppState extends State bool _biometricGateEnabled = false; int _biometricGateSession = 0; Timer? _biometricLockTimer; + Timer? _syncTimer; bool _isHandlingWindowClose = false; _AppPhase _phase = _AppPhase.loading; _AppSection _currentSection = _AppSection.home; + SyncStatus _syncStatus = SyncStatus.idle; + String? _syncErrorMessage; @override void initState() { @@ -79,6 +90,7 @@ class _NotesAppState extends State windowManager.setPreventClose(false); } _biometricLockTimer?.cancel(); + _syncTimer?.cancel(); _database?.close(); super.dispose(); } @@ -179,9 +191,18 @@ class _NotesAppState extends State setState(() { _database = database; - _repository = NoteRepository(database: database); + _repository = NoteRepository( + database: database, + authApi: AuthApi.instance, + masterKey: encryptionKey, + ); _phase = _AppPhase.notes; }); + + // Start periodic sync + _startPeriodicSync(); + // Run an initial full sync immediately to pull server changes + unawaited(_performSync(forceFull: true)); } catch (e) { // If the database file is not a valid SQLite DB (e.g., wrong key or corruption), // reset the local vault so the app doesn't crash. The reset will delete DB files @@ -238,6 +259,86 @@ class _NotesAppState extends State } } + Future _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 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 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 _enterWithoutAccount() { return _beginInitialVaultFlow(); } @@ -496,6 +597,66 @@ class _NotesAppState extends State await WindowStateStore.instance.saveWindowSize(currentSize); } + void _startPeriodicSync() { + _syncTimer?.cancel(); + _syncTimer = Timer.periodic(_syncInterval, (_) { + _performSync(); + }); + } + + Future _performSync({bool forceFull = false}) async { + if (_repository == null) { + return; + } + + if (!mounted) { + return; + } + + setState(() { + _syncStatus = SyncStatus.syncing; + _syncErrorMessage = null; + }); + + try { + final Map 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.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() { return MaterialApp( navigatorKey: _navigatorKey, @@ -557,8 +718,23 @@ class _NotesAppState extends State debugShowCheckedModeBanner: false, scaffoldMessengerKey: _scaffoldMessengerKey, theme: AppTheme.theme, - home: Scaffold( - body: Container( + home: Shortcuts( + shortcuts: { + LogicalKeySet(LogicalKeyboardKey.f5): const PerformSyncIntent(), + }, + child: Actions( + actions: >{ + PerformSyncIntent: CallbackAction( + onInvoke: (PerformSyncIntent intent) { + _performSync(); + return null; + }, + ), + }, + child: Focus( + autofocus: true, + child: Scaffold( + body: Container( decoration: const BoxDecoration( gradient: LinearGradient( colors: [ @@ -573,7 +749,10 @@ class _NotesAppState extends State child: SafeArea( child: Column( children: [ - const AppTitleBar(), + AppTitleBar( + syncStatus: _syncStatus, + syncErrorMessage: _syncErrorMessage, + ), Expanded( child: AnimatedSwitcher( duration: _screenTransitionDuration, @@ -598,6 +777,9 @@ class _NotesAppState extends State ), ), ), + ), + ), +), ); } @@ -669,10 +851,18 @@ class _NotesAppState extends State home: VaultAccessScreen( isBusy: _isUnlocking, 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 { - await _beginInitialVaultFlow(actionLabel: 'Iniciar sesión'); + await _beginRemoteVaultFlow( + username: email, + password: password, + isRegister: false, + ); }, onContinueWithoutAccount: _enterWithoutAccount, ), diff --git a/lib/data/api_client.dart b/lib/data/api_client.dart new file mode 100644 index 0000000..22ea4ac --- /dev/null +++ b/lib/data/api_client.dart @@ -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 getEndpoint() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_endpointKey) ?? defaultEndpoint; + } + + static Future setEndpoint(String endpoint) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_endpointKey, endpoint); + } + + static Future 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 get accessToken async => + await _secureStorage.read(key: _accessTokenKey); + + Future get refreshToken async => + await _secureStorage.read(key: _refreshTokenKey); + + Future 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 encryptWithPassword( + String plaintext, + String password, + ) async { + final List salt = _randomBytes(16); + final SecretKey secretKey = await _kdf.deriveKey( + secretKey: SecretKey(utf8.encode(password)), + nonce: salt, + ); + + final List nonce = _randomBytes(12); + final SecretBox box = await _aes.encrypt( + utf8.encode(plaintext), + secretKey: secretKey, + nonce: nonce, + ); + + return jsonEncode({ + 'v': _passwordHashVersion, + 'salt': base64Encode(salt), + 'nonce': base64Encode(box.nonce), + 'cipherText': base64Encode(box.cipherText), + 'mac': base64Encode(box.mac.bytes), + }); + } + + Future decryptWithPassword( + String encodedBox, + String password, + ) async { + final Map payload = jsonDecode(encodedBox) as Map; + final List salt = base64Decode(payload['salt'] as String); + final List nonce = base64Decode(payload['nonce'] as String); + final List cipherText = base64Decode(payload['cipherText'] as String); + final List 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 clearText = await _aes.decrypt( + box, + secretKey: secretKey, + ); + + return utf8.decode(clearText); + } + + Future> 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 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 json = + jsonDecode(res.body) as Map; + + 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> 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 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 json = + jsonDecode(res.body) as Map; + + 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 _randomBytes(int length) { + final Random random = Random.secure(); + return List.generate(length, (_) => random.nextInt(256)); + } + + // ========== Sync API ========== + + static const String _lastSyncAtKey = 'api_last_sync_at_v1'; + + Future getLastSyncAt() async { + final prefs = await SharedPreferences.getInstance(); + final String? timestamp = prefs.getString(_lastSyncAtKey); + if (timestamp == null) return null; + return DateTime.tryParse(timestamp); + } + + Future setLastSyncAt(DateTime timestamp) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_lastSyncAtKey, timestamp.toIso8601String()); + } + + Future> 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 headers = { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }; + + final String bodyJson = jsonEncode(syncRequest.toJson()); + + // Log request (mask authorization header) + final Map 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 json = + jsonDecode(res.body) as Map; + 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 _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 json = + jsonDecode(res.body) as Map; + final String? newAccess = json['accessToken'] as String?; + + if (newAccess != null) { + await _secureStorage.write(key: _accessTokenKey, value: newAccess); + return true; + } + } + + return false; + } +} diff --git a/lib/data/app_database.dart b/lib/data/app_database.dart index 0e6e7f6..0bc6b55 100644 --- a/lib/data/app_database.dart +++ b/lib/data/app_database.dart @@ -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 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> getAllCategories() { + return (select(categories)..where((c) => c.isDeleted.equals(false))).get(); + } + + Future upsertCategory(CategoriesCompanion category) { + return into(categories).insertOnConflictUpdate(category); + } + + Future deleteCategory(String uuid) { + return (update(categories)..where((c) => c.uuid.equals(uuid))) + .write(CategoriesCompanion(isDeleted: Value(true))); + } + + // ========== Notes ========== Future> 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 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(0))); }); } Future replaceAllNotes(List 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 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 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 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> getUnsyncedNotes() { + return (select(notes)..where((n) => n.isDeleted.equals(true) | n.serverVersion.equals(0))).get(); + } + + Future> getUnsyncedCategories() { + return (select(categories)..where((c) => c.isDeleted.equals(true) | c.serverVersion.equals(0))).get(); + } } LazyDatabase _openConnection(String encryptionKey) { diff --git a/lib/data/app_database.g.dart b/lib/data/app_database.g.dart index dd5dd0c..aae36b1 100644 --- a/lib/data/app_database.g.dart +++ b/lib/data/app_database.g.dart @@ -21,6 +21,16 @@ class $NotesTable extends Notes with TableInfo<$NotesTable, DbNote> { 'PRIMARY KEY AUTOINCREMENT', ), ); + static const VerificationMeta _uuidMeta = const VerificationMeta('uuid'); + @override + late final GeneratedColumn uuid = GeneratedColumn( + 'uuid', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways('UNIQUE'), + ); static const VerificationMeta _titleMeta = const VerificationMeta('title'); @override late final GeneratedColumn title = GeneratedColumn( @@ -72,14 +82,56 @@ class $NotesTable extends Notes with TableInfo<$NotesTable, DbNote> { type: DriftSqlType.int, requiredDuringInsert: true, ); + static const VerificationMeta _serverVersionMeta = const VerificationMeta( + 'serverVersion', + ); + @override + late final GeneratedColumn serverVersion = GeneratedColumn( + 'server_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + static const VerificationMeta _isDeletedMeta = const VerificationMeta( + 'isDeleted', + ); + @override + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_deleted" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _categoryIdMeta = const VerificationMeta( + 'categoryId', + ); + @override + late final GeneratedColumn categoryId = GeneratedColumn( + 'category_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); @override List get $columns => [ id, + uuid, title, body, createdAt, updatedAt, sortIndex, + serverVersion, + isDeleted, + categoryId, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -96,6 +148,14 @@ class $NotesTable extends Notes with TableInfo<$NotesTable, DbNote> { if (data.containsKey('id')) { context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); } + if (data.containsKey('uuid')) { + context.handle( + _uuidMeta, + uuid.isAcceptableOrUnknown(data['uuid']!, _uuidMeta), + ); + } else if (isInserting) { + context.missing(_uuidMeta); + } if (data.containsKey('title')) { context.handle( _titleMeta, @@ -136,6 +196,27 @@ class $NotesTable extends Notes with TableInfo<$NotesTable, DbNote> { } else if (isInserting) { context.missing(_sortIndexMeta); } + if (data.containsKey('server_version')) { + context.handle( + _serverVersionMeta, + serverVersion.isAcceptableOrUnknown( + data['server_version']!, + _serverVersionMeta, + ), + ); + } + if (data.containsKey('is_deleted')) { + context.handle( + _isDeletedMeta, + isDeleted.isAcceptableOrUnknown(data['is_deleted']!, _isDeletedMeta), + ); + } + if (data.containsKey('category_id')) { + context.handle( + _categoryIdMeta, + categoryId.isAcceptableOrUnknown(data['category_id']!, _categoryIdMeta), + ); + } return context; } @@ -149,6 +230,10 @@ class $NotesTable extends Notes with TableInfo<$NotesTable, DbNote> { DriftSqlType.int, data['${effectivePrefix}id'], )!, + uuid: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}uuid'], + )!, title: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}title'], @@ -169,6 +254,18 @@ class $NotesTable extends Notes with TableInfo<$NotesTable, DbNote> { DriftSqlType.int, data['${effectivePrefix}sort_index'], )!, + serverVersion: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}server_version'], + )!, + isDeleted: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_deleted'], + )!, + categoryId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}category_id'], + ), ); } @@ -180,39 +277,59 @@ class $NotesTable extends Notes with TableInfo<$NotesTable, DbNote> { class DbNote extends DataClass implements Insertable { final int id; + final String uuid; final String title; final String body; final DateTime createdAt; final DateTime updatedAt; final int sortIndex; + final int serverVersion; + final bool isDeleted; + final String? categoryId; const DbNote({ required this.id, + required this.uuid, required this.title, required this.body, required this.createdAt, required this.updatedAt, required this.sortIndex, + required this.serverVersion, + required this.isDeleted, + this.categoryId, }); @override Map toColumns(bool nullToAbsent) { final map = {}; map['id'] = Variable(id); + map['uuid'] = Variable(uuid); map['title'] = Variable(title); map['body'] = Variable(body); map['created_at'] = Variable(createdAt); map['updated_at'] = Variable(updatedAt); map['sort_index'] = Variable(sortIndex); + map['server_version'] = Variable(serverVersion); + map['is_deleted'] = Variable(isDeleted); + if (!nullToAbsent || categoryId != null) { + map['category_id'] = Variable(categoryId); + } return map; } NotesCompanion toCompanion(bool nullToAbsent) { return NotesCompanion( id: Value(id), + uuid: Value(uuid), title: Value(title), body: Value(body), createdAt: Value(createdAt), updatedAt: Value(updatedAt), sortIndex: Value(sortIndex), + serverVersion: Value(serverVersion), + isDeleted: Value(isDeleted), + categoryId: categoryId == null && nullToAbsent + ? const Value.absent() + : Value(categoryId), ); } @@ -223,11 +340,15 @@ class DbNote extends DataClass implements Insertable { serializer ??= driftRuntimeOptions.defaultSerializer; return DbNote( id: serializer.fromJson(json['id']), + uuid: serializer.fromJson(json['uuid']), title: serializer.fromJson(json['title']), body: serializer.fromJson(json['body']), createdAt: serializer.fromJson(json['createdAt']), updatedAt: serializer.fromJson(json['updatedAt']), sortIndex: serializer.fromJson(json['sortIndex']), + serverVersion: serializer.fromJson(json['serverVersion']), + isDeleted: serializer.fromJson(json['isDeleted']), + categoryId: serializer.fromJson(json['categoryId']), ); } @override @@ -235,37 +356,57 @@ class DbNote extends DataClass implements Insertable { serializer ??= driftRuntimeOptions.defaultSerializer; return { 'id': serializer.toJson(id), + 'uuid': serializer.toJson(uuid), 'title': serializer.toJson(title), 'body': serializer.toJson(body), 'createdAt': serializer.toJson(createdAt), 'updatedAt': serializer.toJson(updatedAt), 'sortIndex': serializer.toJson(sortIndex), + 'serverVersion': serializer.toJson(serverVersion), + 'isDeleted': serializer.toJson(isDeleted), + 'categoryId': serializer.toJson(categoryId), }; } DbNote copyWith({ int? id, + String? uuid, String? title, String? body, DateTime? createdAt, DateTime? updatedAt, int? sortIndex, + int? serverVersion, + bool? isDeleted, + Value categoryId = const Value.absent(), }) => DbNote( id: id ?? this.id, + uuid: uuid ?? this.uuid, title: title ?? this.title, body: body ?? this.body, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, sortIndex: sortIndex ?? this.sortIndex, + serverVersion: serverVersion ?? this.serverVersion, + isDeleted: isDeleted ?? this.isDeleted, + categoryId: categoryId.present ? categoryId.value : this.categoryId, ); DbNote copyWithCompanion(NotesCompanion data) { return DbNote( id: data.id.present ? data.id.value : this.id, + uuid: data.uuid.present ? data.uuid.value : this.uuid, title: data.title.present ? data.title.value : this.title, body: data.body.present ? data.body.value : this.body, createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, sortIndex: data.sortIndex.present ? data.sortIndex.value : this.sortIndex, + serverVersion: data.serverVersion.present + ? data.serverVersion.value + : this.serverVersion, + isDeleted: data.isDeleted.present ? data.isDeleted.value : this.isDeleted, + categoryId: data.categoryId.present + ? data.categoryId.value + : this.categoryId, ); } @@ -273,90 +414,137 @@ class DbNote extends DataClass implements Insertable { String toString() { return (StringBuffer('DbNote(') ..write('id: $id, ') + ..write('uuid: $uuid, ') ..write('title: $title, ') ..write('body: $body, ') ..write('createdAt: $createdAt, ') ..write('updatedAt: $updatedAt, ') - ..write('sortIndex: $sortIndex') + ..write('sortIndex: $sortIndex, ') + ..write('serverVersion: $serverVersion, ') + ..write('isDeleted: $isDeleted, ') + ..write('categoryId: $categoryId') ..write(')')) .toString(); } @override - int get hashCode => - Object.hash(id, title, body, createdAt, updatedAt, sortIndex); + int get hashCode => Object.hash( + id, + uuid, + title, + body, + createdAt, + updatedAt, + sortIndex, + serverVersion, + isDeleted, + categoryId, + ); @override bool operator ==(Object other) => identical(this, other) || (other is DbNote && other.id == this.id && + other.uuid == this.uuid && other.title == this.title && other.body == this.body && other.createdAt == this.createdAt && other.updatedAt == this.updatedAt && - other.sortIndex == this.sortIndex); + other.sortIndex == this.sortIndex && + other.serverVersion == this.serverVersion && + other.isDeleted == this.isDeleted && + other.categoryId == this.categoryId); } class NotesCompanion extends UpdateCompanion { final Value id; + final Value uuid; final Value title; final Value body; final Value createdAt; final Value updatedAt; final Value sortIndex; + final Value serverVersion; + final Value isDeleted; + final Value categoryId; const NotesCompanion({ this.id = const Value.absent(), + this.uuid = const Value.absent(), this.title = const Value.absent(), this.body = const Value.absent(), this.createdAt = const Value.absent(), this.updatedAt = const Value.absent(), this.sortIndex = const Value.absent(), + this.serverVersion = const Value.absent(), + this.isDeleted = const Value.absent(), + this.categoryId = const Value.absent(), }); NotesCompanion.insert({ this.id = const Value.absent(), + required String uuid, required String title, required String body, required DateTime createdAt, required DateTime updatedAt, required int sortIndex, - }) : title = Value(title), + this.serverVersion = const Value.absent(), + this.isDeleted = const Value.absent(), + this.categoryId = const Value.absent(), + }) : uuid = Value(uuid), + title = Value(title), body = Value(body), createdAt = Value(createdAt), updatedAt = Value(updatedAt), sortIndex = Value(sortIndex); static Insertable custom({ Expression? id, + Expression? uuid, Expression? title, Expression? body, Expression? createdAt, Expression? updatedAt, Expression? sortIndex, + Expression? serverVersion, + Expression? isDeleted, + Expression? categoryId, }) { return RawValuesInsertable({ if (id != null) 'id': id, + if (uuid != null) 'uuid': uuid, if (title != null) 'title': title, if (body != null) 'body': body, if (createdAt != null) 'created_at': createdAt, if (updatedAt != null) 'updated_at': updatedAt, if (sortIndex != null) 'sort_index': sortIndex, + if (serverVersion != null) 'server_version': serverVersion, + if (isDeleted != null) 'is_deleted': isDeleted, + if (categoryId != null) 'category_id': categoryId, }); } NotesCompanion copyWith({ Value? id, + Value? uuid, Value? title, Value? body, Value? createdAt, Value? updatedAt, Value? sortIndex, + Value? serverVersion, + Value? isDeleted, + Value? categoryId, }) { return NotesCompanion( id: id ?? this.id, + uuid: uuid ?? this.uuid, title: title ?? this.title, body: body ?? this.body, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, sortIndex: sortIndex ?? this.sortIndex, + serverVersion: serverVersion ?? this.serverVersion, + isDeleted: isDeleted ?? this.isDeleted, + categoryId: categoryId ?? this.categoryId, ); } @@ -366,6 +554,9 @@ class NotesCompanion extends UpdateCompanion { if (id.present) { map['id'] = Variable(id.value); } + if (uuid.present) { + map['uuid'] = Variable(uuid.value); + } if (title.present) { map['title'] = Variable(title.value); } @@ -381,6 +572,15 @@ class NotesCompanion extends UpdateCompanion { if (sortIndex.present) { map['sort_index'] = Variable(sortIndex.value); } + if (serverVersion.present) { + map['server_version'] = Variable(serverVersion.value); + } + if (isDeleted.present) { + map['is_deleted'] = Variable(isDeleted.value); + } + if (categoryId.present) { + map['category_id'] = Variable(categoryId.value); + } return map; } @@ -388,11 +588,391 @@ class NotesCompanion extends UpdateCompanion { String toString() { return (StringBuffer('NotesCompanion(') ..write('id: $id, ') + ..write('uuid: $uuid, ') ..write('title: $title, ') ..write('body: $body, ') ..write('createdAt: $createdAt, ') ..write('updatedAt: $updatedAt, ') - ..write('sortIndex: $sortIndex') + ..write('sortIndex: $sortIndex, ') + ..write('serverVersion: $serverVersion, ') + ..write('isDeleted: $isDeleted, ') + ..write('categoryId: $categoryId') + ..write(')')) + .toString(); + } +} + +class $CategoriesTable extends Categories + with TableInfo<$CategoriesTable, DbCategory> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $CategoriesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _uuidMeta = const VerificationMeta('uuid'); + @override + late final GeneratedColumn uuid = GeneratedColumn( + 'uuid', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways('UNIQUE'), + ); + static const VerificationMeta _encryptedNameMeta = const VerificationMeta( + 'encryptedName', + ); + @override + late final GeneratedColumn encryptedName = GeneratedColumn( + 'encrypted_name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _serverVersionMeta = const VerificationMeta( + 'serverVersion', + ); + @override + late final GeneratedColumn serverVersion = GeneratedColumn( + 'server_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + static const VerificationMeta _isDeletedMeta = const VerificationMeta( + 'isDeleted', + ); + @override + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_deleted" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta( + 'updatedAt', + ); + @override + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + uuid, + encryptedName, + serverVersion, + isDeleted, + updatedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'categories'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('uuid')) { + context.handle( + _uuidMeta, + uuid.isAcceptableOrUnknown(data['uuid']!, _uuidMeta), + ); + } else if (isInserting) { + context.missing(_uuidMeta); + } + if (data.containsKey('encrypted_name')) { + context.handle( + _encryptedNameMeta, + encryptedName.isAcceptableOrUnknown( + data['encrypted_name']!, + _encryptedNameMeta, + ), + ); + } else if (isInserting) { + context.missing(_encryptedNameMeta); + } + if (data.containsKey('server_version')) { + context.handle( + _serverVersionMeta, + serverVersion.isAcceptableOrUnknown( + data['server_version']!, + _serverVersionMeta, + ), + ); + } + if (data.containsKey('is_deleted')) { + context.handle( + _isDeletedMeta, + isDeleted.isAcceptableOrUnknown(data['is_deleted']!, _isDeletedMeta), + ); + } + if (data.containsKey('updated_at')) { + context.handle( + _updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta), + ); + } else if (isInserting) { + context.missing(_updatedAtMeta); + } + return context; + } + + @override + Set get $primaryKey => {uuid}; + @override + DbCategory map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return DbCategory( + uuid: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}uuid'], + )!, + encryptedName: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}encrypted_name'], + )!, + serverVersion: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}server_version'], + )!, + isDeleted: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_deleted'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + ); + } + + @override + $CategoriesTable createAlias(String alias) { + return $CategoriesTable(attachedDatabase, alias); + } +} + +class DbCategory extends DataClass implements Insertable { + final String uuid; + final String encryptedName; + final int serverVersion; + final bool isDeleted; + final DateTime updatedAt; + const DbCategory({ + required this.uuid, + required this.encryptedName, + required this.serverVersion, + required this.isDeleted, + required this.updatedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['uuid'] = Variable(uuid); + map['encrypted_name'] = Variable(encryptedName); + map['server_version'] = Variable(serverVersion); + map['is_deleted'] = Variable(isDeleted); + map['updated_at'] = Variable(updatedAt); + return map; + } + + CategoriesCompanion toCompanion(bool nullToAbsent) { + return CategoriesCompanion( + uuid: Value(uuid), + encryptedName: Value(encryptedName), + serverVersion: Value(serverVersion), + isDeleted: Value(isDeleted), + updatedAt: Value(updatedAt), + ); + } + + factory DbCategory.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return DbCategory( + uuid: serializer.fromJson(json['uuid']), + encryptedName: serializer.fromJson(json['encryptedName']), + serverVersion: serializer.fromJson(json['serverVersion']), + isDeleted: serializer.fromJson(json['isDeleted']), + updatedAt: serializer.fromJson(json['updatedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'uuid': serializer.toJson(uuid), + 'encryptedName': serializer.toJson(encryptedName), + 'serverVersion': serializer.toJson(serverVersion), + 'isDeleted': serializer.toJson(isDeleted), + 'updatedAt': serializer.toJson(updatedAt), + }; + } + + DbCategory copyWith({ + String? uuid, + String? encryptedName, + int? serverVersion, + bool? isDeleted, + DateTime? updatedAt, + }) => DbCategory( + uuid: uuid ?? this.uuid, + encryptedName: encryptedName ?? this.encryptedName, + serverVersion: serverVersion ?? this.serverVersion, + isDeleted: isDeleted ?? this.isDeleted, + updatedAt: updatedAt ?? this.updatedAt, + ); + DbCategory copyWithCompanion(CategoriesCompanion data) { + return DbCategory( + uuid: data.uuid.present ? data.uuid.value : this.uuid, + encryptedName: data.encryptedName.present + ? data.encryptedName.value + : this.encryptedName, + serverVersion: data.serverVersion.present + ? data.serverVersion.value + : this.serverVersion, + isDeleted: data.isDeleted.present ? data.isDeleted.value : this.isDeleted, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ); + } + + @override + String toString() { + return (StringBuffer('DbCategory(') + ..write('uuid: $uuid, ') + ..write('encryptedName: $encryptedName, ') + ..write('serverVersion: $serverVersion, ') + ..write('isDeleted: $isDeleted, ') + ..write('updatedAt: $updatedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(uuid, encryptedName, serverVersion, isDeleted, updatedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is DbCategory && + other.uuid == this.uuid && + other.encryptedName == this.encryptedName && + other.serverVersion == this.serverVersion && + other.isDeleted == this.isDeleted && + other.updatedAt == this.updatedAt); +} + +class CategoriesCompanion extends UpdateCompanion { + final Value uuid; + final Value encryptedName; + final Value serverVersion; + final Value isDeleted; + final Value updatedAt; + final Value rowid; + const CategoriesCompanion({ + this.uuid = const Value.absent(), + this.encryptedName = const Value.absent(), + this.serverVersion = const Value.absent(), + this.isDeleted = const Value.absent(), + this.updatedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + CategoriesCompanion.insert({ + required String uuid, + required String encryptedName, + this.serverVersion = const Value.absent(), + this.isDeleted = const Value.absent(), + required DateTime updatedAt, + this.rowid = const Value.absent(), + }) : uuid = Value(uuid), + encryptedName = Value(encryptedName), + updatedAt = Value(updatedAt); + static Insertable custom({ + Expression? uuid, + Expression? encryptedName, + Expression? serverVersion, + Expression? isDeleted, + Expression? updatedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (uuid != null) 'uuid': uuid, + if (encryptedName != null) 'encrypted_name': encryptedName, + if (serverVersion != null) 'server_version': serverVersion, + if (isDeleted != null) 'is_deleted': isDeleted, + if (updatedAt != null) 'updated_at': updatedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + CategoriesCompanion copyWith({ + Value? uuid, + Value? encryptedName, + Value? serverVersion, + Value? isDeleted, + Value? updatedAt, + Value? rowid, + }) { + return CategoriesCompanion( + uuid: uuid ?? this.uuid, + encryptedName: encryptedName ?? this.encryptedName, + serverVersion: serverVersion ?? this.serverVersion, + isDeleted: isDeleted ?? this.isDeleted, + updatedAt: updatedAt ?? this.updatedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (uuid.present) { + map['uuid'] = Variable(uuid.value); + } + if (encryptedName.present) { + map['encrypted_name'] = Variable(encryptedName.value); + } + if (serverVersion.present) { + map['server_version'] = Variable(serverVersion.value); + } + if (isDeleted.present) { + map['is_deleted'] = Variable(isDeleted.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('CategoriesCompanion(') + ..write('uuid: $uuid, ') + ..write('encryptedName: $encryptedName, ') + ..write('serverVersion: $serverVersion, ') + ..write('isDeleted: $isDeleted, ') + ..write('updatedAt: $updatedAt, ') + ..write('rowid: $rowid') ..write(')')) .toString(); } @@ -402,30 +982,39 @@ abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); $AppDatabaseManager get managers => $AppDatabaseManager(this); late final $NotesTable notes = $NotesTable(this); + late final $CategoriesTable categories = $CategoriesTable(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @override - List get allSchemaEntities => [notes]; + List get allSchemaEntities => [notes, categories]; } typedef $$NotesTableCreateCompanionBuilder = NotesCompanion Function({ Value id, + required String uuid, required String title, required String body, required DateTime createdAt, required DateTime updatedAt, required int sortIndex, + Value serverVersion, + Value isDeleted, + Value categoryId, }); typedef $$NotesTableUpdateCompanionBuilder = NotesCompanion Function({ Value id, + Value uuid, Value title, Value body, Value createdAt, Value updatedAt, Value sortIndex, + Value serverVersion, + Value isDeleted, + Value categoryId, }); class $$NotesTableFilterComposer extends Composer<_$AppDatabase, $NotesTable> { @@ -441,6 +1030,11 @@ class $$NotesTableFilterComposer extends Composer<_$AppDatabase, $NotesTable> { builder: (column) => ColumnFilters(column), ); + ColumnFilters get uuid => $composableBuilder( + column: $table.uuid, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get title => $composableBuilder( column: $table.title, builder: (column) => ColumnFilters(column), @@ -465,6 +1059,21 @@ class $$NotesTableFilterComposer extends Composer<_$AppDatabase, $NotesTable> { column: $table.sortIndex, builder: (column) => ColumnFilters(column), ); + + ColumnFilters get serverVersion => $composableBuilder( + column: $table.serverVersion, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get isDeleted => $composableBuilder( + column: $table.isDeleted, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get categoryId => $composableBuilder( + column: $table.categoryId, + builder: (column) => ColumnFilters(column), + ); } class $$NotesTableOrderingComposer @@ -481,6 +1090,11 @@ class $$NotesTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get uuid => $composableBuilder( + column: $table.uuid, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get title => $composableBuilder( column: $table.title, builder: (column) => ColumnOrderings(column), @@ -505,6 +1119,21 @@ class $$NotesTableOrderingComposer column: $table.sortIndex, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get serverVersion => $composableBuilder( + column: $table.serverVersion, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get isDeleted => $composableBuilder( + column: $table.isDeleted, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get categoryId => $composableBuilder( + column: $table.categoryId, + builder: (column) => ColumnOrderings(column), + ); } class $$NotesTableAnnotationComposer @@ -519,6 +1148,9 @@ class $$NotesTableAnnotationComposer GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); + GeneratedColumn get uuid => + $composableBuilder(column: $table.uuid, builder: (column) => column); + GeneratedColumn get title => $composableBuilder(column: $table.title, builder: (column) => column); @@ -533,6 +1165,19 @@ class $$NotesTableAnnotationComposer GeneratedColumn get sortIndex => $composableBuilder(column: $table.sortIndex, builder: (column) => column); + + GeneratedColumn get serverVersion => $composableBuilder( + column: $table.serverVersion, + builder: (column) => column, + ); + + GeneratedColumn get isDeleted => + $composableBuilder(column: $table.isDeleted, builder: (column) => column); + + GeneratedColumn get categoryId => $composableBuilder( + column: $table.categoryId, + builder: (column) => column, + ); } class $$NotesTableTableManager @@ -564,34 +1209,50 @@ class $$NotesTableTableManager updateCompanionCallback: ({ Value id = const Value.absent(), + Value uuid = const Value.absent(), Value title = const Value.absent(), Value body = const Value.absent(), Value createdAt = const Value.absent(), Value updatedAt = const Value.absent(), Value sortIndex = const Value.absent(), + Value serverVersion = const Value.absent(), + Value isDeleted = const Value.absent(), + Value categoryId = const Value.absent(), }) => NotesCompanion( id: id, + uuid: uuid, title: title, body: body, createdAt: createdAt, updatedAt: updatedAt, sortIndex: sortIndex, + serverVersion: serverVersion, + isDeleted: isDeleted, + categoryId: categoryId, ), createCompanionCallback: ({ Value id = const Value.absent(), + required String uuid, required String title, required String body, required DateTime createdAt, required DateTime updatedAt, required int sortIndex, + Value serverVersion = const Value.absent(), + Value isDeleted = const Value.absent(), + Value categoryId = const Value.absent(), }) => NotesCompanion.insert( id: id, + uuid: uuid, title: title, body: body, createdAt: createdAt, updatedAt: updatedAt, sortIndex: sortIndex, + serverVersion: serverVersion, + isDeleted: isDeleted, + categoryId: categoryId, ), withReferenceMapper: (p0) => p0 .map((e) => (e.readTable(table), BaseReferences(db, table, e))) @@ -615,10 +1276,213 @@ typedef $$NotesTableProcessedTableManager = DbNote, PrefetchHooks Function() >; +typedef $$CategoriesTableCreateCompanionBuilder = + CategoriesCompanion Function({ + required String uuid, + required String encryptedName, + Value serverVersion, + Value isDeleted, + required DateTime updatedAt, + Value rowid, + }); +typedef $$CategoriesTableUpdateCompanionBuilder = + CategoriesCompanion Function({ + Value uuid, + Value encryptedName, + Value serverVersion, + Value isDeleted, + Value updatedAt, + Value rowid, + }); + +class $$CategoriesTableFilterComposer + extends Composer<_$AppDatabase, $CategoriesTable> { + $$CategoriesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get uuid => $composableBuilder( + column: $table.uuid, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get encryptedName => $composableBuilder( + column: $table.encryptedName, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get serverVersion => $composableBuilder( + column: $table.serverVersion, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get isDeleted => $composableBuilder( + column: $table.isDeleted, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$CategoriesTableOrderingComposer + extends Composer<_$AppDatabase, $CategoriesTable> { + $$CategoriesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get uuid => $composableBuilder( + column: $table.uuid, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get encryptedName => $composableBuilder( + column: $table.encryptedName, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get serverVersion => $composableBuilder( + column: $table.serverVersion, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get isDeleted => $composableBuilder( + column: $table.isDeleted, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$CategoriesTableAnnotationComposer + extends Composer<_$AppDatabase, $CategoriesTable> { + $$CategoriesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get uuid => + $composableBuilder(column: $table.uuid, builder: (column) => column); + + GeneratedColumn get encryptedName => $composableBuilder( + column: $table.encryptedName, + builder: (column) => column, + ); + + GeneratedColumn get serverVersion => $composableBuilder( + column: $table.serverVersion, + builder: (column) => column, + ); + + GeneratedColumn get isDeleted => + $composableBuilder(column: $table.isDeleted, builder: (column) => column); + + GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); +} + +class $$CategoriesTableTableManager + extends + RootTableManager< + _$AppDatabase, + $CategoriesTable, + DbCategory, + $$CategoriesTableFilterComposer, + $$CategoriesTableOrderingComposer, + $$CategoriesTableAnnotationComposer, + $$CategoriesTableCreateCompanionBuilder, + $$CategoriesTableUpdateCompanionBuilder, + ( + DbCategory, + BaseReferences<_$AppDatabase, $CategoriesTable, DbCategory>, + ), + DbCategory, + PrefetchHooks Function() + > { + $$CategoriesTableTableManager(_$AppDatabase db, $CategoriesTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$CategoriesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$CategoriesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$CategoriesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value uuid = const Value.absent(), + Value encryptedName = const Value.absent(), + Value serverVersion = const Value.absent(), + Value isDeleted = const Value.absent(), + Value updatedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => CategoriesCompanion( + uuid: uuid, + encryptedName: encryptedName, + serverVersion: serverVersion, + isDeleted: isDeleted, + updatedAt: updatedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String uuid, + required String encryptedName, + Value serverVersion = const Value.absent(), + Value isDeleted = const Value.absent(), + required DateTime updatedAt, + Value rowid = const Value.absent(), + }) => CategoriesCompanion.insert( + uuid: uuid, + encryptedName: encryptedName, + serverVersion: serverVersion, + isDeleted: isDeleted, + updatedAt: updatedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$CategoriesTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $CategoriesTable, + DbCategory, + $$CategoriesTableFilterComposer, + $$CategoriesTableOrderingComposer, + $$CategoriesTableAnnotationComposer, + $$CategoriesTableCreateCompanionBuilder, + $$CategoriesTableUpdateCompanionBuilder, + (DbCategory, BaseReferences<_$AppDatabase, $CategoriesTable, DbCategory>), + DbCategory, + PrefetchHooks Function() + >; class $AppDatabaseManager { final _$AppDatabase _db; $AppDatabaseManager(this._db); $$NotesTableTableManager get notes => $$NotesTableTableManager(_db, _db.notes); + $$CategoriesTableTableManager get categories => + $$CategoriesTableTableManager(_db, _db.categories); } diff --git a/lib/data/local_vault_service.dart b/lib/data/local_vault_service.dart index 2e5463f..c5a1d6c 100644 --- a/lib/data/local_vault_service.dart +++ b/lib/data/local_vault_service.dart @@ -74,6 +74,14 @@ class LocalVaultService { return _secureStorage.read(key: _encryptionKeyStorageKey); } + Future storeEncryptionKey(String encryptionKey) async { + await _secureStorage.write(key: _encryptionKeyStorageKey, value: encryptionKey); + } + + String generateEncryptionKey() { + return _generateEncryptionKey(); + } + Future hasEncryptionKey() async { return (await readStoredEncryptionKeyRaw()) != null; } @@ -117,10 +125,7 @@ class LocalVaultService { Future 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. diff --git a/lib/data/note_encryption.dart b/lib/data/note_encryption.dart new file mode 100644 index 0000000..86c1eac --- /dev/null +++ b/lib/data/note_encryption.dart @@ -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 encryptNote( + String plaintext, + String masterKey, + ) async { + final List salt = _randomBytes(16); + final SecretKey secretKey = await _kdf.deriveKey( + secretKey: SecretKey(utf8.encode(masterKey)), + nonce: salt, + ); + + final List nonce = _randomBytes(12); + final SecretBox box = await _aes.encrypt( + utf8.encode(plaintext), + secretKey: secretKey, + nonce: nonce, + ); + + return jsonEncode({ + '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 decryptNote( + String encodedBox, + String masterKey, + ) async { + try { + final Map payload = + jsonDecode(encodedBox) as Map; + final List salt = base64Decode(payload['salt'] as String); + final List nonce = base64Decode(payload['nonce'] as String); + final List cipherText = + base64Decode(payload['cipherText'] as String); + final List 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 clearText = await _aes.decrypt( + box, + secretKey: secretKey, + ); + + return utf8.decode(clearText); + } catch (e) { + throw Exception('Failed to decrypt note: $e'); + } + } + + static List _randomBytes(int length) { + final Random random = Random.secure(); + return List.generate(length, (_) => random.nextInt(256)); + } +} diff --git a/lib/data/note_repository.dart b/lib/data/note_repository.dart index e0a3a1d..9282dec 100644 --- a/lib/data/note_repository.dart +++ b/lib/data/note_repository.dart @@ -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> loadNotes() async { return _loadNotesFromDatabase(); @@ -13,11 +26,15 @@ class NoteRepository { Future 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> _loadNotesFromDatabase() async { - final List 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> 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 unsyncedNotes = await _database.getUnsyncedNotes(); + final List unsyncedCategories = await _database.getUnsyncedCategories(); + + // Build sync request (note: we send encrypted data, but locally we have plaintext) + // Encrypt all notes before sending + final List 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 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 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 _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> _loadNotesFromDatabase() async { + final List 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, ); } -} \ No newline at end of file + + Category _fromDbCategory(DbCategory row) { + return Category( + uuid: row.uuid, + encryptedName: row.encryptedName, + serverVersion: row.serverVersion, + isDeleted: row.isDeleted, + updatedAt: row.updatedAt, + ); + } +} diff --git a/lib/data/sync_models.dart b/lib/data/sync_models.dart new file mode 100644 index 0000000..48ba97a --- /dev/null +++ b/lib/data/sync_models.dart @@ -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 toJson() { + return { + 'lastSyncAt': lastSyncAt.toIso8601String(), + 'changes': changes.toJson(), + }; + } +} + +class SyncChanges { + const SyncChanges({ + this.categories = const [], + this.notes = const [], + }); + + final List categories; + final List notes; + + Map 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 categories; + final List notes; + + factory SyncChangesResponse.fromJson(Map json) { + final List categoriesJson = json['categories'] as List? ?? []; + final List notesJson = json['notes'] as List? ?? []; + + return SyncChangesResponse( + categories: categoriesJson + .map((c) => SyncCategoryResponse.fromJson(c as Map)) + .toList(), + notes: notesJson + .map((n) => SyncNoteResponse.fromJson(n as Map)) + .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 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 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 json) { + return SyncResponse( + serverTimestamp: + DateTime.parse(json['serverTimestamp'] as String), + synced: json['synced'] as bool? ?? false, + changes: SyncChangesResponse.fromJson( + json['changes'] as Map? ?? {}), + ); + } +} + +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 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 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, + ); + } +} diff --git a/lib/models/category.dart b/lib/models/category.dart new file mode 100644 index 0000000..8b17510 --- /dev/null +++ b/lib/models/category.dart @@ -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; +} diff --git a/lib/models/note.dart b/lib/models/note.dart index 34b5de4..93f998d 100644 --- a/lib/models/note.dart +++ b/lib/models/note.dart @@ -1,39 +1,60 @@ +import 'package:uuid/uuid.dart'; + // Model: Note // - 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. +// - `serverVersion` se usa para resolver conflictos en sync. +// - `isDeleted` marca eliminaciones blandas. class Note { - const Note({ + Note({ this.id, + String? uuid, required this.title, required this.body, required this.createdAt, required this.updatedAt, required this.index, - }); + this.serverVersion = 0, + this.isDeleted = false, + this.categoryId, + }) : uuid = uuid ?? Uuid().v4(); final int? id; + final String uuid; final String title; final String body; final DateTime createdAt; final DateTime updatedAt; final int index; + final int serverVersion; + final bool isDeleted; + final String? categoryId; Note copyWith({ int? id, + String? uuid, String? title, String? body, DateTime? createdAt, DateTime? updatedAt, int? index, + int? serverVersion, + bool? isDeleted, + String? categoryId, }) { return Note( id: id ?? this.id, + uuid: uuid ?? this.uuid, title: title ?? this.title, body: body ?? this.body, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, 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 other is Note && id != null && other.id == id; + return other is Note && uuid == other.uuid; } @override - int get hashCode => id?.hashCode ?? Object.hash(title, body, createdAt, updatedAt, index); + int get hashCode => uuid.hashCode; } \ No newline at end of file diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 5000e08..835798b 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -75,6 +75,12 @@ class _HomeScreenState extends State { setState(() { _notes = updatedNotes; }); + + // Trigger sync after creating a note and refresh local list + try { + await widget.repository.performSync(); + await _loadNotes(); + } catch (_) {} } } @@ -92,6 +98,12 @@ class _HomeScreenState extends State { setState(() { _notes = updatedNotes; }); + + // Trigger sync after deleting a note and refresh local list + try { + await widget.repository.performSync(); + await _loadNotes(); + } catch (_) {} } Future _reorderNote(int oldIndex, int newIndex) async { @@ -140,6 +152,11 @@ class _HomeScreenState extends State { setState(() { _notes = _normalizeNotes(updatedNotes); }); + // Trigger sync after editing a note and refresh local list + try { + await widget.repository.performSync(); + await _loadNotes(); + } catch (_) {} } } } @@ -172,9 +189,16 @@ class _HomeScreenState extends State { ? const Center(child: CircularProgressIndicator()) : _notes.isEmpty ? const _EmptyState() - : MouseRegion( - cursor: _isDragging ? SystemMouseCursors.grabbing : SystemMouseCursors.basic, - child: MasonryGridView.count( + : RefreshIndicator( + onRefresh: () async { + try { + await widget.repository.performSync(); + } catch (_) {} + await _loadNotes(); + }, + child: MouseRegion( + cursor: _isDragging ? SystemMouseCursors.grabbing : SystemMouseCursors.basic, + child: MasonryGridView.count( crossAxisCount: crossAxisCount, mainAxisSpacing: 10, crossAxisSpacing: 10, @@ -296,7 +320,8 @@ class _HomeScreenState extends State { ); }, ), - ); + ), + ); return Scaffold( body: Container( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 987d16a..691b48b 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:notas/data/local_vault_service.dart'; import 'package:notas/widgets/search_app_bar.dart'; +import 'package:notas/data/api_client.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({ @@ -17,6 +19,11 @@ class SettingsScreen extends StatefulWidget { class _SettingsScreenState extends State { bool _isBusy = false; + final TextEditingController _endpointController = TextEditingController(); + final TextEditingController _encryptionKeyController = TextEditingController(); + bool _endpointLoading = true; + bool _encryptionKeyLoading = false; + bool _encryptionKeyVisible = false; Future _confirmAndDeleteAll() async { final bool? confirmed = await showDialog( @@ -59,6 +66,91 @@ class _SettingsScreenState extends State { } @override + void initState() { + super.initState(); + _loadEndpoint(); + } + + Future _loadEndpoint() async { + final String endpoint = await ApiConfig.getEndpoint(); + if (!mounted) return; + _endpointController.text = endpoint; + setState(() { + _endpointLoading = false; + }); + } + + Future _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 _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 _resetEndpoint() async { + await ApiConfig.clearEndpoint(); + final String endpoint = await ApiConfig.getEndpoint(); + if (!mounted) return; + _endpointController.text = endpoint; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Endpoint restaurado al valor por defecto'))); + } Widget build(BuildContext context) { return Scaffold( body: Container( @@ -97,6 +189,113 @@ class _SettingsScreenState extends State { ), const SizedBox(height: 16), const Text('Esto cerrará el vault actual y eliminará la base de datos local junto con la clave de cifrado.'), + const SizedBox(height: 24), + const Text('API endpoint (ej: http://localhost:3000/api)'), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _endpointLoading + ? const SizedBox(height: 48, child: Center(child: CircularProgressIndicator())) + : TextField( + controller: _endpointController, + style: const TextStyle(color: Colors.white), + keyboardType: TextInputType.url, + decoration: InputDecoration( + labelText: 'API endpoint', + labelStyle: TextStyle(color: Colors.white.withValues(alpha: 0.7)), + filled: true, + fillColor: Colors.white.withValues(alpha: 0.05), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: Colors.amber, width: 1.2), + ), + ), + ), + ), + const SizedBox(width: 8), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + ElevatedButton( + onPressed: _endpointLoading ? null : _saveEndpoint, + child: const Text('Guardar'), + ), + const SizedBox(height: 8), + OutlinedButton( + onPressed: _endpointLoading ? null : _resetEndpoint, + child: const Text('Restaurar'), + ), + ], + ), + ], + ), + const SizedBox(height: 24), + const Text('Clave de cifrado local'), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextField( + controller: _encryptionKeyController, + readOnly: true, + obscureText: !_encryptionKeyVisible, + enableSuggestions: false, + autocorrect: false, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + labelText: _encryptionKeyVisible ? 'Clave de cifrado' : 'Oculta hasta pulsar mostrar', + labelStyle: TextStyle(color: Colors.white.withValues(alpha: 0.7)), + filled: true, + fillColor: Colors.white.withValues(alpha: 0.05), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: Colors.amber, width: 1.2), + ), + ), + ), + ), + const SizedBox(width: 8), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + ElevatedButton( + onPressed: _encryptionKeyLoading ? null : _loadEncryptionKey, + child: _encryptionKeyLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Mostrar'), + ), + const SizedBox(height: 8), + OutlinedButton( + onPressed: _encryptionKeyVisible ? _hideEncryptionKey : null, + child: const Text('Ocultar'), + ), + ], + ), + ], + ), ], ), ), diff --git a/lib/screens/vault_access_screen.dart b/lib/screens/vault_access_screen.dart index fd37862..72cf239 100644 --- a/lib/screens/vault_access_screen.dart +++ b/lib/screens/vault_access_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:notas/widgets/app_title_bar.dart'; +import 'package:notas/data/api_client.dart'; class VaultAccessScreen extends StatefulWidget { const VaultAccessScreen({ @@ -22,6 +23,29 @@ class VaultAccessScreen extends StatefulWidget { class _VaultAccessScreenState extends State { final TextEditingController _emailController = TextEditingController(); final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _endpointController = TextEditingController(); + bool _endpointLoading = true; + + @override + void initState() { + super.initState(); + _loadEndpoint(); + } + + Future _loadEndpoint() async { + final String endpoint = await ApiConfig.getEndpoint(); + if (!mounted) return; + _endpointController.text = endpoint; + setState(() { + _endpointLoading = false; + }); + } + + Future _persistEndpointFromField() async { + final String value = _endpointController.text.trim(); + if (value.isEmpty) return; + await ApiConfig.setEndpoint(value); + } @override void dispose() { @@ -31,6 +55,8 @@ class _VaultAccessScreenState extends State { } Future _handleCreateAccount() async { + await _persistEndpointFromField(); + await widget.onCreateAccountPressed( _emailController.text.trim(), _passwordController.text, @@ -38,6 +64,8 @@ class _VaultAccessScreenState extends State { } Future _handleSignIn() async { + await _persistEndpointFromField(); + await widget.onSignInPressed( _emailController.text.trim(), _passwordController.text, @@ -112,13 +140,43 @@ class _VaultAccessScreenState extends State { ), ), const SizedBox(height: 28), + _endpointLoading + ? const SizedBox( + height: 48, + child: Center(child: CircularProgressIndicator()), + ) + : TextField( + controller: _endpointController, + enabled: !widget.isBusy, + keyboardType: TextInputType.url, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + labelText: 'API endpoint', + labelStyle: TextStyle(color: Colors.white.withValues(alpha: 0.7)), + filled: true, + fillColor: Colors.white.withValues(alpha: 0.05), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: Colors.amber, width: 1.2), + ), + ), + ), + const SizedBox(height: 16), TextField( controller: _emailController, enabled: !widget.isBusy, - keyboardType: TextInputType.emailAddress, + keyboardType: TextInputType.text, style: const TextStyle(color: Colors.white), decoration: InputDecoration( - labelText: 'Email', + labelText: 'Usuario', labelStyle: TextStyle(color: Colors.white.withValues(alpha: 0.7)), filled: true, fillColor: Colors.white.withValues(alpha: 0.05), diff --git a/lib/widgets/app_title_bar_io.dart b/lib/widgets/app_title_bar_io.dart index 2e01517..f932982 100644 --- a/lib/widgets/app_title_bar_io.dart +++ b/lib/widgets/app_title_bar_io.dart @@ -1,9 +1,17 @@ import 'package:flutter/material.dart'; import 'package:notas/platform/app_platform.dart'; +import 'package:notas/widgets/sync_status_indicator.dart'; import 'package:window_manager/window_manager.dart'; 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 Widget build(BuildContext context) { @@ -12,33 +20,62 @@ class AppTitleBar extends StatelessWidget { } if (isMacOS) { - return const SizedBox( + return SizedBox( height: 28, child: Center( - child: Text( - 'Mis Notas', - style: TextStyle(color: Colors.white70, fontSize: 13), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'Mis Notas', + style: TextStyle(color: Colors.white70, fontSize: 13), + ), + const SizedBox(width: 8), + SyncStatusIndicator( + status: syncStatus, + errorMessage: syncErrorMessage, + ), + ], ), ), ); } if (isLinux) { - return const _KdeTitleBar(); + return _KdeTitleBar( + syncStatus: syncStatus, + syncErrorMessage: syncErrorMessage, + ); } - return const SizedBox( + return SizedBox( height: 40, child: WindowCaption( 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 { - const _KdeTitleBar(); + const _KdeTitleBar({ + this.syncStatus = SyncStatus.idle, + this.syncErrorMessage, + }); + + final SyncStatus syncStatus; + final String? syncErrorMessage; @override State<_KdeTitleBar> createState() => _KdeTitleBarState(); @@ -130,12 +167,33 @@ class _KdeTitleBarState extends State<_KdeTitleBar> with WindowListener { ), const IgnorePointer( child: Center( - child: Text( - 'Mis Notas', - style: TextStyle( - color: Color.fromARGB(255, 163, 163, 163), - fontSize: 14, - fontWeight: FontWeight.w500, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Mis Notas', + 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, + ), ), ), ), diff --git a/lib/widgets/app_title_bar_stub.dart b/lib/widgets/app_title_bar_stub.dart index f74485b..206c99f 100644 --- a/lib/widgets/app_title_bar_stub.dart +++ b/lib/widgets/app_title_bar_stub.dart @@ -1,7 +1,15 @@ import 'package:flutter/material.dart'; +import 'package:notas/widgets/sync_status_indicator.dart'; 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 Widget build(BuildContext context) { diff --git a/lib/widgets/sync_status_indicator.dart b/lib/widgets/sync_status_indicator.dart new file mode 100644 index 0000000..744e0a9 --- /dev/null +++ b/lib/widgets/sync_status_indicator.dart @@ -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.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, + ), + ); + } + } +} diff --git a/pubspec.lock b/pubspec.lock index 52673e5..2382dab 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -162,13 +162,21 @@ packages: source: hosted version: "3.1.2" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -360,6 +368,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -893,6 +909,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4ed744c..4f18275 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,10 @@ dependencies: flutter_secure_storage: ^10.2.0 local_auth: ^3.0.1 sqlite3: ^3.3.1 + http: ^0.13.6 + crypto: ^3.0.6 + cryptography: ^2.7.0 + uuid: ^4.0.0 dev_dependencies: flutter_test: @@ -70,7 +74,7 @@ flutter: # the material Icons class. uses-material-design: true -assets: + assets: - assets/icon.png hooks: