diff --git a/lib/app.dart b/lib/app.dart index 7a7b4e3..7d29120 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -79,6 +79,79 @@ class _NotesAppState extends State 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: + return true; + case SyncStatus.idle: + case SyncStatus.synced: + case SyncStatus.error: + 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; + + 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: palette.border)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon(Icons.cloud_sync_outlined, color: palette.textSecondary, 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, + ), + ), + ), + ], + ), + 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: @@ -964,6 +1037,10 @@ class _NotesAppState extends State child: activeScreen, ), ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + child: _buildSyncBanner(context), + ), ], ), ), diff --git a/lib/data/note_repository.dart b/lib/data/note_repository.dart index 845228b..5f06297 100644 --- a/lib/data/note_repository.dart +++ b/lib/data/note_repository.dart @@ -56,6 +56,10 @@ class NoteRepository { return categories; } + Future getLastSyncAt() async { + return _authApi.getLastSyncAt(); + } + Future createCategory(Category category) async { debugPrint('createCategory called with: ${category.name}'); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index d249a76..5cfe747 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -1,22 +1,14 @@ -import 'dart:async'; -import 'dart:math' as math; - -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:intl/intl.dart'; import 'package:notas/data/note_body.dart'; import 'package:notas/data/note_repository.dart'; +import 'package:notas/models/category.dart'; import 'package:notas/models/note.dart'; import 'package:notas/screens/note_editor_screen.dart'; -import 'package:notas/models/category.dart'; -import 'package:notas/widgets/menu_drawer.dart'; -import 'package:notas/widgets/category_style.dart'; -import 'package:notas/widgets/note_card.dart'; -import 'package:notas/widgets/search_app_bar.dart'; -import 'package:notas/widgets/sync_status.dart'; -import 'package:notas/widgets/sync_status_indicator.dart'; import 'package:notas/theme/app_palette.dart'; +import 'package:notas/widgets/note_card.dart'; +import 'package:notas/widgets/sync_status.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({ @@ -47,239 +39,106 @@ class HomeScreen extends StatefulWidget { } class _HomeScreenState extends State { + static const double _desktopBreakpoint = 900; + + final TextEditingController _searchController = TextEditingController(); + final GlobalKey _filterButtonKey = GlobalKey(); + List _notes = []; - String _searchQuery = ''; - bool _isLoading = true; - bool _isDragging = false; - bool _isMenuOpen = false; - bool _showDeletedNotes = false; - PointerDeviceKind _lastPointerKind = PointerDeviceKind.mouse; List _categories = []; + bool _isLoading = true; + String _searchQuery = ''; String? _selectedCategoryId; + String? _selectedNoteId; + DateTime? _lastSyncAt; - void _openMenu() { - if (_isMenuOpen) { - return; - } - - setState(() { - _isMenuOpen = true; - }); - } - - void _closeMenu() { - if (!_isMenuOpen) { - return; - } - - setState(() { - _isMenuOpen = false; - }); - } - - bool _requiresLongPressToDrag(PointerDeviceKind kind) { - return kind == PointerDeviceKind.touch || - kind == PointerDeviceKind.stylus || - kind == PointerDeviceKind.invertedStylus; + AppPalette _paletteOf(BuildContext context) { + return Theme.of(context).extension() ?? + AppPalette.fromBrightness(Theme.of(context).brightness); } @override void initState() { super.initState(); - _loadNotesAndCategories(); + _loadData(); } - Future _loadNotesAndCategories({ - bool showLoadingIndicator = true, - }) async { - if (showLoadingIndicator) { - setState(() { - _isLoading = true; - }); - } - - final Future> notesFuture = _showDeletedNotes - ? widget.repository.loadDeletedNotes() - : widget.repository.loadNotes(); - final Future> categoriesFuture = widget.repository - .loadCategories(); - - List notesResult = []; - List categoriesResult = []; - - try { - notesResult = await notesFuture; - } catch (e, st) { - debugPrint('Failed to load notes: $e\n$st'); - if (widget.onVaultInvalid != null) { - await widget.onVaultInvalid!(); - } - return; - } - - try { - categoriesResult = await categoriesFuture; - } catch (e) { - debugPrint('Failed to load categories: $e'); - categoriesResult = []; - } - - if (!mounted) return; - - setState(() { - _notes = notesResult; - _categories = categoriesResult; - if (showLoadingIndicator) { - _isLoading = false; - } - }); - } - - Future _loadCategories() async { - try { - final List cats = await widget.repository.loadCategories(); - if (!mounted) return; - setState(() { - _categories = cats; - }); - } catch (e) { - debugPrint('Failed to load categories: $e'); - } - } - - Category? _currentCategory() { - final String? selectedCategoryId = _selectedCategoryId; - if (selectedCategoryId == null) { - return null; - } - - for (final Category category in _categories) { - if (category.id == selectedCategoryId) { - return category; - } - } - - return null; + @override + void dispose() { + _searchController.dispose(); + super.dispose(); } @override void didUpdateWidget(covariant HomeScreen oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.refreshToken != widget.refreshToken) { - // Refresh in background without showing the full-screen loader - _loadNotesAndCategories(showLoadingIndicator: false); + _loadData(keepSelection: true); } } - Future _loadNotes() async { - try { - final List storedNotes = _showDeletedNotes - ? await widget.repository.loadDeletedNotes() - : await widget.repository.loadNotes(); + Future _loadData({bool keepSelection = false}) async { + if (mounted) { + setState(() { + _isLoading = true; + }); + } - if (!mounted) return; + try { + final List notes = await widget.repository.loadNotes(); + final List categories = await widget.repository.loadCategories(); + final DateTime? lastSyncAt = await widget.repository.getLastSyncAt(); + + if (!mounted) { + return; + } setState(() { - _notes = storedNotes; + _notes = notes; + _categories = categories; + _lastSyncAt = lastSyncAt; _isLoading = false; + + if (!keepSelection) { + _selectedNoteId = null; + } else if (_selectedNoteId != null && + !_notes.any((Note note) => note.id == _selectedNoteId)) { + _selectedNoteId = null; + } }); - } catch (e, st) { - // Log the error so we can inspect the root cause before resetting the vault. - debugPrint('Failed to load notes: $e\n$st'); - // If loading notes fails (e.g., DB corrupt), notify the app to reset the vault. + } catch (error, stackTrace) { + debugPrint('Failed to load home data: $error\n$stackTrace'); if (widget.onVaultInvalid != null) { await widget.onVaultInvalid!(); } } } - Future _openNoteComposer() async { - final dynamic result = await NoteEditorScreen.showDialog( - context, - categoryId: _showDeletedNotes ? null : _selectedCategoryId, - categories: _categories, - ); - - if (result == null) { - return; - } - - if (result is Note) { - await widget.repository.createNote(result); - await _loadNotes(); - - // Trigger sync after creating a note. - try { - await widget.onRequestSync(); - } catch (_) {} - } - } - - Future _deleteNote(Note note) async { - await widget.repository.deleteNote(note); - await _loadNotes(); - - // Trigger sync after deleting a note. + Future _reloadNotes({bool keepSelection = true}) async { try { - await widget.onRequestSync(); - } catch (_) {} - } - - Future _reorderNote(int oldIndex, int newIndex) async { - if (oldIndex == newIndex) { - return; - } - - final Note movedNote = _notes[oldIndex]; - - try { - await widget.repository.moveNote(movedNote, newIndex); - await _loadNotes(); - } catch (e, st) { - // Don't let DB errors cause the app to reset the vault automatically. - debugPrint('Failed to move note: $e\n$st'); - if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Error al reordenar la nota: $e'))); - } - } - - Future _openNoteEditor(Note note) async { - final dynamic result = await NoteEditorScreen.showDialog( - context, - note: note, - categories: _categories, - ); - - if (result == null) { - return; - } - - if (result == 'delete') { - await _deleteNote(note); - return; - } - - if (result is Note) { - if (_notes.any((Note item) => item == note)) { - await widget.repository.updateNote(result); - await _loadNotes(); - // Trigger sync after editing a note. - try { - await widget.onRequestSync(); - } catch (_) {} + final List notes = await widget.repository.loadNotes(); + if (!mounted) { + return; } + + setState(() { + _notes = notes; + if (!keepSelection || + (_selectedNoteId != null && + !_notes.any((Note note) => note.id == _selectedNoteId))) { + _selectedNoteId = null; + } + }); + } catch (error) { + debugPrint('Failed to reload notes: $error'); } } - List _getFilteredNotes() { + List _visibleNotes() { Iterable notes = _notes; if (_selectedCategoryId != null) { - notes = notes.where( - (Note note) => note.categoryId == _selectedCategoryId, - ); + notes = notes.where((Note note) => note.categoryId == _selectedCategoryId); } if (_searchQuery.isEmpty) { @@ -296,917 +155,588 @@ class _HomeScreenState extends State { .toList(); } - Future _handleMenuItemTapped(String item) async { - _closeMenu(); - - if (item == 'create_category') { - await _showCreateCategoryDialog(); - return; + Note? _selectedNote() { + final String? selectedId = _selectedNoteId; + if (selectedId == null) { + return null; } - if (item.startsWith('category_')) { - final String id = item.substring('category_'.length); - setState(() { - _selectedCategoryId = id; - _showDeletedNotes = false; - _searchQuery = ''; - _isLoading = true; - }); - await _loadNotes(); - return; + for (final Note note in _notes) { + if (note.id == selectedId) { + return note; + } } - if (item == 'settings') { - widget.onOpenSettings(); - return; - } - - if (item == 'deleted_notes') { - setState(() { - _showDeletedNotes = true; - _selectedCategoryId = null; - _searchQuery = ''; - _isLoading = true; - }); - await _loadNotes(); - return; - } - - if (item == 'all_notes') { - setState(() { - _showDeletedNotes = false; - _selectedCategoryId = null; - _searchQuery = ''; - _isLoading = true; - }); - await _loadNotes(); - } + return null; } - Future _showCreateCategoryDialog([Category? category]) async { - await showDialog( - context: context, - builder: (BuildContext context) { - return _CategoryDialog( - category: category, - repository: widget.repository, - onCategoriesChanged: _loadCategories, - onRequestSync: widget.onRequestSync, - onCategoryDeleted: () => _handleMenuItemTapped('all_notes'), - ); - }, + String _formatLastSyncAt() { + final DateTime? timestamp = _lastSyncAt; + if (timestamp == null) { + return 'Última sincronización: nunca'; + } + + return 'Última sincronización: ${DateFormat('dd/MM/yyyy HH:mm').format(timestamp)}'; + } + + RelativeRect _menuRectFromContext(BuildContext anchorContext) { + final RenderBox button = anchorContext.findRenderObject()! as RenderBox; + final RenderBox overlay = Overlay.of(anchorContext).context.findRenderObject()! + as RenderBox; + final Offset topLeft = button.localToGlobal(Offset.zero, ancestor: overlay); + final Offset bottomRight = button.localToGlobal( + button.size.bottomRight(Offset.zero), + ancestor: overlay, + ); + + return RelativeRect.fromRect( + Rect.fromLTRB( + topLeft.dx, + topLeft.dy, + bottomRight.dx, + bottomRight.dy, + ), + Offset.zero & overlay.size, ); } - @override - Widget build(BuildContext context) { - final double width = MediaQuery.of(context).size.width; - final int crossAxisCount = math.max((width / 250).floor().round(), 2); - final List visibleNotes = _getFilteredNotes(); - final Category? currentCategory = _currentCategory(); - final Map categoryBorderColors = { - for (final Category category in _categories) - if (category.colorValue != null) - category.id: Color(category.colorValue!), - }; + Future _showAnchoredCategoryMenu({ + required BuildContext anchorContext, + required List> items, + }) { + return showMenu( + context: anchorContext, + position: _menuRectFromContext(anchorContext), + items: items, + elevation: 10, + color: _paletteOf(anchorContext).surfaceElevated, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ); + } - final Widget body = _isLoading - ? const Center(child: CircularProgressIndicator()) - : RefreshIndicator( - onRefresh: () async { - await widget.onRequestSync(); - await _loadNotesAndCategories(showLoadingIndicator: false); - }, - child: MouseRegion( - cursor: _isDragging - ? SystemMouseCursors.grabbing - : SystemMouseCursors.basic, - child: CustomScrollView( - physics: const AlwaysScrollableScrollPhysics(), - slivers: visibleNotes.isEmpty - ? [ - SliverFillRemaining( - hasScrollBody: false, - child: _EmptyState( - showDeletedNotes: _showDeletedNotes, - categoryName: currentCategory?.name, - searchQuery: _searchQuery, - ), - ), - ] - : [ - SliverPadding( - padding: const EdgeInsets.only(bottom: 8), - sliver: SliverMasonryGrid.count( - crossAxisCount: crossAxisCount, - mainAxisSpacing: 10, - crossAxisSpacing: 10, - childCount: visibleNotes.length, - itemBuilder: (BuildContext context, int index) { - final List filteredNotes = visibleNotes; - return DragTarget( - onAcceptWithDetails: - (DragTargetDetails details) { - final Note targetNote = - filteredNotes[index]; - final int originalTargetIndex = _notes - .indexOf(targetNote); - _reorderNote( - details.data, - originalTargetIndex, - ); - }, - builder: (context, candidateData, rejectedData) { - return LayoutBuilder( - builder: (context, constraints) { - final double cellWidth = - constraints.maxWidth; - final bool requiresLongPressToDrag = - _requiresLongPressToDrag( - _lastPointerKind, - ); + Future _openCategoryFilter(BuildContext anchorContext) async { + final String? selectedCategoryId = await _showAnchoredCategoryMenu( + anchorContext: anchorContext, + items: >[ + const PopupMenuItem( + value: '', + child: Text('Todas las categorías'), + ), + const PopupMenuDivider(), + for (final Category category in _categories) + PopupMenuItem( + value: category.id, + child: Text(category.name), + ), + ], + ); - final Widget - draggableNote = _DraggableNote( - note: filteredNotes[index], - borderColor: - categoryBorderColors[filteredNotes[index] - .categoryId], - dataIndex: _notes.indexOf( - filteredNotes[index], - ), - cellWidth: cellWidth, - requiresLongPressToDrag: - requiresLongPressToDrag, - isDragging: _isDragging, - isDragTargetActive: - candidateData.isNotEmpty, - onTap: () => _openNoteEditor( - filteredNotes[index], - ), - onDragStarted: () { - if (!mounted) return; - setState(() { - _isDragging = true; - }); - }, - onDragEnd: (_) { - if (!mounted) return; - setState(() { - _isDragging = false; - }); - }, - onDraggableCanceled: () { - if (!mounted) return; - setState(() { - _isDragging = false; - }); - }, - ); + if (!mounted || selectedCategoryId == null) { + return; + } - return Listener( - onPointerDown: - (PointerDownEvent event) { - if (_lastPointerKind == - event.kind) { - return; - } + setState(() { + _selectedCategoryId = selectedCategoryId.isEmpty + ? null + : selectedCategoryId; + if (_selectedNoteId != null && + !_visibleNotes().any((Note note) => note.id == _selectedNoteId)) { + _selectedNoteId = null; + } + }); + } - setState(() { - _lastPointerKind = event.kind; - }); - }, - child: draggableNote, - ); - }, - ); - }, - ); - }, - ), - ), - ], - ), - ), - ); + Future _changeNoteCategory(BuildContext anchorContext, Note note) async { + final Category? selected = await _showAnchoredCategoryMenu( + anchorContext: anchorContext, + items: >[ + const PopupMenuItem( + value: null, + child: Text('Sin categoría'), + ), + const PopupMenuDivider(), + for (final Category category in _categories) + PopupMenuItem( + value: category, + child: Text(category.name), + ), + ], + ); - final AppPalette palette = Theme.of(context).extension()!; + if (!mounted) { + return; + } - return Scaffold( - body: Container( - decoration: BoxDecoration(gradient: palette.backdropGradient), - child: SafeArea( - child: Column( - children: [ - SearchAppBar( - onMenuPressed: () { - setState(() { - _isMenuOpen = !_isMenuOpen; - }); - }, - trailingWidget: SyncStatusIndicator( - status: widget.syncStatus, - progress: widget.syncProgress, - detailMessage: widget.syncDetailMessage, - errorMessage: widget.syncErrorMessage, - onTap: widget.onRequestSync, + final String? categoryId = selected?.id; + if (categoryId == note.categoryId) { + return; + } + + try { + final Note updated = await widget.repository.updateNote( + note.copyWith( + categoryId: categoryId, + updatedAt: DateTime.now(), + isDirty: true, + ), + ); + + if (!mounted) { + return; + } + + setState(() { + _notes = [ + for (final Note item in _notes) + if (item.id == updated.id) updated else item, + ]; + }); + } catch (error) { + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('No se pudo cambiar la categoría: $error')), + ); + } + } + + Future _deleteNote(Note note) async { + try { + await widget.repository.deleteNote(note); + if (!mounted) { + return; + } + + setState(() { + _notes = _notes.where((Note item) => item.id != note.id).toList(); + if (_selectedNoteId == note.id) { + _selectedNoteId = null; + } + }); + } catch (error) { + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('No se pudo eliminar la nota: $error')), + ); + } + } + + Future _createNote({required bool openEditor}) async { + final DateTime now = DateTime.now(); + final Note draft = Note( + title: 'Sin título', + body: '', + createdAt: now, + updatedAt: now, + position: 0, + categoryId: _selectedCategoryId, + ); + + try { + if (_searchQuery.isNotEmpty) { + _searchController.clear(); + _searchQuery = ''; + } + + final Note created = await widget.repository.createNote(draft); + if (!mounted) { + return; + } + + await _reloadNotes(keepSelection: false); + + if (!mounted) { + return; + } + + setState(() { + _selectedNoteId = created.id; + }); + + if (openEditor) { + await _openEditor(created, embedded: false); + } + } catch (error) { + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('No se pudo crear la nota: $error')), + ); + } + } + + Future _openExistingNote(Note note, {required bool embedded}) async { + setState(() { + _selectedNoteId = note.id; + }); + + if (embedded) { + return; + } + + await _openEditor(note, embedded: false); + } + + Future _openEditor(Note note, {required bool embedded}) async { + final Widget editor = NoteEditorScreen( + key: ValueKey(note.id), + repository: widget.repository, + note: note, + categories: _categories, + embedded: embedded, + onSaved: (Note saved) { + if (!mounted) { + return; + } + + setState(() { + _notes = [ + for (final Note item in _notes) + if (item.id == saved.id) saved else item, + ]; + }); + }, + ); + + if (embedded) { + return; + } + + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => editor), + ); + } + + Future _handleNoteTap(Note note, bool isDesktop) async { + if (isDesktop) { + await _openExistingNote(note, embedded: true); + return; + } + + await _openEditor(note, embedded: false); + } + + Future _handleReorder(int oldIndex, int newIndex) async { + final List visibleNotes = _visibleNotes(); + if (oldIndex < 0 || oldIndex >= visibleNotes.length) { + return; + } + + final Note movedNote = visibleNotes[oldIndex]; + final List remainingVisible = [...visibleNotes]..removeAt(oldIndex); + final int clampedNewIndex = newIndex.clamp(0, remainingVisible.length); + + int targetFullIndex; + if (remainingVisible.isEmpty) { + targetFullIndex = 0; + } else if (clampedNewIndex == 0) { + targetFullIndex = 0; + } else if (clampedNewIndex >= remainingVisible.length) { + targetFullIndex = _notes.length - 1; + } else { + final Note afterNote = remainingVisible[clampedNewIndex]; + targetFullIndex = _notes.indexWhere((Note note) => note.id == afterNote.id); + if (targetFullIndex < 0) { + targetFullIndex = 0; + } + } + + try { + await widget.repository.moveNote(movedNote, targetFullIndex); + await _reloadNotes(keepSelection: true); + } catch (error) { + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('No se pudo reordenar la nota: $error')), + ); + } + } + + Widget _buildHeader(BuildContext context) { + final AppPalette palette = _paletteOf(context); + + return Container( + decoration: BoxDecoration( + color: palette.transparent, + border: Border(bottom: BorderSide(color: palette.border, width: 0.5)), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Builder( + builder: (BuildContext buttonContext) { + return IconButton( + key: _filterButtonKey, + onPressed: () => _openCategoryFilter(buttonContext), + icon: Icon( + Icons.filter_alt_outlined, + color: palette.textSecondary, ), - onSearchChanged: (String query) { - setState(() { - _searchQuery = query; - }); - }, - ), - Expanded( - child: Listener( - onPointerDown: (PointerDownEvent event) { - if (_lastPointerKind == event.kind) { - return; - } - + tooltip: 'Filtrar por categorías', + ); + }, + ), + const SizedBox(width: 8), + Expanded( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 640), + child: TextField( + controller: _searchController, + onChanged: (String value) { setState(() { - _lastPointerKind = event.kind; + _searchQuery = value.trim(); }); }, - child: Stack( - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12.0, - vertical: 8.0, - ), - child: body, - ), - Positioned( - left: 0, - top: 0, - bottom: 0, - width: 28, - child: IgnorePointer( - ignoring: - _isMenuOpen || - !_requiresLongPressToDrag(_lastPointerKind), - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onHorizontalDragUpdate: - (DragUpdateDetails details) { - if ((details.primaryDelta ?? 0) > 6) { - _openMenu(); - } - }, - child: const SizedBox.expand(), - ), - ), - ), - Positioned.fill( - child: IgnorePointer( - ignoring: !_isMenuOpen, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 300), - opacity: _isMenuOpen ? 0.5 : 0.0, - curve: Curves.easeOutCubic, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: _closeMenu, - child: Container(color: palette.overlay), + decoration: InputDecoration( + hintText: 'Buscar notas...', + hintStyle: TextStyle(color: palette.textSecondary), + prefixIcon: Icon(Icons.search, color: palette.textSecondary), + suffixIcon: _searchQuery.isEmpty + ? null + : IconButton( + onPressed: () { + _searchController.clear(); + setState(() { + _searchQuery = ''; + }); + }, + icon: Icon( + Icons.clear, + color: palette.textSecondary, ), ), - ), - ), - AnimatedPositioned( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - left: _isMenuOpen ? 0 : -280, - top: 0, - bottom: 0, - width: 280, - child: Material( - color: palette.cardBackground, - elevation: 8, - child: MenuDrawer( - onMenuItemTapped: _handleMenuItemTapped, - selectedItem: _selectedCategoryId != null - ? 'category_$_selectedCategoryId' - : (_showDeletedNotes - ? 'deleted_notes' - : 'all_notes'), - categories: _categories, - onEditCategory: (Category c) => - _showCreateCategoryDialog(c), - onCreateCategory: _showCreateCategoryDialog, - ), - ), - ), - ], + filled: true, + fillColor: palette.fill, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: palette.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: palette.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: palette.accent), + ), + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), ), ), ), - ], - ), - ), - ), - floatingActionButton: _showDeletedNotes - ? null - : FloatingActionButton( - onPressed: _openNoteComposer, - child: const MouseRegion( - cursor: SystemMouseCursors.click, - child: Icon(Icons.add), - ), - ), - ); - } -} - -class _DraggableNote extends StatelessWidget { - const _DraggableNote({ - required this.note, - this.borderColor, - required this.dataIndex, - required this.cellWidth, - required this.requiresLongPressToDrag, - required this.isDragging, - required this.isDragTargetActive, - required this.onTap, - required this.onDragStarted, - required this.onDragEnd, - required this.onDraggableCanceled, - }); - - final Note note; - final Color? borderColor; - final int dataIndex; - final double cellWidth; - final bool requiresLongPressToDrag; - final bool isDragging; - final bool isDragTargetActive; - final VoidCallback onTap; - final void Function(DraggableDetails) onDragEnd; - final VoidCallback onDraggableCanceled; - final VoidCallback onDragStarted; - - Widget _buildFeedback(BuildContext context) { - final AppPalette palette = Theme.of(context).extension()!; - - return MouseRegion( - cursor: SystemMouseCursors.grabbing, - child: Material( - color: palette.transparent, - elevation: 8, - child: SizedBox( - width: cellWidth, - child: TweenAnimationBuilder( - tween: Tween(begin: 0.97, end: 1.0), - duration: const Duration(milliseconds: 180), - curve: Curves.easeOutCubic, - builder: (context, scale, child) { - return Transform.scale( - scale: scale, - alignment: Alignment.topLeft, - child: child, - ); - }, - child: Opacity( - opacity: 0.95, - child: NoteCard( - note: note, - onTap: () {}, - isDragging: true, - borderColor: borderColor, - ), ), ), - ), - ), - ); - } - - Widget _buildChildWhenDragging(BuildContext context) { - final AppPalette palette = Theme.of(context).extension()!; - - return MouseRegion( - cursor: SystemMouseCursors.grabbing, - child: Opacity( - opacity: 0.3, - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: palette.cardBackground, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: borderColor ?? palette.textSecondary, - width: 1, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - note.title, - style: TextStyle( - color: palette.textPrimary, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 8), - Text( - noteBodyToPlainText(note.body), - style: TextStyle(color: palette.textSecondary, fontSize: 14), - maxLines: 20, - overflow: TextOverflow.clip, - ), - ], - ), - ), - ), - ); - } - - @override - Widget build(BuildContext context) { - final AppPalette palette = Theme.of(context).extension()!; - - final Widget content = Container( - decoration: BoxDecoration( - border: isDragTargetActive - ? Border.all(color: palette.dragTargetBorder, width: 2) - : null, - borderRadius: BorderRadius.circular(12), - ), - child: NoteCard( - key: ValueKey(note.id), - note: note, - onTap: onTap, - isDragging: isDragging, - borderColor: borderColor, - ), - ); - - if (requiresLongPressToDrag) { - return LongPressDraggable( - data: dataIndex, - delay: const Duration(milliseconds: 280), - onDragStarted: onDragStarted, - onDragEnd: onDragEnd, - onDraggableCanceled: (Velocity _v, Offset _o) => onDraggableCanceled(), - feedback: _buildFeedback(context), - childWhenDragging: _buildChildWhenDragging(context), - child: content, - ); - } - - return Draggable( - data: dataIndex, - onDragStarted: onDragStarted, - onDragEnd: onDragEnd, - onDraggableCanceled: (Velocity _v, Offset _o) => onDraggableCanceled(), - feedback: _buildFeedback(context), - childWhenDragging: _buildChildWhenDragging(context), - child: content, - ); - } -} - -class _EmptyState extends StatelessWidget { - const _EmptyState({ - required this.showDeletedNotes, - this.categoryName, - this.searchQuery, - }); - - final bool showDeletedNotes; - final String? categoryName; - final String? searchQuery; - - @override - Widget build(BuildContext context) { - final AppPalette palette = Theme.of(context).extension()!; - - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.note_add_outlined, color: palette.textSecondary, size: 48), - const SizedBox(height: 12), - Text( - searchQuery != null && searchQuery!.isNotEmpty - ? 'No hay resultados' - : showDeletedNotes - ? 'No hay notas borradas' - : categoryName != null - ? 'No hay notas en esta categoría' - : 'Aún no hay notas', - style: TextStyle( - color: palette.textPrimary, - fontSize: 18, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - Text( - searchQuery != null && searchQuery!.isNotEmpty - ? 'Prueba a buscar con otro término o crea una nota nueva.' - : showDeletedNotes - ? 'Las notas borradas aparecerán aquí para poder restaurarlas.' - : categoryName != null - ? 'Pulsa el botón + para crear una nota en “$categoryName”.' - : 'Pulsa el botón + para crear la primera.', - textAlign: TextAlign.center, - style: TextStyle(color: palette.textSecondary), + const SizedBox(width: 8), + IconButton( + onPressed: widget.onOpenSettings, + icon: Icon(Icons.settings_outlined, color: palette.textSecondary), + tooltip: 'Ajustes', ), ], ), ); } -} -class _CategoryDialog extends StatefulWidget { - const _CategoryDialog({ - required this.category, - required this.repository, - required this.onCategoriesChanged, - required this.onRequestSync, - required this.onCategoryDeleted, - }); + Widget _buildNoteList(BuildContext context, {required bool isDesktop}) { + final AppPalette palette = _paletteOf(context); + final List visibleNotes = _visibleNotes(); - final Category? category; - final NoteRepository repository; - final Future Function() onCategoriesChanged; - final Future Function() onRequestSync; - final Future Function() onCategoryDeleted; + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } - @override - State<_CategoryDialog> createState() => _CategoryDialogState(); -} - -class _CategoryDialogState extends State<_CategoryDialog> { - late final TextEditingController _controller; - Color? _selectedColor; - IconData? _selectedIcon; - int _selectedSection = 0; - bool _nameHasError = false; - bool _isSaving = false; - - @override - void initState() { - super.initState(); - _controller = TextEditingController(text: widget.category?.name ?? ''); - _selectedColor = widget.category == null - ? CategoryStyle.colors.first - : widget.category!.colorValue != null - ? Color(widget.category!.colorValue!) - : null; - if (widget.category != null && widget.category!.iconCodePoint != null) { - _selectedIcon = CategoryStyle.icons.firstWhere( - (IconData icon) => icon.codePoint == widget.category!.iconCodePoint, - orElse: () => CategoryStyle.icons.first, + if (visibleNotes.isEmpty) { + return Center( + child: Text( + 'No hay notas para mostrar', + style: TextStyle(color: palette.textSecondary), + ), ); - } else if (widget.category == null) { - _selectedIcon = CategoryStyle.icons.first; - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - Future _saveCategory() async { - if (_isSaving) { - return; } - final String name = _controller.text.trim(); - if (name.isEmpty) { - setState(() { - _nameHasError = true; - }); - return; - } - - try { - setState(() { - _nameHasError = false; - _isSaving = true; - }); - - final Category newCategory = Category( - id: widget.category?.id, - name: name, - serverVersion: widget.category?.serverVersion ?? 0, - updatedAt: DateTime.now(), - colorValue: _selectedColor?.toARGB32(), - iconCodePoint: _selectedIcon?.codePoint, - ); - if (widget.category == null) { - debugPrint( - 'Creating category: ${newCategory.name}, color: ${newCategory.colorValue}, icon: ${newCategory.iconCodePoint}', - ); - } else { - debugPrint( - 'Updating category: ${newCategory.name}, color: ${newCategory.colorValue}, icon: ${newCategory.iconCodePoint}', - ); - } - await widget.repository.createCategory(newCategory); - await widget.onCategoriesChanged(); - - if (mounted) { - Navigator.pop(context); - } - - widget.onRequestSync().catchError((_) {}); - } catch (e) { - debugPrint('ERROR creating category: $e'); - if (mounted) { - setState(() { - _isSaving = false; - }); - } - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Error al crear categoría: $e'))); - } - } - } - - Future _deleteCategory() async { - final bool? confirm = await showDialog( - context: context, - builder: (BuildContext context) { - final AppPalette palette = Theme.of(context).extension()!; - return AlertDialog( - backgroundColor: palette.cardBackground, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide(color: palette.border), - ), - title: const Text('Borrar categoría'), - content: const Text('¿Seguro que quieres borrar esta categoría?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Cancelar'), - ), - TextButton( - style: TextButton.styleFrom( - foregroundColor: palette.destructiveAccent, - ), - onPressed: () => Navigator.pop(context, true), - child: const Text('Borrar'), - ), - ], - ); - }, - ); - - if (confirm != true) { - return; - } - - try { - await widget.repository.deleteCategory(widget.category!.id); - await widget.onCategoriesChanged(); - // Notify parent to switch view to all notes when a category is deleted. - await widget.onCategoryDeleted(); - if (mounted) { - Navigator.pop(context); - } - try { + return RefreshIndicator( + onRefresh: () async { await widget.onRequestSync(); - } catch (_) {} - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error al borrar categoría: $e')), - ); - } - } - } - - @override - Widget build(BuildContext context) { - final AppPalette palette = Theme.of(context).extension()!; - - return AlertDialog( - backgroundColor: palette.cardBackground, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide(color: palette.border), - ), - title: Text( - widget.category == null ? 'Crear categoría' : 'Editar categoría', - ), - content: SizedBox( - width: 380, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - TextField( - controller: _controller, - onChanged: (_) { - if (_nameHasError) { - setState(() { - _nameHasError = false; - }); - } - }, - decoration: - const InputDecoration( - hintText: 'Nombre de la categoría', - ).copyWith( - errorText: _nameHasError - ? 'El nombre es obligatorio' - : null, - ), - ), - const SizedBox(height: 16), - Container( - decoration: BoxDecoration( - color: palette.fill, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: palette.border), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Expanded( - child: _PickerTabButton( - label: 'Color', - selected: _selectedSection == 0, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(13), - ), - onTap: () => setState(() => _selectedSection = 0), - ), - ), - Expanded( - child: _PickerTabButton( - label: 'Icono', - selected: _selectedSection == 1, - borderRadius: const BorderRadius.only( - topRight: Radius.circular(13), - ), - onTap: () => setState(() => _selectedSection = 1), - ), - ), - ], - ), - Divider(height: 1, color: palette.border), - Padding( - padding: const EdgeInsets.all(12), - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 180), - child: _selectedSection == 0 - ? Wrap( - key: const ValueKey('colors'), - spacing: 10, - runSpacing: 10, - children: CategoryStyle.colors.map((Color color) { - final bool isSelected = - _selectedColor?.value == color.value; - return GestureDetector( - onTap: () => setState(() { - _selectedColor = color; - _selectedSection = 0; - }), - child: Container( - width: 42, - height: 42, - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(10), - border: isSelected - ? Border.all( - color: palette.textPrimary, - width: 2, - ) - : Border.all( - color: palette.border, - width: 1, - ), - ), - ), - ); - }).toList(), - ) - : Wrap( - key: const ValueKey('icons'), - spacing: 10, - runSpacing: 10, - children: CategoryStyle.icons.map(( - IconData icon, - ) { - final bool isSelected = _selectedIcon == icon; - return GestureDetector( - onTap: () => setState(() { - _selectedIcon = icon; - _selectedSection = 1; - }), - child: Container( - width: 42, - height: 42, - decoration: BoxDecoration( - color: isSelected - ? palette.hover - : palette.transparent, - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: isSelected - ? palette.textPrimary - : palette.border, - width: 1, - ), - ), - child: Icon( - icon, - color: isSelected - ? palette.textPrimary - : palette.textSecondary, - ), - ), - ); - }).toList(), - ), - ), - ), - ], - ), - ), - ], - ), - ), - actions: [ - if (widget.category != null) - TextButton( - onPressed: _deleteCategory, - child: Text( - 'Borrar', - style: TextStyle(color: palette.destructiveAccent), + await _loadData(keepSelection: true); + }, + child: ReorderableListView.builder( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 20), + buildDefaultDragHandles: false, + itemCount: visibleNotes.length, + onReorder: _handleReorder, + footer: Padding( + padding: const EdgeInsets.only(top: 8, bottom: 88), + child: Text( + _formatLastSyncAt(), + style: TextStyle( + color: palette.textSecondary, + fontSize: 12, ), ), - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancelar'), ), - TextButton( - onPressed: _isSaving ? null : _saveCategory, - child: _isSaving - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Text(widget.category == null ? 'Crear' : 'Guardar'), + itemBuilder: (BuildContext context, int index) { + final Note note = visibleNotes[index]; + return Padding( + key: ValueKey(note.id), + padding: const EdgeInsets.only(bottom: 10), + child: ReorderableDelayedDragStartListener( + index: index, + child: NoteCard( + note: note, + isSelected: note.id == _selectedNoteId, + onTap: () => _handleNoteTap(note, isDesktop), + onDelete: () => _deleteNote(note), + onChangeCategory: (BuildContext buttonContext) => + _changeNoteCategory(buttonContext, note), + ), + ), + ); + }, + ), + ); + } + + Widget _buildEmptyDetailPane(BuildContext context) { + final AppPalette palette = _paletteOf(context); + + return Container( + decoration: BoxDecoration( + color: palette.surfaceElevated, + border: Border.all(color: palette.border), + borderRadius: BorderRadius.circular(18), + ), + child: Center( + child: Text( + 'Selecciona una nota o crea una nueva para empezar', + textAlign: TextAlign.center, + style: TextStyle( + color: palette.textSecondary, + fontSize: 18, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } + + Widget _buildDesktopLayout(BuildContext context, BoxConstraints constraints) { + final AppPalette palette = _paletteOf(context); + final double leftWidth = (constraints.maxWidth * 0.34).clamp(320, 440); + final Note? selectedNote = _selectedNote(); + final bool selectedIsVisible = selectedNote != null && + _visibleNotes().any((Note note) => note.id == selectedNote.id); + + return Row( + children: [ + SizedBox( + width: leftWidth, + child: Container( + decoration: BoxDecoration( + color: palette.transparent, + border: Border(right: BorderSide(color: palette.border)), + ), + child: Stack( + children: [ + Column( + children: [ + _buildHeader(context), + Expanded(child: _buildNoteList(context, isDesktop: true)), + ], + ), + Positioned( + right: 20, + bottom: 20, + child: FloatingActionButton( + onPressed: () => _createNote(openEditor: false), + child: const Icon(Icons.add), + ), + ), + ], + ), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(16), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 220), + child: selectedIsVisible + ? NoteEditorScreen( + key: ValueKey(selectedNote.id), + repository: widget.repository, + note: selectedNote, + categories: _categories, + embedded: true, + onSaved: (Note saved) { + if (!mounted) { + return; + } + + setState(() { + _notes = [ + for (final Note item in _notes) + if (item.id == saved.id) saved else item, + ]; + }); + }, + ) + : _buildEmptyDetailPane(context), + ), + ), ), ], ); } -} -class _PickerTabButton extends StatelessWidget { - const _PickerTabButton({ - required this.label, - required this.selected, - required this.borderRadius, - required this.onTap, - }); - - final String label; - final bool selected; - final BorderRadius borderRadius; - final VoidCallback onTap; + Widget _buildMobileLayout(BuildContext context) { + return Stack( + children: [ + Column( + children: [ + _buildHeader(context), + Expanded(child: _buildNoteList(context, isDesktop: false)), + ], + ), + Positioned( + right: 20, + bottom: 20, + child: FloatingActionButton( + onPressed: () => _createNote(openEditor: true), + child: const Icon(Icons.add), + ), + ), + ], + ); + } @override Widget build(BuildContext context) { - final AppPalette palette = Theme.of(context).extension()!; + final AppPalette palette = _paletteOf(context); - return Material( - borderRadius: borderRadius, - clipBehavior: Clip.antiAlias, - color: selected ? palette.hover : palette.transparent, - child: InkWell( - borderRadius: borderRadius, - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 14), - child: Center( - child: Text( - label, - style: TextStyle( - color: selected ? palette.textPrimary : palette.textSecondary, - fontWeight: selected ? FontWeight.w600 : FontWeight.w400, - ), - ), - ), + return Scaffold( + body: Container( + decoration: BoxDecoration(gradient: palette.backdropGradient), + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final bool isDesktop = constraints.maxWidth >= _desktopBreakpoint; + return isDesktop + ? _buildDesktopLayout(context, constraints) + : _buildMobileLayout(context); + }, ), ), ); diff --git a/lib/screens/note_editor_screen.dart b/lib/screens/note_editor_screen.dart index fea79da..2189259 100644 --- a/lib/screens/note_editor_screen.dart +++ b/lib/screens/note_editor_screen.dart @@ -1,128 +1,51 @@ import 'dart:async'; -import 'dart:math' as math; import 'package:flutter/material.dart'; -import 'package:flutter_quill/flutter_quill.dart' hide Text; -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/category.dart'; import 'package:notas/models/note.dart'; -import 'package:notas/platform/app_platform.dart'; import 'package:notas/theme/app_palette.dart'; import 'package:notas/widgets/category_style.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. - class NoteEditorScreen extends StatefulWidget { const NoteEditorScreen({ super.key, + this.repository, + this.saveNote, required this.note, - this.categoryId, this.categories = const [], - this.onComplete, + this.embedded = false, + this.onSaved, }); - final Note? note; - final String? categoryId; + final NoteRepository? repository; + final Future Function(Note note)? saveNote; + final Note note; final List categories; - final ValueChanged? onComplete; + final bool embedded; + final ValueChanged? onSaved; @override State createState() => _NoteEditorScreenState(); - - static Future _showGeneralEditorDialog( - BuildContext context, { - Note? note, - String? categoryId, - List categories = const [], - }) { - return showGeneralDialog( - context: context, - barrierDismissible: false, - barrierColor: Colors.transparent, - transitionDuration: const Duration(milliseconds: 200), - pageBuilder: (context, animation, secondaryAnimation) { - return NoteEditorScreen( - note: note, - categoryId: categoryId, - categories: categories, - ); - }, - transitionBuilder: (context, animation, secondaryAnimation, child) { - return ScaleTransition(scale: animation, child: child); - }, - ); - } - - static Future showDialog( - BuildContext context, { - Note? note, - String? categoryId, - List categories = const [], - }) { - if (isAndroid || isIOS) { - return _showGeneralEditorDialog( - context, - note: note, - categoryId: categoryId, - categories: categories, - ); - } - - final OverlayState? overlayState = Overlay.of(context, rootOverlay: true); - if (overlayState == null) { - return _showGeneralEditorDialog( - context, - note: note, - categoryId: categoryId, - categories: categories, - ); - } - - final Completer completer = Completer(); - late final OverlayEntry entry; - - entry = OverlayEntry( - builder: (BuildContext overlayContext) { - return NoteEditorScreen( - note: note, - categoryId: categoryId, - categories: categories, - onComplete: (dynamic result) { - if (!completer.isCompleted) { - completer.complete(result); - } - if (entry.mounted) { - entry.remove(); - } - }, - ); - }, - ); - - overlayState.insert(entry); - return completer.future; - } } class _NoteEditorScreenState extends State { - late TextEditingController _titleController; - late QuillController _bodyController; - late FocusNode _bodyFocusNode; - late ScrollController _bodyScrollController; - late Note _currentNote; - late bool _isNewNote; - String? _selectedCategoryId; + static const Duration _debounceDuration = Duration(seconds: 1); final GlobalKey _categorySelectorKey = GlobalKey(); - OverlayEntry? _categoryMenuEntry; - bool _didComplete = false; - bool get _isMobileLayout => isAndroid || isIOS; + 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; + String? _selectedCategoryId; AppPalette _paletteOf(BuildContext context) { return Theme.of(context).extension() ?? @@ -132,198 +55,30 @@ class _NoteEditorScreenState extends State { @override void initState() { super.initState(); - _isNewNote = widget.note == null; - - if (_isNewNote) { - final DateTime now = DateTime.now(); - _currentNote = Note( - title: '', - body: '', - createdAt: now, - updatedAt: now, - position: 0, - categoryId: widget.categoryId, - ); - } else { - _currentNote = widget.note!; - } - - _selectedCategoryId = _currentNote.categoryId ?? widget.categoryId; - - _titleController = TextEditingController(text: _currentNote.title); + _baselineNote = widget.note; + _selectedCategoryId = widget.note.categoryId; + _titleController = TextEditingController(text: widget.note.title) + ..addListener(_scheduleSave); _bodyController = QuillController( - document: noteBodyToDocument(_currentNote.body), + document: noteBodyToDocument(widget.note.body), selection: const TextSelection.collapsed(offset: 0), - ); + )..addListener(_scheduleSave); _bodyFocusNode = FocusNode(); _bodyScrollController = ScrollController(); } @override void dispose() { - _closeCategoryMenu(); + _debounceTimer?.cancel(); _titleController.dispose(); + _bodyController.dispose(); _bodyFocusNode.dispose(); _bodyScrollController.dispose(); - _bodyController.dispose(); super.dispose(); } - void _complete(dynamic result) { - if (_didComplete) { - return; - } - - _didComplete = true; - _closeCategoryMenu(); - - final ValueChanged? callback = widget.onComplete; - - if (callback != null) { - callback(result); - return; - } - - Navigator.of(context).pop(result); - } - - void _closeWithoutSaving() { - _complete(null); - } - - void _saveNote() { - final String title = _titleController.text.trim(); - final String bodyPlainText = _bodyController.document.toPlainText().trim(); - final bool categoryChanged = _selectedCategoryId != _currentNote.categoryId; - - if (title.isEmpty && bodyPlainText.isEmpty && !categoryChanged) { - _complete(null); - return; - } - - final Note updatedNote = _currentNote.copyWith( - title: title.isEmpty ? 'Sin título' : title, - body: noteDocumentToStorageJson(_bodyController.document), - categoryId: _selectedCategoryId, - updatedAt: DateTime.now(), - isDirty: true, - ); - - _complete(updatedNote); - } - - Widget _buildDeleteConfirmationDialog({ - required ValueChanged onConfirmed, - }) { - final bool isDeletedNote = _currentNote.isDeleted; - final AppPalette palette = _paletteOf(context); - - return AlertDialog( - backgroundColor: palette.cardBackground, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide(color: palette.border), - ), - title: Text( - isDeletedNote ? 'Eliminar permanentemente' : 'Eliminar nota', - style: TextStyle(color: palette.textPrimary), - ), - content: Text( - isDeletedNote - ? 'Esta nota ya está borrada. Si la eliminas ahora, se borrará permanentemente.' - : '¿Estás seguro de que deseas eliminar esta nota?', - style: TextStyle(color: palette.textSecondary), - ), - actions: [ - TextButton( - onPressed: () => onConfirmed(false), - child: Text( - 'Cancelar', - style: TextStyle(color: palette.textSecondary), - ), - ), - TextButton( - onPressed: () => onConfirmed(true), - child: Text( - isDeletedNote ? 'Eliminar permanentemente' : 'Eliminar', - style: TextStyle(color: palette.destructiveAccent), - ), - ), - ], - ); - } - - Future _showDeleteConfirmation() async { - if (_isMobileLayout) { - final bool? confirmed = await showDialog( - context: context, - barrierDismissible: false, - barrierColor: Colors.transparent, - builder: (BuildContext dialogContext) { - return _buildDeleteConfirmationDialog( - onConfirmed: (bool confirmed) => - Navigator.of(dialogContext).pop(confirmed), - ); - }, - ); - - return confirmed ?? false; - } - - final OverlayState? overlayState = Overlay.of(context, rootOverlay: true); - if (overlayState == null) { - final bool? confirmed = await showDialog( - context: context, - barrierDismissible: false, - barrierColor: Colors.transparent, - builder: (BuildContext dialogContext) { - return _buildDeleteConfirmationDialog( - onConfirmed: (bool confirmed) => - Navigator.of(dialogContext).pop(confirmed), - ); - }, - ); - - return confirmed ?? false; - } - - final Completer completer = Completer(); - late final OverlayEntry entry; - bool didRemove = false; - - entry = OverlayEntry( - builder: (BuildContext overlayContext) { - final ValueChanged close = (bool confirmed) { - if (!completer.isCompleted) { - completer.complete(confirmed); - } - if (!didRemove && entry.mounted) { - didRemove = true; - entry.remove(); - } - }; - - return Material( - color: Colors.transparent, - child: Stack( - children: [ - const Positioned.fill( - child: ModalBarrier(dismissible: false, color: Colors.black54), - ), - Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: _buildDeleteConfirmationDialog(onConfirmed: close), - ), - ), - ], - ), - ); - }, - ); - - overlayState.insert(entry); - return completer.future; + String _bodyAsJson() { + return noteDocumentToStorageJson(_bodyController.document); } Category? _categoryById(String? id) { @@ -359,392 +114,252 @@ class _NoteEditorScreenState extends State { : palette.textPrimary; } - Widget _buildCategorySelectorBox({Category? category}) { - final AppPalette palette = _paletteOf(context); - final String label = category?.name ?? 'Sin categoría'; - final IconData icon = CategoryStyle.iconForCodePoint( - category?.iconCodePoint, + RelativeRect _menuRectFromContext(BuildContext anchorContext) { + final RenderBox button = anchorContext.findRenderObject()! as RenderBox; + final RenderBox overlay = Overlay.of(anchorContext).context.findRenderObject()! + as RenderBox; + final Offset topLeft = button.localToGlobal(Offset.zero, ancestor: overlay); + final Offset bottomRight = button.localToGlobal( + button.size.bottomRight(Offset.zero), + ancestor: overlay, ); - final Color backgroundColor = _categoryBackgroundColor(category); - final Color foregroundColor = _categoryForegroundColor(category); - return Container( - key: _categorySelectorKey, - padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 6), - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: category?.colorValue != null - ? backgroundColor.withValues(alpha: 0.85) - : palette.textDisabled, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, color: foregroundColor, size: 15), - const SizedBox(width: 6), - Expanded( - child: Text( - label, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: foregroundColor, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - ), - const SizedBox(width: 6), - Icon( - Icons.arrow_drop_down, - color: foregroundColor.withValues(alpha: 0.9), - size: 16, - ), - ], + return RelativeRect.fromRect( + Rect.fromLTRB( + topLeft.dx, + topLeft.dy, + bottomRight.dx, + bottomRight.dy, ), + Offset.zero & overlay.size, ); } - void _closeCategoryMenu() { - final OverlayEntry? entry = _categoryMenuEntry; - if (entry != null && entry.mounted) { - entry.remove(); - } - _categoryMenuEntry = null; + void _scheduleSave() { + _debounceTimer?.cancel(); + _debounceTimer = Timer(_debounceDuration, () { + unawaited(_saveNow()); + }); } - void _toggleCategoryMenu() { - if (_categoryMenuEntry != null) { - _closeCategoryMenu(); + Future _saveNow() async { + if (!mounted) { return; } - final OverlayState? overlayState = Overlay.of(context, rootOverlay: true); - if (overlayState == null) { + 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: _selectedCategoryId, + updatedAt: DateTime.now(), + isDirty: true, + ); + + final bool hasChanges = draft.title != _baselineNote.title || + draft.body != _baselineNote.body || + draft.categoryId != _baselineNote.categoryId; + + if (!hasChanges) { return; } - _categoryMenuEntry = OverlayEntry( - builder: (BuildContext overlayContext) { - final AppPalette palette = _paletteOf(overlayContext); - final Size screenSize = MediaQuery.of(overlayContext).size; - final double menuWidth = math.min(screenSize.width - 32, 320); - final double menuHeight = math.min(screenSize.height - 32, 360); + if (_isSaving) { + _saveQueued = true; + return; + } - return Material( - color: Colors.transparent, - child: Stack( - children: [ - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: _closeCategoryMenu, - child: const SizedBox.expand(), - ), - ), - Center( - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: menuWidth, - maxHeight: menuHeight, - ), - child: Material( - elevation: 10, - color: palette.surfaceElevated, - borderRadius: BorderRadius.circular(12), - clipBehavior: Clip.antiAlias, - child: ListView( - padding: const EdgeInsets.symmetric(vertical: 8), - shrinkWrap: true, - children: [ - _buildCategoryMenuItem( - category: null, - label: 'Sin categoría', - isSelected: _selectedCategoryId == null, - onTap: () { - setState(() { - _selectedCategoryId = null; - }); - _closeCategoryMenu(); - }, - ), - for (final Category category in widget.categories) - _buildCategoryMenuItem( - category: category, - label: category.name, - isSelected: _selectedCategoryId == category.id, - onTap: () { - setState(() { - _selectedCategoryId = category.id; - }); - _closeCategoryMenu(); - }, - ), - ], - ), - ), - ), - ), - ], - ), + _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')), ); - }, - ); - - overlayState.insert(_categoryMenuEntry!); + } + } finally { + _isSaving = false; + if (_saveQueued) { + _saveQueued = false; + unawaited(_saveNow()); + } + } } - Widget _buildCategoryMenuItem({ - required Category? category, - required String label, - required bool isSelected, - required VoidCallback onTap, - }) { + Future _selectCategory(BuildContext anchorContext) async { + final Category? selected = await showMenu( + context: anchorContext, + position: _menuRectFromContext(anchorContext), + elevation: 10, + color: _paletteOf(anchorContext).surfaceElevated, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + items: >[ + const PopupMenuItem( + value: null, + child: Text('Sin categoría'), + ), + const PopupMenuDivider(), + for (final Category category in widget.categories) + PopupMenuItem( + value: category, + child: Text(category.name), + ), + ], + ); + + if (!mounted) { + return; + } + + setState(() { + _selectedCategoryId = selected?.id; + }); + _scheduleSave(); + } + + Widget _buildCategorySelector() { + final Category? category = _categoryById(_selectedCategoryId); final AppPalette palette = _paletteOf(context); final Color backgroundColor = _categoryBackgroundColor(category); final Color foregroundColor = _categoryForegroundColor(category); - final IconData icon = CategoryStyle.iconForCodePoint( - category?.iconCodePoint, - ); return InkWell( - onTap: onTap, + key: const ValueKey('category_selector'), + borderRadius: BorderRadius.circular(12), + onTap: () => _selectCategory(context), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - color: isSelected ? palette.hover : null, + key: _categorySelectorKey, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: category?.colorValue != null + ? backgroundColor.withValues(alpha: 0.9) + : palette.textDisabled, + ), + ), child: Row( + mainAxisSize: MainAxisSize.min, children: [ - Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: category?.colorValue != null - ? backgroundColor.withValues(alpha: 0.85) - : palette.textDisabled, - ), - ), - child: Icon(icon, size: 16, color: foregroundColor), + Icon( + CategoryStyle.iconForCodePoint(category?.iconCodePoint), + color: foregroundColor, + size: 16, ), - const SizedBox(width: 12), - Expanded( + const SizedBox(width: 8), + Flexible( child: Text( - label, + category?.name ?? 'Sin categoría', overflow: TextOverflow.ellipsis, style: TextStyle( color: foregroundColor, - fontSize: 13, + fontSize: 12, fontWeight: FontWeight.w600, ), ), ), - if (isSelected) Icon(Icons.check, color: foregroundColor, size: 18), + const SizedBox(width: 6), + Icon(Icons.arrow_drop_down, color: foregroundColor, size: 18), ], ), ), ); } - Future _deleteNote() async { - final bool confirmed = await _showDeleteConfirmation(); - if (!mounted || !confirmed) { - return; - } - - _complete('delete'); - } - - 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); - } - } - - Widget _buildEditorContent({required bool isMobile}) { + Widget _buildEditorBody() { final AppPalette palette = _paletteOf(context); - final double titleSpacing = isMobile ? 16.0 : 8.0; return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: palette.border, width: 1)), - ), - child: Row( - children: [ - IconButton( - onPressed: _closeWithoutSaving, - icon: Icon(Icons.close, color: palette.textSecondary), - tooltip: 'Cerrar sin guardar', - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Posicion: ${_currentNote.position}', - style: TextStyle(color: palette.textMuted, fontSize: 12), - ), - Text( - 'Creado: ${_formatDate(_currentNote.createdAt)}', - style: TextStyle(color: palette.textMuted, fontSize: 12), - ), - if (_currentNote.updatedAt != _currentNote.createdAt) - Text( - 'Modificado: ${_formatDate(_currentNote.updatedAt)}', - style: TextStyle( - color: palette.textMuted, - fontSize: 12, - ), - ), - ], + Row( + children: [ + Expanded( + child: TextField( + controller: _titleController, + style: TextStyle( + color: palette.textPrimary, + fontSize: 28, + fontWeight: FontWeight.w700, + ), + decoration: InputDecoration( + hintText: 'Título', + hintStyle: TextStyle(color: palette.textHint), + border: InputBorder.none, ), ), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 150), - child: SizedBox( - width: 150, - child: KeyedSubtree( - key: const ValueKey('category_selector'), - child: InkWell( - onTap: _toggleCategoryMenu, - borderRadius: BorderRadius.circular(12), - child: _buildCategorySelectorBox( - category: _categoryById(_selectedCategoryId), - ), - ), - ), - ), - ), - ], - ), + ), + const SizedBox(width: 12), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 240), + child: _buildCategorySelector(), + ), + ], ), + const SizedBox(height: 16), Expanded( - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextField( - controller: _titleController, - style: TextStyle( - color: palette.textPrimary, - fontSize: 28, - fontWeight: FontWeight.bold, - ), - decoration: InputDecoration( - hintText: 'Título', - hintStyle: TextStyle(color: palette.textHint), - border: InputBorder.none, - contentPadding: EdgeInsets.zero, - ), - ), - SizedBox(height: titleSpacing), - Expanded( - child: Padding( - padding: const EdgeInsets.only(top: 4), - 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, - ), - ), - ), - ), - ], + child: Container( + decoration: BoxDecoration( + color: palette.transparent, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: palette.border), + ), + padding: const EdgeInsets.all(14), + 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, + ), ), ), ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - border: Border(top: BorderSide(color: palette.border, width: 1)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - 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, - showClipboardCut: false, - showClipboardCopy: false, - showClipboardPaste: false, - axis: Axis.horizontal, - ), - ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - if (!_isNewNote) - IconButton( - onPressed: _deleteNote, - icon: Icon( - Icons.delete_outline, - color: palette.destructiveAccent, - ), - tooltip: 'Eliminar nota', - ) - else - const SizedBox(width: 48), - FilledButton( - onPressed: _saveNote, - child: const Text('Guardar'), - ), - ], - ), - ], + const SizedBox(height: 12), + 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, ), ), ], @@ -755,48 +370,21 @@ class _NoteEditorScreenState extends State { Widget build(BuildContext context) { final AppPalette palette = _paletteOf(context); - if (_isMobileLayout) { - return Material( - color: palette.transparent, - child: SafeArea( - child: Container( - color: palette.cardBackground, - child: _buildEditorContent(isMobile: true), - ), - ), - ); + final Widget editor = Padding( + padding: const EdgeInsets.all(20), + child: _buildEditorBody(), + ); + + if (widget.embedded) { + return Container(color: palette.cardBackground, child: editor); } - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - final double maxWidth = math.min(constraints.maxWidth - 32, 600); - final double maxHeight = math.min(constraints.maxHeight - 32, 720); - - return Stack( - children: [ - Positioned.fill( - child: ModalBarrier(dismissible: false, color: palette.shadowDim), - ), - Positioned.fill( - child: Center( - child: SizedBox( - width: maxWidth, - height: maxHeight, - child: Material( - color: palette.cardBackground, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide(color: palette.textDisabled, width: 1), - ), - clipBehavior: Clip.antiAlias, - child: _buildEditorContent(isMobile: false), - ), - ), - ), - ), - ], - ); - }, + return Scaffold( + backgroundColor: palette.cardBackground, + appBar: AppBar( + title: const Text('Editar nota'), + ), + body: SafeArea(child: editor), ); } } diff --git a/lib/widgets/note_card.dart b/lib/widgets/note_card.dart index 74362c2..cd4b6ac 100644 --- a/lib/widgets/note_card.dart +++ b/lib/widgets/note_card.dart @@ -4,150 +4,110 @@ import 'package:notas/data/note_body.dart'; import 'package:notas/models/note.dart'; import 'package:notas/theme/app_palette.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 { +class NoteCard extends StatelessWidget { const NoteCard({ super.key, required this.note, - this.onTap, - this.isDragging = false, + this.isSelected = false, this.borderColor, + this.onTap, + this.onDelete, + this.onChangeCategory, }); final Note note; - final VoidCallback? onTap; - final bool isDragging; + final bool isSelected; final Color? borderColor; - - @override - State createState() => _NoteCardState(); -} - -class _NoteCardState extends State { - bool _isPressed = false; + final VoidCallback? onTap; + final VoidCallback? onDelete; + final ValueChanged? onChangeCategory; @override Widget build(BuildContext context) { final AppPalette palette = Theme.of(context).extension()!; - final bool showGrabbing = widget.isDragging || _isPressed; + final String bodyText = noteBodyToPlainText(note.body).trim(); - 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: palette.cardBackground, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: widget.borderColor ?? palette.textDisabled, - width: 1, +return Material( + color: Colors.transparent, // 1. Fondo completamente transparente + shape: BorderDirectional( + start: BorderSide( + color: isSelected ? palette.accent : Colors.transparent, + width: isSelected ? 1.6 : 1.0, + ), + ), + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: onTap, + hoverColor: Colors.transparent, // 2. Desactiva el efecto hover (pasar el ratón) + splashColor: Colors.transparent, // 3. Desactiva el efecto de onda al hacer clic + highlightColor: Colors.transparent, // Desactiva el brillo al mantener pulsado + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + note.title.isEmpty ? 'Sin título' : note.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + 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: 13, + height: 1.2, + ), + ), + ], ), ), - 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 String bodyText = noteBodyToPlainText(widget.note.body); - final List rawLines = bodyText.split('\n'); - const int avgCharsPerLine = 40; - int estimatedLines = 0; - for (final String line in rawLines) { - estimatedLines += (line.trim().length ~/ avgCharsPerLine) + 1; + const SizedBox(width: 8), + PopupMenuButton( + tooltip: 'Más opciones', + icon: Icon( + Icons.more_vert, + color: palette.textSecondary, + ), + onOpened: () {}, + onSelected: (String value) { + switch (value) { + case 'delete': + onDelete?.call(); + return; + case 'category': + onChangeCategory?.call(context); + return; } - - final bool needsPreciseMeasurement = estimatedLines > 15; - final bool isBodyTruncated; - - if (needsPreciseMeasurement) { - final TextPainter textPainter = TextPainter( - text: TextSpan( - text: bodyText, - style: TextStyle( - color: palette.textSecondary, - fontSize: 14, - ), - ), - maxLines: 15, - textDirection: TextDirection.ltr, - )..layout(maxWidth: constraints.maxWidth); - - isBodyTruncated = textPainter.didExceedMaxLines; - } else { - isBodyTruncated = false; - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - widget.note.title, - style: TextStyle( - color: palette.textPrimary, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 8), - Text( - bodyText, - style: TextStyle( - color: palette.textSecondary, - fontSize: 14, - ), - maxLines: 15, - overflow: TextOverflow.clip, - ), - if (isBodyTruncated) ...[ - const SizedBox(height: 4), - Text( - '...', - style: TextStyle( - color: palette.textMuted, - fontSize: 18, - height: 1, - ), - ), - ], - ], - ); }, + itemBuilder: (BuildContext context) => >[ + const PopupMenuItem( + value: 'delete', + child: Text('Eliminar nota'), + ), + const PopupMenuItem( + value: 'category', + child: Text('Cambiar categoría'), + ), + ], ), - ), + ], ), - ); - } + ), + ), +); +} } diff --git a/test/note_editor_screen_test.dart b/test/note_editor_screen_test.dart index 3e67f53..83ffb24 100644 --- a/test/note_editor_screen_test.dart +++ b/test/note_editor_screen_test.dart @@ -8,11 +8,19 @@ import 'package:notas/models/note.dart'; import 'package:notas/screens/note_editor_screen.dart'; void main() { - testWidgets('saves a note when only the category changes', ( + 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 >[ @@ -23,8 +31,8 @@ void main() { ], home: Scaffold( body: NoteEditorScreen( - note: null, - categoryId: null, + repository: null, + note: initialNote, categories: [ Category( id: 'work', @@ -32,8 +40,9 @@ void main() { updatedAt: DateTime(2026, 5, 21), ), ], - onComplete: (dynamic result) { - savedNote = result as Note?; + saveNote: (Note note) async => note, + onSaved: (Note result) { + savedNote = result; }, ), ), @@ -46,20 +55,26 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.text('Trabajo').last); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Guardar')); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(const Duration(seconds: 2)); expect(savedNote, isNotNull); expect(savedNote!.categoryId, 'work'); expect(savedNote!.title, 'Sin título'); }); - testWidgets('only completes once when save is tapped twice', ( + testWidgets('debounces multiple edits into a single save', ( WidgetTester tester, ) async { - int completionCount = 0; + 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( @@ -71,25 +86,23 @@ void main() { ], home: Scaffold( body: NoteEditorScreen( - note: null, - categoryId: null, + repository: null, + note: initialNote, categories: [], - onComplete: (dynamic result) { - if (result is Note) { - completionCount += 1; - } + saveNote: (Note note) async { + saveCount += 1; + return note; }, ), ), ), ); - await tester.enterText(find.byType(TextField).first, 'Nota de prueba'); + 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)); - await tester.tap(find.text('Guardar')); - await tester.tap(find.text('Guardar')); - await tester.pumpAndSettle(); - - expect(completionCount, 1); + expect(saveCount, 1); }); }