feat: Refactor sync status handling and improve synchronization feedback in the app
This commit is contained in:
+119
-43
@@ -16,24 +16,15 @@ import 'package:notas/screens/settings_screen.dart';
|
|||||||
import 'package:notas/screens/vault_access_screen.dart';
|
import 'package:notas/screens/vault_access_screen.dart';
|
||||||
import 'package:notas/theme/app_theme.dart';
|
import 'package:notas/theme/app_theme.dart';
|
||||||
import 'package:notas/widgets/app_title_bar.dart';
|
import 'package:notas/widgets/app_title_bar.dart';
|
||||||
import 'package:notas/widgets/sync_status_indicator.dart';
|
import 'package:notas/widgets/sync_status.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
enum _AppSection {
|
enum _AppSection { home, settings }
|
||||||
home,
|
|
||||||
settings,
|
|
||||||
}
|
|
||||||
|
|
||||||
enum _AppPhase {
|
enum _AppPhase { loading, access, biometricChoice, biometricGate, notes }
|
||||||
loading,
|
|
||||||
access,
|
|
||||||
biometricChoice,
|
|
||||||
biometricGate,
|
|
||||||
notes,
|
|
||||||
}
|
|
||||||
|
|
||||||
class PerformSyncIntent extends Intent {
|
class PerformSyncIntent extends Intent {
|
||||||
const PerformSyncIntent();
|
const PerformSyncIntent();
|
||||||
@@ -71,7 +62,10 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
_AppPhase _phase = _AppPhase.loading;
|
_AppPhase _phase = _AppPhase.loading;
|
||||||
_AppSection _currentSection = _AppSection.home;
|
_AppSection _currentSection = _AppSection.home;
|
||||||
SyncStatus _syncStatus = SyncStatus.idle;
|
SyncStatus _syncStatus = SyncStatus.idle;
|
||||||
|
double? _syncProgress;
|
||||||
|
String? _syncDetailMessage;
|
||||||
String? _syncErrorMessage;
|
String? _syncErrorMessage;
|
||||||
|
int _syncOperationId = 0;
|
||||||
int _homeRefreshToken = 0;
|
int _homeRefreshToken = 0;
|
||||||
Color _themeSeedColor = Colors.amber;
|
Color _themeSeedColor = Colors.amber;
|
||||||
|
|
||||||
@@ -150,7 +144,8 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
Future<void> _bootstrapVault() async {
|
Future<void> _bootstrapVault() async {
|
||||||
try {
|
try {
|
||||||
final bool hasEncryptionKey = await _vaultService.hasEncryptionKey();
|
final bool hasEncryptionKey = await _vaultService.hasEncryptionKey();
|
||||||
_biometricGateEnabled = hasEncryptionKey && await _vaultService.isBiometricGateEnabled();
|
_biometricGateEnabled =
|
||||||
|
hasEncryptionKey && await _vaultService.isBiometricGateEnabled();
|
||||||
|
|
||||||
if (!hasEncryptionKey) {
|
if (!hasEncryptionKey) {
|
||||||
_pendingEncryptionKey = null;
|
_pendingEncryptionKey = null;
|
||||||
@@ -163,10 +158,12 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
}
|
}
|
||||||
|
|
||||||
final bool accessCompleted = await _vaultService.isVaultAccessCompleted();
|
final bool accessCompleted = await _vaultService.isVaultAccessCompleted();
|
||||||
final bool biometricChoicePending = await _vaultService.isBiometricChoicePending();
|
final bool biometricChoicePending = await _vaultService
|
||||||
|
.isBiometricChoicePending();
|
||||||
|
|
||||||
if (!accessCompleted) {
|
if (!accessCompleted) {
|
||||||
_pendingEncryptionKey = await _vaultService.readStoredEncryptionKeyRaw();
|
_pendingEncryptionKey = await _vaultService
|
||||||
|
.readStoredEncryptionKeyRaw();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_phase = _AppPhase.access;
|
_phase = _AppPhase.access;
|
||||||
@@ -176,7 +173,8 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (biometricChoicePending) {
|
if (biometricChoicePending) {
|
||||||
_pendingEncryptionKey = await _vaultService.readStoredEncryptionKeyRaw();
|
_pendingEncryptionKey = await _vaultService
|
||||||
|
.readStoredEncryptionKeyRaw();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_phase = _AppPhase.biometricChoice;
|
_phase = _AppPhase.biometricChoice;
|
||||||
@@ -194,7 +192,8 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final String? encryptionKey = await _vaultService.readStoredEncryptionKeyRaw();
|
final String? encryptionKey = await _vaultService
|
||||||
|
.readStoredEncryptionKeyRaw();
|
||||||
if (encryptionKey != null) {
|
if (encryptionKey != null) {
|
||||||
await _openVault(encryptionKey);
|
await _openVault(encryptionKey);
|
||||||
} else if (mounted) {
|
} else if (mounted) {
|
||||||
@@ -244,7 +243,11 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_scaffoldMessengerKey.currentState?.showSnackBar(
|
_scaffoldMessengerKey.currentState?.showSnackBar(
|
||||||
const SnackBar(content: Text('El vault local estaba corrupto y ha sido reiniciado.')),
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'El vault local estaba corrupto y ha sido reiniciado.',
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -264,8 +267,10 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
_showAccountPlaceholder(actionLabel);
|
_showAccountPlaceholder(actionLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
final String? existingKey = await _vaultService.readStoredEncryptionKeyRaw();
|
final String? existingKey = await _vaultService
|
||||||
final String encryptionKey = existingKey ?? await _vaultService.createEncryptionKey();
|
.readStoredEncryptionKeyRaw();
|
||||||
|
final String encryptionKey =
|
||||||
|
existingKey ?? await _vaultService.createEncryptionKey();
|
||||||
|
|
||||||
_pendingEncryptionKey = encryptionKey;
|
_pendingEncryptionKey = encryptionKey;
|
||||||
await _vaultService.setVaultAccessCompleted(true);
|
await _vaultService.setVaultAccessCompleted(true);
|
||||||
@@ -308,8 +313,8 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
try {
|
try {
|
||||||
if (isRegister) {
|
if (isRegister) {
|
||||||
final String encryptionKey = _vaultService.generateEncryptionKey();
|
final String encryptionKey = _vaultService.generateEncryptionKey();
|
||||||
final String encryptedMasterKey =
|
final String encryptedMasterKey = await AuthApi.instance
|
||||||
await AuthApi.instance.encryptWithPassword(encryptionKey, password);
|
.encryptWithPassword(encryptionKey, password);
|
||||||
|
|
||||||
final Map<String, dynamic> response = await AuthApi.instance.register(
|
final Map<String, dynamic> response = await AuthApi.instance.register(
|
||||||
username,
|
username,
|
||||||
@@ -341,8 +346,10 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
throw StateError('La API no devolvió la clave de encriptación.');
|
throw StateError('La API no devolvió la clave de encriptación.');
|
||||||
}
|
}
|
||||||
|
|
||||||
final String encryptionKey =
|
final String encryptionKey = await AuthApi.instance.decryptWithPassword(
|
||||||
await AuthApi.instance.decryptWithPassword(encryptedMasterKey, password);
|
encryptedMasterKey,
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
|
||||||
await _vaultService.storeEncryptionKey(encryptionKey);
|
await _vaultService.storeEncryptionKey(encryptionKey);
|
||||||
_pendingEncryptionKey = encryptionKey;
|
_pendingEncryptionKey = encryptionKey;
|
||||||
@@ -360,7 +367,9 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
_scaffoldMessengerKey.currentState?.showSnackBar(
|
_scaffoldMessengerKey.currentState?.showSnackBar(
|
||||||
SnackBar(content: Text('No se pudo completar la autenticación: $error')),
|
SnackBar(
|
||||||
|
content: Text('No se pudo completar la autenticación: $error'),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -376,7 +385,9 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
return _beginInitialVaultFlow();
|
return _beginInitialVaultFlow();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _completeBiometricChoice({required bool enableBiometrics}) async {
|
Future<void> _completeBiometricChoice({
|
||||||
|
required bool enableBiometrics,
|
||||||
|
}) async {
|
||||||
if (_isUnlocking) {
|
if (_isUnlocking) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -386,7 +397,9 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final String? pendingKey = _pendingEncryptionKey ?? await _vaultService.readStoredEncryptionKeyRaw();
|
final String? pendingKey =
|
||||||
|
_pendingEncryptionKey ??
|
||||||
|
await _vaultService.readStoredEncryptionKeyRaw();
|
||||||
|
|
||||||
if (pendingKey == null) {
|
if (pendingKey == null) {
|
||||||
throw StateError('No se encontró la llave local.');
|
throw StateError('No se encontró la llave local.');
|
||||||
@@ -409,10 +422,18 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
context: dialogCtx,
|
context: dialogCtx,
|
||||||
builder: (BuildContext context) => AlertDialog(
|
builder: (BuildContext context) => AlertDialog(
|
||||||
title: const Text('No se pudo activar la biometría'),
|
title: const Text('No se pudo activar la biometría'),
|
||||||
content: const Text('No se pudo activar la biometría. ¿Quieres intentarlo de nuevo o entrar sin huella?'),
|
content: const Text(
|
||||||
|
'No se pudo activar la biometría. ¿Quieres intentarlo de nuevo o entrar sin huella?',
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: () => navigator.pop(false), child: const Text('Entrar sin huella')),
|
TextButton(
|
||||||
FilledButton(onPressed: () => navigator.pop(true), child: const Text('Reintentar')),
|
onPressed: () => navigator.pop(false),
|
||||||
|
child: const Text('Entrar sin huella'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => navigator.pop(true),
|
||||||
|
child: const Text('Reintentar'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -436,7 +457,11 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
}
|
}
|
||||||
|
|
||||||
_scaffoldMessengerKey.currentState?.showSnackBar(
|
_scaffoldMessengerKey.currentState?.showSnackBar(
|
||||||
const SnackBar(content: Text('La biometría no está disponible en este dispositivo.')),
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'La biometría no está disponible en este dispositivo.',
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -449,7 +474,11 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
await _openVault(pendingKey);
|
await _openVault(pendingKey);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
_scaffoldMessengerKey.currentState?.showSnackBar(
|
_scaffoldMessengerKey.currentState?.showSnackBar(
|
||||||
SnackBar(content: Text('No se pudo finalizar la configuración del vault: $error')),
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'No se pudo finalizar la configuración del vault: $error',
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -646,17 +675,48 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_syncStatus == SyncStatus.syncing) {
|
if (_syncStatus == SyncStatus.preparing ||
|
||||||
|
_syncStatus == SyncStatus.encrypting ||
|
||||||
|
_syncStatus == SyncStatus.uploading ||
|
||||||
|
_syncStatus == SyncStatus.waitingResponse ||
|
||||||
|
_syncStatus == SyncStatus.decrypting ||
|
||||||
|
_syncStatus == SyncStatus.syncing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final int syncOperationId = ++_syncOperationId;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_syncStatus = SyncStatus.preparing;
|
||||||
|
_syncProgress = null;
|
||||||
|
_syncDetailMessage = 'Preparando sincronización...';
|
||||||
|
_syncErrorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
void updateSyncState(
|
||||||
|
SyncStatus status, {
|
||||||
|
double? progress,
|
||||||
|
String? message,
|
||||||
|
}) {
|
||||||
|
if (!mounted || syncOperationId != _syncOperationId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_syncStatus = SyncStatus.syncing;
|
_syncStatus = status;
|
||||||
|
_syncProgress = progress;
|
||||||
|
_syncDetailMessage = message;
|
||||||
|
if (status != SyncStatus.error) {
|
||||||
_syncErrorMessage = null;
|
_syncErrorMessage = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final Map<String, dynamic> result = await _repository!.performSync(forceFull: forceFull);
|
final Map<String, dynamic> result = await _repository!.performSync(
|
||||||
|
forceFull: forceFull,
|
||||||
|
onProgress: updateSyncState,
|
||||||
|
);
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
@@ -666,19 +726,25 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
setState(() {
|
setState(() {
|
||||||
_syncStatus = SyncStatus.error;
|
_syncStatus = SyncStatus.error;
|
||||||
_syncErrorMessage = result['message'] as String?;
|
_syncErrorMessage = result['message'] as String?;
|
||||||
|
_syncProgress = null;
|
||||||
|
_syncDetailMessage = null;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setState(() {
|
setState(() {
|
||||||
_syncStatus = SyncStatus.synced;
|
_syncStatus = SyncStatus.synced;
|
||||||
_syncErrorMessage = null;
|
_syncErrorMessage = null;
|
||||||
|
_syncProgress = null;
|
||||||
|
_syncDetailMessage = 'Sincronización completada';
|
||||||
_homeRefreshToken += 1;
|
_homeRefreshToken += 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset to idle after 3 seconds
|
// Reset to idle after 3 seconds
|
||||||
Future<void>.delayed(const Duration(seconds: 3), () {
|
Future<void>.delayed(const Duration(seconds: 3), () {
|
||||||
if (mounted) {
|
if (mounted && syncOperationId == _syncOperationId) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_syncStatus = SyncStatus.idle;
|
_syncStatus = SyncStatus.idle;
|
||||||
|
_syncProgress = null;
|
||||||
|
_syncDetailMessage = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -691,6 +757,8 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
setState(() {
|
setState(() {
|
||||||
_syncStatus = SyncStatus.error;
|
_syncStatus = SyncStatus.error;
|
||||||
_syncErrorMessage = e.toString();
|
_syncErrorMessage = e.toString();
|
||||||
|
_syncProgress = null;
|
||||||
|
_syncDetailMessage = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -745,6 +813,8 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
onRequestSync: _performSync,
|
onRequestSync: _performSync,
|
||||||
onVaultInvalid: _resetLocalVaultData,
|
onVaultInvalid: _resetLocalVaultData,
|
||||||
syncStatus: _syncStatus,
|
syncStatus: _syncStatus,
|
||||||
|
syncProgress: _syncProgress,
|
||||||
|
syncDetailMessage: _syncDetailMessage,
|
||||||
syncErrorMessage: _syncErrorMessage,
|
syncErrorMessage: _syncErrorMessage,
|
||||||
refreshToken: _homeRefreshToken,
|
refreshToken: _homeRefreshToken,
|
||||||
)
|
)
|
||||||
@@ -800,15 +870,20 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
duration: _screenTransitionDuration,
|
duration: _screenTransitionDuration,
|
||||||
switchInCurve: Curves.easeOutCubic,
|
switchInCurve: Curves.easeOutCubic,
|
||||||
switchOutCurve: Curves.easeInCubic,
|
switchOutCurve: Curves.easeInCubic,
|
||||||
transitionBuilder: (Widget child, Animation<double> animation) {
|
transitionBuilder:
|
||||||
final Animation<Offset> offsetAnimation = Tween<Offset>(
|
(Widget child, Animation<double> animation) {
|
||||||
|
final Animation<Offset> offsetAnimation =
|
||||||
|
Tween<Offset>(
|
||||||
begin: const Offset(0.08, 0.0),
|
begin: const Offset(0.08, 0.0),
|
||||||
end: Offset.zero,
|
end: Offset.zero,
|
||||||
).animate(animation);
|
).animate(animation);
|
||||||
|
|
||||||
return FadeTransition(
|
return FadeTransition(
|
||||||
opacity: animation,
|
opacity: animation,
|
||||||
child: SlideTransition(position: offsetAnimation, child: child),
|
child: SlideTransition(
|
||||||
|
position: offsetAnimation,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: activeScreen,
|
child: activeScreen,
|
||||||
@@ -821,7 +896,7 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -913,8 +988,10 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
return _buildAppShell(
|
return _buildAppShell(
|
||||||
home: BiometricChoiceScreen(
|
home: BiometricChoiceScreen(
|
||||||
isBusy: _isUnlocking,
|
isBusy: _isUnlocking,
|
||||||
onEnableBiometrics: () => _completeBiometricChoice(enableBiometrics: true),
|
onEnableBiometrics: () =>
|
||||||
onSkipBiometrics: () => _completeBiometricChoice(enableBiometrics: false),
|
_completeBiometricChoice(enableBiometrics: true),
|
||||||
|
onSkipBiometrics: () =>
|
||||||
|
_completeBiometricChoice(enableBiometrics: false),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
case _AppPhase.biometricGate:
|
case _AppPhase.biometricGate:
|
||||||
@@ -932,6 +1009,5 @@ class _NotesAppState extends State<NotesApp>
|
|||||||
|
|
||||||
return _buildMainShell(repository);
|
return _buildMainShell(repository);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import 'package:notas/models/note.dart';
|
|||||||
import 'package:notas/models/category.dart';
|
import 'package:notas/models/category.dart';
|
||||||
|
|
||||||
import 'package:notas/data/note_encryption.dart';
|
import 'package:notas/data/note_encryption.dart';
|
||||||
|
import 'package:notas/widgets/sync_status.dart';
|
||||||
|
|
||||||
class NoteRepository {
|
class NoteRepository {
|
||||||
NoteRepository({
|
NoteRepository({
|
||||||
@@ -89,8 +90,17 @@ class NoteRepository {
|
|||||||
|
|
||||||
/// Sincroniza notas con el servidor.
|
/// Sincroniza notas con el servidor.
|
||||||
/// Requiere que el usuario esté autenticado (token válido).
|
/// Requiere que el usuario esté autenticado (token válido).
|
||||||
Future<Map<String, dynamic>> performSync({bool forceFull = false}) async {
|
Future<Map<String, dynamic>> performSync({
|
||||||
|
bool forceFull = false,
|
||||||
|
void Function(SyncStatus status, {double? progress, String? message})?
|
||||||
|
onProgress,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
|
onProgress?.call(
|
||||||
|
SyncStatus.preparing,
|
||||||
|
message: 'Preparando sincronización...',
|
||||||
|
);
|
||||||
|
|
||||||
// Get last sync timestamp
|
// Get last sync timestamp
|
||||||
final DateTime? lastSync = await _authApi.getLastSyncAt();
|
final DateTime? lastSync = await _authApi.getLastSyncAt();
|
||||||
final DateTime? lastSyncForRequest = forceFull
|
final DateTime? lastSyncForRequest = forceFull
|
||||||
@@ -113,10 +123,21 @@ class NoteRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final int totalNotesToEncrypt = unsyncedNotes.length;
|
||||||
|
|
||||||
// Build sync request (note: we send encrypted data, but locally we have plaintext)
|
// Build sync request (note: we send encrypted data, but locally we have plaintext)
|
||||||
// Encrypt all notes before sending
|
// Encrypt all notes before sending
|
||||||
final List<SyncNotePayload> encryptedNotesPayload = [];
|
final List<SyncNotePayload> encryptedNotesPayload = [];
|
||||||
for (final dbNote in unsyncedNotes) {
|
if (totalNotesToEncrypt == 0) {
|
||||||
|
onProgress?.call(
|
||||||
|
SyncStatus.encrypting,
|
||||||
|
progress: 1.0,
|
||||||
|
message: 'No hay notas pendientes de encriptar.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var index = 0; index < unsyncedNotes.length; index += 1) {
|
||||||
|
final DbNote dbNote = unsyncedNotes[index];
|
||||||
final note = _fromDbNote(dbNote);
|
final note = _fromDbNote(dbNote);
|
||||||
final encryptedTitle = await NoteEncryption.encryptNote(
|
final encryptedTitle = await NoteEncryption.encryptNote(
|
||||||
note.title,
|
note.title,
|
||||||
@@ -133,6 +154,13 @@ class NoteRepository {
|
|||||||
encryptedBody: encryptedBody,
|
encryptedBody: encryptedBody,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
onProgress?.call(
|
||||||
|
SyncStatus.encrypting,
|
||||||
|
progress: (index + 1) / totalNotesToEncrypt,
|
||||||
|
message:
|
||||||
|
'Encriptando notas para subir: ${index + 1} de $totalNotesToEncrypt',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final List<SyncCategoryPayload> categoriesPayload = unsyncedCategories
|
final List<SyncCategoryPayload> categoriesPayload = unsyncedCategories
|
||||||
@@ -148,6 +176,10 @@ class NoteRepository {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Call sync API
|
// Call sync API
|
||||||
|
onProgress?.call(
|
||||||
|
SyncStatus.uploading,
|
||||||
|
message: 'Subiendo datos al servidor...',
|
||||||
|
);
|
||||||
final Map<String, dynamic> syncResult = await _authApi.sync(syncRequest);
|
final Map<String, dynamic> syncResult = await _authApi.sync(syncRequest);
|
||||||
|
|
||||||
if (syncResult['error'] == true) {
|
if (syncResult['error'] == true) {
|
||||||
@@ -157,7 +189,23 @@ class NoteRepository {
|
|||||||
final SyncResponse response = syncResult['data'] as SyncResponse;
|
final SyncResponse response = syncResult['data'] as SyncResponse;
|
||||||
|
|
||||||
// Apply server changes to local database
|
// Apply server changes to local database
|
||||||
await _applySyncResponse(response);
|
onProgress?.call(
|
||||||
|
SyncStatus.waitingResponse,
|
||||||
|
message: 'Esperando respuesta del servidor...',
|
||||||
|
);
|
||||||
|
|
||||||
|
await _applySyncResponse(
|
||||||
|
response,
|
||||||
|
onDecryptProgress: (int processed, int total) {
|
||||||
|
onProgress?.call(
|
||||||
|
SyncStatus.decrypting,
|
||||||
|
progress: total == 0 ? 1.0 : processed / total,
|
||||||
|
message: total == 0
|
||||||
|
? 'Desencriptando datos recibidos...'
|
||||||
|
: 'Desencriptando respuesta: $processed de $total',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Update lastSyncAt
|
// Update lastSyncAt
|
||||||
await _authApi.setLastSyncAt(response.serverTimestamp);
|
await _authApi.setLastSyncAt(response.serverTimestamp);
|
||||||
@@ -173,7 +221,10 @@ class NoteRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _applySyncResponse(SyncResponse response) async {
|
Future<void> _applySyncResponse(
|
||||||
|
SyncResponse response, {
|
||||||
|
void Function(int processed, int total)? onDecryptProgress,
|
||||||
|
}) async {
|
||||||
// Apply categories from server
|
// Apply categories from server
|
||||||
for (final SyncCategoryResponse catResponse
|
for (final SyncCategoryResponse catResponse
|
||||||
in response.changes.categories) {
|
in response.changes.categories) {
|
||||||
@@ -189,7 +240,9 @@ class NoteRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply notes from server
|
// Apply notes from server
|
||||||
for (final SyncNoteResponse noteResponse in response.changes.notes) {
|
final int totalNotesToDecrypt = response.changes.notes.length;
|
||||||
|
for (var index = 0; index < response.changes.notes.length; index += 1) {
|
||||||
|
final SyncNoteResponse noteResponse = response.changes.notes[index];
|
||||||
final existingNote = await (_database.select(
|
final existingNote = await (_database.select(
|
||||||
_database.notes,
|
_database.notes,
|
||||||
)..where((n) => n.uuid.equals(noteResponse.id))).getSingleOrNull();
|
)..where((n) => n.uuid.equals(noteResponse.id))).getSingleOrNull();
|
||||||
@@ -245,6 +298,8 @@ class NoteRepository {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onDecryptProgress?.call(index + 1, totalNotesToDecrypt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:notas/screens/note_editor_screen.dart';
|
|||||||
import 'package:notas/widgets/menu_drawer.dart';
|
import 'package:notas/widgets/menu_drawer.dart';
|
||||||
import 'package:notas/widgets/note_card.dart';
|
import 'package:notas/widgets/note_card.dart';
|
||||||
import 'package:notas/widgets/search_app_bar.dart';
|
import 'package:notas/widgets/search_app_bar.dart';
|
||||||
|
import 'package:notas/widgets/sync_status.dart';
|
||||||
import 'package:notas/widgets/sync_status_indicator.dart';
|
import 'package:notas/widgets/sync_status_indicator.dart';
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
@@ -20,6 +21,8 @@ class HomeScreen extends StatefulWidget {
|
|||||||
required this.onRequestSync,
|
required this.onRequestSync,
|
||||||
this.onVaultInvalid,
|
this.onVaultInvalid,
|
||||||
this.syncStatus = SyncStatus.idle,
|
this.syncStatus = SyncStatus.idle,
|
||||||
|
this.syncProgress,
|
||||||
|
this.syncDetailMessage,
|
||||||
this.syncErrorMessage,
|
this.syncErrorMessage,
|
||||||
this.refreshToken = 0,
|
this.refreshToken = 0,
|
||||||
});
|
});
|
||||||
@@ -29,6 +32,8 @@ class HomeScreen extends StatefulWidget {
|
|||||||
final Future<void> Function() onRequestSync;
|
final Future<void> Function() onRequestSync;
|
||||||
final Future<void> Function()? onVaultInvalid;
|
final Future<void> Function()? onVaultInvalid;
|
||||||
final SyncStatus syncStatus;
|
final SyncStatus syncStatus;
|
||||||
|
final double? syncProgress;
|
||||||
|
final String? syncDetailMessage;
|
||||||
final String? syncErrorMessage;
|
final String? syncErrorMessage;
|
||||||
final int refreshToken;
|
final int refreshToken;
|
||||||
|
|
||||||
@@ -280,7 +285,9 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
width: cellWidth,
|
width: cellWidth,
|
||||||
child: TweenAnimationBuilder<double>(
|
child: TweenAnimationBuilder<double>(
|
||||||
tween: Tween(begin: 0.97, end: 1.0),
|
tween: Tween(begin: 0.97, end: 1.0),
|
||||||
duration: const Duration(milliseconds: 180),
|
duration: const Duration(
|
||||||
|
milliseconds: 180,
|
||||||
|
),
|
||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
builder: (context, scale, child) {
|
builder: (context, scale, child) {
|
||||||
return Transform.scale(
|
return Transform.scale(
|
||||||
@@ -308,8 +315,15 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color.fromRGBO(24, 25, 26, 1),
|
color: const Color.fromRGBO(
|
||||||
borderRadius: BorderRadius.circular(12),
|
24,
|
||||||
|
25,
|
||||||
|
26,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
12,
|
||||||
|
),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: Colors.white24,
|
color: Colors.white24,
|
||||||
width: 1,
|
width: 1,
|
||||||
@@ -396,7 +410,9 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
width: cellWidth,
|
width: cellWidth,
|
||||||
child: TweenAnimationBuilder<double>(
|
child: TweenAnimationBuilder<double>(
|
||||||
tween: Tween(begin: 0.97, end: 1.0),
|
tween: Tween(begin: 0.97, end: 1.0),
|
||||||
duration: const Duration(milliseconds: 180),
|
duration: const Duration(
|
||||||
|
milliseconds: 180,
|
||||||
|
),
|
||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
builder: (context, scale, child) {
|
builder: (context, scale, child) {
|
||||||
return Transform.scale(
|
return Transform.scale(
|
||||||
@@ -424,8 +440,15 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color.fromRGBO(24, 25, 26, 1),
|
color: const Color.fromRGBO(
|
||||||
borderRadius: BorderRadius.circular(12),
|
24,
|
||||||
|
25,
|
||||||
|
26,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
12,
|
||||||
|
),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: Colors.white24,
|
color: Colors.white24,
|
||||||
width: 1,
|
width: 1,
|
||||||
@@ -525,6 +548,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
},
|
},
|
||||||
trailingWidget: SyncStatusIndicator(
|
trailingWidget: SyncStatusIndicator(
|
||||||
status: widget.syncStatus,
|
status: widget.syncStatus,
|
||||||
|
progress: widget.syncProgress,
|
||||||
|
detailMessage: widget.syncDetailMessage,
|
||||||
errorMessage: widget.syncErrorMessage,
|
errorMessage: widget.syncErrorMessage,
|
||||||
onTap: widget.onRequestSync,
|
onTap: widget.onRequestSync,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:notas/widgets/sync_status_indicator.dart';
|
import 'package:notas/widgets/sync_status.dart';
|
||||||
|
|
||||||
class AppTitleBar extends StatelessWidget {
|
class AppTitleBar extends StatelessWidget {
|
||||||
const AppTitleBar({
|
const AppTitleBar({
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
enum SyncStatus {
|
||||||
|
idle,
|
||||||
|
preparing,
|
||||||
|
encrypting,
|
||||||
|
uploading,
|
||||||
|
waitingResponse,
|
||||||
|
decrypting,
|
||||||
|
syncing,
|
||||||
|
synced,
|
||||||
|
error,
|
||||||
|
}
|
||||||
@@ -1,21 +1,19 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:notas/widgets/sync_status.dart';
|
||||||
enum SyncStatus {
|
|
||||||
idle,
|
|
||||||
syncing,
|
|
||||||
synced,
|
|
||||||
error,
|
|
||||||
}
|
|
||||||
|
|
||||||
class SyncStatusIndicator extends StatelessWidget {
|
class SyncStatusIndicator extends StatelessWidget {
|
||||||
const SyncStatusIndicator({
|
const SyncStatusIndicator({
|
||||||
required this.status,
|
required this.status,
|
||||||
|
this.progress,
|
||||||
|
this.detailMessage,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final SyncStatus status;
|
final SyncStatus status;
|
||||||
|
final double? progress;
|
||||||
|
final String? detailMessage;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
@@ -34,59 +32,154 @@ class SyncStatusIndicator extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _messageForStatus() {
|
||||||
|
switch (status) {
|
||||||
|
case SyncStatus.idle:
|
||||||
|
return 'Sincronización en espera';
|
||||||
|
case SyncStatus.preparing:
|
||||||
|
return detailMessage ?? 'Preparando sincronización...';
|
||||||
|
case SyncStatus.encrypting:
|
||||||
|
return detailMessage ?? 'Encriptando datos para subir...';
|
||||||
|
case SyncStatus.uploading:
|
||||||
|
return detailMessage ?? 'Subiendo datos al servidor...';
|
||||||
|
case SyncStatus.waitingResponse:
|
||||||
|
return detailMessage ?? 'Esperando respuesta del servidor...';
|
||||||
|
case SyncStatus.decrypting:
|
||||||
|
return detailMessage ?? 'Desencriptando datos recibidos...';
|
||||||
|
case SyncStatus.syncing:
|
||||||
|
return detailMessage ?? 'Sincronizando...';
|
||||||
|
case SyncStatus.synced:
|
||||||
|
return detailMessage ?? 'Sincronizado';
|
||||||
|
case SyncStatus.error:
|
||||||
|
return errorMessage ?? detailMessage ?? 'Error al sincronizar';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusBadge({
|
||||||
|
required IconData icon,
|
||||||
|
required Color color,
|
||||||
|
required bool determinate,
|
||||||
|
}) {
|
||||||
|
final double ringProgress = (progress ?? 0).clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
value: determinate ? ringProgress : null,
|
||||||
|
backgroundColor: color.withValues(alpha: 0.16),
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(color),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(icon, size: 10, color: color),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case SyncStatus.idle:
|
case SyncStatus.idle:
|
||||||
return Tooltip(
|
return Tooltip(
|
||||||
message: 'Sincronización en espera',
|
message: _messageForStatus(),
|
||||||
child: _buildIndicator(
|
child: _buildIndicator(
|
||||||
const Icon(
|
const Icon(Icons.cloud_outlined, size: 16, color: Colors.white38),
|
||||||
Icons.cloud_outlined,
|
),
|
||||||
size: 16,
|
);
|
||||||
color: Colors.white38,
|
|
||||||
|
case SyncStatus.preparing:
|
||||||
|
return Tooltip(
|
||||||
|
message: _messageForStatus(),
|
||||||
|
child: _buildIndicator(
|
||||||
|
_buildStatusBadge(
|
||||||
|
icon: Icons.sync,
|
||||||
|
color: const Color.fromARGB(255, 165, 165, 165),
|
||||||
|
determinate: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
case SyncStatus.encrypting:
|
||||||
|
return Tooltip(
|
||||||
|
message: _messageForStatus(),
|
||||||
|
child: _buildIndicator(
|
||||||
|
_buildStatusBadge(
|
||||||
|
icon: Icons.cloud_upload_outlined,
|
||||||
|
color: const Color.fromARGB(255, 109, 191, 255),
|
||||||
|
determinate: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
case SyncStatus.uploading:
|
||||||
|
return Tooltip(
|
||||||
|
message: _messageForStatus(),
|
||||||
|
child: _buildIndicator(
|
||||||
|
_buildStatusBadge(
|
||||||
|
icon: Icons.cloud_upload,
|
||||||
|
color: const Color.fromARGB(255, 98, 190, 255),
|
||||||
|
determinate: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
case SyncStatus.waitingResponse:
|
||||||
|
return Tooltip(
|
||||||
|
message: _messageForStatus(),
|
||||||
|
child: _buildIndicator(
|
||||||
|
_buildStatusBadge(
|
||||||
|
icon: Icons.cloud_sync_outlined,
|
||||||
|
color: const Color.fromARGB(255, 150, 150, 150),
|
||||||
|
determinate: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
case SyncStatus.decrypting:
|
||||||
|
return Tooltip(
|
||||||
|
message: _messageForStatus(),
|
||||||
|
child: _buildIndicator(
|
||||||
|
_buildStatusBadge(
|
||||||
|
icon: Icons.cloud_download_outlined,
|
||||||
|
color: const Color.fromARGB(255, 154, 194, 112),
|
||||||
|
determinate: false,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
case SyncStatus.syncing:
|
case SyncStatus.syncing:
|
||||||
return Tooltip(
|
return Tooltip(
|
||||||
message: 'Sincronizando...',
|
message: _messageForStatus(),
|
||||||
child: _buildIndicator(
|
child: _buildIndicator(
|
||||||
const SizedBox(
|
_buildStatusBadge(
|
||||||
width: 16,
|
icon: Icons.sync,
|
||||||
height: 16,
|
color: const Color.fromARGB(255, 150, 150, 150),
|
||||||
child: CircularProgressIndicator(
|
determinate: false,
|
||||||
strokeWidth: 2,
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
|
||||||
Color.fromARGB(255, 150, 150, 150),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
case SyncStatus.synced:
|
case SyncStatus.synced:
|
||||||
return Tooltip(
|
return Tooltip(
|
||||||
message: 'Sincronizado',
|
message: _messageForStatus(),
|
||||||
child: _buildIndicator(
|
child: _buildIndicator(
|
||||||
const Icon(
|
const Icon(Icons.check_circle, size: 16, color: Colors.green),
|
||||||
Icons.check_circle,
|
|
||||||
size: 16,
|
|
||||||
color: Colors.green,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
case SyncStatus.error:
|
case SyncStatus.error:
|
||||||
return Tooltip(
|
return Tooltip(
|
||||||
message: errorMessage ?? 'Error al sincronizar',
|
message: _messageForStatus(),
|
||||||
child: _buildIndicator(
|
child: _buildIndicator(
|
||||||
const Icon(
|
const Icon(Icons.error, size: 16, color: Colors.red),
|
||||||
Icons.error,
|
|
||||||
size: 16,
|
|
||||||
color: Colors.red,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user