feat: Enhance drag-and-drop functionality with long press support and pointer detection

This commit is contained in:
2026-05-18 19:06:34 +02:00
parent efe602a5da
commit 989d307fd6
+310 -136
View File
@@ -1,5 +1,6 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
@@ -32,6 +33,13 @@ class _HomeScreenState extends State<HomeScreen> {
bool _isLoading = true; bool _isLoading = true;
bool _isDragging = false; bool _isDragging = false;
bool _isMenuOpen = false; bool _isMenuOpen = false;
PointerDeviceKind _lastPointerKind = PointerDeviceKind.mouse;
bool _requiresLongPressToDrag(PointerDeviceKind kind) {
return kind == PointerDeviceKind.touch ||
kind == PointerDeviceKind.stylus ||
kind == PointerDeviceKind.invertedStylus;
}
@override @override
void initState() { void initState() {
@@ -66,7 +74,10 @@ class _HomeScreenState extends State<HomeScreen> {
if (result is Note) { if (result is Note) {
final Note createdNote = await widget.repository.createNote(result); final Note createdNote = await widget.repository.createNote(result);
final List<Note> updatedNotes = _normalizeNotes(<Note>[createdNote, ..._notes]); final List<Note> updatedNotes = _normalizeNotes(<Note>[
createdNote,
..._notes,
]);
if (!mounted) { if (!mounted) {
return; return;
@@ -127,7 +138,10 @@ class _HomeScreenState extends State<HomeScreen> {
} }
Future<void> _openNoteEditor(Note note) async { Future<void> _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) { if (result == null) {
return; return;
@@ -174,9 +188,11 @@ class _HomeScreenState extends State<HomeScreen> {
final String query = _searchQuery.toLowerCase(); final String query = _searchQuery.toLowerCase();
return _notes return _notes
.where((Note note) => .where(
note.title.toLowerCase().contains(query) || (Note note) =>
note.body.toLowerCase().contains(query)) note.title.toLowerCase().contains(query) ||
note.body.toLowerCase().contains(query),
)
.toList(); .toList();
} }
@@ -188,150 +204,301 @@ class _HomeScreenState extends State<HomeScreen> {
final Widget body = _isLoading final Widget body = _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _notes.isEmpty : _notes.isEmpty
? const _EmptyState() ? const _EmptyState()
: RefreshIndicator( : RefreshIndicator(
onRefresh: () async { onRefresh: () async {
try { try {
await widget.repository.performSync(); await widget.repository.performSync();
} catch (_) {} } catch (_) {}
await _loadNotes(); await _loadNotes();
}, },
child: MouseRegion( child: MouseRegion(
cursor: _isDragging ? SystemMouseCursors.grabbing : SystemMouseCursors.basic, cursor: _isDragging
child: MasonryGridView.count( ? SystemMouseCursors.grabbing
crossAxisCount: crossAxisCount, : SystemMouseCursors.basic,
mainAxisSpacing: 10, child: MasonryGridView.count(
crossAxisSpacing: 10, crossAxisCount: crossAxisCount,
itemCount: _getFilteredNotes().length, mainAxisSpacing: 10,
itemBuilder: (BuildContext context, int index) { crossAxisSpacing: 10,
final List<Note> filteredNotes = _getFilteredNotes(); itemCount: _getFilteredNotes().length,
return DragTarget<int>( itemBuilder: (BuildContext context, int index) {
onAcceptWithDetails: (DragTargetDetails<int> details) { final List<Note> filteredNotes = _getFilteredNotes();
final Note targetNote = filteredNotes[index]; return DragTarget<int>(
final int originalTargetIndex = _notes.indexOf(targetNote); onAcceptWithDetails: (DragTargetDetails<int> details) {
_reorderNote(details.data, originalTargetIndex); final Note targetNote = filteredNotes[index];
}, final int originalTargetIndex = _notes.indexOf(
builder: (context, candidateData, rejectedData) { targetNote,
return LayoutBuilder( );
builder: (context, constraints) { _reorderNote(details.data, originalTargetIndex);
final double cellWidth = constraints.maxWidth; },
builder: (context, candidateData, rejectedData) {
return LayoutBuilder(
builder: (context, constraints) {
final double cellWidth = constraints.maxWidth;
final bool requiresLongPressToDrag =
_requiresLongPressToDrag(_lastPointerKind);
return Draggable<int>( final Widget draggableNote = requiresLongPressToDrag
data: _notes.indexOf(filteredNotes[index]), ? LongPressDraggable<int>(
onDragStarted: () { data: _notes.indexOf(filteredNotes[index]),
if (!mounted) return; delay: const Duration(milliseconds: 280),
setState(() { onDragStarted: () {
_isDragging = true; if (!mounted) return;
}); setState(() {
}, _isDragging = true;
onDragEnd: (_) { });
if (!mounted) return; },
setState(() { onDragEnd: (_) {
_isDragging = false; if (!mounted) return;
}); setState(() {
}, _isDragging = false;
onDraggableCanceled: (_, _) { });
if (!mounted) return; },
setState(() { onDraggableCanceled: (_, _) {
_isDragging = false; if (!mounted) return;
}); setState(() {
}, _isDragging = false;
feedback: MouseRegion( });
cursor: SystemMouseCursors.grabbing, },
child: Material( feedback: MouseRegion(
color: Colors.transparent, cursor: SystemMouseCursors.grabbing,
elevation: 8, child: Material(
child: SizedBox( color: Colors.transparent,
width: cellWidth, elevation: 8,
child: TweenAnimationBuilder<double>( child: SizedBox(
tween: Tween(begin: 0.97, end: 1.0), width: cellWidth,
duration: const Duration(milliseconds: 180), child: TweenAnimationBuilder<double>(
curve: Curves.easeOutCubic, tween: Tween(begin: 0.97, end: 1.0),
builder: (context, scale, child) { duration: const Duration(milliseconds: 180),
return Transform.scale( curve: Curves.easeOutCubic,
scale: scale, builder: (context, scale, child) {
alignment: Alignment.topLeft, return Transform.scale(
child: child, scale: scale,
); alignment: Alignment.topLeft,
}, child: child,
child: Opacity( );
opacity: 0.95, },
child: NoteCard(note: filteredNotes[index], onTap: () {}, isDragging: true), child: Opacity(
opacity: 0.95,
child: NoteCard(
note: filteredNotes[index],
onTap: () {},
isDragging: true,
),
),
),
), ),
), ),
), ),
), childWhenDragging: MouseRegion(
), cursor: SystemMouseCursors.grabbing,
childWhenDragging: MouseRegion( child: Opacity(
cursor: SystemMouseCursors.grabbing, opacity: 0.3,
child: Opacity( child: Container(
opacity: 0.3, padding: const EdgeInsets.all(16),
child: Container( decoration: BoxDecoration(
padding: const EdgeInsets.all(16), color: const Color.fromRGBO(24, 25, 26, 1),
decoration: BoxDecoration( borderRadius: BorderRadius.circular(12),
color: const Color.fromRGBO(24, 25, 26, 1), border: Border.all(
borderRadius: BorderRadius.circular(12), color: Colors.white24,
border: Border.all(color: Colors.white24, width: 1), 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), child: Column(
Text( crossAxisAlignment:
filteredNotes[index].body, CrossAxisAlignment.start,
style: const TextStyle(color: Colors.white70, fontSize: 14), mainAxisSize: MainAxisSize.min,
maxLines: 20, children: [
overflow: TextOverflow.clip, 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(
child: Container( border: candidateData.isNotEmpty
decoration: BoxDecoration( ? Border.all(
border: candidateData.isNotEmpty color: Colors.blue.shade400,
? Border.all(color: Colors.blue.shade400, width: 2) width: 2,
: null, )
borderRadius: BorderRadius.circular(12), : null,
), borderRadius: BorderRadius.circular(12),
child: NoteCard( ),
key: ValueKey<int>(filteredNotes[index].id ?? filteredNotes[index].index), child: NoteCard(
note: filteredNotes[index], key: ValueKey<int>(
onTap: () => _openNoteEditor(filteredNotes[index]), filteredNotes[index].id ??
isDragging: _isDragging, filteredNotes[index].index,
), ),
), note: filteredNotes[index],
); onTap: () =>
}, _openNoteEditor(filteredNotes[index]),
); isDragging: _isDragging,
}, ),
); ),
}, )
), : Draggable<int>(
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<double>(
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<int>(
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( return Scaffold(
body: Container( body: Container(
decoration: const BoxDecoration( decoration: const BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
colors: [ colors: [Color(0xFF191A1D), Color(0xFF222326), Color(0xFF101114)],
Color(0xFF191A1D),
Color(0xFF222326),
Color(0xFF101114),
],
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
), ),
@@ -355,7 +522,10 @@ class _HomeScreenState extends State<HomeScreen> {
child: Stack( child: Stack(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 8.0,
),
child: body, child: body,
), ),
Positioned.fill( Positioned.fill(
@@ -431,7 +601,11 @@ class _EmptyState extends StatelessWidget {
SizedBox(height: 12), SizedBox(height: 12),
Text( Text(
'Aún no hay notas', '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), SizedBox(height: 8),
Text( Text(