Compare commits

..

58 Commits

Author SHA1 Message Date
Marcos b201da0552 refactor: Improve code formatting and replace print with debugPrint for error logging 2026-07-02 23:47:52 +02:00
Marcos 9b6d92c372 refactor: Enhance sync status handling and improve UI feedback in NotesApp 2026-07-02 20:59:22 +02:00
Marcos f662e59547 refactor: Enhance category handling in note editor and card components 2026-07-02 12:52:41 +02:00
Marcos 78dddd571a refactor: Remove category handling from note editor and simplify note card options 2026-07-02 11:20:28 +02:00
Marcos c2db704155 refactor: Improve code readability by formatting and simplifying widget structures 2026-07-02 10:52:33 +02:00
Marcos b00da9ae88 update 2026-07-01 18:47:15 +02:00
Marcos 972006c29f refactor: Update color handling to use ARGB format and improve theme consistency 2026-07-01 10:41:59 +02:00
Marcos 82515960f6 Reestructuracion de la app 2026-06-29 20:32:47 +02:00
Marcos 710be805ee feat: integrate Flutter Quill for rich text editing and update note handling
- Added Flutter Quill package for rich text editing capabilities.
- Refactored note body handling to support Quill's Document format.
- Updated note editor screen to use QuillEditor and QuillController.
- Enhanced note search functionality to convert note body to plain text.
- Modified note card display to show plain text from note body.
- Updated localization support for Quill in the app.
- Registered necessary plugins for URL launching and file selection on all platforms.
- Updated app icons and other assets for consistency across platforms.
- Updated pubspec.yaml and pubspec.lock to include new dependencies and versions.
2026-05-24 17:52:11 +02:00
Marcos d849c25ed6 refactor: Simplify AppPalette color definitions and gradients for improved readability 2026-05-23 18:28:43 +02:00
Marcos 7c1d4e5fd8 feat: Update application icons for various resolutions and platforms 2026-05-23 17:51:43 +02:00
Marcos 1dede9eb78 Refactor theme management: Replace AppColors with AppPalette
- Removed AppColors class and migrated all references to AppPalette.
- Updated VaultAccessScreen, MenuDrawer, NoteCard, SearchAppBar, and other widgets to use AppPalette for color management.
- Introduced AppPalette to handle light and dark themes with appropriate color schemes.
- Adjusted theme application in AppTheme to utilize AppPalette extensions.
- Updated tests to reflect changes in theme structure and color references.
2026-05-23 13:55:40 +02:00
Marcos 29881183ed feat: Update dialog styles and background colors for consistency across screens 2026-05-23 11:27:26 +02:00
Marcos f4bb5104e2 Refactor theme colors and styles across the application
- Introduced AppColors class to centralize color definitions for better maintainability and consistency.
- Updated various screens (Settings, Vault Access, Note Card, etc.) to use AppColors for styling instead of hardcoded colors.
- Enhanced UI elements with improved color contrast and accessibility.
- Replaced gradient backgrounds with defined color schemes for a cohesive look.
- Refactored button styles and text colors to align with the new theme structure.
2026-05-23 09:38:26 +02:00
Marcos 814f8f7c04 style: Format code for consistency and readability across database and note positioning files 2026-05-22 17:31:49 +02:00
Marcos 729e575a60 feat: Implement note positioning logic and tests for position conversion 2026-05-22 17:31:40 +02:00
Marcos cdfd4f9342 feat: Update application icons for various resolutions and platforms 2026-05-22 12:24:48 +02:00
Marcos a31cc12b7e feat: Update category server version handling in createCategory and CategoryDialog 2026-05-22 11:26:41 +02:00
Marcos 2069de55ae feat: Add long press functionality to menu items for category editing 2026-05-22 10:54:56 +02:00
Marcos e0f226d3bc feat: Refactor note decryption method and update category handling for improved clarity 2026-05-22 10:11:41 +02:00
Marcos 27e1199178 feat: Rename encryptedName to name in Categories table and update related logic 2026-05-22 09:27:20 +02:00
Marcos f595f33f4a feat: Implement window size save scheduling to enhance window management 2026-05-21 21:27:46 +02:00
Marcos d7495a461a feat: Consolidate note and category loading into a single method for improved efficiency 2026-05-21 19:37:21 +02:00
Marcos 8be7819528 feat: Introduce category selection functionality in NoteEditorScreen and related components 2026-05-21 19:31:29 +02:00
Marcos 7e210871dd feat: Implement color and icon selection tabs in CategoryDialog for improved user experience 2026-05-21 18:39:28 +02:00
Marcos a9d818dec4 feat: Add border color support to NoteCard and DraggableNote for enhanced visual distinction 2026-05-21 17:34:50 +02:00
Marcos 2f942c4e82 feat: Enhance MenuDrawer item styling with padding and constraints for better layout 2026-05-21 17:20:25 +02:00
Marcos 63f0079a5a feat: Convert _MenuItemTile to StatefulWidget for hover effect support 2026-05-21 17:16:54 +02:00
Marcos 48d09fe170 feat: Update deleted notes query to include notes with empty body in trash 2026-05-21 17:10:23 +02:00
Marcos 62d47904d9 feat: Optimize theme data handling and simplify widget structure in NotesApp 2026-05-21 17:02:05 +02:00
Marcos 063b300428 feat: Refactor draggable note implementation for improved readability and maintainability 2026-05-21 16:50:11 +02:00
Marcos 28f4ede4aa feat: Refactor note editor dialog and delete confirmation for improved readability and reusability 2026-05-21 16:26:31 +02:00
Marcos 49fc33edc0 feat: Update Kotlin configuration and dependencies in build files 2026-05-21 14:04:40 +02:00
Marcos 95c3e6fc38 feat: Update category deletion logic to reset related notes and track changes 2026-05-21 12:01:44 +02:00
Marcos 5412d31066 feat: Add window icon setting and ensure minimum window size on bootstrap 2026-05-21 08:40:52 +02:00
Marcos 0e450df50d feat: Update default API endpoint to production URL in ApiConfig and settings screen 2026-05-21 08:18:45 +02:00
Marcos c0372b3587 feat: Center window on startup for improved user experience 2026-05-21 08:15:06 +02:00
Marcos f1aca3c812 feat: Refactor NoteEditorScreen build method for improved readability and maintainability 2026-05-20 23:55:45 +02:00
Marcos bed34f4cb5 refactor: Remove AppTitleBar widget and its references from various screens 2026-05-20 20:16:57 +02:00
Marcos 2d76dd2a43 feat: Refactor category dialog to improve UI and functionality for creating and editing categories 2026-05-20 19:16:00 +02:00
Marcos b1ab4235bd feat: Add category deletion functionality and enhance category dialog for editing 2026-05-20 19:07:26 +02:00
Marcos 2ef9cf1dbb feat: Implement server data deletion functionality with confirmation dialog in SettingsScreen 2026-05-20 17:33:22 +02:00
Marcos 3ff4efb738 feat: Add color and icon properties to categories, enhance category management in UI 2026-05-20 17:10:44 +02:00
Marcos def755e1c5 refactor: Update Note and Category models to use 'id' instead of 'uuid', and adjust related database operations
- Changed 'uuid' to 'id' in Note and Category models for consistency.
- Updated database operations in NoteRepository to reflect the new 'id' field.
- Modified sync models to accommodate changes in Note and Category structures.
- Adjusted the handling of notes and categories during synchronization.
- Refactored the note editor and home screen to use the new 'id' field.
- Ensured that the 'isDirty' flag is properly set and utilized across models.
2026-05-20 11:05:30 +02:00
Marcos 34f45a912f feat: Enhance error handling in note loading and moving operations with detailed logging 2026-05-20 10:05:47 +02:00
Marcos d0a985b4ab feat: Update biometric icon color to match theme and enhance visibility 2026-05-19 23:05:17 +02:00
Marcos 6035e3bc18 feat: Implement menu open/close functionality in HomeScreen for improved user interaction 2026-05-19 20:24:03 +02:00
Marcos 4912316845 feat: Update biometric screens to remove amber color from fingerprint icon for consistency 2026-05-19 20:20:03 +02:00
Marcos 72afa7b5fe feat: Refactor NoteEditorScreen layout for improved text field handling and UI consistency 2026-05-19 18:48:03 +02:00
Marcos 59a5229e46 feat: Refactor NoteEditorScreen for improved mobile layout handling and UI consistency 2026-05-19 17:55:16 +02:00
Marcos c6994b9355 feat: Refactor NoteEditorScreen for improved layout handling and code readability 2026-05-19 17:28:26 +02:00
Marcos 48cd1b2403 feat: Enhance NoteEditorScreen with completion callback and improved mobile UI 2026-05-19 17:09:33 +02:00
Marcos f550476177 feat: Implement permanent deletion and restoration of notes with updated UI 2026-05-19 11:40:01 +02:00
Marcos 2a898111fa feat: Optimize note encryption and decryption processes with parallel execution 2026-05-19 10:09:20 +02:00
Marcos 9769087fd8 feat: Clear authentication tokens and shared preferences on app shutdown 2026-05-19 09:26:51 +02:00
Marcos a5ab223e1f feat: Refactor sync status handling and improve synchronization feedback in the app 2026-05-19 09:23:38 +02:00
Marcos bb8caeef93 refactor: Improve code formatting and readability in database and note repository 2026-05-19 09:11:52 +02:00
Marcos 6de318786b feat: Update window bootstrap logic and improve note editor UI layout 2026-05-18 23:09:11 +02:00
57 changed files with 5199 additions and 2204 deletions
+2
View File
@@ -43,3 +43,5 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
problems-report.html
+6 -5
View File
@@ -1,6 +1,5 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
@@ -15,10 +14,6 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.notas"
@@ -39,6 +34,12 @@ android {
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 951 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

+5 -1
View File
@@ -1,2 +1,6 @@
org.gradle.jvmargs=-Xmx2048M -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.useAndroidX=true
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 19 KiB

+513 -200
View File
@@ -3,6 +3,8 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_quill/flutter_quill.dart';
import 'package:notas/data/app_database.dart';
import 'package:notas/data/api_client.dart';
import 'package:notas/data/local_vault_service.dart';
@@ -14,26 +16,17 @@ import 'package:notas/screens/biometric_gate_screen.dart';
import 'package:notas/screens/home_screen.dart';
import 'package:notas/screens/settings_screen.dart';
import 'package:notas/screens/vault_access_screen.dart';
import 'package:notas/theme/app_palette.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:notas/widgets/sync_status.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:window_manager/window_manager.dart';
enum _AppSection {
home,
settings,
}
enum _AppSection { home, settings }
enum _AppPhase {
loading,
access,
biometricChoice,
biometricGate,
notes,
}
enum _AppPhase { loading, access, biometricChoice, biometricGate, notes }
class PerformSyncIntent extends Intent {
const PerformSyncIntent();
@@ -51,7 +44,9 @@ class _NotesAppState extends State<NotesApp>
static const Duration _screenTransitionDuration = Duration(milliseconds: 280);
static const Duration _biometricInactivityTimeout = Duration(minutes: 5);
static const Duration _syncInterval = Duration(minutes: 5);
static const Duration _windowSizeSaveDelay = Duration(milliseconds: 350);
static const String _themeSeedColorKey = 'theme_seed_color_v1';
static const String _themeModeKey = 'theme_mode_v1';
final LocalVaultService _vaultService = LocalVaultService.instance;
final GlobalKey<ScaffoldMessengerState> _scaffoldMessengerKey =
@@ -66,14 +61,150 @@ class _NotesAppState extends State<NotesApp>
bool _biometricGateEnabled = false;
int _biometricGateSession = 0;
Timer? _biometricLockTimer;
Timer? _windowSizeSaveTimer;
Timer? _syncTimer;
bool _isHandlingWindowClose = false;
_AppPhase _phase = _AppPhase.loading;
_AppSection _currentSection = _AppSection.home;
SyncStatus _syncStatus = SyncStatus.idle;
double? _syncProgress;
String? _syncDetailMessage;
String? _syncErrorMessage;
int _syncOperationId = 0;
int _homeRefreshToken = 0;
Color _themeSeedColor = Colors.amber;
ThemeMode _themeMode = ThemeMode.system;
// Cached ThemeData for light and dark variants.
ThemeData? _lightTheme;
ThemeData? _darkTheme;
bool _isSyncBannerVisible() {
switch (_syncStatus) {
case SyncStatus.preparing:
case SyncStatus.encrypting:
case SyncStatus.uploading:
case SyncStatus.waitingResponse:
case SyncStatus.decrypting:
case SyncStatus.syncing:
case SyncStatus.synced:
case SyncStatus.error:
return true;
case SyncStatus.idle:
return false;
}
}
Widget _buildSyncBanner(BuildContext context) {
if (!_isSyncBannerVisible()) {
return const SizedBox.shrink();
}
final AppPalette palette = _activePalette();
final String message =
_syncErrorMessage ?? _syncDetailMessage ?? 'Sincronizando...';
final double? progress = _syncProgress;
final IconData icon;
final Color accentColor;
switch (_syncStatus) {
case SyncStatus.preparing:
case SyncStatus.encrypting:
case SyncStatus.uploading:
case SyncStatus.waitingResponse:
case SyncStatus.decrypting:
case SyncStatus.syncing:
icon = Icons.cloud_sync_outlined;
accentColor = palette.textSecondary;
break;
case SyncStatus.synced:
icon = Icons.check_circle;
accentColor = palette.success;
break;
case SyncStatus.error:
icon = Icons.error;
accentColor = palette.destructiveAccent;
break;
case SyncStatus.idle:
icon = Icons.cloud_sync_outlined;
accentColor = palette.textSecondary;
break;
}
return Material(
color: palette.surfaceElevated,
elevation: 12,
child: SafeArea(
top: false,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: accentColor.withValues(alpha: 0.45)),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(icon, color: accentColor, size: 18),
const SizedBox(width: 10),
Expanded(
child: Text(
message,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: palette.textPrimary,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
],
),
if (_syncStatus == SyncStatus.preparing ||
_syncStatus == SyncStatus.encrypting ||
_syncStatus == SyncStatus.uploading ||
_syncStatus == SyncStatus.waitingResponse ||
_syncStatus == SyncStatus.decrypting ||
_syncStatus == SyncStatus.syncing) ...[
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
minHeight: 4,
value: progress,
backgroundColor: palette.borderMuted,
),
),
],
],
),
),
),
);
}
Brightness _effectiveBrightness() {
switch (_themeMode) {
case ThemeMode.dark:
return Brightness.dark;
case ThemeMode.light:
return Brightness.light;
case ThemeMode.system:
return WidgetsBinding.instance.platformDispatcher.platformBrightness;
}
}
AppPalette _activePalette() {
return AppPalette.fromBrightness(
_effectiveBrightness(),
seedColor: _themeSeedColor,
);
}
@override
void initState() {
@@ -84,6 +215,7 @@ class _NotesAppState extends State<NotesApp>
windowManager.setPreventClose(true);
}
_loadThemeSeedColor();
_loadThemeMode();
_bootstrapVault();
}
@@ -95,6 +227,7 @@ class _NotesAppState extends State<NotesApp>
windowManager.setPreventClose(false);
}
_biometricLockTimer?.cancel();
_windowSizeSaveTimer?.cancel();
_syncTimer?.cancel();
_database?.close();
super.dispose();
@@ -109,12 +242,13 @@ class _NotesAppState extends State<NotesApp>
setState(() {
_themeSeedColor = Color(storedColorValue);
_updateThemeData();
});
}
Future<void> _setThemeSeedColor(Color color) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setInt(_themeSeedColorKey, color.value);
await prefs.setInt(_themeSeedColorKey, color.toARGB32());
if (!mounted) {
return;
@@ -122,10 +256,54 @@ class _NotesAppState extends State<NotesApp>
setState(() {
_themeSeedColor = color;
_updateThemeData();
});
}
ThemeData get _theme => AppTheme.theme(seedColor: _themeSeedColor);
Future<void> _loadThemeMode() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
final int? stored = prefs.getInt(_themeModeKey);
if (!mounted) return;
setState(() {
if (stored == null) {
_themeMode = ThemeMode.system;
} else if (stored == 1) {
_themeMode = ThemeMode.light;
} else if (stored == 2) {
_themeMode = ThemeMode.dark;
} else {
_themeMode = ThemeMode.system;
}
_updateThemeData();
});
}
Future<void> _setThemeMode(ThemeMode mode) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
final int stored = mode == ThemeMode.system
? 0
: (mode == ThemeMode.light ? 1 : 2);
await prefs.setInt(_themeModeKey, stored);
if (!mounted) return;
setState(() {
_themeMode = mode;
});
}
void _updateThemeData() {
_lightTheme = AppTheme.theme(
seedColor: _themeSeedColor,
brightness: Brightness.light,
);
_darkTheme = AppTheme.theme(
seedColor: _themeSeedColor,
brightness: Brightness.dark,
);
// Updated light/dark themes regenerated
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
@@ -150,7 +328,8 @@ class _NotesAppState extends State<NotesApp>
Future<void> _bootstrapVault() async {
try {
final bool hasEncryptionKey = await _vaultService.hasEncryptionKey();
_biometricGateEnabled = hasEncryptionKey && await _vaultService.isBiometricGateEnabled();
_biometricGateEnabled =
hasEncryptionKey && await _vaultService.isBiometricGateEnabled();
if (!hasEncryptionKey) {
_pendingEncryptionKey = null;
@@ -163,10 +342,12 @@ class _NotesAppState extends State<NotesApp>
}
final bool accessCompleted = await _vaultService.isVaultAccessCompleted();
final bool biometricChoicePending = await _vaultService.isBiometricChoicePending();
final bool biometricChoicePending = await _vaultService
.isBiometricChoicePending();
if (!accessCompleted) {
_pendingEncryptionKey = await _vaultService.readStoredEncryptionKeyRaw();
_pendingEncryptionKey = await _vaultService
.readStoredEncryptionKeyRaw();
if (mounted) {
setState(() {
_phase = _AppPhase.access;
@@ -176,7 +357,8 @@ class _NotesAppState extends State<NotesApp>
}
if (biometricChoicePending) {
_pendingEncryptionKey = await _vaultService.readStoredEncryptionKeyRaw();
_pendingEncryptionKey = await _vaultService
.readStoredEncryptionKeyRaw();
if (mounted) {
setState(() {
_phase = _AppPhase.biometricChoice;
@@ -194,7 +376,8 @@ class _NotesAppState extends State<NotesApp>
return;
}
final String? encryptionKey = await _vaultService.readStoredEncryptionKeyRaw();
final String? encryptionKey = await _vaultService
.readStoredEncryptionKeyRaw();
if (encryptionKey != null) {
await _openVault(encryptionKey);
} else if (mounted) {
@@ -226,7 +409,7 @@ class _NotesAppState extends State<NotesApp>
_repository = NoteRepository(
database: database,
authApi: AuthApi.instance,
masterKey: encryptionKey,
masterKey: encryptionKey,
);
_phase = _AppPhase.notes;
});
@@ -244,7 +427,11 @@ class _NotesAppState extends State<NotesApp>
if (mounted) {
_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 +451,10 @@ class _NotesAppState extends State<NotesApp>
_showAccountPlaceholder(actionLabel);
}
final String? existingKey = await _vaultService.readStoredEncryptionKeyRaw();
final String encryptionKey = existingKey ?? await _vaultService.createEncryptionKey();
final String? existingKey = await _vaultService
.readStoredEncryptionKeyRaw();
final String encryptionKey =
existingKey ?? await _vaultService.createEncryptionKey();
_pendingEncryptionKey = encryptionKey;
await _vaultService.setVaultAccessCompleted(true);
@@ -308,8 +497,8 @@ class _NotesAppState extends State<NotesApp>
try {
if (isRegister) {
final String encryptionKey = _vaultService.generateEncryptionKey();
final String encryptedMasterKey =
await AuthApi.instance.encryptWithPassword(encryptionKey, password);
final String encryptedMasterKey = await AuthApi.instance
.encryptWithPassword(encryptionKey, password);
final Map<String, dynamic> response = await AuthApi.instance.register(
username,
@@ -341,8 +530,10 @@ class _NotesAppState extends State<NotesApp>
throw StateError('La API no devolvió la clave de encriptación.');
}
final String encryptionKey =
await AuthApi.instance.decryptWithPassword(encryptedMasterKey, password);
final String encryptionKey = await AuthApi.instance.decryptWithPassword(
encryptedMasterKey,
password,
);
await _vaultService.storeEncryptionKey(encryptionKey);
_pendingEncryptionKey = encryptionKey;
@@ -360,7 +551,9 @@ class _NotesAppState extends State<NotesApp>
}
} catch (error) {
_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 {
if (mounted) {
@@ -376,7 +569,9 @@ class _NotesAppState extends State<NotesApp>
return _beginInitialVaultFlow();
}
Future<void> _completeBiometricChoice({required bool enableBiometrics}) async {
Future<void> _completeBiometricChoice({
required bool enableBiometrics,
}) async {
if (_isUnlocking) {
return;
}
@@ -386,7 +581,9 @@ class _NotesAppState extends State<NotesApp>
});
try {
final String? pendingKey = _pendingEncryptionKey ?? await _vaultService.readStoredEncryptionKeyRaw();
final String? pendingKey =
_pendingEncryptionKey ??
await _vaultService.readStoredEncryptionKeyRaw();
if (pendingKey == null) {
throw StateError('No se encontró la llave local.');
@@ -397,24 +594,38 @@ class _NotesAppState extends State<NotesApp>
if (available) {
bool activated = await _vaultService.enableBiometricProtection();
while (!activated) {
// Ask the user to retry or skip
final BuildContext? dialogCtx = _navigatorKey.currentContext;
if (dialogCtx == null) {
break;
}
// Ask the user to retry or skip - pass currentContext directly in builder
final NavigatorState? navigator = _navigatorKey.currentState;
final NavigatorState navigator = Navigator.of(dialogCtx);
if (navigator == null) break;
if (!mounted) return;
final bool? retry = await showDialog<bool>(
context: dialogCtx,
builder: (BuildContext context) => AlertDialog(
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?'),
actions: [
TextButton(onPressed: () => navigator.pop(false), child: const Text('Entrar sin huella')),
FilledButton(onPressed: () => navigator.pop(true), child: const Text('Reintentar')),
],
),
context: context,
builder: (BuildContext dialogContext) {
final AppPalette palette = _activePalette();
return AlertDialog(
backgroundColor: palette.cardBackground,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: palette.border),
),
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?',
),
actions: [
TextButton(
onPressed: () => navigator.pop(false),
child: const Text('Entrar sin huella'),
),
FilledButton(
onPressed: () => navigator.pop(true),
child: const Text('Reintentar'),
),
],
);
},
);
if (retry != true) {
@@ -436,7 +647,11 @@ class _NotesAppState extends State<NotesApp>
}
_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;
}
@@ -449,7 +664,11 @@ class _NotesAppState extends State<NotesApp>
await _openVault(pendingKey);
} catch (error) {
_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 {
if (mounted) {
@@ -523,6 +742,10 @@ class _NotesAppState extends State<NotesApp>
await database?.close();
await _vaultService.clearEncryptionKey();
await AuthApi.instance.clearTokens();
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.clear();
final Directory supportDir = await getApplicationSupportDirectory();
final String dbPath = p.join(supportDir.path, 'notes.sqlite');
@@ -630,6 +853,22 @@ class _NotesAppState extends State<NotesApp>
await WindowStateStore.instance.saveWindowSize(currentSize);
}
void _scheduleWindowSizeSave() {
if (!isDesktop) {
return;
}
_windowSizeSaveTimer?.cancel();
_windowSizeSaveTimer = Timer(_windowSizeSaveDelay, () {
_windowSizeSaveTimer = null;
if (!mounted) {
return;
}
unawaited(_saveWindowSize());
});
}
void _startPeriodicSync() {
_syncTimer?.cancel();
_syncTimer = Timer.periodic(_syncInterval, (_) {
@@ -646,17 +885,48 @@ class _NotesAppState extends State<NotesApp>
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.syncing;
_syncStatus = SyncStatus.preparing;
_syncProgress = null;
_syncDetailMessage = 'Preparando sincronización...';
_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 {
final Map<String, dynamic> result = await _repository!.performSync(forceFull: forceFull);
final Map<String, dynamic> result = await _repository!.performSync(
forceFull: forceFull,
onProgress: updateSyncState,
);
if (!mounted) {
return;
@@ -666,77 +936,72 @@ class _NotesAppState extends State<NotesApp>
setState(() {
_syncStatus = SyncStatus.error;
_syncErrorMessage = result['message'] as String?;
_syncProgress = null;
_syncDetailMessage = null;
});
} else {
setState(() {
_syncStatus = SyncStatus.synced;
_syncErrorMessage = null;
_syncProgress = null;
_syncDetailMessage = 'Sincronización completada';
_homeRefreshToken += 1;
});
// Reset to idle after 3 seconds
Future<void>.delayed(const Duration(seconds: 3), () {
if (mounted) {
// Keep the completion state visible briefly so it can be read.
Future<void>.delayed(const Duration(seconds: 1), () {
if (mounted && syncOperationId == _syncOperationId) {
setState(() {
_syncStatus = SyncStatus.idle;
_syncProgress = null;
_syncDetailMessage = null;
});
}
});
}
} catch (e) {
} catch (e, st) {
if (!mounted) {
return;
}
setState(() {
_syncStatus = SyncStatus.error;
_syncErrorMessage = e.toString();
_syncErrorMessage = '$e\n\nStackTrace: $st';
_syncProgress = null;
_syncDetailMessage = null;
});
}
}
Widget _buildLoadingScreen() {
return MaterialApp(
navigatorKey: _navigatorKey,
title: 'Mis Notas',
debugShowCheckedModeBanner: false,
theme: _theme,
home: const Scaffold(
body: SafeArea(
child: Column(
children: [
AppTitleBar(),
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Preparando el vault local...'),
],
),
return const Scaffold(
body: SafeArea(
child: Column(
children: [
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Preparando el vault local...'),
],
),
),
],
),
),
],
),
),
);
}
Widget _buildAppShell({required Widget home}) {
return MaterialApp(
navigatorKey: _navigatorKey,
title: 'Mis Notas',
debugShowCheckedModeBanner: false,
scaffoldMessengerKey: _scaffoldMessengerKey,
theme: _theme,
home: home,
);
return home;
}
Widget _buildMainShell(NoteRepository repository) {
final AppPalette palette = _activePalette();
final Widget activeScreen = _currentSection == _AppSection.home
? HomeScreen(
key: const ValueKey<String>('home-screen'),
@@ -745,6 +1010,8 @@ class _NotesAppState extends State<NotesApp>
onRequestSync: _performSync,
onVaultInvalid: _resetLocalVaultData,
syncStatus: _syncStatus,
syncProgress: _syncProgress,
syncDetailMessage: _syncDetailMessage,
syncErrorMessage: _syncErrorMessage,
refreshToken: _homeRefreshToken,
)
@@ -755,84 +1022,95 @@ class _NotesAppState extends State<NotesApp>
onForceSync: () => _performSync(forceFull: true),
currentSeedColor: _themeSeedColor,
onThemeColorSelected: _setThemeSeedColor,
currentThemeMode: _themeMode,
onThemeModeSelected: _setThemeMode,
);
return MaterialApp(
navigatorKey: _navigatorKey,
title: 'Mis Notas',
debugShowCheckedModeBanner: false,
scaffoldMessengerKey: _scaffoldMessengerKey,
theme: _theme,
home: Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.f5): const PerformSyncIntent(),
},
child: Actions(
actions: <Type, Action<Intent>>{
PerformSyncIntent: CallbackAction<PerformSyncIntent>(
onInvoke: (PerformSyncIntent intent) {
_performSync();
return null;
},
),
},
child: Focus(
autofocus: true,
child: Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [
Color(0xFF191A1D),
Color(0xFF222326),
Color(0xFF101114),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
return Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.f5): const PerformSyncIntent(),
},
child: Actions(
actions: <Type, Action<Intent>>{
PerformSyncIntent: CallbackAction<PerformSyncIntent>(
onInvoke: (PerformSyncIntent intent) {
_performSync();
return null;
},
),
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);
},
child: Focus(
autofocus: true,
child: Scaffold(
body: Container(
decoration: BoxDecoration(gradient: palette.backdropGradient),
child: SafeArea(
child: Column(
children: [
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,
),
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: offsetAnimation,
child: child,
),
);
},
child: activeScreen,
),
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder:
(Widget child, Animation<double> animation) {
final Animation<Offset> slideAnimation =
Tween<Offset>(
begin: const Offset(0.0, 0.35),
end: Offset.zero,
).animate(animation);
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: slideAnimation,
child: child,
),
);
},
child: _buildSyncBanner(context),
),
],
),
],
),
),
),
),
),
),
),
),
);
}
@override
void onWindowResize() {
_saveWindowSize();
_scheduleWindowSizeSave();
}
@override
void onWindowResized() {
_saveWindowSize();
_scheduleWindowSizeSave();
}
@override
@@ -851,6 +1129,10 @@ class _NotesAppState extends State<NotesApp>
return;
}
_windowSizeSaveTimer?.cancel();
_windowSizeSaveTimer = null;
unawaited(_saveWindowSize());
if (!_needsBiometricLock) {
unawaited(_allowWindowClose());
return;
@@ -875,63 +1157,94 @@ class _NotesAppState extends State<NotesApp>
@override
Widget build(BuildContext context) {
Widget homeWidget;
if (_isBootstrapping) {
return _buildLoadingScreen();
}
homeWidget = _buildLoadingScreen();
} else {
final NoteRepository? repository = _repository;
final NoteRepository? repository = _repository;
if (repository != null) {
return _buildMainShell(repository);
}
switch (_phase) {
case _AppPhase.loading:
return _buildLoadingScreen();
case _AppPhase.access:
return _buildAppShell(
home: VaultAccessScreen(
isBusy: _isUnlocking,
onCreateAccountPressed: (String email, String password) async {
await _beginRemoteVaultFlow(
username: email,
password: password,
isRegister: true,
);
},
onSignInPressed: (String email, String password) async {
await _beginRemoteVaultFlow(
username: email,
password: password,
isRegister: false,
);
},
onContinueWithoutAccount: _enterWithoutAccount,
),
);
case _AppPhase.biometricChoice:
return _buildAppShell(
home: BiometricChoiceScreen(
isBusy: _isUnlocking,
onEnableBiometrics: () => _completeBiometricChoice(enableBiometrics: true),
onSkipBiometrics: () => _completeBiometricChoice(enableBiometrics: false),
),
);
case _AppPhase.biometricGate:
return _buildAppShell(
home: BiometricGateScreen(
key: ValueKey<int>(_biometricGateSession),
isBusy: _isUnlocking,
onUnlockRequested: _unlockBiometricGate,
),
);
case _AppPhase.notes:
if (repository == null) {
return _buildLoadingScreen();
if (repository != null) {
homeWidget = _buildMainShell(repository);
} else {
switch (_phase) {
case _AppPhase.loading:
homeWidget = _buildLoadingScreen();
break;
case _AppPhase.access:
homeWidget = _buildAppShell(
home: VaultAccessScreen(
isBusy: _isUnlocking,
onCreateAccountPressed: (String email, String password) async {
await _beginRemoteVaultFlow(
username: email,
password: password,
isRegister: true,
);
},
onSignInPressed: (String email, String password) async {
await _beginRemoteVaultFlow(
username: email,
password: password,
isRegister: false,
);
},
onContinueWithoutAccount: _enterWithoutAccount,
),
);
break;
case _AppPhase.biometricChoice:
homeWidget = _buildAppShell(
home: BiometricChoiceScreen(
isBusy: _isUnlocking,
onEnableBiometrics: () =>
_completeBiometricChoice(enableBiometrics: true),
onSkipBiometrics: () =>
_completeBiometricChoice(enableBiometrics: false),
),
);
break;
case _AppPhase.biometricGate:
homeWidget = _buildAppShell(
home: BiometricGateScreen(
key: ValueKey<int>(_biometricGateSession),
isBusy: _isUnlocking,
onUnlockRequested: _unlockBiometricGate,
),
);
break;
case _AppPhase.notes:
homeWidget = _buildLoadingScreen();
break;
}
return _buildMainShell(repository);
}
}
return MaterialApp(
navigatorKey: _navigatorKey,
title: 'Mis Notas',
debugShowCheckedModeBanner: false,
scaffoldMessengerKey: _scaffoldMessengerKey,
localizationsDelegates: const <LocalizationsDelegate<dynamic>>[
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
FlutterQuillLocalizations.delegate,
],
theme:
_lightTheme ??
AppTheme.theme(
seedColor: _themeSeedColor,
brightness: Brightness.light,
),
darkTheme:
_darkTheme ??
AppTheme.theme(
seedColor: _themeSeedColor,
brightness: Brightness.dark,
),
themeMode: _themeMode,
home: homeWidget,
);
}
}
}
+57 -6
View File
@@ -15,7 +15,7 @@ class 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 const String defaultEndpoint = 'https://notas-api.lpncnd.es/api';
static Future<String> getEndpoint() async {
final prefs = await SharedPreferences.getInstance();
@@ -217,6 +217,35 @@ class AuthApi {
}
}
Future<Map<String, dynamic>> deleteAllServerData({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/auth/delete-all-data');
final http.Response res = await http.post(
url,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
);
if (res.statusCode >= 200 && res.statusCode < 300) {
return {'error': false, 'status': res.statusCode, 'body': res.body};
}
try {
final dynamic decoded = jsonDecode(res.body);
return {'error': true, 'status': res.statusCode, 'body': decoded};
} catch (_) {
return {'error': true, 'status': res.statusCode, 'body': res.body};
}
}
List<int> _randomBytes(int length) {
final Random random = Random.secure();
return List<int>.generate(length, (_) => random.nextInt(256));
@@ -277,9 +306,23 @@ class AuthApi {
debugPrint('Response body: ${res.body}');
if (res.statusCode >= 200 && res.statusCode < 300) {
final Map<String, dynamic> json =
jsonDecode(res.body) as Map<String, dynamic>;
return {'error': false, 'data': SyncResponse.fromJson(json)};
try {
final Map<String, dynamic> json =
jsonDecode(res.body) as Map<String, dynamic>;
return {'error': false, 'data': SyncResponse.fromJson(json)};
} catch (e, st) {
debugPrint('SYNC PARSE ERROR -> $e');
debugPrint(st.toString());
debugPrint('SYNC PARSE RAW BODY -> ${res.body}');
return {
'error': true,
'message': 'Error parseando respuesta de sync: $e',
'exception': e.toString(),
'stackTrace': st.toString(),
'body': res.body,
'status': res.statusCode,
};
}
}
// If token expired (401), try to refresh
@@ -298,8 +341,16 @@ class AuthApi {
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};
} catch (e, st) {
debugPrint('SYNC HTTP ERROR PARSE FAILED -> $e');
debugPrint(st.toString());
return {
'error': true,
'status': res.statusCode,
'body': res.body,
'exception': e.toString(),
'stackTrace': st.toString(),
};
}
}
+224 -43
View File
@@ -5,39 +5,102 @@ import 'package:drift/native.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:notas/data/note_positioning.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))();
TextColumn get id => text()();
TextColumn get name => text().named('name')();
IntColumn get serverVersion =>
integer().named('server_version').withDefault(const Constant(0))();
BoolColumn get isDeleted =>
boolean().named('is_deleted').withDefault(const Constant(false))();
IntColumn get colorValue => integer().nullable().named('color_value')();
IntColumn get iconCodePoint =>
integer().nullable().named('icon_code_point')();
BoolColumn get isDirty =>
boolean().named('is_dirty').withDefault(const Constant(true))();
DateTimeColumn get updatedAt => dateTime().named('updated_at')();
@override
Set<Column> get primaryKey => {uuid};
Set<Column> get primaryKey => {id};
}
@DataClassName('DbNote')
class Notes extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get uuid => text().unique()();
TextColumn get id => text().named('id')();
TextColumn get title => text()();
TextColumn get body => text()();
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))();
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')();
BoolColumn get isDirty =>
boolean().named('is_dirty').withDefault(const Constant(true))();
@override
Set<Column> get primaryKey => {id};
}
@DriftDatabase(tables: [Notes, Categories])
class AppDatabase extends _$AppDatabase {
@override
int get schemaVersion => 1;
AppDatabase({required String encryptionKey}) : super(_openConnection(encryptionKey));
int get schemaVersion => 4;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (Migrator migrator) async {
await migrator.createAll();
},
onUpgrade: (Migrator migrator, int from, int to) async {
if (from < 2) {
await migrator.addColumn(notes, notes.isDirty);
await migrator.addColumn(categories, categories.isDirty);
await customStatement('UPDATE notes SET is_dirty = 0');
await customStatement('UPDATE categories SET is_dirty = 0');
}
if (from < 3) {
await migrator.addColumn(categories, categories.colorValue);
await migrator.addColumn(categories, categories.iconCodePoint);
await customStatement('UPDATE categories SET color_value = NULL');
await customStatement('UPDATE categories SET icon_code_point = NULL');
}
if (from < 4) {
final List<DbNote> activeNotes =
await (select(notes)
..where((n) => n.isDeleted.equals(false))
..orderBy([
(note) => OrderingTerm(expression: note.sortIndex),
]))
.get();
final List<int> rebalancedPositions = rebalanceNotePositions(
activeNotes.length,
);
for (var index = 0; index < activeNotes.length; index += 1) {
final DbNote row = activeNotes[index];
await (update(notes)..where((n) => n.id.equals(row.id))).write(
NotesCompanion(
sortIndex: Value(rebalancedPositions[index]),
updatedAt: Value(DateTime.now()),
isDirty: const Value(true),
),
);
}
}
},
);
AppDatabase({required String encryptionKey})
: super(_openConnection(encryptionKey));
// ========== Categories ==========
Future<List<DbCategory>> getAllCategories() {
@@ -48,23 +111,51 @@ class AppDatabase extends _$AppDatabase {
return into(categories).insertOnConflictUpdate(category);
}
Future<void> deleteCategory(String uuid) {
return (update(categories)..where((c) => c.uuid.equals(uuid)))
.write(CategoriesCompanion(isDeleted: Value(true)));
Future<void> deleteCategory(String id) {
return (update(categories)..where((c) => c.id.equals(id))).write(
CategoriesCompanion(
isDeleted: const Value(true),
updatedAt: Value(DateTime.now()),
isDirty: const Value(true),
),
);
}
// ========== Notes ==========
Future<List<DbNote>> getAllNotes() {
return (select(notes)
..orderBy([(note) => OrderingTerm(expression: note.sortIndex)])
..orderBy([
(note) => OrderingTerm(
expression: note.sortIndex,
mode: OrderingMode.desc,
),
])
..where((n) => n.isDeleted.equals(false)))
.get();
}
Future<int> insertNoteAtTop(NotesCompanion note) {
return transaction(() async {
await customStatement('UPDATE notes SET sort_index = sort_index + 1 WHERE is_deleted = 0');
return into(notes).insert(note.copyWith(sortIndex: const Value<int>(0)));
final DbNote? topNote =
await (select(notes)
..where((n) => n.isDeleted.equals(false))
..orderBy([
(note) => OrderingTerm(
expression: note.sortIndex,
mode: OrderingMode.desc,
),
])
..limit(1))
.getSingleOrNull();
final int nextSortIndex = topNote == null
? 0
: topNote.sortIndex + notePositionStep;
await into(
notes,
).insert(note.copyWith(sortIndex: Value<int>(nextSortIndex)));
return nextSortIndex;
});
}
@@ -82,58 +173,148 @@ class AppDatabase extends _$AppDatabase {
return update(notes).replace(note);
}
Future<void> deleteNote(int id, int removedIndex) async {
await (update(notes)..where((n) => n.id.equals(id))).write(NotesCompanion(isDeleted: Value(true)));
await customStatement(
'UPDATE notes SET sort_index = sort_index - 1 WHERE sort_index > ? AND is_deleted = 0',
[removedIndex],
Future<void> deleteNote(String id, int removedIndex) async {
await (update(notes)..where((n) => n.id.equals(id))).write(
NotesCompanion(
isDeleted: const Value(true),
updatedAt: Value(DateTime.now()),
isDirty: const Value(true),
),
);
}
Future<void> deleteNoteAndShift({
required int id,
required String id,
required int removedIndex,
}) {
return deleteNote(id, removedIndex);
}
Future<void> permanentlyDeleteNote(String id) async {
await (update(notes)..where((n) => n.id.equals(id))).write(
NotesCompanion(
title: const Value(''),
body: const Value(''),
categoryId: const Value(null),
isDeleted: const Value(true),
updatedAt: Value(DateTime.now()),
isDirty: const Value(true),
),
);
}
Future<void> moveNote({
required int id,
required String id,
required int oldIndex,
required int newIndex,
}) {
if (oldIndex == newIndex) {
return Future<void>.value();
}
return transaction(() async {
if (oldIndex < newIndex) {
await customStatement(
'UPDATE notes SET sort_index = sort_index - 1 WHERE sort_index > ? AND sort_index <= ? AND is_deleted = 0',
[oldIndex, newIndex],
);
final List<DbNote> orderedNotes =
await (select(notes)
..where((n) => n.isDeleted.equals(false))
..orderBy([
(note) => OrderingTerm(
expression: note.sortIndex,
mode: OrderingMode.desc,
),
]))
.get();
final int currentIndex = orderedNotes.indexWhere(
(DbNote row) => row.id == id,
);
if (currentIndex == -1) {
return;
}
final int safeNewIndex = newIndex.clamp(0, orderedNotes.length - 1);
if (currentIndex == safeNewIndex) {
return;
}
final DbNote movedNote = orderedNotes.removeAt(currentIndex);
orderedNotes.insert(safeNewIndex, movedNote);
final int? newStoredPosition;
if (safeNewIndex == 0) {
newStoredPosition = orderedNotes[1].sortIndex + notePositionStep;
} else if (safeNewIndex == orderedNotes.length - 1) {
newStoredPosition =
orderedNotes[orderedNotes.length - 2].sortIndex - notePositionStep;
} else {
await customStatement(
'UPDATE notes SET sort_index = sort_index + 1 WHERE sort_index >= ? AND sort_index < ? AND is_deleted = 0',
[newIndex, oldIndex],
newStoredPosition = midpointNotePosition(
higherPosition: orderedNotes[safeNewIndex - 1].sortIndex,
lowerPosition: orderedNotes[safeNewIndex + 1].sortIndex,
);
}
await customStatement(
'UPDATE notes SET sort_index = ? WHERE id = ?',
[newIndex, id],
if (newStoredPosition == null) {
final List<int> rebalancedPositions = rebalanceNotePositions(
orderedNotes.length,
);
for (var index = 0; index < orderedNotes.length; index += 1) {
final DbNote row = orderedNotes[index];
await (update(notes)..where((n) => n.id.equals(row.id))).write(
NotesCompanion(
sortIndex: Value<int>(rebalancedPositions[index]),
updatedAt: Value<DateTime>(DateTime.now()),
isDirty: const Value(true),
),
);
}
return;
}
await (update(notes)..where((n) => n.id.equals(id))).write(
NotesCompanion(
sortIndex: Value<int>(newStoredPosition),
updatedAt: Value<DateTime>(DateTime.now()),
isDirty: const Value(true),
),
);
});
}
Future<List<DbNote>> getNotesChangedSince(DateTime since) {
return (select(
notes,
)..where((n) => n.updatedAt.isBiggerThanValue(since))).get();
}
Future<List<DbNote>> getDeletedNotes() {
// A note is considered deleted (in the trash) when `is_deleted` is true
// and at least one of `title` or `body` is not empty. Previously the
// query required both title AND body to be non-empty which excluded
// notes that had an empty body (common) from appearing in the trash.
return (select(notes)
..where(
(n) =>
n.isDeleted.equals(true) &
(n.title.isNotValue('') | n.body.isNotValue('')),
)
..orderBy([
(note) => OrderingTerm(
expression: note.sortIndex,
mode: OrderingMode.desc,
),
]))
.get();
}
Future<List<DbCategory>> getCategoriesChangedSince(DateTime since) {
return (select(
categories,
)..where((c) => c.updatedAt.isBiggerThanValue(since))).get();
}
// ========== Sync helpers ==========
Future<List<DbNote>> getUnsyncedNotes() {
return (select(notes)..where((n) => n.isDeleted.equals(true) | n.serverVersion.equals(0))).get();
return (select(notes)..where((n) => n.isDirty.equals(true))).get();
}
Future<List<DbCategory>> getUnsyncedCategories() {
return (select(categories)..where((c) => c.isDeleted.equals(true) | c.serverVersion.equals(0))).get();
return (select(categories)..where((c) => c.isDirty.equals(true))).get();
}
}
@@ -154,4 +335,4 @@ LazyDatabase _openConnection(String encryptionKey) {
},
);
});
}
}
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
import 'dart:convert';
import 'package:flutter_quill/flutter_quill.dart';
Document noteBodyToDocument(String storedBody) {
final String trimmedBody = storedBody.trimLeft();
if (trimmedBody.startsWith('[')) {
try {
final dynamic decoded = jsonDecode(storedBody);
if (decoded is List) {
return Document.fromJson(decoded);
}
} catch (_) {
// Fall back to plain text for legacy notes or malformed JSON.
}
}
if (storedBody.isEmpty) {
return Document();
}
final String plainText = storedBody.endsWith('\n')
? storedBody
: '$storedBody\n';
return Document.fromJson(<dynamic>[
<String, String>{'insert': plainText},
]);
}
String noteBodyToPlainText(String storedBody) {
return noteBodyToDocument(storedBody).toPlainText();
}
String noteDocumentToStorageJson(Document document) {
return jsonEncode(document.toDelta().toJson());
}
+1 -1
View File
@@ -44,7 +44,7 @@ class NoteEncryption {
}
/// Desencripta el contenido de una nota usando el master key
static Future<String> decryptNote(
static Future<String> decrypt(
String encodedBox,
String masterKey,
) async {
+46
View File
@@ -0,0 +1,46 @@
const int notePositionScale = 10;
const int notePositionStep = 1000 * notePositionScale;
const int notePositionRebalanceThreshold = 1;
int toStoredNotePosition(double position) {
return (position * notePositionScale).round();
}
double fromStoredNotePosition(int storedPosition) {
return storedPosition / notePositionScale;
}
int nextTopNotePosition(Iterable<int> storedPositions) {
int? highestPosition;
for (final int position in storedPositions) {
if (highestPosition == null || position > highestPosition) {
highestPosition = position;
}
}
if (highestPosition == null) {
return 0;
}
return highestPosition + notePositionStep;
}
int? midpointNotePosition({
required int higherPosition,
required int lowerPosition,
}) {
final int gap = higherPosition - lowerPosition;
if (gap <= notePositionRebalanceThreshold) {
return null;
}
return lowerPosition + (gap ~/ 2);
}
List<int> rebalanceNotePositions(int itemCount) {
return List<int>.generate(
itemCount,
(int index) => (itemCount - 1 - index) * notePositionStep,
);
}
+560 -106
View File
@@ -1,19 +1,28 @@
import 'dart:async';
import 'dart:isolate';
import 'dart:math' as math;
import 'dart:io' show Platform;
import 'package:drift/drift.dart';
import 'package:notas/data/app_database.dart';
import 'package:notas/data/api_client.dart';
import 'package:notas/data/note_positioning.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';
import 'package:notas/widgets/sync_status.dart';
import 'package:flutter/foundation.dart' show debugPrint;
class NoteRepository {
NoteRepository({
required AppDatabase database,
required AuthApi authApi,
required String masterKey,
}) : _database = database,
_authApi = authApi,
_masterKey = masterKey;
required String masterKey,
}) : _database = database,
_authApi = authApi,
_masterKey = masterKey;
final AppDatabase _database;
final AuthApi _authApi;
@@ -23,10 +32,73 @@ class NoteRepository {
return _loadNotesFromDatabase();
}
Future<List<Note>> loadDeletedNotes() async {
return _loadDeletedNotesFromDatabase();
}
Future<List<Category>> loadCategories() async {
final List<DbCategory> dbCategories = await _database.getAllCategories();
final List<Category> categories = [];
for (final DbCategory row in dbCategories) {
categories.add(
Category(
id: row.id,
name: row.name,
serverVersion: row.serverVersion,
isDeleted: row.isDeleted,
updatedAt: row.updatedAt,
isDirty: row.isDirty,
colorValue: row.colorValue,
iconCodePoint: row.iconCodePoint,
),
);
}
return categories;
}
Future<DateTime?> getLastSyncAt() async {
return _authApi.getLastSyncAt();
}
Future<void> createCategory(Category category) async {
debugPrint('createCategory called with: ${category.name}');
final DbCategory? existingCategory = await (_database.select(
_database.categories,
)..where((c) => c.id.equals(category.id))).getSingleOrNull();
final int effectiveServerVersion = math.max(
category.serverVersion,
existingCategory?.serverVersion ?? category.serverVersion,
);
await _database.upsertCategory(
CategoriesCompanion.insert(
id: category.id,
name: category.name,
updatedAt: category.updatedAt,
serverVersion: Value(effectiveServerVersion),
isDeleted: const Value(false),
isDirty: const Value(true),
colorValue: Value<int?>(category.colorValue),
iconCodePoint: Value<int?>(category.iconCodePoint),
),
);
debugPrint('Category inserted to database');
}
Future<void> deleteCategory(String id) async {
await _database.deleteCategory(id);
await _database.customStatement(
'UPDATE notes SET category_id = NULL, is_dirty = 1 WHERE category_id = ?',
[id],
);
}
Future<Note> createNote(Note note) async {
final int id = await _database.insertNoteAtTop(
final int storedPosition = await _database.insertNoteAtTop(
NotesCompanion.insert(
uuid: note.uuid,
id: note.id,
title: note.title,
body: note.body,
createdAt: note.createdAt,
@@ -34,49 +106,80 @@ class NoteRepository {
sortIndex: 0,
serverVersion: const Value(0),
isDeleted: const Value(false),
categoryId: const Value(null),
categoryId: Value(note.categoryId),
isDirty: const Value(true),
),
);
return note.copyWith(id: id, index: 0);
return note.copyWith(
position: fromStoredNotePosition(storedPosition),
isDirty: true,
);
}
Future<Note> updateNote(Note note) async {
final int noteId = note.id ?? (throw ArgumentError('Note id is required to update a note.'));
final DbNote? existingNote = await (_database.select(
_database.notes,
)..where((n) => n.id.equals(note.id))).getSingleOrNull();
final DbNote row =
existingNote ??
(throw ArgumentError('Note not found for id ${note.id}.'));
await _database.updateNoteRow(
DbNote(
id: noteId,
uuid: note.uuid,
id: row.id,
title: note.title,
body: note.body,
createdAt: note.createdAt,
createdAt: row.createdAt,
updatedAt: note.updatedAt,
sortIndex: note.index,
sortIndex: row.sortIndex,
serverVersion: note.serverVersion,
isDeleted: note.isDeleted,
isDeleted: false,
categoryId: note.categoryId,
isDirty: true,
),
);
return note;
}
Future<void> deleteNote(Note note) async {
final int noteId = note.id ?? (throw ArgumentError('Note id is required to delete a note.'));
await _database.deleteNoteAndShift(
id: noteId,
removedIndex: note.index,
return note.copyWith(
isDeleted: false,
isPermanentlyDeleted: false,
isDirty: true,
position: fromStoredNotePosition(row.sortIndex),
);
}
Future<void> deleteNote(Note note) async {
final DbNote? existingNote = await (_database.select(
_database.notes,
)..where((n) => n.id.equals(note.id))).getSingleOrNull();
final DbNote row =
existingNote ??
(throw ArgumentError('Note not found for id ${note.id}.'));
if (row.isDeleted || note.isDeleted || note.isPermanentlyDeleted) {
await _database.permanentlyDeleteNote(row.id);
} else {
await _database.deleteNoteAndShift(
id: row.id,
removedIndex: row.sortIndex,
);
}
}
Future<void> moveNote(Note note, int newIndex) async {
final int noteId = note.id ?? (throw ArgumentError('Note id is required to reorder a note.'));
final DbNote? existingNote = await (_database.select(
_database.notes,
)..where((n) => n.id.equals(note.id))).getSingleOrNull();
final DbNote row =
existingNote ??
(throw ArgumentError('Note not found for id ${note.id}.'));
await _database.moveNote(
id: noteId,
oldIndex: note.index,
id: row.id,
oldIndex: row.sortIndex,
newIndex: newIndex,
);
}
@@ -85,37 +188,60 @@ class NoteRepository {
/// Sincroniza notas con el servidor.
/// 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 {
onProgress?.call(
SyncStatus.preparing,
message: 'Preparando sincronización...',
);
// Get last sync timestamp
final DateTime? lastSync = await _authApi.getLastSyncAt();
final DateTime? lastSyncForRequest = forceFull ? DateTime.utc(1970, 1, 1) : lastSync;
// Collect pending changes
final List<DbNote> unsyncedNotes = await _database.getUnsyncedNotes();
final List<DbCategory> unsyncedCategories = await _database.getUnsyncedCategories();
final DateTime? lastSyncForRequest = forceFull
? DateTime.utc(1970, 1, 1)
: lastSync;
// Build sync request (note: we send encrypted data, but locally we have plaintext)
// Encrypt all notes before sending
final List<SyncNotePayload> encryptedNotesPayload = [];
for (final dbNote in unsyncedNotes) {
final note = _fromDbNote(dbNote);
final encryptedTitle = await NoteEncryption.encryptNote(note.title, _masterKey);
final encryptedBody = await NoteEncryption.encryptNote(note.body, _masterKey);
encryptedNotesPayload.add(
SyncNotePayload.fromNote(
note,
encryptedTitle: encryptedTitle,
encryptedBody: encryptedBody,
),
// Collect pending local changes. Dirty flags are the source of truth for
// outbound sync; `lastSyncAt` is only used for asking the server what it
// changed since our previous successful sync.
final List<DbNote> unsyncedNotes = await _database.getUnsyncedNotes();
final List<DbCategory> unsyncedCategories = await _database
.getUnsyncedCategories();
final int totalNotesToEncrypt = unsyncedNotes.length;
// Build sync request: local data is plaintext, encryption happens only
// for the outbound payload.
if (totalNotesToEncrypt == 0) {
onProgress?.call(
SyncStatus.encrypting,
progress: 1.0,
message: 'No hay notas pendientes de encriptar.',
);
}
final List<SyncCategoryPayload> categoriesPayload = unsyncedCategories
.map((cat) => SyncCategoryPayload.fromCategory(
_fromDbCategory(cat),
))
.toList();
final List<SyncNotePayload>
encryptedNotesPayload = await _encryptNotesInParallel(
unsyncedNotes,
masterKey: _masterKey,
onProgress: (int encryptedCount) {
onProgress?.call(
SyncStatus.encrypting,
progress: totalNotesToEncrypt == 0
? 1.0
: encryptedCount / totalNotesToEncrypt,
message:
'Encriptando notas para subir: $encryptedCount de $totalNotesToEncrypt',
);
},
);
final List<SyncCategoryPayload> categoriesPayload =
await _encryptCategories(unsyncedCategories, masterKey: _masterKey);
final SyncRequest syncRequest = SyncRequest(
lastSyncAt: lastSyncForRequest,
@@ -126,17 +252,53 @@ class NoteRepository {
);
// Call sync API
final Map<String, dynamic> syncResult =
await _authApi.sync(syncRequest);
onProgress?.call(
SyncStatus.uploading,
message: 'Subiendo datos al servidor...',
);
final Map<String, dynamic> syncResult = await _authApi.sync(syncRequest);
if (syncResult['error'] == true) {
return {'error': true, 'message': syncResult['body']};
final List<String> details = [];
final Object? message = syncResult['message'];
final Object? exception = syncResult['exception'];
final Object? stackTrace = syncResult['stackTrace'];
final Object? body = syncResult['body'];
if (message != null) details.add(message.toString());
if (exception != null && exception.toString() != details.firstOrNull) {
details.add('Exception: ${exception.toString()}');
}
if (body != null) {
details.add('Body: ${body.toString()}');
}
if (stackTrace != null) {
details.add('StackTrace: ${stackTrace.toString()}');
}
return {'error': true, 'message': details.join('\n\n')};
}
final SyncResponse response = syncResult['data'] as SyncResponse;
// 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
await _authApi.setLastSyncAt(response.serverTimestamp);
@@ -147,77 +309,94 @@ class NoteRepository {
'notesCount': response.changes.notes.length,
'categoriesCount': response.changes.categories.length,
};
} catch (e) {
return {'error': true, 'message': e.toString()};
} catch (e, st) {
return {'error': true, 'message': '$e\n\nStackTrace: $st'};
}
}
Future<void> _applySyncResponse(SyncResponse response) async {
Future<void> _applySyncResponse(
SyncResponse response, {
void Function(int processed, int total)? onDecryptProgress,
}) async {
// Apply categories from server
for (final SyncCategoryResponse catResponse in response.changes.categories) {
for (final SyncCategoryResponse catResponse
in response.changes.categories) {
final Category category = await catResponse.toCategory(
masterKey: _masterKey,
);
await _database.upsertCategory(
CategoriesCompanion(
uuid: Value(catResponse.id),
encryptedName: Value(catResponse.encryptedName),
serverVersion: Value(catResponse.serverVersion),
isDeleted: Value(catResponse.isDeleted),
updatedAt: Value(catResponse.updatedAt),
id: Value(category.id),
name: Value(category.name),
serverVersion: Value(category.serverVersion),
isDeleted: Value(category.isDeleted),
colorValue: Value<int?>(category.colorValue),
iconCodePoint: Value<int?>(category.iconCodePoint),
updatedAt: Value(category.updatedAt),
isDirty: const Value(false),
),
);
}
// 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();
final int totalNotesToDecrypt = response.changes.notes.length;
final List<Map<String, Object?>> decryptedNotes =
await _decryptResponseNotesInParallel(
response.changes.notes,
masterKey: _masterKey,
onProgress: onDecryptProgress == null
? null
: (processed) =>
onDecryptProgress(processed, totalNotesToDecrypt),
);
// 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');
}
for (var index = 0; index < decryptedNotes.length; index += 1) {
final Map<String, Object?> decryptedNote = decryptedNotes[index];
final SyncNoteResponse noteResponse = response.changes.notes[index];
final String noteId = (decryptedNote['id'] as String?) ?? noteResponse.id;
final existingNote = await (_database.select(
_database.notes,
)..where((n) => n.id.equals(noteId))).getSingleOrNull();
final String decryptedTitle = (decryptedNote['title'] as String?) ?? '';
final String decryptedBody = (decryptedNote['body'] as String?) ?? '';
final bool isPermanentlyDeleted = noteResponse.isPermanentlyDeleted;
if (existingNote != null) {
// Update existing note
await _database.updateNoteRow(
DbNote(
id: existingNote.id,
uuid: noteResponse.id,
title: decryptedTitle,
body: decryptedBody,
title: isPermanentlyDeleted ? '' : decryptedTitle,
body: isPermanentlyDeleted ? '' : decryptedBody,
createdAt: existingNote.createdAt,
updatedAt: noteResponse.updatedAt,
sortIndex: noteResponse.position,
sortIndex: toStoredNotePosition(noteResponse.position),
serverVersion: noteResponse.serverVersion,
isDeleted: noteResponse.isDeleted,
categoryId: noteResponse.categoryId,
categoryId: isPermanentlyDeleted ? null : noteResponse.categoryId,
isDirty: false,
),
);
} else {
// Insert new note
await _database.into(_database.notes).insert(
await _database
.into(_database.notes)
.insert(
NotesCompanion(
uuid: Value(noteResponse.id),
title: Value(decryptedTitle),
body: Value(decryptedBody),
id: Value(noteResponse.id),
title: Value(isPermanentlyDeleted ? '' : decryptedTitle),
body: Value(isPermanentlyDeleted ? '' : decryptedBody),
createdAt: Value(noteResponse.updatedAt),
updatedAt: Value(noteResponse.updatedAt),
sortIndex: Value(noteResponse.position),
sortIndex: Value(toStoredNotePosition(noteResponse.position)),
serverVersion: Value(noteResponse.serverVersion),
isDeleted: Value(noteResponse.isDeleted),
categoryId: Value(noteResponse.categoryId),
categoryId: Value(
isPermanentlyDeleted ? null : noteResponse.categoryId,
),
isDirty: const Value(false),
),
);
}
@@ -229,28 +408,303 @@ class NoteRepository {
return rows.map(_fromDbNote).toList();
}
Future<List<Note>> _loadDeletedNotesFromDatabase() async {
final List<DbNote> rows = await _database.getDeletedNotes();
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,
position: fromStoredNotePosition(row.sortIndex),
serverVersion: row.serverVersion,
isDeleted: row.isDeleted,
isPermanentlyDeleted: _isPermanentlyDeleted(row),
categoryId: row.categoryId,
);
}
Category _fromDbCategory(DbCategory row) {
return Category(
uuid: row.uuid,
encryptedName: row.encryptedName,
serverVersion: row.serverVersion,
isDeleted: row.isDeleted,
updatedAt: row.updatedAt,
isDirty: row.isDirty,
);
}
}
List<Future<List<Map<String, Object?>>>> _encryptNoteBatches(
List<DbNote> notes, {
required String masterKey,
}) {
if (notes.isEmpty) {
return <Future<List<Map<String, Object?>>>>[];
}
final int workerCount = _parallelWorkerCount(notes.length);
final int batchSize = (notes.length / workerCount).ceil();
final List<Future<List<Map<String, Object?>>>> batchFutures = [];
for (var start = 0; start < notes.length; start += batchSize) {
final int end = math.min(start + batchSize, notes.length);
final List<Map<String, Object?>> batchNotes = [];
for (var index = start; index < end; index += 1) {
batchNotes.add(_dbNoteToEncryptionInput(notes[index], index));
}
batchFutures.add(
Isolate.run(
() => _encryptNoteBatch({'masterKey': masterKey, 'notes': batchNotes}),
),
);
}
return batchFutures;
}
Future<List<SyncNotePayload>> _encryptNotesInParallel(
List<DbNote> notes, {
required String masterKey,
void Function(int encryptedCount)? onProgress,
}) async {
final List<Future<List<Map<String, Object?>>>> batchFutures =
_encryptNoteBatches(notes, masterKey: masterKey);
final List<SyncNotePayload?> orderedPayloads = List<SyncNotePayload?>.filled(
notes.length,
null,
);
var encryptedCount = 0;
await for (final List<Map<String, Object?>> batchResult in Stream.fromFutures(
batchFutures,
)) {
for (final Map<String, Object?> row in batchResult) {
final int index = row['index']! as int;
orderedPayloads[index] = _syncNotePayloadFromEncryptionResult(row);
}
encryptedCount += batchResult.length;
onProgress?.call(encryptedCount);
}
return orderedPayloads.cast<SyncNotePayload>();
}
Future<List<SyncCategoryPayload>> _encryptCategories(
List<DbCategory> categories, {
required String masterKey,
}) async {
final List<SyncCategoryPayload> payloads = [];
for (final DbCategory row in categories) {
payloads.add(
await SyncCategoryPayload.fromCategory(
Category(
id: row.id,
name: row.name,
serverVersion: row.serverVersion,
isDeleted: row.isDeleted,
updatedAt: row.updatedAt,
isDirty: row.isDirty,
colorValue: row.colorValue,
iconCodePoint: row.iconCodePoint,
),
masterKey: masterKey,
),
);
}
return payloads;
}
List<Future<List<Map<String, Object?>>>> _decryptNoteBatches(
List<SyncNoteResponse> notes, {
required String masterKey,
}) {
if (notes.isEmpty) {
return <Future<List<Map<String, Object?>>>>[];
}
final int workerCount = _parallelWorkerCount(notes.length);
final int batchSize = (notes.length / workerCount).ceil();
final List<Future<List<Map<String, Object?>>>> batchFutures = [];
for (var start = 0; start < notes.length; start += batchSize) {
final int end = math.min(start + batchSize, notes.length);
final List<Map<String, Object?>> batchNotes = [];
for (var index = start; index < end; index += 1) {
batchNotes.add(_syncNoteToDecryptionInput(notes[index], index));
}
batchFutures.add(
Isolate.run(
() => _decryptNoteBatch({'masterKey': masterKey, 'notes': batchNotes}),
),
);
}
return batchFutures;
}
Future<List<Map<String, Object?>>> _decryptResponseNotesInParallel(
List<SyncNoteResponse> notes, {
required String masterKey,
void Function(int processed)? onProgress,
}) async {
final List<Future<List<Map<String, Object?>>>> batchFutures =
_decryptNoteBatches(notes, masterKey: masterKey);
final List<Map<String, Object?>?> decryptedNotes =
List<Map<String, Object?>?>.filled(notes.length, null);
var processed = 0;
await for (final List<Map<String, Object?>> batchResult in Stream.fromFutures(
batchFutures,
)) {
for (final Map<String, Object?> row in batchResult) {
final int index = row['index']! as int;
decryptedNotes[index] = row;
}
processed += batchResult.length;
onProgress?.call(processed);
}
return decryptedNotes.cast<Map<String, Object?>>();
}
Map<String, Object?> _syncNoteToDecryptionInput(
SyncNoteResponse row,
int index,
) {
return <String, Object?>{
'index': index,
'id': row.id,
'encryptedTitle': row.encryptedTitle,
'encryptedBody': row.encryptedBody,
'isPermanentlyDeleted': row.isPermanentlyDeleted,
};
}
Map<String, Object?> _dbNoteToEncryptionInput(DbNote row, int index) {
final bool isPermanentlyDeleted = _isPermanentlyDeleted(row);
return <String, Object?>{
'index': index,
'id': row.id,
'title': row.title,
'body': row.body,
'createdAt': row.createdAt.toIso8601String(),
'updatedAt': row.updatedAt.toIso8601String(),
'categoryId': row.categoryId,
'serverVersion': row.serverVersion,
'position': fromStoredNotePosition(row.sortIndex),
'isDeleted': row.isDeleted,
'isPermanentlyDeleted': isPermanentlyDeleted,
};
}
SyncNotePayload _syncNotePayloadFromEncryptionResult(Map<String, Object?> row) {
return SyncNotePayload(
id: row['id']! as String,
categoryId: row['categoryId'] as String?,
encryptedTitle: row['encryptedTitle']! as String,
encryptedBody: row['encryptedBody']! as String,
serverVersion: row['serverVersion']! as int,
position: (row['position']! as num).toDouble(),
isDeleted: row['isDeleted']! as bool,
isPermanentlyDeleted: row['isPermanentlyDeleted']! as bool,
updatedAt: DateTime.parse(row['updatedAt']! as String),
);
}
Future<List<Map<String, Object?>>> _encryptNoteBatch(
Map<String, Object?> request,
) async {
final String masterKey = request['masterKey']! as String;
final List<Map<String, Object?>> notes = (request['notes']! as List)
.cast<Map<String, Object?>>();
final List<Map<String, Object?>> encryptedNotes = [];
for (final Map<String, Object?> note in notes) {
final bool isPermanentlyDeleted = note['isPermanentlyDeleted']! as bool;
final String title = note['title']! as String;
final String body = note['body']! as String;
final String encryptedTitle;
final String encryptedBody;
if (isPermanentlyDeleted) {
encryptedTitle = '';
encryptedBody = '';
} else {
encryptedTitle = await NoteEncryption.encryptNote(title, masterKey);
encryptedBody = await NoteEncryption.encryptNote(body, masterKey);
}
encryptedNotes.add(<String, Object?>{
'index': note['index'] as int,
'id': note['id'] as String,
'categoryId': note['categoryId'] as String?,
'encryptedTitle': encryptedTitle,
'encryptedBody': encryptedBody,
'serverVersion': note['serverVersion']! as int,
'position': (note['position'] as num).toDouble(),
'isDeleted': note['isDeleted']! as bool,
'isPermanentlyDeleted': isPermanentlyDeleted,
'updatedAt': note['updatedAt']! as String,
});
}
return encryptedNotes;
}
Future<List<Map<String, Object?>>> _decryptNoteBatch(
Map<String, Object?> request,
) async {
final String masterKey = request['masterKey']! as String;
final List<Map<String, Object?>> notes = (request['notes']! as List)
.cast<Map<String, Object?>>();
final List<Map<String, Object?>> decryptedNotes = [];
for (final Map<String, Object?> note in notes) {
final bool isPermanentlyDeleted = note['isPermanentlyDeleted']! as bool;
String decryptedTitle = 'Encrypted';
String decryptedBody = 'Encrypted';
if (!isPermanentlyDeleted) {
try {
decryptedTitle = await NoteEncryption.decrypt(
note['encryptedTitle']! as String,
masterKey,
);
decryptedBody = await NoteEncryption.decrypt(
note['encryptedBody']! as String,
masterKey,
);
} catch (e) {
debugPrint('Failed to decrypt note ${note['id']}: $e');
}
} else {
decryptedTitle = '';
decryptedBody = '';
}
final String noteId = note['id']! as String;
decryptedNotes.add(<String, Object?>{
'index': note['index'] as int,
'id': noteId,
'title': decryptedTitle,
'body': decryptedBody,
'isPermanentlyDeleted': isPermanentlyDeleted,
});
}
return decryptedNotes;
}
bool _isPermanentlyDeleted(DbNote row) {
return row.isDeleted && row.title.isEmpty && row.body.isEmpty;
}
int _parallelWorkerCount(int itemCount) {
final int cappedByCpu = math.max(
1,
(Platform.numberOfProcessors * 0.6).floor(),
);
return math.max(1, math.min(itemCount, cappedByCpu));
}
+107 -46
View File
@@ -1,13 +1,13 @@
import 'package:notas/data/note_encryption.dart';
import 'package:notas/models/note.dart';
import 'package:notas/models/category.dart';
import 'dart:convert';
// DTOs para sincronización con el servidor
class SyncRequest {
SyncRequest({
DateTime? lastSyncAt,
required this.changes,
}) : lastSyncAt = lastSyncAt ?? DateTime.utc(1970, 1, 1);
SyncRequest({DateTime? lastSyncAt, required this.changes})
: lastSyncAt = lastSyncAt ?? DateTime.utc(1970, 1, 1);
final DateTime lastSyncAt;
final SyncChanges changes;
@@ -21,10 +21,7 @@ class SyncRequest {
}
class SyncChanges {
const SyncChanges({
this.categories = const [],
this.notes = const [],
});
const SyncChanges({this.categories = const [], this.notes = const []});
final List<SyncCategoryPayload> categories;
final List<SyncNotePayload> notes;
@@ -33,8 +30,7 @@ class SyncChanges {
return {
if (categories.isNotEmpty)
'categories': categories.map((c) => c.toJson()).toList(),
if (notes.isNotEmpty)
'notes': notes.map((n) => n.toJson()).toList(),
if (notes.isNotEmpty) 'notes': notes.map((n) => n.toJson()).toList(),
};
}
}
@@ -49,7 +45,8 @@ class SyncChangesResponse {
final List<SyncNoteResponse> notes;
factory SyncChangesResponse.fromJson(Map<String, dynamic> json) {
final List<dynamic> categoriesJson = json['categories'] as List<dynamic>? ?? [];
final List<dynamic> categoriesJson =
json['categories'] as List<dynamic>? ?? [];
final List<dynamic> notesJson = json['notes'] as List<dynamic>? ?? [];
return SyncChangesResponse(
@@ -62,27 +59,67 @@ class SyncChangesResponse {
);
}
}
String _readStringValue(dynamic value) {
if (value is String) {
return value;
}
if (value == null) {
throw FormatException('Expected String value but found null');
}
return jsonEncode(value);
}
String _readOptionalStringValue(dynamic value) {
if (value == null) {
return '';
}
return _readStringValue(value);
}
int _readIntValue(dynamic value) {
if (value is int) {
return value;
}
if (value is String) {
final int? parsed = int.tryParse(value);
if (parsed != null) {
return parsed;
}
}
throw FormatException('Expected int value but found $value');
}
class SyncCategoryPayload {
const SyncCategoryPayload({
required this.id,
required this.encryptedName,
required this.serverVersion,
this.isDeleted = false,
this.colorValue,
this.iconCodePoint,
required this.updatedAt,
});
final String id; // uuid
final String id;
final String encryptedName;
final int serverVersion;
final bool isDeleted;
final int? colorValue;
final int? iconCodePoint;
final DateTime updatedAt;
factory SyncCategoryPayload.fromCategory(Category category) {
static Future<SyncCategoryPayload> fromCategory(
Category category, {
required String masterKey,
}) async {
return SyncCategoryPayload(
id: category.uuid,
encryptedName: category.encryptedName,
id: category.id,
encryptedName: await NoteEncryption.encryptNote(category.name, masterKey),
serverVersion: category.serverVersion,
isDeleted: category.isDeleted,
colorValue: category.colorValue,
iconCodePoint: category.iconCodePoint,
updatedAt: category.updatedAt,
);
}
@@ -93,6 +130,8 @@ class SyncCategoryPayload {
'encrypted_name': encryptedName,
'serverVersion': serverVersion,
'isDeleted': isDeleted,
if (colorValue != null) 'colorValue': colorValue!.toSigned(32),
if (iconCodePoint != null) 'iconCodePoint': iconCodePoint,
'updatedAt': updatedAt.toIso8601String(),
};
}
@@ -105,33 +144,37 @@ class SyncNotePayload {
required this.encryptedTitle,
required this.encryptedBody,
required this.serverVersion,
this.position = 0,
this.position = 0.0,
this.isDeleted = false,
this.isPermanentlyDeleted = false,
required this.updatedAt,
});
final String id; // uuid
final String id;
final String? categoryId;
final String encryptedTitle;
final String encryptedBody;
final int serverVersion;
final int position;
final double position;
final bool isDeleted;
final bool isPermanentlyDeleted;
final DateTime updatedAt;
factory SyncNotePayload.fromNote(
Note note, {
required String encryptedTitle,
required String encryptedBody,
bool isPermanentlyDeleted = false,
}) {
return SyncNotePayload(
id: note.uuid,
id: note.id,
categoryId: note.categoryId,
encryptedTitle: encryptedTitle,
encryptedBody: encryptedBody,
serverVersion: note.serverVersion,
position: note.index,
position: note.position,
isDeleted: note.isDeleted,
isPermanentlyDeleted: isPermanentlyDeleted,
updatedAt: note.updatedAt,
);
}
@@ -145,6 +188,7 @@ class SyncNotePayload {
'serverVersion': serverVersion,
if (position != 0) 'position': position,
if (isDeleted) 'isDeleted': isDeleted,
if (isPermanentlyDeleted) 'isPermanentlyDeleted': isPermanentlyDeleted,
'updatedAt': updatedAt.toIso8601String(),
};
}
@@ -163,11 +207,11 @@ class SyncResponse {
factory SyncResponse.fromJson(Map<String, dynamic> json) {
return SyncResponse(
serverTimestamp:
DateTime.parse(json['serverTimestamp'] as String),
serverTimestamp: DateTime.parse(json['serverTimestamp'] as String),
synced: json['synced'] as bool? ?? false,
changes: SyncChangesResponse.fromJson(
json['changes'] as Map<String, dynamic>? ?? {}),
changes: SyncChangesResponse.fromJson(
json['changes'] as Map<String, dynamic>? ?? {},
),
);
}
}
@@ -178,32 +222,44 @@ class SyncCategoryResponse {
required this.encryptedName,
required this.serverVersion,
this.isDeleted = false,
this.colorValue,
this.iconCodePoint,
required this.updatedAt,
});
final String id; // uuid
final String id;
final String encryptedName;
final int serverVersion;
final bool isDeleted;
final int? colorValue;
final int? iconCodePoint;
final DateTime updatedAt;
factory SyncCategoryResponse.fromJson(Map<String, dynamic> json) {
return SyncCategoryResponse(
id: json['id'] as String,
encryptedName: json['encrypted_name'] as String,
serverVersion: json['serverVersion'] as int,
id: _readStringValue(json['id']),
encryptedName: _readStringValue(json['encrypted_name']),
serverVersion: _readIntValue(json['serverVersion']),
isDeleted: json['isDeleted'] as bool? ?? false,
colorValue: json['colorValue'] == null
? null
: _readIntValue(json['colorValue']),
iconCodePoint: json['iconCodePoint'] == null
? null
: _readIntValue(json['iconCodePoint']),
updatedAt: DateTime.parse(json['updatedAt'] as String),
);
}
Category toCategory() {
Future<Category> toCategory({required String masterKey}) async {
return Category(
uuid: id,
encryptedName: encryptedName,
id: id,
name: await NoteEncryption.decrypt(encryptedName, masterKey),
serverVersion: serverVersion,
isDeleted: isDeleted,
colorValue: colorValue,
iconCodePoint: iconCodePoint,
updatedAt: updatedAt,
isDirty: false,
);
}
}
@@ -215,44 +271,49 @@ class SyncNoteResponse {
required this.encryptedTitle,
required this.encryptedBody,
required this.serverVersion,
this.position = 0,
this.position = 0.0,
this.isDeleted = false,
this.isPermanentlyDeleted = false,
required this.updatedAt,
});
final String id; // uuid
final String id;
final String? categoryId;
final String encryptedTitle;
final String encryptedBody;
final int serverVersion;
final int position;
final double position;
final bool isDeleted;
final bool isPermanentlyDeleted;
final DateTime updatedAt;
factory SyncNoteResponse.fromJson(Map<String, dynamic> json) {
return SyncNoteResponse(
id: json['id'] as String,
categoryId: json['categoryId'] as String?,
encryptedTitle: json['encrypted_title'] as String,
encryptedBody: json['encrypted_body'] as String,
serverVersion: json['serverVersion'] as int,
position: json['position'] as int? ?? 0,
id: _readStringValue(json['id']),
categoryId: json['categoryId'] == null
? null
: _readStringValue(json['categoryId']),
encryptedTitle: _readOptionalStringValue(json['encrypted_title']),
encryptedBody: _readOptionalStringValue(json['encrypted_body']),
serverVersion: _readIntValue(json['serverVersion']),
position: (json['position'] as num?)?.toDouble() ?? 0,
isDeleted: json['isDeleted'] as bool? ?? false,
isPermanentlyDeleted: json['isPermanentlyDeleted'] 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
id: id,
title: isPermanentlyDeleted ? '' : 'Encrypted',
body: isPermanentlyDeleted ? '' : 'Encrypted',
createdAt: updatedAt,
updatedAt: updatedAt,
index: position,
position: position,
serverVersion: serverVersion,
isDeleted: isDeleted,
categoryId: categoryId,
isDirty: false,
);
}
}
+23 -11
View File
@@ -2,32 +2,44 @@ import 'package:uuid/uuid.dart';
class Category {
Category({
String? uuid,
required this.encryptedName,
String? id,
required this.name,
this.serverVersion = 0,
this.isDeleted = false,
required this.updatedAt,
}) : uuid = uuid ?? Uuid().v4();
this.isDirty = true,
this.colorValue,
this.iconCodePoint,
}) : id = id ?? Uuid().v4();
final String uuid;
final String encryptedName;
final String id;
final String name;
final int serverVersion;
final bool isDeleted;
final DateTime updatedAt;
final bool isDirty;
final int? colorValue;
final int? iconCodePoint;
Category copyWith({
String? uuid,
String? encryptedName,
String? id,
String? name,
int? serverVersion,
bool? isDeleted,
DateTime? updatedAt,
bool? isDirty,
int? colorValue,
int? iconCodePoint,
}) {
return Category(
uuid: uuid ?? this.uuid,
encryptedName: encryptedName ?? this.encryptedName,
id: id ?? this.id,
name: name ?? this.name,
serverVersion: serverVersion ?? this.serverVersion,
isDeleted: isDeleted ?? this.isDeleted,
updatedAt: updatedAt ?? this.updatedAt,
isDirty: isDirty ?? this.isDirty,
colorValue: colorValue ?? this.colorValue,
iconCodePoint: iconCodePoint ?? this.iconCodePoint,
);
}
@@ -37,9 +49,9 @@ class Category {
return true;
}
return other is Category && uuid == other.uuid;
return other is Category && id == other.id;
}
@override
int get hashCode => uuid.hashCode;
int get hashCode => id.hashCode;
}
+29 -24
View File
@@ -1,60 +1,65 @@
import 'package:uuid/uuid.dart';
const Object _unsetCategoryId = Object();
// Model: Note
// - Representa una nota guardada en la app.
// - `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 {
Note({
this.id,
String? uuid,
String? id,
required this.title,
required this.body,
required this.createdAt,
required this.updatedAt,
required this.index,
required this.position,
this.categoryId,
this.serverVersion = 0,
this.isDeleted = false,
this.categoryId,
}) : uuid = uuid ?? Uuid().v4();
this.isPermanentlyDeleted = false,
this.isDirty = true,
}) : id = id ?? Uuid().v4();
final int? id;
final String uuid;
final String id;
final String title;
final String body;
final DateTime createdAt;
final DateTime updatedAt;
final int index;
final double position;
final String? categoryId;
final int serverVersion;
final bool isDeleted;
final String? categoryId;
final bool isPermanentlyDeleted;
final bool isDirty;
Note copyWith({
int? id,
String? uuid,
String? id,
String? title,
String? body,
DateTime? createdAt,
DateTime? updatedAt,
int? index,
double? position,
Object? categoryId = _unsetCategoryId,
int? serverVersion,
bool? isDeleted,
String? categoryId,
bool? isPermanentlyDeleted,
bool? isDirty,
}) {
final String? resolvedCategoryId = identical(categoryId, _unsetCategoryId)
? this.categoryId
: categoryId as String?;
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,
position: position ?? this.position,
categoryId: resolvedCategoryId,
serverVersion: serverVersion ?? this.serverVersion,
isDeleted: isDeleted ?? this.isDeleted,
categoryId: categoryId ?? this.categoryId,
isPermanentlyDeleted: isPermanentlyDeleted ?? this.isPermanentlyDeleted,
isDirty: isDirty ?? this.isDirty,
);
}
@@ -64,9 +69,9 @@ class Note {
return true;
}
return other is Note && uuid == other.uuid;
return other is Note && id == other.id;
}
@override
int get hashCode => uuid.hashCode;
}
int get hashCode => id.hashCode;
}
+9 -3
View File
@@ -9,7 +9,6 @@ Future<void> bootstrapWindow() async {
}
await windowManager.ensureInitialized();
final Size initialSize =
await WindowStateStore.instance.loadWindowSize() ?? const Size(900, 700);
@@ -17,10 +16,17 @@ Future<void> bootstrapWindow() async {
size: initialSize,
minimumSize: Size(400, 600),
center: true,
titleBarStyle: TitleBarStyle.hidden,
titleBarStyle: TitleBarStyle.normal,
);
windowManager.waitUntilReadyToShow(windowOptions, () async {
await windowManager.waitUntilReadyToShow(windowOptions, () async {
// Re-apply size after the window is ready to ensure it takes effect.
try {
await windowManager.setSize(initialSize);
} catch (_) {}
await windowManager.setIcon('assets/icon.png');
await windowManager.show();
await windowManager.setMinimumSize(const Size(400, 600));
await windowManager.setSize(initialSize);
+21 -24
View File
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:notas/widgets/app_title_bar.dart';
import 'package:notas/theme/app_palette.dart';
class BiometricChoiceScreen extends StatelessWidget {
const BiometricChoiceScreen({
@@ -15,23 +15,14 @@ class BiometricChoiceScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final AppPalette palette = Theme.of(context).extension<AppPalette>()!;
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [
Color(0xFF191A1D),
Color(0xFF222326),
Color(0xFF101114),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
decoration: BoxDecoration(gradient: palette.backdropGradient),
child: SafeArea(
child: Column(
children: [
const AppTitleBar(),
Expanded(
child: Center(
child: SingleChildScrollView(
@@ -41,12 +32,12 @@ class BiometricChoiceScreen extends StatelessWidget {
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: const Color(0xFF1D1E20),
color: palette.cardBackground,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
border: Border.all(color: palette.border),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.35),
color: palette.shadowSoft,
blurRadius: 30,
offset: const Offset(0, 18),
),
@@ -56,10 +47,10 @@ class BiometricChoiceScreen extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(
Icon(
Icons.fingerprint,
color: Colors.amber,
size: 44,
color: IconTheme.of(context).color,
),
const SizedBox(height: 16),
const Text(
@@ -76,7 +67,7 @@ class BiometricChoiceScreen extends StatelessWidget {
'¿Quieres que la app te pida huella o cara antes de entrar a tus notas?',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.72),
color: palette.textSecondary,
height: 1.4,
),
),
@@ -84,13 +75,17 @@ class BiometricChoiceScreen extends StatelessWidget {
FilledButton(
onPressed: isBusy ? null : onEnableBiometrics,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
padding: const EdgeInsets.symmetric(
vertical: 14,
),
),
child: isBusy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Text('Sí, activar huella'),
),
@@ -98,9 +93,11 @@ class BiometricChoiceScreen extends StatelessWidget {
OutlinedButton(
onPressed: isBusy ? null : onSkipBiometrics,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
side: const BorderSide(color: Colors.white24),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
vertical: 14,
),
side: BorderSide(color: palette.border),
foregroundColor: palette.textPrimary,
),
child: const Text('No, entrar sin huella'),
),
+19 -22
View File
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:notas/widgets/app_title_bar.dart';
import 'package:notas/theme/app_palette.dart';
class BiometricGateScreen extends StatefulWidget {
const BiometricGateScreen({
@@ -39,23 +39,14 @@ class _BiometricGateScreenState extends State<BiometricGateScreen> {
@override
Widget build(BuildContext context) {
final AppPalette palette = Theme.of(context).extension<AppPalette>()!;
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [
Color(0xFF191A1D),
Color(0xFF222326),
Color(0xFF101114),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
decoration: BoxDecoration(gradient: palette.backdropGradient),
child: SafeArea(
child: Column(
children: [
const AppTitleBar(),
Expanded(
child: Center(
child: SingleChildScrollView(
@@ -65,12 +56,12 @@ class _BiometricGateScreenState extends State<BiometricGateScreen> {
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: const Color(0xFF1D1E20),
color: palette.cardBackground,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
border: Border.all(color: palette.border),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.35),
color: palette.shadowSoft,
blurRadius: 30,
offset: const Offset(0, 18),
),
@@ -80,10 +71,10 @@ class _BiometricGateScreenState extends State<BiometricGateScreen> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(
Icon(
Icons.fingerprint,
color: Colors.amber,
size: 44,
color: IconTheme.of(context).color,
),
const SizedBox(height: 16),
const Text(
@@ -100,21 +91,27 @@ class _BiometricGateScreenState extends State<BiometricGateScreen> {
'Pon tu huella o cara para entrar a tus notas.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.72),
color: palette.textSecondary,
height: 1.4,
),
),
const SizedBox(height: 22),
FilledButton(
onPressed: widget.isBusy ? null : widget.onUnlockRequested,
onPressed: widget.isBusy
? null
: widget.onUnlockRequested,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
padding: const EdgeInsets.symmetric(
vertical: 14,
),
),
child: widget.isBusy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Text('Desbloquear'),
),
+1125 -533
View File
File diff suppressed because it is too large Load Diff
+202 -218
View File
@@ -1,272 +1,256 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:flutter_quill/flutter_quill.dart';
import 'package:notas/data/note_body.dart';
import 'package:notas/data/note_repository.dart';
import 'package:notas/models/note.dart';
// NoteEditorScreen: unified UI for creating and editing notes.
// - Use `NoteEditorScreen.showDialog(context, note: existing)` to edit.
// - Use `NoteEditorScreen.showDialog(context)` to create a new note.
// The screen returns either a `Note` (saved) or the string `'delete'` when
// the user confirmed deletion. `null` indicates the user closed without saving.
import 'package:notas/theme/app_palette.dart';
class NoteEditorScreen extends StatefulWidget {
const NoteEditorScreen({super.key, required this.note});
const NoteEditorScreen({
super.key,
this.repository,
this.saveNote,
required this.note,
this.embedded = false,
this.onSaved,
});
final Note? note;
final NoteRepository? repository;
final Future<Note> Function(Note note)? saveNote;
final Note note;
final bool embedded;
final ValueChanged<Note>? onSaved;
@override
State<NoteEditorScreen> createState() => _NoteEditorScreenState();
static Future<dynamic> showDialog(BuildContext context, {Note? note}) {
return showGeneralDialog<dynamic>(
context: context,
barrierDismissible: false,
barrierColor: const Color.fromARGB(127, 0, 0, 0).withValues(alpha: 0.5),
transitionDuration: const Duration(milliseconds: 200),
pageBuilder: (context, animation, secondaryAnimation) {
return NoteEditorScreen(note: note);
},
transitionBuilder: (context, animation, secondaryAnimation, child) {
return ScaleTransition(scale: animation, child: child);
},
);
}
}
class _NoteEditorScreenState extends State<NoteEditorScreen> {
late TextEditingController _titleController;
late TextEditingController _bodyController;
late Note _currentNote;
late bool _isNewNote;
static const Duration _debounceDuration = Duration(seconds: 1);
late final TextEditingController _titleController;
late final QuillController _bodyController;
late final FocusNode _bodyFocusNode;
late final ScrollController _bodyScrollController;
Timer? _debounceTimer;
bool _isSaving = false;
bool _saveQueued = false;
late Note _baselineNote;
AppPalette _paletteOf(BuildContext context) {
return Theme.of(context).extension<AppPalette>() ??
AppPalette.fromBrightness(Theme.of(context).brightness);
}
@override
void initState() {
super.initState();
_isNewNote = widget.note?.id == null;
if (_isNewNote) {
final DateTime now = DateTime.now();
_currentNote = Note(
title: '',
body: '',
createdAt: now,
updatedAt: now,
index: 0,
);
} else {
_currentNote = widget.note!;
}
_titleController = TextEditingController(text: _currentNote.title);
_bodyController = TextEditingController(text: _currentNote.body);
_baselineNote = widget.note;
_titleController = TextEditingController(text: widget.note.title)
..addListener(_scheduleSave);
_bodyController = QuillController(
document: noteBodyToDocument(widget.note.body),
selection: const TextSelection.collapsed(offset: 0),
)..addListener(_scheduleSave);
_bodyFocusNode = FocusNode();
_bodyScrollController = ScrollController();
}
@override
void dispose() {
_debounceTimer?.cancel();
_titleController.dispose();
_bodyController.dispose();
_bodyFocusNode.dispose();
_bodyScrollController.dispose();
super.dispose();
}
void _closeWithoutSaving() {
Navigator.of(context).pop();
String _bodyAsJson() {
return noteDocumentToStorageJson(_bodyController.document);
}
void _saveNote() {
final String title = _titleController.text.trim();
final String body = _bodyController.text.trim();
void _scheduleSave() {
_debounceTimer?.cancel();
_debounceTimer = Timer(_debounceDuration, () {
unawaited(_saveNow());
});
}
if (title.isEmpty && body.isEmpty) {
Navigator.of(context).pop();
Future<void> _saveNow() async {
if (!mounted) {
return;
}
final Note updatedNote = _currentNote.copyWith(
final String title = _titleController.text.trim();
final String body = _bodyAsJson();
final Note draft = _baselineNote.copyWith(
title: title.isEmpty ? 'Sin título' : title,
body: body,
categoryId: _baselineNote.categoryId,
updatedAt: DateTime.now(),
isDirty: true,
);
Navigator.of(context).pop(updatedNote);
final bool hasChanges =
draft.title != _baselineNote.title ||
draft.body != _baselineNote.body ||
draft.categoryId != _baselineNote.categoryId;
if (!hasChanges) {
return;
}
if (_isSaving) {
_saveQueued = true;
return;
}
_isSaving = true;
try {
final Note saved = widget.saveNote != null
? await widget.saveNote!(draft)
: await widget.repository!.updateNote(draft);
_baselineNote = saved;
widget.onSaved?.call(saved);
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('No se pudo guardar la nota: $error')),
);
}
} finally {
_isSaving = false;
if (_saveQueued) {
_saveQueued = false;
unawaited(_saveNow());
}
}
}
void _deleteNote() {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
backgroundColor: const Color(0xFF303134),
title: const Text('Eliminar nota', style: TextStyle(color: Colors.white)),
content: const Text(
'¿Estás seguro de que deseas eliminar esta nota?',
style: TextStyle(color: Colors.white70),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancelar', style: TextStyle(color: Colors.white70)),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
Navigator.of(context).pop('delete');
},
child: const Text('Eliminar', style: TextStyle(color: Colors.red)),
Widget _buildEditorBody() {
final AppPalette palette = _paletteOf(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 8),
child: TextField(
controller: _titleController,
style: TextStyle(
color: palette.textPrimary,
fontSize: 26,
fontWeight: FontWeight.w700,
),
decoration: InputDecoration(
hintText: 'Título',
hintStyle: TextStyle(color: palette.textHint),
border: InputBorder.none,
),
),
),
),
],
);
},
),
const SizedBox(height: 10),
Expanded(
child: Container(
padding: const EdgeInsets.all(8),
child: QuillEditor.basic(
controller: _bodyController,
focusNode: _bodyFocusNode,
scrollController: _bodyScrollController,
config: QuillEditorConfig(
scrollable: true,
padding: EdgeInsets.zero,
autoFocus: false,
expands: true,
placeholder: 'Escribe tu nota...',
keyboardAppearance: Theme.of(context).brightness,
),
),
),
),
const SizedBox(height: 8),
QuillSimpleToolbar(
controller: _bodyController,
config: const QuillSimpleToolbarConfig(
color: Colors.transparent,
showBoldButton: true,
showItalicButton: true,
showUnderLineButton: true,
showStrikeThrough: false,
showInlineCode: false,
showColorButton: false,
showBackgroundColorButton: false,
showClearFormat: false,
showAlignmentButtons: false,
showHeaderStyle: false,
showListNumbers: true,
showListBullets: true,
showListCheck: true,
showCodeBlock: false,
showQuote: false,
showIndent: false,
showLink: false,
showUndo: false,
showRedo: false,
showDividers: false,
showFontFamily: false,
showFontSize: false,
showDirection: false,
showSearchButton: false,
showSubscript: false,
showSuperscript: false,
multiRowsDisplay: false,
axis: Axis.horizontal,
),
),
],
);
}
String _formatDate(DateTime date) {
final DateTime now = DateTime.now();
final DateTime today = DateTime(now.year, now.month, now.day);
final DateTime yesterday = today.subtract(const Duration(days: 1));
final DateTime noteDate = DateTime(date.year, date.month, date.day);
if (noteDate == today) {
return 'Hoy ${DateFormat('HH:mm').format(date)}';
} else if (noteDate == yesterday) {
return 'Ayer ${DateFormat('HH:mm').format(date)}';
} else {
return DateFormat('dd/MM/yyyy HH:mm').format(date);
}
}
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.transparent,
elevation: 0,
child: Container(
constraints: const BoxConstraints(maxWidth: 600),
decoration: BoxDecoration(
color: const Color.fromRGBO(24, 25, 26, 1),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white24, width: 1),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.5),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header con botones y fechas
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
final AppPalette palette = _paletteOf(context);
final Widget editor = Padding(
padding: const EdgeInsets.all(8),
child: _buildEditorBody(),
);
if (widget.embedded) {
return editor;
}
return Container(
decoration: BoxDecoration(gradient: palette.backdropGradient),
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
title: const Text('Editar nota'),
backgroundColor: Colors.transparent,
elevation: 0,
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1),
child: Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: Colors.white12, width: 1),
),
),
child: Row(
children: [
IconButton(
onPressed: _closeWithoutSaving,
icon: const Icon(Icons.close, color: Colors.white70),
tooltip: 'Cerrar sin guardar',
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Creado: ${_formatDate(_currentNote.createdAt)}',
style: const TextStyle(color: Colors.white54, fontSize: 12),
),
if (_currentNote.updatedAt != _currentNote.createdAt)
Text(
'Modificado: ${_formatDate(_currentNote.updatedAt)}',
style: const TextStyle(color: Colors.white54, fontSize: 12),
),
],
),
),
],
),
),
// Contenido editable
Expanded(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Título
TextField(
controller: _titleController,
style: const TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.bold,
),
decoration: const InputDecoration(
hintText: 'Título',
hintStyle: TextStyle(color: Colors.white30),
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 16),
// Cuerpo de la nota
TextField(
controller: _bodyController,
maxLines: null,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
height: 1.6,
),
decoration: const InputDecoration(
hintText: 'Escribe tu nota...',
hintStyle: TextStyle(color: Colors.white30),
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
),
),
],
),
bottom: BorderSide(color: palette.border, width: 0.5),
),
),
),
// Footer con botones de acción
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: Colors.white12, width: 1),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Botón de borrar (izquierda) - solo para notas existentes
if (!_isNewNote)
IconButton(
onPressed: _deleteNote,
icon: const Icon(Icons.delete_outline, color: Colors.red),
tooltip: 'Eliminar nota',
)
else
const SizedBox(width: 48), // Espacio para mantener alineación
// Botón de guardar (derecha)
FilledButton(
onPressed: _saveNote,
child: const Text('Guardar'),
),
],
),
),
],
),
),
body: SafeArea(child: editor),
),
);
}
+413 -189
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:notas/data/local_vault_service.dart';
import 'package:notas/theme/app_palette.dart';
import 'package:notas/widgets/search_app_bar.dart';
import 'package:notas/data/api_client.dart';
@@ -11,6 +12,8 @@ class SettingsScreen extends StatefulWidget {
required this.onForceSync,
required this.currentSeedColor,
required this.onThemeColorSelected,
required this.currentThemeMode,
required this.onThemeModeSelected,
});
final Future<void> Function() onDeleteAllData;
@@ -18,6 +21,8 @@ class SettingsScreen extends StatefulWidget {
final Future<void> Function() onForceSync;
final Color currentSeedColor;
final Future<void> Function(Color color) onThemeColorSelected;
final ThemeMode currentThemeMode;
final Future<void> Function(ThemeMode mode) onThemeModeSelected;
@override
State<SettingsScreen> createState() => _SettingsScreenState();
@@ -26,34 +31,49 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> {
bool _isBusy = false;
bool _isSyncing = false;
bool _isServerDeleting = false;
bool _isThemeSaving = false;
final TextEditingController _endpointController = TextEditingController();
final TextEditingController _encryptionKeyController = TextEditingController();
final TextEditingController _encryptionKeyController =
TextEditingController();
bool _endpointLoading = true;
bool _encryptionKeyLoading = false;
bool _encryptionKeyVisible = false;
late Color _selectedSeedColor;
late ThemeMode _selectedThemeMode;
static const List<Color> _themeColorOptions = <Color>[
Colors.amber,
Colors.blue,
Colors.teal,
Colors.green,
Colors.pink,
Colors.purple,
];
static const List<Color> _themeColorOptions = AppPalette.themeSeedColors;
Future<void> _confirmAndDeleteAll() async {
final bool? confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Borrar todos los datos'),
content: const Text('¿Estás seguro? Esta acción eliminará la base de datos local y la clave de cifrado. Asegúrate de tener una copia de seguridad si es necesario o cuenta sincronizada.'),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancelar')),
TextButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('Borrar', style: TextStyle(color: Colors.red))),
],
),
builder: (context) {
final AppPalette palette = Theme.of(context).extension<AppPalette>()!;
return AlertDialog(
backgroundColor: palette.cardBackground,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: palette.border),
),
title: const Text('Borrar todos los datos'),
content: const Text(
'¿Estás seguro? Esta acción eliminará la base de datos local y la clave de cifrado. Asegúrate de tener una copia de seguridad si es necesario o cuenta sincronizada.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancelar'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(
'Borrar',
style: TextStyle(color: palette.destructiveAccent),
),
),
],
);
},
);
if (confirmed != true) return;
@@ -68,7 +88,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Todos los datos locales han sido eliminados.')),
const SnackBar(
content: Text('Todos los datos locales han sido eliminados.'),
),
);
} catch (error) {
if (!mounted) return;
@@ -76,10 +98,82 @@ class _SettingsScreenState extends State<SettingsScreen> {
SnackBar(content: Text('Error al borrar los datos: $error')),
);
} finally {
if (mounted) {
setState(() {
_isBusy = false;
});
}
}
}
Future<void> _confirmAndDeleteServerData() async {
final bool? confirmed = await showDialog<bool>(
context: context,
builder: (context) {
final AppPalette palette = Theme.of(context).extension<AppPalette>()!;
return AlertDialog(
backgroundColor: palette.cardBackground,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: palette.border),
),
title: const Text('Borrar toda la info del servidor'),
content: const Text(
'¿Estás seguro? Esta acción eliminará toda la información almacenada en el servidor y no se puede deshacer.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancelar'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(
'Borrar',
style: TextStyle(color: palette.destructiveAccent),
),
),
],
);
},
);
if (confirmed != true) return;
setState(() {
_isServerDeleting = true;
});
try {
final Map<String, dynamic> response = await AuthApi.instance
.deleteAllServerData();
if (response['error'] == true) {
throw Exception(
response['body'] ?? response['message'] ?? 'Error desconocido',
);
}
await AuthApi.instance.clearTokens();
if (!mounted) return;
setState(() {
_isBusy = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Toda la información del servidor ha sido eliminada.'),
),
);
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error al borrar la info del servidor: $error')),
);
} finally {
if (mounted) {
setState(() {
_isServerDeleting = false;
});
}
}
}
@@ -107,11 +201,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
SnackBar(content: Text('Error al forzar la sincronización: $error')),
);
} finally {
if (!mounted) return;
setState(() {
_isSyncing = false;
});
if (mounted) {
setState(() {
_isSyncing = false;
});
}
}
}
@@ -153,6 +247,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
void initState() {
super.initState();
_selectedSeedColor = widget.currentSeedColor;
_selectedThemeMode = widget.currentThemeMode;
_loadEndpoint();
}
@@ -163,6 +258,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
widget.currentSeedColor != _selectedSeedColor) {
_selectedSeedColor = widget.currentSeedColor;
}
if (oldWidget.currentThemeMode != widget.currentThemeMode) {
_selectedThemeMode = widget.currentThemeMode;
}
}
Future<void> _selectThemeMode(ThemeMode mode) async {
if (_selectedThemeMode == mode) return;
setState(() {
_selectedThemeMode = mode;
});
try {
await widget.onThemeModeSelected(mode);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('No se pudo guardar la preferencia de tema: $e'),
),
);
}
}
Future<void> _loadEndpoint() async {
@@ -180,7 +295,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
});
try {
final String? encryptionKey = await LocalVaultService.instance.readEncryptionKey();
final String? encryptionKey = await LocalVaultService.instance
.readEncryptionKey();
if (!mounted) return;
@@ -219,17 +335,44 @@ class _SettingsScreenState extends State<SettingsScreen> {
});
}
Widget _buildDestructiveButton({
required String label,
required VoidCallback? onPressed,
required bool isLoading,
required IconData icon,
}) {
final AppPalette palette = Theme.of(context).extension<AppPalette>()!;
return ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: palette.destructiveAccent,
foregroundColor: palette.textPrimary,
textStyle: const TextStyle(fontWeight: FontWeight.w600),
),
onPressed: onPressed,
icon: isLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(icon),
label: Text(label),
);
}
Widget _buildThemeColorButton(Color color) {
final bool isSelected = _selectedSeedColor.value == color.value;
final AppPalette palette = Theme.of(context).extension<AppPalette>()!;
final bool isSelected = _selectedSeedColor.toARGB32() == color.toARGB32();
final Color foregroundColor =
ThemeData.estimateBrightnessForColor(color) == Brightness.dark
? Colors.white
: Colors.black;
? palette.textPrimary
: palette.textOnAccent;
return Semantics(
button: true,
selected: isSelected,
label: 'Color ${color.value.toRadixString(16)}',
label: 'Color ${color.toARGB32().toRadixString(16)}',
child: Tooltip(
message: isSelected ? 'Color actual' : 'Usar este color',
child: InkWell(
@@ -243,12 +386,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
color: color,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected ? Colors.white : Colors.white24,
color: isSelected ? palette.textPrimary : palette.textSecondary,
width: isSelected ? 2.5 : 1.2,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.25),
color: palette.shadowSoft,
blurRadius: 8,
offset: const Offset(0, 3),
),
@@ -258,11 +401,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
alignment: Alignment.center,
children: [
if (isSelected)
Icon(
Icons.check,
size: 22,
color: foregroundColor,
),
Icon(Icons.check, size: 22, color: foregroundColor),
],
),
),
@@ -283,10 +422,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
try {
await ApiConfig.setEndpoint(value);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Endpoint guardado')));
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')));
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error guardando endpoint: $e')));
}
}
@@ -295,7 +438,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
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')));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Endpoint restaurado al valor por defecto')),
);
}
Widget _buildResponsiveInputActionsRow({
@@ -347,20 +492,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
@override
Widget build(BuildContext context) {
final AppPalette palette = Theme.of(context).extension<AppPalette>()!;
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [
Color(0xFF191A1D),
Color(0xFF222326),
Color(0xFF101114),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
decoration: BoxDecoration(gradient: palette.backdropGradient),
child: SafeArea(
child: Column(
children: [
@@ -372,152 +510,238 @@ class _SettingsScreenState extends State<SettingsScreen> {
titleText: 'Configuración',
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Expanded(
child: Text('Borrar datos locales:'),
),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.redAccent,
foregroundColor: Colors.white,
textStyle: const TextStyle(fontWeight: FontWeight.w600),
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Expanded(
child: Text('Borrar datos locales:'),
),
onPressed: _isBusy ? null : _confirmAndDeleteAll,
icon: _isBusy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.delete_forever),
label: const Text('Borrar'),
),
],
),
const SizedBox(height: 16),
Row(
children: [
const Expanded(
child: Text('Forzar sincronizacion total:'),
),
ElevatedButton.icon(
onPressed: (_isBusy || _isSyncing) ? null : _forceSync,
icon: _isSyncing
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync),
label: const Text('Sincronizar'),
),
],
),
const SizedBox(height: 24),
const Text('Color del esquema'),
const SizedBox(height: 8),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
for (final Color color in _themeColorOptions)
_buildThemeColorButton(color),
],
),
const SizedBox(height: 24),
const Text('API endpoint (ej: http://localhost:3000/api)'),
const SizedBox(height: 8),
_buildResponsiveInputActionsRow(
input: _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)),
_buildDestructiveButton(
label: 'Borrar',
onPressed: (_isBusy || _isServerDeleting)
? null
: _confirmAndDeleteAll,
isLoading: _isBusy,
icon: Icons.delete_forever,
),
],
),
const SizedBox(height: 16),
Row(
children: [
const Expanded(
child: Text('Borrar info del servidor:'),
),
_buildDestructiveButton(
label: 'Borrar',
onPressed:
(_isBusy || _isSyncing || _isServerDeleting)
? null
: _confirmAndDeleteServerData,
isLoading: _isServerDeleting,
icon: Icons.cloud_off,
),
],
),
const SizedBox(height: 16),
Row(
children: [
const Expanded(
child: Text('Forzar sincronizacion total:'),
),
ElevatedButton.icon(
onPressed:
(_isBusy || _isSyncing || _isServerDeleting)
? null
: _forceSync,
icon: _isSyncing
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.sync),
label: const Text('Sincronizar'),
),
],
),
const SizedBox(height: 24),
const Text('Apariencia'),
const SizedBox(height: 8),
Column(
children: [
RadioGroup<ThemeMode>(
groupValue: _selectedThemeMode,
onChanged: (ThemeMode? v) {
if (v != null) {
_selectThemeMode(v);
}
},
child: Column(
children: [
RadioListTile<ThemeMode>(
title: const Text(
'Seguir modo del sistema',
),
value: ThemeMode.system,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)),
RadioListTile<ThemeMode>(
title: const Text('Modo claro'),
value: ThemeMode.light,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: Colors.amber, width: 1.2),
RadioListTile<ThemeMode>(
title: const Text('Modo oscuro'),
value: ThemeMode.dark,
),
),
],
),
actions: [
ElevatedButton(
onPressed: _endpointLoading ? null : _saveEndpoint,
child: const Text('Guardar'),
),
OutlinedButton(
onPressed: _endpointLoading ? null : _resetEndpoint,
child: const Text('Restaurar'),
),
],
),
const SizedBox(height: 24),
const Text('Clave de cifrado local'),
const SizedBox(height: 8),
_buildResponsiveInputActionsRow(
input: 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(height: 16),
const Text('Color del esquema'),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.only(left: 4),
child: Wrap(
spacing: 12,
runSpacing: 12,
children: [
for (final Color color in _themeColorOptions)
_buildThemeColorButton(color),
],
),
),
actions: [
ElevatedButton(
onPressed: _encryptionKeyLoading ? null : _loadEncryptionKey,
child: _encryptionKeyLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Mostrar'),
const SizedBox(height: 24),
const Text(
'API endpoint (ej: https://notas-api.lpncnd.es/api)',
),
const SizedBox(height: 8),
_buildResponsiveInputActionsRow(
input: _endpointLoading
? const SizedBox(
height: 48,
child: Center(
child: CircularProgressIndicator(),
),
)
: TextField(
controller: _endpointController,
style: TextStyle(color: palette.textPrimary),
keyboardType: TextInputType.url,
decoration: InputDecoration(
labelText: 'API endpoint',
labelStyle: TextStyle(
color: palette.textSecondary,
),
filled: true,
fillColor: palette.fill,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(
color: palette.border,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(
color: palette.border,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(
color: palette.accent,
width: 1.2,
),
),
),
),
actions: [
ElevatedButton(
onPressed: _endpointLoading
? null
: _saveEndpoint,
child: const Text('Guardar'),
),
OutlinedButton(
onPressed: _endpointLoading
? null
: _resetEndpoint,
child: const Text('Restaurar'),
),
],
),
const SizedBox(height: 24),
const Text('Clave de cifrado local'),
const SizedBox(height: 8),
_buildResponsiveInputActionsRow(
input: TextField(
controller: _encryptionKeyController,
readOnly: true,
obscureText: !_encryptionKeyVisible,
enableSuggestions: false,
autocorrect: false,
style: TextStyle(color: palette.textPrimary),
decoration: InputDecoration(
labelText: _encryptionKeyVisible
? 'Clave de cifrado'
: 'Oculta hasta pulsar mostrar',
labelStyle: TextStyle(
color: palette.textSecondary,
),
filled: true,
fillColor: palette.fill,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: palette.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: palette.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(
color: palette.accent,
width: 1.2,
),
),
),
),
OutlinedButton(
onPressed: _encryptionKeyVisible ? _hideEncryptionKey : null,
child: const Text('Ocultar'),
),
],
),
],
actions: [
ElevatedButton(
onPressed: _encryptionKeyLoading
? null
: _loadEncryptionKey,
child: _encryptionKeyLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Text('Mostrar'),
),
OutlinedButton(
onPressed: _encryptionKeyVisible
? _hideEncryptionKey
: null,
child: const Text('Ocultar'),
),
],
),
],
),
),
),
),
+66 -43
View File
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:notas/widgets/app_title_bar.dart';
import 'package:notas/data/api_client.dart';
import 'package:notas/theme/app_palette.dart';
class VaultAccessScreen extends StatefulWidget {
const VaultAccessScreen({
@@ -12,7 +12,8 @@ class VaultAccessScreen extends StatefulWidget {
});
final bool isBusy;
final Future<void> Function(String email, String password) onCreateAccountPressed;
final Future<void> Function(String email, String password)
onCreateAccountPressed;
final Future<void> Function(String email, String password) onSignInPressed;
final Future<void> Function() onContinueWithoutAccount;
@@ -74,23 +75,14 @@ class _VaultAccessScreenState extends State<VaultAccessScreen> {
@override
Widget build(BuildContext context) {
final AppPalette palette = Theme.of(context).extension<AppPalette>()!;
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [
Color(0xFF191A1D),
Color(0xFF222326),
Color(0xFF101114),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
decoration: BoxDecoration(gradient: palette.backdropGradient),
child: SafeArea(
child: Column(
children: [
const AppTitleBar(),
Expanded(
child: Center(
child: SingleChildScrollView(
@@ -100,12 +92,12 @@ class _VaultAccessScreenState extends State<VaultAccessScreen> {
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: const Color(0xFF1D1E20),
color: palette.cardBackground,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
border: Border.all(color: palette.border),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.35),
color: palette.shadowSoft,
blurRadius: 30,
offset: const Offset(0, 18),
),
@@ -117,7 +109,7 @@ class _VaultAccessScreenState extends State<VaultAccessScreen> {
children: [
const Icon(
Icons.lock_outline,
color: Colors.amber,
color: Colors.white,
size: 44,
),
const SizedBox(height: 16),
@@ -135,7 +127,7 @@ class _VaultAccessScreenState extends State<VaultAccessScreen> {
'Tus notas se guardan cifradas en este dispositivo. La cuenta y la sincronización vendrán después.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.72),
color: palette.textSecondary,
height: 1.4,
),
),
@@ -143,7 +135,9 @@ class _VaultAccessScreenState extends State<VaultAccessScreen> {
_endpointLoading
? const SizedBox(
height: 48,
child: Center(child: CircularProgressIndicator()),
child: Center(
child: CircularProgressIndicator(),
),
)
: TextField(
controller: _endpointController,
@@ -152,20 +146,29 @@ class _VaultAccessScreenState extends State<VaultAccessScreen> {
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: 'API endpoint',
labelStyle: TextStyle(color: Colors.white.withValues(alpha: 0.7)),
labelStyle: TextStyle(
color: palette.textSecondary,
),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.05),
fillColor: palette.fill,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)),
borderSide: BorderSide(
color: palette.border,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)),
borderSide: BorderSide(
color: palette.border,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: Colors.amber, width: 1.2),
borderSide: BorderSide(
color: palette.accent,
width: 1.2,
),
),
),
),
@@ -177,20 +180,25 @@ class _VaultAccessScreenState extends State<VaultAccessScreen> {
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: 'Usuario',
labelStyle: TextStyle(color: Colors.white.withValues(alpha: 0.7)),
labelStyle: TextStyle(
color: palette.textSecondary,
),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.05),
fillColor: palette.fill,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)),
borderSide: BorderSide(color: palette.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)),
borderSide: BorderSide(color: palette.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: Colors.amber, width: 1.2),
borderSide: BorderSide(
color: palette.accent,
width: 1.2,
),
),
),
),
@@ -202,34 +210,45 @@ class _VaultAccessScreenState extends State<VaultAccessScreen> {
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: 'Contraseña',
labelStyle: TextStyle(color: Colors.white.withValues(alpha: 0.7)),
labelStyle: TextStyle(
color: palette.textSecondary,
),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.05),
fillColor: palette.fill,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)),
borderSide: BorderSide(color: palette.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)),
borderSide: BorderSide(color: palette.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: Colors.amber, width: 1.2),
borderSide: BorderSide(
color: palette.accent,
width: 1.2,
),
),
),
),
const SizedBox(height: 22),
FilledButton(
onPressed: widget.isBusy ? null : _handleCreateAccount,
onPressed: widget.isBusy
? null
: _handleCreateAccount,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
padding: const EdgeInsets.symmetric(
vertical: 14,
),
),
child: widget.isBusy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Text('Crear cuenta'),
),
@@ -237,15 +256,19 @@ class _VaultAccessScreenState extends State<VaultAccessScreen> {
OutlinedButton(
onPressed: widget.isBusy ? null : _handleSignIn,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
side: const BorderSide(color: Colors.white24),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
vertical: 14,
),
side: BorderSide(color: palette.border),
foregroundColor: palette.textPrimary,
),
child: const Text('Iniciar sesión'),
),
const SizedBox(height: 18),
TextButton(
onPressed: widget.isBusy ? null : widget.onContinueWithoutAccount,
onPressed: widget.isBusy
? null
: widget.onContinueWithoutAccount,
child: const Text('Entrar sin cuenta'),
),
],
@@ -261,4 +284,4 @@ class _VaultAccessScreenState extends State<VaultAccessScreen> {
),
);
}
}
}
+303
View File
@@ -0,0 +1,303 @@
import 'package:flutter/material.dart';
@immutable
class AppPalette extends ThemeExtension<AppPalette> {
static const Color darkDefaultThemeSeedColor = Colors.amber;
static const Color lightDefaultThemeSeedColor = Colors.blue;
static const List<Color> defaultCategoryColors = <Color>[
Colors.amber,
Colors.blue,
Colors.green,
Colors.purple,
Colors.red,
Colors.teal,
Colors.orange,
Colors.grey,
];
static const List<Color> themeSeedColors = <Color>[
Colors.blue,
Colors.teal,
Colors.green,
Colors.pink,
Colors.purple,
Colors.orange,
];
const AppPalette({
required this.backdropGradient,
required this.drawerBackground,
required this.cardBackground,
required this.categoryColors,
required this.surfaceElevated,
required this.border,
required this.borderMuted,
required this.accent,
required this.textPrimary,
required this.textSecondary,
required this.textOnSurfaceDark,
required this.textMuted,
required this.textDisabled,
required this.textHint,
required this.fill,
required this.hover,
required this.shadowSoft,
required this.shadowDim,
required this.destructiveAccent,
required this.textOnAccent,
required this.overlay,
required this.transparent,
required this.dragTargetBorder,
required this.syncPreparing,
required this.syncEncrypting,
required this.syncUploading,
required this.syncWaiting,
required this.syncDecrypting,
required this.success,
});
final Gradient backdropGradient;
final Color drawerBackground;
final Color cardBackground;
final List<Color> categoryColors;
final Color surfaceElevated;
final Color border;
final Color borderMuted;
final Color accent;
final Color textPrimary;
final Color textSecondary;
final Color textOnSurfaceDark;
final Color textMuted;
final Color textDisabled;
final Color textHint;
final Color fill;
final Color hover;
final Color shadowSoft;
final Color shadowDim;
final Color destructiveAccent;
final Color textOnAccent;
final Color overlay;
final Color transparent;
final Color dragTargetBorder;
final Color syncPreparing;
final Color syncEncrypting;
final Color syncUploading;
final Color syncWaiting;
final Color syncDecrypting;
final Color success;
@override
AppPalette copyWith({
Gradient? backdropGradient,
Color? drawerBackground,
Color? cardBackground,
List<Color>? categoryColors,
Color? surfaceElevated,
Color? border,
Color? borderMuted,
Color? accent,
Color? textPrimary,
Color? textSecondary,
Color? textOnSurfaceDark,
Color? textMuted,
Color? textDisabled,
Color? textHint,
Color? fill,
Color? hover,
Color? shadowSoft,
Color? shadowDim,
Color? destructiveAccent,
Color? textOnAccent,
Color? overlay,
Color? transparent,
Color? dragTargetBorder,
Color? syncPreparing,
Color? syncEncrypting,
Color? syncUploading,
Color? syncWaiting,
Color? syncDecrypting,
Color? success,
}) {
return AppPalette(
backdropGradient: backdropGradient ?? this.backdropGradient,
drawerBackground: drawerBackground ?? this.drawerBackground,
cardBackground: cardBackground ?? this.cardBackground,
categoryColors: categoryColors ?? this.categoryColors,
surfaceElevated: surfaceElevated ?? this.surfaceElevated,
border: border ?? this.border,
borderMuted: borderMuted ?? this.borderMuted,
accent: accent ?? this.accent,
textPrimary: textPrimary ?? this.textPrimary,
textSecondary: textSecondary ?? this.textSecondary,
textOnSurfaceDark: textOnSurfaceDark ?? this.textOnSurfaceDark,
textMuted: textMuted ?? this.textMuted,
textDisabled: textDisabled ?? this.textDisabled,
textHint: textHint ?? this.textHint,
fill: fill ?? this.fill,
hover: hover ?? this.hover,
shadowSoft: shadowSoft ?? this.shadowSoft,
shadowDim: shadowDim ?? this.shadowDim,
destructiveAccent: destructiveAccent ?? this.destructiveAccent,
textOnAccent: textOnAccent ?? this.textOnAccent,
overlay: overlay ?? this.overlay,
transparent: transparent ?? this.transparent,
dragTargetBorder: dragTargetBorder ?? this.dragTargetBorder,
syncPreparing: syncPreparing ?? this.syncPreparing,
syncEncrypting: syncEncrypting ?? this.syncEncrypting,
syncUploading: syncUploading ?? this.syncUploading,
syncWaiting: syncWaiting ?? this.syncWaiting,
syncDecrypting: syncDecrypting ?? this.syncDecrypting,
success: success ?? this.success,
);
}
@override
AppPalette lerp(ThemeExtension<AppPalette>? other, double t) {
if (other is! AppPalette) return this;
return AppPalette(
backdropGradient:
LinearGradient.lerp(
backdropGradient as LinearGradient,
other.backdropGradient as LinearGradient,
t,
) ??
backdropGradient,
drawerBackground:
Color.lerp(drawerBackground, other.drawerBackground, t) ??
drawerBackground,
cardBackground:
Color.lerp(cardBackground, other.cardBackground, t) ?? cardBackground,
categoryColors: t < 0.5 ? categoryColors : other.categoryColors,
surfaceElevated:
Color.lerp(surfaceElevated, other.surfaceElevated, t) ??
surfaceElevated,
border: Color.lerp(border, other.border, t) ?? border,
borderMuted: Color.lerp(borderMuted, other.borderMuted, t) ?? borderMuted,
accent: Color.lerp(accent, other.accent, t) ?? accent,
textPrimary: Color.lerp(textPrimary, other.textPrimary, t) ?? textPrimary,
textSecondary:
Color.lerp(textSecondary, other.textSecondary, t) ?? textSecondary,
textOnSurfaceDark:
Color.lerp(textOnSurfaceDark, other.textOnSurfaceDark, t) ??
textOnSurfaceDark,
textMuted: Color.lerp(textMuted, other.textMuted, t) ?? textMuted,
textDisabled:
Color.lerp(textDisabled, other.textDisabled, t) ?? textDisabled,
textHint: Color.lerp(textHint, other.textHint, t) ?? textHint,
fill: Color.lerp(fill, other.fill, t) ?? fill,
hover: Color.lerp(hover, other.hover, t) ?? hover,
shadowSoft: Color.lerp(shadowSoft, other.shadowSoft, t) ?? shadowSoft,
shadowDim: Color.lerp(shadowDim, other.shadowDim, t) ?? shadowDim,
destructiveAccent:
Color.lerp(destructiveAccent, other.destructiveAccent, t) ??
destructiveAccent,
textOnAccent:
Color.lerp(textOnAccent, other.textOnAccent, t) ?? textOnAccent,
overlay: Color.lerp(overlay, other.overlay, t) ?? overlay,
transparent: Color.lerp(transparent, other.transparent, t) ?? transparent,
dragTargetBorder:
Color.lerp(dragTargetBorder, other.dragTargetBorder, t) ??
dragTargetBorder,
syncPreparing:
Color.lerp(syncPreparing, other.syncPreparing, t) ?? syncPreparing,
syncEncrypting:
Color.lerp(syncEncrypting, other.syncEncrypting, t) ?? syncEncrypting,
syncUploading:
Color.lerp(syncUploading, other.syncUploading, t) ?? syncUploading,
syncWaiting: Color.lerp(syncWaiting, other.syncWaiting, t) ?? syncWaiting,
syncDecrypting:
Color.lerp(syncDecrypting, other.syncDecrypting, t) ?? syncDecrypting,
success: Color.lerp(success, other.success, t) ?? success,
);
}
static AppPalette fromBrightness(Brightness brightness, {Color? seedColor}) {
final bool isLight = brightness == Brightness.light;
return AppPalette(
backdropGradient: isLight
? const LinearGradient(
colors: <Color>[
Color(0xFFFFFFFF),
Color(0xFFF2F4F6),
Color(0xFFECEFF1),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
)
: const LinearGradient(
colors: <Color>[
Color(0xFF191A1D),
Color(0xFF222326),
Color(0xFF101114),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
drawerBackground: isLight
? const Color(0xFFF7F9FA)
: const Color.fromARGB(255, 30, 31, 35),
cardBackground: isLight
? const Color(0xFFFFFFFF)
: const Color.fromRGBO(24, 25, 26, 1),
categoryColors: defaultCategoryColors,
surfaceElevated: isLight
? const Color(0xFFF0F2F4)
: const Color(0xFF303134),
border: isLight
? const Color.fromRGBO(15, 23, 32, 0.06)
: const Color.fromRGBO(255, 255, 255, 0.12),
borderMuted: isLight
? const Color.fromRGBO(15, 23, 32, 0.03)
: const Color.fromRGBO(255, 255, 255, 0.08),
accent:
seedColor ??
(isLight
? AppPalette.lightDefaultThemeSeedColor
: AppPalette.darkDefaultThemeSeedColor),
textPrimary: isLight ? const Color(0xFF0F1720) : Colors.white,
textSecondary: isLight ? const Color(0xFF374151) : Colors.white70,
textOnSurfaceDark: isLight ? Colors.black87 : Colors.black87,
textMuted: isLight ? const Color(0xFF6B7280) : Colors.white54,
textDisabled: isLight ? const Color(0xFFBDBDBD) : Colors.white24,
textHint: isLight
? const Color.fromRGBO(15, 23, 32, 0.30)
: const Color.fromRGBO(255, 255, 255, 0.30),
fill: isLight
? const Color.fromRGBO(15, 23, 32, 0.02)
: const Color.fromRGBO(255, 255, 255, 0.05),
hover: isLight
? const Color.fromRGBO(15, 23, 32, 0.04)
: const Color.fromRGBO(255, 255, 255, 0.10),
shadowSoft: isLight
? const Color.fromRGBO(0, 0, 0, 0.08)
: const Color.fromRGBO(0, 0, 0, 0.25),
shadowDim: isLight
? const Color.fromARGB(40, 0, 0, 0)
: const Color.fromARGB(54, 0, 0, 0),
destructiveAccent: Colors.redAccent,
textOnAccent: isLight ? Colors.white : Colors.black,
overlay: isLight
? const Color.fromARGB(120, 0, 0, 0)
: const Color.fromARGB(140, 0, 0, 0),
transparent: Colors.transparent,
dragTargetBorder: isLight
? const Color(0xFF1565C0)
: const Color(0xFF42A5F5),
syncPreparing: isLight
? const Color.fromARGB(255, 120, 120, 120)
: const Color.fromARGB(255, 165, 165, 165),
syncEncrypting: isLight
? const Color.fromARGB(255, 2, 136, 209)
: const Color.fromARGB(255, 109, 191, 255),
syncUploading: isLight
? const Color.fromARGB(255, 2, 119, 189)
: const Color.fromARGB(255, 98, 190, 255),
syncWaiting: const Color.fromARGB(255, 150, 150, 150),
syncDecrypting: isLight
? const Color.fromARGB(255, 76, 175, 80)
: const Color.fromARGB(255, 154, 194, 112),
success: Colors.green,
);
}
}
+23 -9
View File
@@ -1,23 +1,37 @@
import 'package:flutter/material.dart';
import 'package:notas/theme/app_palette.dart';
class AppTheme {
static ThemeData theme({Color seedColor = Colors.amber}) {
static ThemeData theme({
Color seedColor = Colors.amber,
Brightness brightness = Brightness.dark,
}) {
final Brightness foregroundBrightness =
ThemeData.estimateBrightnessForColor(seedColor);
final Color foregroundColor =
foregroundBrightness == Brightness.dark ? Colors.white : Colors.black;
final Color foregroundColor = foregroundBrightness == Brightness.dark
? Colors.white
: Colors.black87;
final ColorScheme scheme = ColorScheme.fromSeed(
seedColor: seedColor,
brightness: brightness,
);
final AppPalette palette = AppPalette.fromBrightness(
brightness,
seedColor: seedColor,
);
return ThemeData(
useMaterial3: true,
scaffoldBackgroundColor: const Color.fromRGBO(31, 32, 33, 1),
colorScheme: ColorScheme.fromSeed(
seedColor: seedColor,
brightness: Brightness.dark,
),
scaffoldBackgroundColor: scheme.surface,
colorScheme: scheme,
extensions: <ThemeExtension<dynamic>>[palette],
brightness: brightness,
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: seedColor,
foregroundColor: foregroundColor,
),
);
}
}
}
-1
View File
@@ -1 +0,0 @@
export 'app_title_bar_stub.dart' if (dart.library.io) 'app_title_bar_io.dart';
-241
View File
@@ -1,241 +0,0 @@
import 'package:flutter/material.dart';
import 'package:notas/platform/app_platform.dart';
import 'package:window_manager/window_manager.dart';
class AppTitleBar extends StatelessWidget {
const AppTitleBar({
super.key,
});
@override
Widget build(BuildContext context) {
if (isAndroid || isIOS) {
return const SizedBox(height: 10);
}
if (isMacOS) {
return SizedBox(
height: 28,
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Mis Notas',
style: TextStyle(color: Colors.white70, fontSize: 13),
),
],
),
),
);
}
if (isLinux) {
return const _KdeTitleBar();
}
return SizedBox(
height: 40,
child: WindowCaption(
brightness: Brightness.dark,
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Mis Notas', style: TextStyle(color: Colors.white)),
],
),
),
);
}
}
class _KdeTitleBar extends StatefulWidget {
const _KdeTitleBar();
@override
State<_KdeTitleBar> createState() => _KdeTitleBarState();
}
class _KdeTitleBarState extends State<_KdeTitleBar> with WindowListener {
bool _isFullScreen = false;
bool _isMaximized = false;
@override
void initState() {
super.initState();
if (isDesktop) {
windowManager.addListener(this);
_refreshWindowState();
}
}
@override
void dispose() {
if (isDesktop) {
windowManager.removeListener(this);
}
super.dispose();
}
Future<void> _refreshWindowState() async {
final bool isFullScreen = await windowManager.isFullScreen();
final bool isMaximized = await windowManager.isMaximized();
if (!mounted) {
return;
}
setState(() {
_isFullScreen = isFullScreen;
_isMaximized = isMaximized;
});
}
@override
void onWindowEnterFullScreen() {
setState(() {
_isFullScreen = true;
});
}
@override
void onWindowLeaveFullScreen() {
setState(() {
_isFullScreen = false;
});
}
@override
void onWindowMaximize() {
setState(() {
_isMaximized = true;
});
}
@override
void onWindowUnmaximize() {
setState(() {
_isMaximized = false;
});
}
@override
Widget build(BuildContext context) {
final IconData maximizeIcon =
(_isFullScreen || _isMaximized) ? Icons.fullscreen_exit : Icons.fullscreen;
return Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Colors.white.withValues(alpha: 0.12),
width: 0.5,
),
),
),
child: SizedBox(
height: 32,
child: Stack(
children: [
const DragToMoveArea(
child: SizedBox.expand(),
),
const IgnorePointer(
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Mis Notas',
style: TextStyle(
color: Color.fromARGB(255, 163, 163, 163),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
Positioned(
right: 0,
top: 0,
bottom: 0,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_KdeButton(
icon: Icons.minimize,
onPressed: () => windowManager.minimize(),
),
_KdeButton(
icon: maximizeIcon,
onPressed: () async {
if (await windowManager.isFullScreen()) {
await windowManager.setFullScreen(false);
} else if (await windowManager.isMaximized()) {
await windowManager.unmaximize();
} else {
await windowManager.maximize();
}
},
),
_KdeButton(
icon: Icons.close,
isClose: true,
onPressed: () => windowManager.close(),
),
],
),
),
],
),
),
);
}
}
class _KdeButton extends StatelessWidget {
const _KdeButton({
required this.icon,
required this.onPressed,
this.isClose = false,
});
final IconData icon;
final VoidCallback onPressed;
final bool isClose;
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onPressed,
child: SizedBox(
width: 32,
height: double.infinity,
child: Material(
color: Colors.transparent,
child: InkResponse(
highlightShape: BoxShape.circle,
containedInkWell: false,
radius: 10,
hoverColor: isClose ? Colors.red : Colors.white.withValues(alpha: 0.08),
onTap: onPressed,
child: Center(
child: Icon(
icon,
color: Colors.white70,
size: 16,
),
),
),
),
),
),
);
}
}
-18
View File
@@ -1,18 +0,0 @@
import 'package:flutter/material.dart';
import 'package:notas/widgets/sync_status_indicator.dart';
class AppTitleBar extends StatelessWidget {
const AppTitleBar({
this.syncStatus = SyncStatus.idle,
this.syncErrorMessage,
super.key,
});
final SyncStatus syncStatus;
final String? syncErrorMessage;
@override
Widget build(BuildContext context) {
return const SizedBox.shrink();
}
}
+42
View File
@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:notas/theme/app_palette.dart';
class CategoryStyle {
CategoryStyle._();
static const List<Color> colors = AppPalette.defaultCategoryColors;
static List<Color> colorsOf(BuildContext context) {
final AppPalette? palette = Theme.of(context).extension<AppPalette>();
if (palette != null) {
return palette.categoryColors;
}
return AppPalette.defaultCategoryColors;
}
static const List<IconData> icons = <IconData>[
Icons.label_outline_rounded,
Icons.work,
Icons.star,
Icons.home,
Icons.school,
Icons.book,
Icons.music_note,
Icons.lightbulb,
];
static IconData iconForCodePoint(int? codePoint) {
if (codePoint == null) {
return Icons.folder_outlined;
}
for (final IconData icon in icons) {
if (icon.codePoint == codePoint) {
return icon;
}
}
return Icons.folder_outlined;
}
}
+138 -16
View File
@@ -1,71 +1,193 @@
import 'package:flutter/material.dart';
import 'package:notas/models/category.dart';
import 'package:notas/theme/app_palette.dart';
import 'package:notas/widgets/category_style.dart';
class MenuDrawer extends StatelessWidget {
const MenuDrawer({
super.key,
this.onMenuItemTapped,
this.selectedItem,
this.categories = const [],
this.onCreateCategory,
this.onEditCategory,
});
final ValueChanged<String>? onMenuItemTapped;
final String? selectedItem;
final List<Category> categories;
final VoidCallback? onCreateCategory;
final ValueChanged<Category>? onEditCategory;
@override
Widget build(BuildContext context) {
final AppPalette palette = Theme.of(context).extension<AppPalette>()!;
return Container(
decoration: const BoxDecoration(
color: Color.fromARGB(255, 30, 31, 35),
border: Border(
right: BorderSide(color: Colors.white12, width: 0.5),
),
decoration: BoxDecoration(
color: palette.drawerBackground,
border: Border(right: BorderSide(color: palette.border, width: 0.5)),
),
child: Column(
children: [
const SizedBox(height: 8),
_MenuItemTile(
icon: Icons.note,
label: 'Todas mis notas',
selected: selectedItem == 'all_notes',
onTap: () => onMenuItemTapped?.call('all_notes'),
),
Expanded(
child: SingleChildScrollView(
child: Column(
children: const [
SizedBox.shrink(),
children: [
if (categories.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
children: categories.map((category) {
final categoryId = 'category_${category.id}';
final IconData categoryIcon =
CategoryStyle.iconForCodePoint(
category.iconCodePoint,
);
return _MenuItemTile(
icon: categoryIcon,
label: category.name,
selected: selectedItem == categoryId,
onTap: () => onMenuItemTapped?.call(categoryId),
onLongPress: onEditCategory == null
? null
: () => onEditCategory?.call(category),
iconColor: Color(
category.colorValue ?? palette.accent.toARGB32(),
),
textColor: Color(
category.colorValue ?? palette.accent.toARGB32(),
),
trailing: IconButton(
padding: const EdgeInsets.all(8),
constraints: const BoxConstraints(
minWidth: 0,
minHeight: 0,
),
icon: Icon(
Icons.more_vert,
color: palette.textSecondary,
size: 20,
),
onPressed: () => onEditCategory?.call(category),
),
);
}).toList(),
),
),
_MenuItemTile(
icon: Icons.add_circle_outline,
label: 'Crear categoría',
onTap: onCreateCategory,
),
],
),
),
),
const Divider(color: Colors.white12, height: 16),
_MenuItemTile(
icon: Icons.delete_outline,
label: 'Mis notas borradas',
selected: selectedItem == 'deleted_notes',
onTap: () => onMenuItemTapped?.call('deleted_notes'),
iconColor: palette.destructiveAccent,
textColor: palette.destructiveAccent,
),
Divider(color: palette.border, height: 16),
_MenuItemTile(
icon: Icons.settings,
label: 'Configuración',
onTap: () => onMenuItemTapped?.call('settings'),
),
const SizedBox(height: 6),
],
),
);
}
}
class _MenuItemTile extends StatelessWidget {
class _MenuItemTile extends StatefulWidget {
const _MenuItemTile({
required this.icon,
required this.label,
this.selected = false,
this.onTap,
this.onLongPress,
this.iconColor,
this.textColor,
this.trailing,
});
final IconData icon;
final String label;
final bool selected;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final Color? iconColor;
final Color? textColor;
final Widget? trailing;
@override
State<_MenuItemTile> createState() => _MenuItemTileState();
}
class _MenuItemTileState extends State<_MenuItemTile> {
bool _hovering = false;
@override
Widget build(BuildContext context) {
return ListTile(
leading: Icon(icon, color: Colors.white70),
title: Text(
label,
style: const TextStyle(color: Colors.white70, fontSize: 14),
final AppPalette palette = Theme.of(context).extension<AppPalette>()!;
final bool active = widget.selected || _hovering;
final Color backgroundColor = active ? palette.hover : palette.transparent;
final Color foregroundColor = active
? palette.textPrimary
: palette.textSecondary;
final Widget? trailing = _hovering ? widget.trailing : null;
return MouseRegion(
onEnter: (_) {
setState(() => _hovering = true);
},
onExit: (_) {
setState(() => _hovering = false);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
margin: const EdgeInsets.only(right: 8, top: 2, bottom: 2),
child: Material(
color: backgroundColor,
borderRadius: const BorderRadius.only(
topRight: Radius.circular(999),
bottomRight: Radius.circular(999),
),
clipBehavior: Clip.antiAlias,
child: ListTile(
contentPadding: const EdgeInsets.only(left: 16, right: 8),
leading: Icon(
widget.icon,
color: widget.iconColor ?? foregroundColor,
),
trailing: trailing,
title: Text(
widget.label,
style: TextStyle(
color: widget.textColor ?? foregroundColor,
fontSize: 14,
),
),
onTap: widget.onTap,
onLongPress: widget.onLongPress,
),
),
),
onTap: onTap,
hoverColor: Colors.white.withValues(alpha: 0.1),
);
}
}
+143 -110
View File
@@ -1,131 +1,164 @@
import 'package:flutter/material.dart';
import 'package:notas/data/note_body.dart';
import 'package:notas/models/category.dart';
import 'package:notas/models/note.dart';
import 'package:notas/theme/app_palette.dart';
import 'package:notas/widgets/category_style.dart';
// Small presentational widget for a note inside the grid.
// Keep this widget lightweight and layout-agnostic: it should not force
// width/height constraints (so it works inside different parent layouts
// like MasonryGridView or Draggable feedback). Visual styling only.
class NoteCard extends StatefulWidget {
const NoteCard({super.key, required this.note, this.onTap, this.isDragging = false});
class NoteCard extends StatelessWidget {
const NoteCard({
super.key,
required this.note,
this.category,
this.isSelected = false,
this.borderColor,
this.onTap,
this.onDelete,
this.onChangeCategory,
this.showSelectionBorder = true,
});
final Note note;
final Category? category;
final bool isSelected;
final Color? borderColor;
final VoidCallback? onTap;
final bool isDragging;
@override
State<NoteCard> createState() => _NoteCardState();
}
class _NoteCardState extends State<NoteCard> {
bool _isPressed = false;
final VoidCallback? onDelete;
final ValueChanged<BuildContext>? onChangeCategory;
final bool showSelectionBorder;
@override
Widget build(BuildContext context) {
final bool showGrabbing = widget.isDragging || _isPressed;
final AppPalette palette = Theme.of(context).extension<AppPalette>()!;
final String bodyText = noteBodyToPlainText(note.body).trim();
final Color? categoryColor = category?.colorValue == null
? null
: Color(category!.colorValue!);
final IconData? categoryIcon = category == null
? null
: CategoryStyle.iconForCodePoint(category!.iconCodePoint);
return MouseRegion(
cursor: showGrabbing ? SystemMouseCursors.grabbing : SystemMouseCursors.grab,
child: GestureDetector(
onTapDown: widget.onTap == null
? null
: (_) {
setState(() {
_isPressed = true;
});
},
onTapUp: widget.onTap == null
? null
: (_) {
setState(() {
_isPressed = false;
});
},
onTapCancel: widget.onTap == null
? null
: () {
setState(() {
_isPressed = false;
});
},
onTap: widget.onTap,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color.fromRGBO(24, 25, 26, 1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white24, width: 1),
),
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
// Estimate whether the body will exceed 20 lines without always
// running the expensive TextPainter layout. This heuristic counts
// newline characters and estimates wrapped lines based on an
// average characters-per-line to handle many short lines well.
final List<String> rawLines = widget.note.body.split('\n');
const int avgCharsPerLine = 40;
int estimatedLines = 0;
for (final String line in rawLines) {
estimatedLines += (line.trim().length ~/ avgCharsPerLine) + 1;
}
final bool needsPreciseMeasurement = estimatedLines > 20;
final bool isBodyTruncated;
if (needsPreciseMeasurement) {
final TextPainter textPainter = TextPainter(
text: TextSpan(
text: widget.note.body,
style: const TextStyle(color: Colors.white70, fontSize: 14),
),
maxLines: 20,
textDirection: TextDirection.ltr,
)..layout(maxWidth: constraints.maxWidth);
isBodyTruncated = textPainter.didExceedMaxLines;
} else {
isBodyTruncated = false;
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.note.title,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
return Material(
color: Colors.transparent, // 1. Fondo completamente transparente
shape: BorderDirectional(
start: BorderSide(
color: (isSelected && showSelectionBorder)
? palette.accent
: Colors.transparent,
width: isSelected ? 1.6 : 1.0,
),
),
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onTap,
hoverColor:
Colors.transparent, // 2. Desactiva el efecto hover (pasar el ratón)
splashColor:
Colors.transparent, // 3. Desactiva el efecto de onda al hacer clic
highlightColor:
Colors.transparent, // Desactiva el brillo al mantener pulsado
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (categoryIcon != null) ...[
SizedBox(
width: 18,
height: 18,
child: Icon(
categoryIcon,
size: 18,
color: categoryColor ?? palette.textSecondary,
),
),
const SizedBox(width: 4),
],
Expanded(
child: Text(
note.title.isEmpty ? 'Sin título' : note.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: palette.textPrimary,
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
),
],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Text(
widget.note.body,
style: const TextStyle(color: Colors.white70, fontSize: 14),
maxLines: 20,
overflow: TextOverflow.clip,
),
if (isBodyTruncated) ...[
const SizedBox(height: 4),
const Text(
'...',
const SizedBox(height: 6),
Text(
bodyText.isEmpty ? ' ' : bodyText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white54,
fontSize: 18,
height: 1,
color: palette.textSecondary,
fontSize: 14,
height: 1.2,
),
),
const SizedBox(height: 4),
],
],
);
},
),
),
const SizedBox(width: 8),
Builder(
builder: (BuildContext buttonContext) {
return PopupMenuButton<String>(
icon: Icon(Icons.more_vert, color: palette.textSecondary),
color: palette.surfaceElevated,
elevation: 10,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
onSelected: (String value) {
switch (value) {
case 'category':
onChangeCategory?.call(buttonContext);
return;
case 'delete':
onDelete?.call();
return;
}
},
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<String>>[
const PopupMenuItem<String>(
value: 'category',
child: Text('Modificar categoría'),
),
PopupMenuItem<String>(
value: 'delete',
child: Row(
children: const [
Icon(Icons.delete_outline, color: Colors.red),
SizedBox(width: 10),
Text(
'Eliminar',
style: TextStyle(color: Colors.red),
),
],
),
),
],
);
},
),
],
),
),
),
);
}
}
}
+28 -23
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:notas/theme/app_palette.dart';
class SearchAppBar extends StatefulWidget {
const SearchAppBar({
@@ -51,22 +52,23 @@ class _SearchAppBarState extends State<SearchAppBar> {
@override
Widget build(BuildContext context) {
final AppPalette palette = Theme.of(context).extension<AppPalette>()!;
return Container(
decoration: BoxDecoration(
color: Colors.transparent,
border: Border(
bottom: BorderSide(
color: Colors.white.withValues(alpha: 0.12),
width: 0.5,
),
),
color: palette.transparent,
border: Border(bottom: BorderSide(color: palette.border, width: 0.5)),
),
padding: const EdgeInsets.only(left: 8, right: 20, top: 7, bottom: 7),
child: Row(
children: [
IconButton(
onPressed: widget.onLeadingPressed ?? widget.onMenuPressed,
icon: Icon(widget.leadingIcon, color: Colors.white70, size: 20),
icon: Icon(
widget.leadingIcon,
color: palette.textSecondary,
size: 20,
),
tooltip: widget.leadingTooltip,
splashRadius: 18,
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
@@ -84,18 +86,21 @@ class _SearchAppBarState extends State<SearchAppBar> {
child: TextField(
controller: _searchController,
onChanged: widget.onSearchChanged,
style: const TextStyle(color: Colors.white, fontSize: 13),
cursorColor: Colors.white70,
style: TextStyle(
color: palette.textPrimary,
fontSize: 13,
),
cursorColor: palette.textSecondary,
decoration: InputDecoration(
hintText: widget.searchHint,
hintStyle: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
color: palette.textSecondary.withValues(alpha: 0.6),
),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(
icon: Icon(
Icons.clear,
color: Colors.white70,
color: palette.textSecondary,
size: 18,
),
onPressed: () {
@@ -107,37 +112,37 @@ class _SearchAppBarState extends State<SearchAppBar> {
minHeight: 36,
),
)
: const Padding(
padding: EdgeInsets.only(right: 8),
: Padding(
padding: const EdgeInsets.only(right: 8),
child: Icon(
Icons.search,
color: Colors.white70,
color: palette.textSecondary,
size: 18,
),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: Colors.white.withValues(alpha: 0.2),
color: palette.border,
width: 0.5,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: Colors.white.withValues(alpha: 0.2),
color: palette.border,
width: 0.5,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: Colors.white.withValues(alpha: 0.4),
width: 0.5,
color: palette.accent,
width: 0.6,
),
),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.05),
fillColor: palette.fill,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
@@ -151,8 +156,8 @@ class _SearchAppBarState extends State<SearchAppBar> {
alignment: Alignment.centerLeft,
child: Text(
widget.titleText ?? '',
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: palette.textPrimary,
fontSize: 18,
fontWeight: FontWeight.w600,
),
+11
View File
@@ -0,0 +1,11 @@
enum SyncStatus {
idle,
preparing,
encrypting,
uploading,
waitingResponse,
decrypting,
syncing,
synced,
error,
}
+130 -34
View File
@@ -1,21 +1,20 @@
import 'package:flutter/material.dart';
enum SyncStatus {
idle,
syncing,
synced,
error,
}
import 'package:notas/theme/app_palette.dart';
import 'package:notas/widgets/sync_status.dart';
class SyncStatusIndicator extends StatelessWidget {
const SyncStatusIndicator({
required this.status,
this.progress,
this.detailMessage,
this.errorMessage,
this.onTap,
super.key,
});
final SyncStatus status;
final double? progress;
final String? detailMessage;
final String? errorMessage;
final VoidCallback? onTap;
@@ -34,59 +33,156 @@ 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
Widget build(BuildContext context) {
final AppPalette palette = Theme.of(context).extension<AppPalette>()!;
switch (status) {
case SyncStatus.idle:
return Tooltip(
message: 'Sincronización en espera',
message: _messageForStatus(),
child: _buildIndicator(
const Icon(
Icons.cloud_outlined,
size: 16,
color: Colors.white38,
Icon(Icons.cloud_outlined, size: 16, color: palette.textSecondary),
),
);
case SyncStatus.preparing:
return Tooltip(
message: _messageForStatus(),
child: _buildIndicator(
_buildStatusBadge(
icon: Icons.sync,
color: palette.syncPreparing,
determinate: false,
),
),
);
case SyncStatus.encrypting:
return Tooltip(
message: _messageForStatus(),
child: _buildIndicator(
_buildStatusBadge(
icon: Icons.cloud_upload_outlined,
color: palette.syncEncrypting,
determinate: true,
),
),
);
case SyncStatus.uploading:
return Tooltip(
message: _messageForStatus(),
child: _buildIndicator(
_buildStatusBadge(
icon: Icons.cloud_upload,
color: palette.syncUploading,
determinate: false,
),
),
);
case SyncStatus.waitingResponse:
return Tooltip(
message: _messageForStatus(),
child: _buildIndicator(
_buildStatusBadge(
icon: Icons.cloud_sync_outlined,
color: palette.syncWaiting,
determinate: false,
),
),
);
case SyncStatus.decrypting:
return Tooltip(
message: _messageForStatus(),
child: _buildIndicator(
_buildStatusBadge(
icon: Icons.cloud_download_outlined,
color: palette.syncDecrypting,
determinate: true,
),
),
);
case SyncStatus.syncing:
return Tooltip(
message: 'Sincronizando...',
message: _messageForStatus(),
child: _buildIndicator(
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Color.fromARGB(255, 150, 150, 150),
),
),
_buildStatusBadge(
icon: Icons.sync,
color: palette.syncWaiting,
determinate: false,
),
),
);
case SyncStatus.synced:
return Tooltip(
message: 'Sincronizado',
message: _messageForStatus(),
child: _buildIndicator(
const Icon(
Icons.check_circle,
size: 16,
color: Colors.green,
),
Icon(Icons.check_circle, size: 16, color: palette.success),
),
);
case SyncStatus.error:
return Tooltip(
message: errorMessage ?? 'Error al sincronizar',
message: _messageForStatus(),
child: _buildIndicator(
const Icon(
Icons.error,
size: 16,
color: Colors.red,
),
Icon(Icons.error, size: 16, color: palette.destructiveAccent),
),
);
}
@@ -8,6 +8,7 @@
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
#include <window_manager/window_manager_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
@@ -17,6 +18,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin");
screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
g_autoptr(FlPluginRegistrar) window_manager_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin");
window_manager_plugin_register_with_registrar(window_manager_registrar);
+1
View File
@@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_linux
screen_retriever_linux
url_launcher_linux
window_manager
)
@@ -7,14 +7,18 @@ import Foundation
import flutter_secure_storage_darwin
import local_auth_darwin
import quill_native_bridge_macos
import screen_retriever_macos
import shared_preferences_foundation
import url_launcher_macos
import window_manager
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
QuillNativeBridgePlugin.register(with: registry.registrar(forPlugin: "QuillNativeBridgePlugin"))
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 521 B

After

Width:  |  Height:  |  Size: 433 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 722 B

After

Width:  |  Height:  |  Size: 813 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

+327 -74
View File
@@ -5,18 +5,18 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d"
sha256: cd6add6f846f35fb79f3c315296703c1a24f3cfd7f4739d91a74961c1c7e9f1b
url: "https://pub.dev"
source: hosted
version: "93.0.0"
version: "100.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b
sha256: "6ba98576948803398b69e3a444df24eacdbe12ed699c7014e120ea38552debbf"
url: "https://pub.dev"
source: hosted
version: "10.0.1"
version: "13.0.0"
archive:
dependency: transitive
description:
@@ -141,10 +141,10 @@ packages:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.dev"
source: hosted
version: "1.0.0"
version: "1.2.1"
collection:
dependency: transitive
description:
@@ -161,6 +161,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.2"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: d687bec93342bf6a764a116d15c8694ebeff10e633dc28a39dd3144f7195024e
url: "https://pub.dev"
source: hosted
version: "0.3.5+3"
crypto:
dependency: "direct main"
description:
@@ -177,6 +185,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.9.0"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_icons:
dependency: "direct main"
description:
@@ -185,30 +201,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.9"
dart_quill_delta:
dependency: transitive
description:
name: dart_quill_delta
sha256: bddb0b2948bd5b5a328f1651764486d162c59a8ccffd4c63e8b2c5e44be1dac4
url: "https://pub.dev"
source: hosted
version: "10.8.3"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2"
sha256: "59d53ef8eaed9d288ed9767618e2b31c4fa0383a127db59d5eb2e737a7638a60"
url: "https://pub.dev"
source: hosted
version: "3.1.7"
version: "3.1.9"
diff_match_patch:
dependency: transitive
description:
name: diff_match_patch
sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4"
url: "https://pub.dev"
source: hosted
version: "0.4.1"
drift:
dependency: "direct main"
description:
name: drift
sha256: "8033500116b24398fba0cca0369cc31678cd627c01e41753a61186911cea743e"
sha256: "6cc0b623c0e83f7080524d8396e9301b1d78b9c66a4fdceeb0f798211303254c"
url: "https://pub.dev"
source: hosted
version: "2.33.0"
version: "2.34.0"
drift_dev:
dependency: "direct dev"
description:
name: drift_dev
sha256: b3dd5b75e30522a91da8abda9f5bb17230cb038097f6d15fa75d42bb563428aa
sha256: "042a5fb507ab5697f67eb55b75cfff2f665701f6606926136d6d4e85f81ff837"
url: "https://pub.dev"
source: hosted
version: "2.33.0"
version: "2.34.1+1"
fake_async:
dependency: transitive
description:
@@ -225,14 +257,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
ffi_leak_tracker:
dependency: transitive
description:
name: ffi_leak_tracker
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
file:
dependency: transitive
description:
@@ -241,6 +265,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_selector_platform_interface:
dependency: transitive
description:
name: file_selector_platform_interface
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
file_selector_windows:
dependency: transitive
description:
name: file_selector_windows
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
url: "https://pub.dev"
source: hosted
version: "0.9.3+5"
fixnum:
dependency: transitive
description:
@@ -254,6 +294,54 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_colorpicker:
dependency: transitive
description:
name: flutter_colorpicker
sha256: "969de5f6f9e2a570ac660fb7b501551451ea2a1ab9e2097e89475f60e07816ea"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
flutter_keyboard_visibility_linux:
dependency: transitive
description:
name: flutter_keyboard_visibility_linux
sha256: "6fba7cd9bb033b6ddd8c2beb4c99ad02d728f1e6e6d9b9446667398b2ac39f08"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
flutter_keyboard_visibility_macos:
dependency: transitive
description:
name: flutter_keyboard_visibility_macos
sha256: c5c49b16fff453dfdafdc16f26bdd8fb8d55812a1d50b0ce25fc8d9f2e53d086
url: "https://pub.dev"
source: hosted
version: "1.0.0"
flutter_keyboard_visibility_platform_interface:
dependency: transitive
description:
name: flutter_keyboard_visibility_platform_interface
sha256: e43a89845873f7be10cb3884345ceb9aebf00a659f479d1c8f4293fcb37022a4
url: "https://pub.dev"
source: hosted
version: "2.0.0"
flutter_keyboard_visibility_temp_fork:
dependency: transitive
description:
name: flutter_keyboard_visibility_temp_fork
sha256: e3d02900640fbc1129245540db16944a0898b8be81694f4bf04b6c985bed9048
url: "https://pub.dev"
source: hosted
version: "0.1.5"
flutter_keyboard_visibility_windows:
dependency: transitive
description:
name: flutter_keyboard_visibility_windows
sha256: fc4b0f0b6be9b93ae527f3d527fb56ee2d918cd88bbca438c478af7bcfd0ef73
url: "https://pub.dev"
source: hosted
version: "1.0.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
@@ -270,38 +358,59 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
url: "https://pub.dev"
source: hosted
version: "2.0.34"
version: "2.0.35"
flutter_quill:
dependency: "direct main"
description:
name: flutter_quill
sha256: "3ee7125b2dd3f3bce3ebdaac722a72f0c8aff3db9aa19053a9d777db12d71c98"
url: "https://pub.dev"
source: hosted
version: "11.5.1"
flutter_quill_delta_from_html:
dependency: transitive
description:
name: flutter_quill_delta_from_html
sha256: "0eb801ea8dd498cadc057507af5da794d4c9599ce58b2569cb3d4bb53ba8bed2"
url: "https://pub.dev"
source: hosted
version: "1.5.3"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "6848263f9744072d0977347c383fb8b57d9780319a6bf5238b5a2866a029de62"
sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
url: "https://pub.dev"
source: hosted
version: "10.2.0"
version: "10.3.1"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: "67cd1ff671add31dc13e45194398187a04bb63804b37fa47866afae296d73fcb"
sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
version: "0.3.2"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5
url: "https://pub.dev"
source: hosted
version: "3.0.0"
version: "3.0.1"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
@@ -322,10 +431,10 @@ packages:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: "8f42f359f187a94dce7a3ab2ec5903d013dddfc7127078ebab19fa244c3840e8"
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
url: "https://pub.dev"
source: hosted
version: "4.2.1"
version: "4.1.0"
flutter_staggered_grid_view:
dependency: "direct main"
description:
@@ -364,18 +473,26 @@ packages:
dependency: transitive
description:
name: hooks
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
version: "2.0.2"
html:
dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http:
dependency: "direct main"
description:
name: http
sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2"
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "0.13.6"
version: "1.6.0"
http_multi_server:
dependency: transitive
description:
@@ -396,10 +513,10 @@ packages:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52"
url: "https://pub.dev"
source: hosted
version: "4.8.0"
version: "4.9.1"
intl:
dependency: "direct main"
description:
@@ -484,10 +601,10 @@ packages:
dependency: transitive
description:
name: local_auth_android
sha256: b201c006fa769c23386f89aa6837ec0eb8179fcfb212eadcf87b422b3f9a6a78
sha256: fdb936d59ab945c7af297defd67bd1ed87b11b6db1bc16d01e94677a8f1c38ec
url: "https://pub.dev"
source: hosted
version: "2.0.8"
version: "2.0.9"
local_auth_darwin:
dependency: transitive
description:
@@ -520,6 +637,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.0"
markdown:
dependency: transitive
description:
name: markdown
sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9
url: "https://pub.dev"
source: hosted
version: "7.3.1"
matcher:
dependency: transitive
description:
@@ -540,10 +665,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.18.0"
mime:
dependency: transitive
description:
@@ -556,18 +681,18 @@ packages:
dependency: transitive
description:
name: native_toolchain_c
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72
url: "https://pub.dev"
source: hosted
version: "0.17.6"
version: "0.19.2"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
version: "9.4.1"
package_config:
dependency: transitive
description:
@@ -588,10 +713,10 @@ packages:
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.dev"
source: hosted
version: "2.1.5"
version: "2.1.6"
path_provider_android:
dependency: transitive
description:
@@ -612,18 +737,18 @@ packages:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
url: "https://pub.dev"
source: hosted
version: "2.2.1"
version: "2.2.2"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
version: "2.1.3"
path_provider_windows:
dependency: transitive
description:
@@ -688,6 +813,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.0"
quill_native_bridge:
dependency: transitive
description:
name: quill_native_bridge
sha256: "76a16512e398e84216f3f659f7cb18a89ec1e141ea908e954652b4ce6cf15b18"
url: "https://pub.dev"
source: hosted
version: "11.1.0"
quill_native_bridge_android:
dependency: transitive
description:
name: quill_native_bridge_android
sha256: b75c7e6ede362a7007f545118e756b1f19053994144ec9eda932ce5e54a57569
url: "https://pub.dev"
source: hosted
version: "0.0.1+2"
quill_native_bridge_ios:
dependency: transitive
description:
name: quill_native_bridge_ios
sha256: d23de3cd7724d482fe2b514617f8eedc8f296e120fb297368917ac3b59d8099f
url: "https://pub.dev"
source: hosted
version: "0.0.1"
quill_native_bridge_macos:
dependency: transitive
description:
name: quill_native_bridge_macos
sha256: "1c0631bd1e2eee765a8b06017c5286a4e829778f4585736e048eb67c97af8a77"
url: "https://pub.dev"
source: hosted
version: "0.0.1"
quill_native_bridge_platform_interface:
dependency: transitive
description:
name: quill_native_bridge_platform_interface
sha256: "8264a2bdb8a294c31377a27b46c0f8717fa9f968cf113f7dc52d332ed9c84526"
url: "https://pub.dev"
source: hosted
version: "0.0.2+1"
quill_native_bridge_web:
dependency: transitive
description:
name: quill_native_bridge_web
sha256: "7c723f6824b0250d7f33e8b6c23f2f8eb0103fe48ee7ebf47ab6786b64d5c05d"
url: "https://pub.dev"
source: hosted
version: "0.0.2"
quill_native_bridge_windows:
dependency: transitive
description:
name: quill_native_bridge_windows
sha256: "3f96ced19e3206ddf4f6f7dde3eb16bdd05e10294964009ea3a806d995aa7caa"
url: "https://pub.dev"
source: hosted
version: "0.0.2"
quiver:
dependency: transitive
description:
name: quiver
sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2
url: "https://pub.dev"
source: hosted
version: "3.2.2"
recase:
dependency: transitive
description:
@@ -708,42 +897,42 @@ packages:
dependency: transitive
description:
name: screen_retriever
sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c"
sha256: "42cc3b402a0f67d2455a0d067553d0f13453f6a008d98eababf8b63958d506bd"
url: "https://pub.dev"
source: hosted
version: "0.2.0"
version: "0.2.1"
screen_retriever_linux:
dependency: transitive
description:
name: screen_retriever_linux
sha256: f7f8120c92ef0784e58491ab664d01efda79a922b025ff286e29aa123ea3dd18
sha256: "2a476f1a5538065bc5badf376cfdc83d6ecf07d77eb2391b9c2bff5a76970048"
url: "https://pub.dev"
source: hosted
version: "0.2.0"
version: "0.2.1"
screen_retriever_macos:
dependency: transitive
description:
name: screen_retriever_macos
sha256: "71f956e65c97315dd661d71f828708bd97b6d358e776f1a30d5aa7d22d78a149"
sha256: b5abb900fcb86614ff10b738b34e37b9e1d03b0447280668e2bc8a98bdc7bd59
url: "https://pub.dev"
source: hosted
version: "0.2.0"
version: "0.2.1"
screen_retriever_platform_interface:
dependency: transitive
description:
name: screen_retriever_platform_interface
sha256: ee197f4581ff0d5608587819af40490748e1e39e648d7680ecf95c05197240c0
sha256: "3af22d926bedf20c2caa308eea376776451a3af125919ce072e56525fded8901"
url: "https://pub.dev"
source: hosted
version: "0.2.0"
version: "0.2.1"
screen_retriever_windows:
dependency: transitive
description:
name: screen_retriever_windows
sha256: "449ee257f03ca98a57288ee526a301a430a344a161f9202b4fcc38576716fe13"
sha256: c44b38a4c4bab34af259180a70a4eee1e29384e7b82e627c9faa68afcdab2e73
url: "https://pub.dev"
source: hosted
version: "0.2.0"
version: "0.2.1"
shared_preferences:
dependency: "direct main"
description:
@@ -756,10 +945,10 @@ packages:
dependency: transitive
description:
name: shared_preferences_android
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
sha256: "93ae5884a9df5d3bb696825bceb3a17590754548b5d740eba51500afc8d088f5"
url: "https://pub.dev"
source: hosted
version: "2.4.23"
version: "2.4.26"
shared_preferences_foundation:
dependency: transitive
description:
@@ -841,18 +1030,18 @@ packages:
dependency: "direct main"
description:
name: sqlite3
sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5"
sha256: "37356bcb56ce0d9404d602c41e4bdb7765e7e9732a3e47adb3d98c556a6abdad"
url: "https://pub.dev"
source: hosted
version: "3.3.1"
version: "3.3.3"
sqlparser:
dependency: transitive
description:
name: sqlparser
sha256: ecdc06d4a7d79dcbc928d99afd2f7f5b0f98a637c46f89be83d911617f759978
sha256: "40bdddb306a727be9ce510bd2d2b9a6c9db6c586d846ef7b22e3990a2b24f02d"
url: "https://pub.dev"
source: hosted
version: "0.44.4"
version: "0.44.5"
stack_trace:
dependency: transitive
description:
@@ -897,10 +1086,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.10"
version: "0.7.11"
typed_data:
dependency: transitive
description:
@@ -909,6 +1098,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher:
dependency: transitive
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
url: "https://pub.dev"
source: hosted
version: "6.3.32"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
uuid:
dependency: "direct main"
description:
@@ -969,10 +1222,10 @@ packages:
dependency: transitive
description:
name: win32
sha256: a1fc9eb9248baa05dfc12ed5b66e377b3e23f095eec078e0371622b9033810d9
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "6.2.0"
version: "5.15.0"
window_manager:
dependency: "direct main"
description:
@@ -993,10 +1246,10 @@ packages:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
version: "7.0.1"
yaml:
dependency: transitive
description:
@@ -1006,5 +1259,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.11.5 <4.0.0"
flutter: ">=3.38.4"
dart: ">=3.12.0 <4.0.0"
flutter: ">=3.44.0"
+10 -8
View File
@@ -2,7 +2,7 @@ name: notas
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
publish_to: "none" # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
@@ -44,10 +44,14 @@ dependencies:
flutter_secure_storage: ^10.2.0
local_auth: ^3.0.1
sqlite3: ^3.3.1
http: ^0.13.6
http: ^1.6.0
crypto: ^3.0.6
cryptography: ^2.7.0
uuid: ^4.0.0
flutter_quill: ^11.5.1
flutter_localizations:
sdk: flutter
dev_dependencies:
flutter_test:
@@ -68,7 +72,6 @@ dev_dependencies:
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
@@ -113,27 +116,26 @@ hooks:
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
flutter_launcher_icons:
# Configuración general y móvil
image_path: "assets/icon.png"
android: true
ios: false
# Configuración para Windows
windows:
generate: true
image_path: "assets/icon.png"
icon_size: 256 # Tamaño máximo requerido por Windows
# Configuración para Web (Opcional, pero recomendado)
web:
generate: false
image_path: "assets/icon.png"
background_color: "#FFFFFF" # Cambia esto al color de fondo de tu app
theme_color: "#FFFFFF"
# Configuración para macOS (Por si en el futuro compilas para Mac)
macos:
generate: true
image_path: "assets/icon.png"
image_path: "assets/icon.png"
+98
View File
@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_quill/flutter_quill.dart';
import 'package:notas/models/note.dart';
import 'package:notas/screens/note_editor_screen.dart';
void main() {
testWidgets('autosaves a note when only the category changes', (
WidgetTester tester,
) async {
Note? savedNote;
final Note initialNote = Note(
title: 'Sin título',
body: '',
createdAt: DateTime(2026, 5, 21),
updatedAt: DateTime(2026, 5, 21),
position: 0,
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const <LocalizationsDelegate<dynamic>>[
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
FlutterQuillLocalizations.delegate,
],
home: Scaffold(
body: NoteEditorScreen(
repository: null,
note: initialNote,
saveNote: (Note note) async => note,
onSaved: (Note result) {
savedNote = result;
},
),
),
),
);
expect(find.text('Sin categoría'), findsWidgets);
await tester.tap(find.byKey(const ValueKey<String>('category_selector')));
await tester.pumpAndSettle();
await tester.tap(find.text('Trabajo').last);
await tester.pump();
await tester.pump(const Duration(seconds: 2));
expect(savedNote, isNotNull);
expect(savedNote!.categoryId, 'work');
expect(savedNote!.title, 'Sin título');
});
testWidgets('debounces multiple edits into a single save', (
WidgetTester tester,
) async {
int saveCount = 0;
final Note initialNote = Note(
title: 'Sin título',
body: '',
createdAt: DateTime(2026, 5, 21),
updatedAt: DateTime(2026, 5, 21),
position: 0,
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const <LocalizationsDelegate<dynamic>>[
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
FlutterQuillLocalizations.delegate,
],
home: Scaffold(
body: NoteEditorScreen(
repository: null,
note: initialNote,
saveNote: (Note note) async {
saveCount += 1;
return note;
},
),
),
),
);
await tester.enterText(find.byType(TextField).first, 'Primera versión');
await tester.pump(const Duration(milliseconds: 300));
await tester.enterText(find.byType(TextField).first, 'Segunda versión');
await tester.pump(const Duration(seconds: 2));
expect(saveCount, 1);
});
}
+20
View File
@@ -0,0 +1,20 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:notas/data/note_positioning.dart';
void main() {
test('converts between stored and displayed positions', () {
expect(toStoredNotePosition(1500.5), 15005);
expect(fromStoredNotePosition(15005), 1500.5);
});
test('supports descending gaps and rebalance', () {
expect(nextTopNotePosition(<int>[0, 10000, 20000]), 30000);
expect(
midpointNotePosition(higherPosition: 20000, lowerPosition: 10000),
15000,
);
expect(midpointNotePosition(higherPosition: 2, lowerPosition: 1), isNull);
expect(rebalanceNotePositions(3), <int>[20000, 10000, 0]);
});
}
+21
View File
@@ -0,0 +1,21 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:notas/models/note.dart';
void main() {
test('copyWith can clear categoryId explicitly', () {
final Note note = Note(
title: 'A',
body: 'B',
createdAt: DateTime(2026, 5, 21),
updatedAt: DateTime(2026, 5, 21),
position: 0,
categoryId: 'cat-1',
);
final Note cleared = note.copyWith(categoryId: null);
expect(cleared.categoryId, isNull);
expect(cleared.id, note.id);
});
}
+1 -1
View File
@@ -8,6 +8,6 @@ void main() {
await tester.pumpWidget(const NotesApp());
expect(find.byType(MaterialApp), findsOneWidget);
expect(find.text('Mis Notas'), findsWidgets);
expect(find.text('Preparando el vault local...'), findsWidgets);
});
}
@@ -6,18 +6,24 @@
#include "generated_plugin_registrant.h"
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <local_auth_windows/local_auth_plugin.h>
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
#include <window_manager/window_manager_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
LocalAuthPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
WindowManagerPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("WindowManagerPlugin"));
}
+2
View File
@@ -3,9 +3,11 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
flutter_secure_storage_windows
local_auth_windows
screen_retriever_windows
url_launcher_windows
window_manager
)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB