From 989d307fd65775aaa874bc169ae9a457e4684617 Mon Sep 17 00:00:00 2001 From: Marcos Date: Mon, 18 May 2026 19:06:34 +0200 Subject: [PATCH] feat: Enhance drag-and-drop functionality with long press support and pointer detection --- lib/screens/home_screen.dart | 446 ++++++++++++++++++++++++----------- 1 file changed, 310 insertions(+), 136 deletions(-) diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 835798b..e7cbecd 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -1,5 +1,6 @@ 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'; @@ -32,6 +33,13 @@ class _HomeScreenState extends State { bool _isLoading = true; bool _isDragging = false; bool _isMenuOpen = false; + PointerDeviceKind _lastPointerKind = PointerDeviceKind.mouse; + + bool _requiresLongPressToDrag(PointerDeviceKind kind) { + return kind == PointerDeviceKind.touch || + kind == PointerDeviceKind.stylus || + kind == PointerDeviceKind.invertedStylus; + } @override void initState() { @@ -66,7 +74,10 @@ class _HomeScreenState extends State { if (result is Note) { final Note createdNote = await widget.repository.createNote(result); - final List updatedNotes = _normalizeNotes([createdNote, ..._notes]); + final List updatedNotes = _normalizeNotes([ + createdNote, + ..._notes, + ]); if (!mounted) { return; @@ -127,7 +138,10 @@ class _HomeScreenState extends State { } Future _openNoteEditor(Note note) async { - final dynamic result = await NoteEditorScreen.showDialog(context, note: note); + final dynamic result = await NoteEditorScreen.showDialog( + context, + note: note, + ); if (result == null) { return; @@ -174,9 +188,11 @@ class _HomeScreenState extends State { final String query = _searchQuery.toLowerCase(); return _notes - .where((Note note) => - note.title.toLowerCase().contains(query) || - note.body.toLowerCase().contains(query)) + .where( + (Note note) => + note.title.toLowerCase().contains(query) || + note.body.toLowerCase().contains(query), + ) .toList(); } @@ -188,150 +204,301 @@ class _HomeScreenState extends State { final Widget body = _isLoading ? const Center(child: CircularProgressIndicator()) : _notes.isEmpty - ? const _EmptyState() - : RefreshIndicator( - onRefresh: () async { - try { - await widget.repository.performSync(); - } catch (_) {} - await _loadNotes(); - }, - child: MouseRegion( - cursor: _isDragging ? SystemMouseCursors.grabbing : SystemMouseCursors.basic, - child: MasonryGridView.count( - crossAxisCount: crossAxisCount, - mainAxisSpacing: 10, - crossAxisSpacing: 10, - itemCount: _getFilteredNotes().length, - itemBuilder: (BuildContext context, int index) { - final List filteredNotes = _getFilteredNotes(); - 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; + ? const _EmptyState() + : RefreshIndicator( + onRefresh: () async { + try { + await widget.repository.performSync(); + } catch (_) {} + await _loadNotes(); + }, + child: MouseRegion( + cursor: _isDragging + ? SystemMouseCursors.grabbing + : SystemMouseCursors.basic, + child: MasonryGridView.count( + crossAxisCount: crossAxisCount, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + itemCount: _getFilteredNotes().length, + itemBuilder: (BuildContext context, int index) { + final List filteredNotes = _getFilteredNotes(); + 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); - return Draggable( - data: _notes.indexOf(filteredNotes[index]), - onDragStarted: () { - if (!mounted) return; - setState(() { - _isDragging = true; - }); - }, - onDragEnd: (_) { - if (!mounted) return; - setState(() { - _isDragging = false; - }); - }, - onDraggableCanceled: (_, _) { - if (!mounted) return; - setState(() { - _isDragging = false; - }); - }, - feedback: 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: filteredNotes[index], onTap: () {}, isDragging: true), + final Widget draggableNote = requiresLongPressToDrag + ? LongPressDraggable( + data: _notes.indexOf(filteredNotes[index]), + delay: const Duration(milliseconds: 280), + onDragStarted: () { + if (!mounted) return; + setState(() { + _isDragging = true; + }); + }, + onDragEnd: (_) { + if (!mounted) return; + setState(() { + _isDragging = false; + }); + }, + onDraggableCanceled: (_, _) { + if (!mounted) return; + setState(() { + _isDragging = false; + }); + }, + feedback: 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: filteredNotes[index], + onTap: () {}, + isDragging: true, + ), + ), + ), ), ), ), - ), - ), - childWhenDragging: 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: Colors.white24, width: 1), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - filteredNotes[index].title, - style: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.bold, + childWhenDragging: 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: Colors.white24, + width: 1, ), - maxLines: 2, - overflow: TextOverflow.ellipsis, ), - const SizedBox(height: 8), - Text( - filteredNotes[index].body, - style: const TextStyle(color: Colors.white70, fontSize: 14), - maxLines: 20, - overflow: TextOverflow.clip, + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + filteredNotes[index].title, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + Text( + filteredNotes[index].body, + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + ), + maxLines: 20, + overflow: TextOverflow.clip, + ), + ], ), - ], + ), ), ), - ), - ), - child: Container( - decoration: BoxDecoration( - border: candidateData.isNotEmpty - ? Border.all(color: Colors.blue.shade400, width: 2) - : null, - borderRadius: BorderRadius.circular(12), - ), - child: NoteCard( - key: ValueKey(filteredNotes[index].id ?? filteredNotes[index].index), - note: filteredNotes[index], - onTap: () => _openNoteEditor(filteredNotes[index]), - isDragging: _isDragging, - ), - ), - ); - }, - ); - }, - ); - }, - ), + child: Container( + decoration: BoxDecoration( + border: candidateData.isNotEmpty + ? Border.all( + color: Colors.blue.shade400, + width: 2, + ) + : null, + borderRadius: BorderRadius.circular(12), + ), + child: NoteCard( + key: ValueKey( + filteredNotes[index].id ?? + filteredNotes[index].index, + ), + note: filteredNotes[index], + onTap: () => + _openNoteEditor(filteredNotes[index]), + isDragging: _isDragging, + ), + ), + ) + : Draggable( + data: _notes.indexOf(filteredNotes[index]), + onDragStarted: () { + if (!mounted) return; + setState(() { + _isDragging = true; + }); + }, + onDragEnd: (_) { + if (!mounted) return; + setState(() { + _isDragging = false; + }); + }, + onDraggableCanceled: (_, _) { + if (!mounted) return; + setState(() { + _isDragging = false; + }); + }, + feedback: 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: filteredNotes[index], + onTap: () {}, + isDragging: true, + ), + ), + ), + ), + ), + ), + childWhenDragging: 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: Colors.white24, + width: 1, + ), + ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + filteredNotes[index].title, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + Text( + filteredNotes[index].body, + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + ), + maxLines: 20, + overflow: TextOverflow.clip, + ), + ], + ), + ), + ), + ), + child: Container( + decoration: BoxDecoration( + border: candidateData.isNotEmpty + ? Border.all( + color: Colors.blue.shade400, + width: 2, + ) + : null, + borderRadius: BorderRadius.circular(12), + ), + child: NoteCard( + key: ValueKey( + filteredNotes[index].id ?? + filteredNotes[index].index, + ), + note: filteredNotes[index], + onTap: () => + _openNoteEditor(filteredNotes[index]), + isDragging: _isDragging, + ), + ), + ); + + 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), - ], + colors: [Color(0xFF191A1D), Color(0xFF222326), Color(0xFF101114)], begin: Alignment.topLeft, end: Alignment.bottomRight, ), @@ -355,7 +522,10 @@ class _HomeScreenState extends State { child: Stack( children: [ Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 8.0, + ), child: body, ), Positioned.fill( @@ -431,7 +601,11 @@ class _EmptyState extends StatelessWidget { SizedBox(height: 12), Text( 'Aún no hay notas', - style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600), + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), ), SizedBox(height: 8), Text(