Compare commits
60 Commits
da0654cd4e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b201da0552 | |||
| 9b6d92c372 | |||
| f662e59547 | |||
| 78dddd571a | |||
| c2db704155 | |||
| b00da9ae88 | |||
| 972006c29f | |||
| 82515960f6 | |||
| 710be805ee | |||
| d849c25ed6 | |||
| 7c1d4e5fd8 | |||
| 1dede9eb78 | |||
| 29881183ed | |||
| f4bb5104e2 | |||
| 814f8f7c04 | |||
| 729e575a60 | |||
| cdfd4f9342 | |||
| a31cc12b7e | |||
| 2069de55ae | |||
| e0f226d3bc | |||
| 27e1199178 | |||
| f595f33f4a | |||
| d7495a461a | |||
| 8be7819528 | |||
| 7e210871dd | |||
| a9d818dec4 | |||
| 2f942c4e82 | |||
| 63f0079a5a | |||
| 48d09fe170 | |||
| 62d47904d9 | |||
| 063b300428 | |||
| 28f4ede4aa | |||
| 49fc33edc0 | |||
| 95c3e6fc38 | |||
| 5412d31066 | |||
| 0e450df50d | |||
| c0372b3587 | |||
| f1aca3c812 | |||
| bed34f4cb5 | |||
| 2d76dd2a43 | |||
| b1ab4235bd | |||
| 2ef9cf1dbb | |||
| 3ff4efb738 | |||
| def755e1c5 | |||
| 34f45a912f | |||
| d0a985b4ab | |||
| 6035e3bc18 | |||
| 4912316845 | |||
| 72afa7b5fe | |||
| 59a5229e46 | |||
| c6994b9355 | |||
| 48cd1b2403 | |||
| f550476177 | |||
| 2a898111fa | |||
| 9769087fd8 | |||
| a5ab223e1f | |||
| bb8caeef93 | |||
| 6de318786b | |||
| e80ae52c08 | |||
| beadf860e2 |
@@ -43,3 +43,5 @@ app.*.map.json
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
problems-report.html
|
||||
@@ -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 = "../.."
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 951 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 5.7 KiB |
@@ -1,2 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx2048M -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||
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
|
||||
|
||||
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 19 KiB |
@@ -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,25 +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();
|
||||
@@ -50,6 +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 =
|
||||
@@ -64,13 +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() {
|
||||
@@ -80,6 +214,8 @@ class _NotesAppState extends State<NotesApp>
|
||||
windowManager.addListener(this);
|
||||
windowManager.setPreventClose(true);
|
||||
}
|
||||
_loadThemeSeedColor();
|
||||
_loadThemeMode();
|
||||
_bootstrapVault();
|
||||
}
|
||||
|
||||
@@ -91,11 +227,84 @@ class _NotesAppState extends State<NotesApp>
|
||||
windowManager.setPreventClose(false);
|
||||
}
|
||||
_biometricLockTimer?.cancel();
|
||||
_windowSizeSaveTimer?.cancel();
|
||||
_syncTimer?.cancel();
|
||||
_database?.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadThemeSeedColor() async {
|
||||
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
final int? storedColorValue = prefs.getInt(_themeSeedColorKey);
|
||||
if (storedColorValue == null || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_themeSeedColor = Color(storedColorValue);
|
||||
_updateThemeData();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _setThemeSeedColor(Color color) async {
|
||||
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_themeSeedColorKey, color.toARGB32());
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_themeSeedColor = color;
|
||||
_updateThemeData();
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
if (_isUnlocking) {
|
||||
@@ -119,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;
|
||||
@@ -132,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;
|
||||
@@ -145,7 +357,8 @@ class _NotesAppState extends State<NotesApp>
|
||||
}
|
||||
|
||||
if (biometricChoicePending) {
|
||||
_pendingEncryptionKey = await _vaultService.readStoredEncryptionKeyRaw();
|
||||
_pendingEncryptionKey = await _vaultService
|
||||
.readStoredEncryptionKeyRaw();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_phase = _AppPhase.biometricChoice;
|
||||
@@ -163,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) {
|
||||
@@ -202,8 +416,9 @@ class _NotesAppState extends State<NotesApp>
|
||||
|
||||
// Start periodic sync
|
||||
_startPeriodicSync();
|
||||
// Run an initial full sync immediately to pull server changes
|
||||
unawaited(_performSync(forceFull: true));
|
||||
// Run an initial sync immediately and let the repository use the
|
||||
// stored lastSyncAt when it exists.
|
||||
unawaited(_performSync());
|
||||
} catch (e) {
|
||||
// If the database file is not a valid SQLite DB (e.g., wrong key or corruption),
|
||||
// reset the local vault so the app doesn't crash. The reset will delete DB files
|
||||
@@ -212,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.',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -232,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);
|
||||
@@ -276,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,
|
||||
@@ -309,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;
|
||||
@@ -328,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) {
|
||||
@@ -344,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;
|
||||
}
|
||||
@@ -354,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.');
|
||||
@@ -365,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) {
|
||||
@@ -404,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;
|
||||
}
|
||||
@@ -417,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) {
|
||||
@@ -491,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');
|
||||
@@ -598,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, (_) {
|
||||
@@ -614,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.preparing;
|
||||
_syncProgress = null;
|
||||
_syncDetailMessage = 'Preparando sincronización...';
|
||||
_syncErrorMessage = null;
|
||||
});
|
||||
|
||||
void updateSyncState(
|
||||
SyncStatus status, {
|
||||
double? progress,
|
||||
String? message,
|
||||
}) {
|
||||
if (!mounted || syncOperationId != _syncOperationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_syncStatus = SyncStatus.syncing;
|
||||
_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;
|
||||
@@ -634,46 +936,48 @@ 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: AppTheme.theme,
|
||||
home: const Scaffold(
|
||||
return const Scaffold(
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
AppTitleBar(),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
@@ -689,22 +993,15 @@ class _NotesAppState extends State<NotesApp>
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppShell({required Widget home}) {
|
||||
return MaterialApp(
|
||||
navigatorKey: _navigatorKey,
|
||||
title: 'Mis Notas',
|
||||
debugShowCheckedModeBanner: false,
|
||||
scaffoldMessengerKey: _scaffoldMessengerKey,
|
||||
theme: AppTheme.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'),
|
||||
@@ -713,6 +1010,8 @@ class _NotesAppState extends State<NotesApp>
|
||||
onRequestSync: _performSync,
|
||||
onVaultInvalid: _resetLocalVaultData,
|
||||
syncStatus: _syncStatus,
|
||||
syncProgress: _syncProgress,
|
||||
syncDetailMessage: _syncDetailMessage,
|
||||
syncErrorMessage: _syncErrorMessage,
|
||||
refreshToken: _homeRefreshToken,
|
||||
)
|
||||
@@ -720,15 +1019,14 @@ class _NotesAppState extends State<NotesApp>
|
||||
key: const ValueKey<String>('settings-screen'),
|
||||
onDeleteAllData: _resetLocalVaultData,
|
||||
onBackToHome: _openHome,
|
||||
onForceSync: () => _performSync(forceFull: true),
|
||||
currentSeedColor: _themeSeedColor,
|
||||
onThemeColorSelected: _setThemeSeedColor,
|
||||
currentThemeMode: _themeMode,
|
||||
onThemeModeSelected: _setThemeMode,
|
||||
);
|
||||
|
||||
return MaterialApp(
|
||||
navigatorKey: _navigatorKey,
|
||||
title: 'Mis Notas',
|
||||
debugShowCheckedModeBanner: false,
|
||||
scaffoldMessengerKey: _scaffoldMessengerKey,
|
||||
theme: AppTheme.theme,
|
||||
home: Shortcuts(
|
||||
return Shortcuts(
|
||||
shortcuts: <LogicalKeySet, Intent>{
|
||||
LogicalKeySet(LogicalKeyboardKey.f5): const PerformSyncIntent(),
|
||||
},
|
||||
@@ -745,42 +1043,57 @@ class _NotesAppState extends State<NotesApp>
|
||||
autofocus: true,
|
||||
child: 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: AnimatedSwitcher(
|
||||
duration: _screenTransitionDuration,
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
transitionBuilder: (Widget child, Animation<double> animation) {
|
||||
final Animation<Offset> offsetAnimation = Tween<Offset>(
|
||||
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: 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -792,12 +1105,12 @@ class _NotesAppState extends State<NotesApp>
|
||||
|
||||
@override
|
||||
void onWindowResize() {
|
||||
_saveWindowSize();
|
||||
_scheduleWindowSizeSave();
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowResized() {
|
||||
_saveWindowSize();
|
||||
_scheduleWindowSizeSave();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -816,6 +1129,10 @@ class _NotesAppState extends State<NotesApp>
|
||||
return;
|
||||
}
|
||||
|
||||
_windowSizeSaveTimer?.cancel();
|
||||
_windowSizeSaveTimer = null;
|
||||
unawaited(_saveWindowSize());
|
||||
|
||||
if (!_needsBiometricLock) {
|
||||
unawaited(_allowWindowClose());
|
||||
return;
|
||||
@@ -840,21 +1157,22 @@ class _NotesAppState extends State<NotesApp>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isBootstrapping) {
|
||||
return _buildLoadingScreen();
|
||||
}
|
||||
Widget homeWidget;
|
||||
|
||||
if (_isBootstrapping) {
|
||||
homeWidget = _buildLoadingScreen();
|
||||
} else {
|
||||
final NoteRepository? repository = _repository;
|
||||
|
||||
if (repository != null) {
|
||||
return _buildMainShell(repository);
|
||||
}
|
||||
|
||||
homeWidget = _buildMainShell(repository);
|
||||
} else {
|
||||
switch (_phase) {
|
||||
case _AppPhase.loading:
|
||||
return _buildLoadingScreen();
|
||||
homeWidget = _buildLoadingScreen();
|
||||
break;
|
||||
case _AppPhase.access:
|
||||
return _buildAppShell(
|
||||
homeWidget = _buildAppShell(
|
||||
home: VaultAccessScreen(
|
||||
isBusy: _isUnlocking,
|
||||
onCreateAccountPressed: (String email, String password) async {
|
||||
@@ -874,29 +1192,59 @@ class _NotesAppState extends State<NotesApp>
|
||||
onContinueWithoutAccount: _enterWithoutAccount,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case _AppPhase.biometricChoice:
|
||||
return _buildAppShell(
|
||||
homeWidget = _buildAppShell(
|
||||
home: BiometricChoiceScreen(
|
||||
isBusy: _isUnlocking,
|
||||
onEnableBiometrics: () => _completeBiometricChoice(enableBiometrics: true),
|
||||
onSkipBiometrics: () => _completeBiometricChoice(enableBiometrics: false),
|
||||
onEnableBiometrics: () =>
|
||||
_completeBiometricChoice(enableBiometrics: true),
|
||||
onSkipBiometrics: () =>
|
||||
_completeBiometricChoice(enableBiometrics: false),
|
||||
),
|
||||
);
|
||||
break;
|
||||
case _AppPhase.biometricGate:
|
||||
return _buildAppShell(
|
||||
homeWidget = _buildAppShell(
|
||||
home: BiometricGateScreen(
|
||||
key: ValueKey<int>(_biometricGateSession),
|
||||
isBusy: _isUnlocking,
|
||||
onUnlockRequested: _unlockBiometricGate,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case _AppPhase.notes:
|
||||
if (repository == null) {
|
||||
return _buildLoadingScreen();
|
||||
}
|
||||
|
||||
return _buildMainShell(repository);
|
||||
homeWidget = _buildLoadingScreen();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,20 @@
|
||||
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,
|
||||
@@ -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;
|
||||
final DateTime? lastSyncForRequest = forceFull
|
||||
? DateTime.utc(1970, 1, 1)
|
||||
: lastSync;
|
||||
|
||||
// Collect pending changes
|
||||
// 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 List<DbCategory> unsyncedCategories = await _database
|
||||
.getUnsyncedCategories();
|
||||
|
||||
// Build sync request (note: we send encrypted data, but locally we have plaintext)
|
||||
// Encrypt all notes before sending
|
||||
final List<SyncNotePayload> encryptedNotesPayload = [];
|
||||
for (final dbNote in unsyncedNotes) {
|
||||
final note = _fromDbNote(dbNote);
|
||||
final encryptedTitle = await NoteEncryption.encryptNote(note.title, _masterKey);
|
||||
final encryptedBody = await NoteEncryption.encryptNote(note.body, _masterKey);
|
||||
encryptedNotesPayload.add(
|
||||
SyncNotePayload.fromNote(
|
||||
note,
|
||||
encryptedTitle: encryptedTitle,
|
||||
encryptedBody: encryptedBody,
|
||||
),
|
||||
final 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,
|
||||
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}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Category _fromDbCategory(DbCategory row) {
|
||||
return Category(
|
||||
uuid: row.uuid,
|
||||
encryptedName: row.encryptedName,
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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>? ?? {}),
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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),
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancelar', style: TextStyle(color: Colors.white70)),
|
||||
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,
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context).pop('delete');
|
||||
},
|
||||
child: const Text('Eliminar', style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
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(
|
||||
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),
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -8,10 +9,20 @@ class SettingsScreen extends StatefulWidget {
|
||||
super.key,
|
||||
required this.onDeleteAllData,
|
||||
required this.onBackToHome,
|
||||
required this.onForceSync,
|
||||
required this.currentSeedColor,
|
||||
required this.onThemeColorSelected,
|
||||
required this.currentThemeMode,
|
||||
required this.onThemeModeSelected,
|
||||
});
|
||||
|
||||
final Future<void> Function() onDeleteAllData;
|
||||
final VoidCallback onBackToHome;
|
||||
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();
|
||||
@@ -19,23 +30,50 @@ 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 = 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. No se podrá recuperar.'),
|
||||
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;
|
||||
@@ -50,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;
|
||||
@@ -58,19 +98,188 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
SnackBar(content: Text('Error al borrar los datos: $error')),
|
||||
);
|
||||
} finally {
|
||||
if (!mounted) return;
|
||||
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;
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _forceSync() async {
|
||||
if (_isBusy || _isSyncing) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSyncing = true;
|
||||
});
|
||||
|
||||
try {
|
||||
await widget.onForceSync();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Sincronización forzada completada.')),
|
||||
);
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error al forzar la sincronización: $error')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSyncing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _selectThemeColor(Color color) async {
|
||||
if (_isThemeSaving || _selectedSeedColor == color) {
|
||||
return;
|
||||
}
|
||||
|
||||
final Color previousColor = _selectedSeedColor;
|
||||
setState(() {
|
||||
_selectedSeedColor = color;
|
||||
_isThemeSaving = true;
|
||||
});
|
||||
|
||||
try {
|
||||
await widget.onThemeColorSelected(color);
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_selectedSeedColor = previousColor;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('No se pudo guardar el color: $error')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isThemeSaving = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedSeedColor = widget.currentSeedColor;
|
||||
_selectedThemeMode = widget.currentThemeMode;
|
||||
_loadEndpoint();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant SettingsScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.currentSeedColor != widget.currentSeedColor &&
|
||||
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 {
|
||||
final String endpoint = await ApiConfig.getEndpoint();
|
||||
if (!mounted) return;
|
||||
@@ -86,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;
|
||||
|
||||
@@ -125,6 +335,81 @@ 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 AppPalette palette = Theme.of(context).extension<AppPalette>()!;
|
||||
final bool isSelected = _selectedSeedColor.toARGB32() == color.toARGB32();
|
||||
final Color foregroundColor =
|
||||
ThemeData.estimateBrightnessForColor(color) == Brightness.dark
|
||||
? palette.textPrimary
|
||||
: palette.textOnAccent;
|
||||
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: isSelected,
|
||||
label: 'Color ${color.toARGB32().toRadixString(16)}',
|
||||
child: Tooltip(
|
||||
message: isSelected ? 'Color actual' : 'Usar este color',
|
||||
child: InkWell(
|
||||
onTap: _isThemeSaving ? null : () => _selectThemeColor(color),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isSelected ? palette.textPrimary : palette.textSecondary,
|
||||
width: isSelected ? 2.5 : 1.2,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: palette.shadowSoft,
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
if (isSelected)
|
||||
Icon(Icons.check, size: 22, color: foregroundColor),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_endpointController.dispose();
|
||||
@@ -137,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')));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,22 +438,67 @@ 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({
|
||||
required Widget input,
|
||||
required List<Widget> actions,
|
||||
}) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final bool isCompact = constraints.maxWidth < 600;
|
||||
|
||||
if (isCompact) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(width: double.infinity, child: input),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.end,
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: actions,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(child: input),
|
||||
const SizedBox(width: 8),
|
||||
Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (int index = 0; index < actions.length; index++) ...[
|
||||
if (index > 0) const SizedBox(width: 8),
|
||||
actions[index],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@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: [
|
||||
@@ -176,127 +510,238 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
titleText: 'Configuración',
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.redAccent),
|
||||
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 todos los datos'),
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text('Borrar datos locales:'),
|
||||
),
|
||||
_buildDestructiveButton(
|
||||
label: 'Borrar',
|
||||
onPressed: (_isBusy || _isServerDeleting)
|
||||
? null
|
||||
: _confirmAndDeleteAll,
|
||||
isLoading: _isBusy,
|
||||
icon: Icons.delete_forever,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Esto cerrará el vault actual y eliminará la base de datos local junto con la clave de cifrado.'),
|
||||
const SizedBox(height: 24),
|
||||
const Text('API endpoint (ej: http://localhost:3000/api)'),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _endpointLoading
|
||||
? const SizedBox(height: 48, child: Center(child: CircularProgressIndicator()))
|
||||
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,
|
||||
),
|
||||
RadioListTile<ThemeMode>(
|
||||
title: const Text('Modo claro'),
|
||||
value: ThemeMode.light,
|
||||
),
|
||||
RadioListTile<ThemeMode>(
|
||||
title: const Text('Modo oscuro'),
|
||||
value: ThemeMode.dark,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
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: const TextStyle(color: Colors.white),
|
||||
style: TextStyle(color: palette.textPrimary),
|
||||
keyboardType: TextInputType.url,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: _endpointLoading ? null : _saveEndpoint,
|
||||
onPressed: _endpointLoading
|
||||
? null
|
||||
: _saveEndpoint,
|
||||
child: const Text('Guardar'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton(
|
||||
onPressed: _endpointLoading ? null : _resetEndpoint,
|
||||
onPressed: _endpointLoading
|
||||
? null
|
||||
: _resetEndpoint,
|
||||
child: const Text('Restaurar'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text('Clave de cifrado local'),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
_buildResponsiveInputActionsRow(
|
||||
input: TextField(
|
||||
controller: _encryptionKeyController,
|
||||
readOnly: true,
|
||||
obscureText: !_encryptionKeyVisible,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
style: TextStyle(color: palette.textPrimary),
|
||||
decoration: InputDecoration(
|
||||
labelText: _encryptionKeyVisible ? 'Clave de cifrado' : 'Oculta hasta pulsar mostrar',
|
||||
labelStyle: TextStyle(color: Colors.white.withValues(alpha: 0.7)),
|
||||
labelText: _encryptionKeyVisible
|
||||
? 'Clave de cifrado'
|
||||
: 'Oculta hasta pulsar mostrar',
|
||||
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(width: 8),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: _encryptionKeyLoading ? null : _loadEncryptionKey,
|
||||
onPressed: _encryptionKeyLoading
|
||||
? null
|
||||
: _loadEncryptionKey,
|
||||
child: _encryptionKeyLoading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Text('Mostrar'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton(
|
||||
onPressed: _encryptionKeyVisible ? _hideEncryptionKey : null,
|
||||
onPressed: _encryptionKeyVisible
|
||||
? _hideEncryptionKey
|
||||
: null,
|
||||
child: const Text('Ocultar'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:notas/theme/app_palette.dart';
|
||||
|
||||
class AppTheme {
|
||||
static ThemeData get theme {
|
||||
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.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: Colors.amber,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
floatingActionButtonTheme: const FloatingActionButtonThemeData(
|
||||
backgroundColor: Colors.amber,
|
||||
foregroundColor: Colors.black,
|
||||
scaffoldBackgroundColor: scheme.surface,
|
||||
colorScheme: scheme,
|
||||
extensions: <ThemeExtension<dynamic>>[palette],
|
||||
brightness: brightness,
|
||||
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||
backgroundColor: seedColor,
|
||||
foregroundColor: foregroundColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export 'app_title_bar_stub.dart' if (dart.library.io) 'app_title_bar_io.dart';
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,129 +1,162 @@
|
||||
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),
|
||||
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: 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(
|
||||
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,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
widget.note.title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
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,
|
||||
),
|
||||
maxLines: 2,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
note.title.isEmpty ? 'Sin título' : note.title,
|
||||
maxLines: 1,
|
||||
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(
|
||||
'...',
|
||||
style: TextStyle(
|
||||
color: Colors.white54,
|
||||
fontSize: 18,
|
||||
height: 1,
|
||||
color: palette.textPrimary,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
bodyText.isEmpty ? ' ' : bodyText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
enum SyncStatus {
|
||||
idle,
|
||||
preparing,
|
||||
encrypting,
|
||||
uploading,
|
||||
waitingResponse,
|
||||
decrypting,
|
||||
syncing,
|
||||
synced,
|
||||
error,
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 521 B After Width: | Height: | Size: 433 B |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 722 B After Width: | Height: | Size: 813 B |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -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"
|
||||
|
||||
@@ -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,7 +116,6 @@ 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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 8.0 KiB |