feat: Refactor sync status handling and improve synchronization feedback in the app

This commit is contained in:
2026-05-19 09:23:38 +02:00
parent bb8caeef93
commit a5ab223e1f
6 changed files with 388 additions and 128 deletions
+152 -76
View File
@@ -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) {
@@ -226,7 +225,7 @@ class _NotesAppState extends State<NotesApp>
_repository = NoteRepository( _repository = NoteRepository(
database: database, database: database,
authApi: AuthApi.instance, authApi: AuthApi.instance,
masterKey: encryptionKey, masterKey: encryptionKey,
); );
_phase = _AppPhase.notes; _phase = _AppPhase.notes;
}); });
@@ -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; return;
} }
final int syncOperationId = ++_syncOperationId;
setState(() { setState(() {
_syncStatus = SyncStatus.syncing; _syncStatus = SyncStatus.preparing;
_syncProgress = null;
_syncDetailMessage = 'Preparando sincronización...';
_syncErrorMessage = null; _syncErrorMessage = null;
}); });
void updateSyncState(
SyncStatus status, {
double? progress,
String? message,
}) {
if (!mounted || syncOperationId != _syncOperationId) {
return;
}
setState(() {
_syncStatus = status;
_syncProgress = progress;
_syncDetailMessage = message;
if (status != SyncStatus.error) {
_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,
) )
@@ -780,48 +850,53 @@ class _NotesAppState extends State<NotesApp>
autofocus: true, autofocus: true,
child: Scaffold( child: Scaffold(
body: Container( body: Container(
decoration: const BoxDecoration( decoration: const BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
colors: [ colors: [
Color(0xFF191A1D), Color(0xFF191A1D),
Color(0xFF222326), Color(0xFF222326),
Color(0xFF101114), Color(0xFF101114),
], ],
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
),
),
child: SafeArea(
child: Column(
children: [
const AppTitleBar(),
Expanded(
child: AnimatedSwitcher(
duration: _screenTransitionDuration,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (Widget child, Animation<double> animation) {
final Animation<Offset> offsetAnimation = Tween<Offset>(
begin: const Offset(0.08, 0.0),
end: Offset.zero,
).animate(animation);
return FadeTransition(
opacity: animation,
child: SlideTransition(position: offsetAnimation, child: child),
);
},
child: activeScreen,
), ),
), ),
], child: SafeArea(
child: Column(
children: [
const AppTitleBar(),
Expanded(
child: AnimatedSwitcher(
duration: _screenTransitionDuration,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder:
(Widget child, Animation<double> animation) {
final Animation<Offset> offsetAnimation =
Tween<Offset>(
begin: const Offset(0.08, 0.0),
end: Offset.zero,
).animate(animation);
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: offsetAnimation,
child: child,
),
);
},
child: activeScreen,
),
),
],
),
),
),
), ),
), ),
), ),
), ),
),
),
),
); );
} }
@@ -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);
} }
} }
} }
+60 -5
View File
@@ -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);
} }
} }
+35 -10
View File
@@ -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;
@@ -272,7 +277,7 @@ class _HomeScreenState extends State<HomeScreen> {
}); });
}, },
feedback: MouseRegion( feedback: MouseRegion(
cursor: SystemMouseCursors.grabbing, cursor: SystemMouseCursors.grabbing,
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
elevation: 8, elevation: 8,
@@ -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(
@@ -302,14 +309,21 @@ class _HomeScreenState extends State<HomeScreen> {
), ),
), ),
childWhenDragging: MouseRegion( childWhenDragging: MouseRegion(
cursor: SystemMouseCursors.grabbing, cursor: SystemMouseCursors.grabbing,
child: Opacity( child: Opacity(
opacity: 0.3, opacity: 0.3,
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,
@@ -388,7 +402,7 @@ class _HomeScreenState extends State<HomeScreen> {
}); });
}, },
feedback: MouseRegion( feedback: MouseRegion(
cursor: SystemMouseCursors.grabbing, cursor: SystemMouseCursors.grabbing,
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
elevation: 8, elevation: 8,
@@ -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(
@@ -418,14 +434,21 @@ class _HomeScreenState extends State<HomeScreen> {
), ),
), ),
childWhenDragging: MouseRegion( childWhenDragging: MouseRegion(
cursor: SystemMouseCursors.grabbing, cursor: SystemMouseCursors.grabbing,
child: Opacity( child: Opacity(
opacity: 0.3, opacity: 0.3,
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 -1
View File
@@ -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({
+11
View File
@@ -0,0 +1,11 @@
enum SyncStatus {
idle,
preparing,
encrypting,
uploading,
waitingResponse,
decrypting,
syncing,
synced,
error,
}
+127 -34
View File
@@ -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,
),
), ),
); );
} }