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:notas/data/note_repository.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'; class HomeScreen extends StatefulWidget { const HomeScreen({ super.key, required this.repository, required this.onOpenSettings, required this.onRequestSync, this.onVaultInvalid, this.syncStatus = SyncStatus.idle, this.syncProgress, this.syncDetailMessage, this.syncErrorMessage, this.refreshToken = 0, }); final NoteRepository repository; final VoidCallback onOpenSettings; final Future Function() onRequestSync; final Future Function()? onVaultInvalid; final SyncStatus syncStatus; final double? syncProgress; final String? syncDetailMessage; final String? syncErrorMessage; final int refreshToken; @override State createState() => _HomeScreenState(); } class _HomeScreenState extends State { List _notes = []; String _searchQuery = ''; bool _isLoading = true; bool _isDragging = false; bool _isMenuOpen = false; bool _showDeletedNotes = false; PointerDeviceKind _lastPointerKind = PointerDeviceKind.mouse; List _categories = []; String? _selectedCategoryId; 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; } @override void initState() { super.initState(); _loadNotesAndCategories(); } 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 didUpdateWidget(covariant HomeScreen oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.refreshToken != widget.refreshToken) { // Refresh in background without showing the full-screen loader _loadNotesAndCategories(showLoadingIndicator: false); } } Future _loadNotes() async { try { final List storedNotes = _showDeletedNotes ? await widget.repository.loadDeletedNotes() : await widget.repository.loadNotes(); if (!mounted) return; setState(() { _notes = storedNotes; _isLoading = false; }); } 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. 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. 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 (_) {} } } } List _getFilteredNotes() { Iterable notes = _notes; if (_selectedCategoryId != null) { notes = notes.where( (Note note) => note.categoryId == _selectedCategoryId, ); } if (_searchQuery.isEmpty) { return notes.toList(); } final String query = _searchQuery.toLowerCase(); return notes .where( (Note note) => note.title.toLowerCase().contains(query) || note.body.toLowerCase().contains(query), ) .toList(); } Future _handleMenuItemTapped(String item) async { _closeMenu(); if (item == 'create_category') { await _showCreateCategoryDialog(); return; } if (item.startsWith('category_')) { final String id = item.substring('category_'.length); setState(() { _selectedCategoryId = id; _showDeletedNotes = false; _searchQuery = ''; _isLoading = true; }); await _loadNotes(); return; } 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(); } } 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'), ); }, ); } @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!), }; 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, ); 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; }); }, ); return Listener( onPointerDown: (PointerDownEvent event) { if (_lastPointerKind == event.kind) { return; } setState(() { _lastPointerKind = event.kind; }); }, child: draggableNote, ); }, ); }, ); }, ), ), ], ), ), ); return Scaffold( body: Container( decoration: const BoxDecoration( gradient: LinearGradient( colors: [Color(0xFF191A1D), Color(0xFF222326), Color(0xFF101114)], begin: Alignment.topLeft, end: Alignment.bottomRight, ), ), 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, ), onSearchChanged: (String query) { setState(() { _searchQuery = query; }); }, ), Expanded( child: Listener( onPointerDown: (PointerDownEvent event) { if (_lastPointerKind == event.kind) { return; } setState(() { _lastPointerKind = event.kind; }); }, 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: Colors.black), ), ), ), ), AnimatedPositioned( duration: const Duration(milliseconds: 300), curve: Curves.easeOutCubic, left: _isMenuOpen ? 0 : -280, top: 0, bottom: 0, width: 280, child: Material( color: const Color.fromRGBO(24, 25, 26, 1), 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, ), ), ), ], ), ), ), ], ), ), ), 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() { return MouseRegion( cursor: SystemMouseCursors.grabbing, child: Material( color: Colors.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() { return MouseRegion( cursor: SystemMouseCursors.grabbing, child: Opacity( opacity: 0.3, 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: borderColor ?? Colors.white24, width: 1), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( note.title, style: const TextStyle( color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 8), Text( note.body, style: const TextStyle(color: Colors.white70, fontSize: 14), maxLines: 20, overflow: TextOverflow.clip, ), ], ), ), ), ); } @override Widget build(BuildContext context) { final Widget content = Container( decoration: BoxDecoration( border: isDragTargetActive ? Border.all(color: Colors.blue.shade400, 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(), childWhenDragging: _buildChildWhenDragging(), child: content, ); } return Draggable( data: dataIndex, onDragStarted: onDragStarted, onDragEnd: onDragEnd, onDraggableCanceled: (Velocity _v, Offset _o) => onDraggableCanceled(), feedback: _buildFeedback(), childWhenDragging: _buildChildWhenDragging(), 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) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.note_add_outlined, color: Colors.white54, 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: const TextStyle( color: Colors.white, 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: const TextStyle(color: Colors.white70), ), ], ), ); } } class _CategoryDialog extends StatefulWidget { const _CategoryDialog({ required this.category, required this.repository, required this.onCategoriesChanged, required this.onRequestSync, required this.onCategoryDeleted, }); final Category? category; final NoteRepository repository; final Future Function() onCategoriesChanged; final Future Function() onRequestSync; final Future Function() onCategoryDeleted; @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, ); } 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) => AlertDialog( 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( onPressed: () => Navigator.pop(context, true), child: const Text('Borrar'), ), ], ), ); if (confirm != true) { return; } try { await widget.repository.deleteCategory(widget.category!.id); await widget.onCategoriesChanged(); try { await widget.onRequestSync(); } catch (_) {} if (mounted) { Navigator.pop(context); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Error al borrar categoría: $e')), ); } } } @override Widget build(BuildContext context) { return AlertDialog( 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: Colors.white.withValues(alpha: 0.04), borderRadius: BorderRadius.circular(14), border: Border.all(color: Colors.white12), ), 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), ), ), ], ), const Divider(height: 1, color: Colors.white12), 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: Colors.white, width: 2, ) : Border.all( color: Colors.white12, 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 ? Colors.white10 : Colors.transparent, borderRadius: BorderRadius.circular(10), border: Border.all( color: isSelected ? Colors.white : Colors.white12, width: 1, ), ), child: Icon( icon, color: isSelected ? Colors.white : Colors.white70, ), ), ); }).toList(), ), ), ), ], ), ), ], ), ), actions: [ if (widget.category != null) TextButton( onPressed: _deleteCategory, child: const Text('Borrar', style: TextStyle(color: Colors.red)), ), 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'), ), ], ); } } 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; @override Widget build(BuildContext context) { return Material( borderRadius: borderRadius, clipBehavior: Clip.antiAlias, color: selected ? Colors.white10 : Colors.transparent, child: InkWell( borderRadius: borderRadius, onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric(vertical: 14), child: Center( child: Text( label, style: TextStyle( color: selected ? Colors.white : Colors.white54, fontWeight: selected ? FontWeight.w600 : FontWeight.w400, ), ), ), ), ), ); } }