Skip to content

Instantly share code, notes, and snippets.

@tock-dev
Created November 5, 2025 17:54
Show Gist options
  • Select an option

  • Save tock-dev/b92e417f3e3048bab723d268d0103956 to your computer and use it in GitHub Desktop.

Select an option

Save tock-dev/b92e417f3e3048bab723d268d0103956 to your computer and use it in GitHub Desktop.
A simple To-Do list app made with Gemini in DartPad
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
// DATA MODEL
/// Manages the list of items that can be reordered.
/// It uses `ChangeNotifier` to notify listeners (widgets) of changes.
class ListItemsData extends ChangeNotifier {
// The internal mutable list of items.
// Using a class to represent items with unique IDs is more robust for reordering,
// especially if item content itself is not unique. For this example, we'll
// assume `itemText` is unique enough for `ValueKey`. If duplicates are possible
// and need distinct identities, a unique ID should be added to each item.
final List<String> _items;
/// Initializes the list with a default item.
ListItemsData() : _items = <String>["Add your items"];
/// Provides an unmodifiable view of the items list.
List<String> get items => List<String>.unmodifiable(_items);
/// Reorders an item within the list.
///
/// [oldIndex]: The original index of the item being moved.
/// [newIndex]: The target insertion point for the item. This index represents
/// the position *before which* the item will be inserted.
void reorderItems(int oldIndex, int newIndex) {
// Validate indices to prevent out-of-bounds errors.
// newIndex can be equal to _items.length, which means inserting at the very end.
if (oldIndex < 0 ||
oldIndex >= _items.length ||
newIndex < 0 ||
newIndex > _items.length) {
return;
}
// Remove the item from its old position.
final String item = _items.removeAt(oldIndex);
// If the item was removed from an index earlier than the newIndex,
// the list shifts, so the effective newIndex for insertion decreases by one.
if (newIndex > oldIndex) {
newIndex--;
}
// Insert the item at the (potentially adjusted) new position.
_items.insert(newIndex, item);
// Notify all listening widgets that the data has changed.
notifyListeners();
}
/// Adds a new item to the end of the list.
void addItem(String item) {
if (item.trim().isNotEmpty) {
_items.add(item.trim());
notifyListeners();
}
}
/// Removes an item at the given index from the list.
void removeItemAtIndex(int index) {
if (index >= 0 && index < _items.length) {
_items.removeAt(index);
notifyListeners();
}
}
/// Removes all items from the list.
void clearAllItems() {
_items.clear();
notifyListeners();
}
}
/// The root widget of the application.
/// It sets up the `ChangeNotifierProvider` for `ListItemsData`.
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider<ListItemsData>(
create: (BuildContext context) => ListItemsData(),
builder: (BuildContext context, Widget? child) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Reorderable Item List',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
appBarTheme: AppBarTheme(
backgroundColor: Colors.blue.shade600,
elevation: 8.0,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
bottom: Radius.circular(20),
),
),
titleTextStyle: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
scaffoldBackgroundColor:
Colors.grey.shade100, // Light background for the list
),
home: const ReorderableListScreen(),
);
},
);
}
}
/// A stateful widget that displays a reorderable list of items.
/// It uses `Draggable` and `DragTarget` to enable drag-and-drop reordering.
class ReorderableListScreen extends StatefulWidget {
const ReorderableListScreen({super.key});
@override
State<ReorderableListScreen> createState() => _ReorderableListScreenState();
}
class _ReorderableListScreenState extends State<ReorderableListScreen> {
// The index of the item currently being dragged. Null if no item is dragged.
int? _draggingItemIndex;
/// Shows a dialog to add a new item to the list.
Future<void> _showAddItemDialog(BuildContext context) async {
final TextEditingController textController = TextEditingController();
await showDialog<void>(
context: context,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text('Add New Item'),
content: TextField(
controller: textController,
decoration: const InputDecoration(
hintText: 'Enter item name',
border: OutlineInputBorder(),
),
autofocus: true,
onSubmitted: (String value) {
if (value.trim().isNotEmpty) {
Provider.of<ListItemsData>(dialogContext, listen: false)
.addItem(value);
}
Navigator.of(dialogContext).pop();
},
),
actions: <Widget>[
TextButton(
onPressed: () {
Navigator.of(dialogContext).pop();
},
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
if (textController.text.trim().isNotEmpty) {
Provider.of<ListItemsData>(dialogContext, listen: false)
.addItem(textController.text);
}
Navigator.of(dialogContext).pop();
},
child: const Text('Add'),
),
],
);
},
);
}
/// Shows a confirmation dialog to clear all items from the list.
Future<void> _showClearAllDialog(BuildContext context) async {
await showDialog<void>(
context: context,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text('Clear All Items?'),
content: const Text(
'Are you sure you want to remove all items from the list? This action cannot be undone.'),
actions: <Widget>[
TextButton(
onPressed: () {
Navigator.of(dialogContext).pop(); // Dismiss dialog
},
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
Provider.of<ListItemsData>(dialogContext, listen: false)
.clearAllItems();
Navigator.of(dialogContext).pop(); // Dismiss dialog
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red, // Highlight dangerous action
foregroundColor: Colors.white,
),
child: const Text('Clear All'),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
// Access the ListItemsData provided by the ChangeNotifierProvider.
final ListItemsData listItemsData =
Provider.of<ListItemsData>(context, listen: true);
final List<String> items = listItemsData.items;
return Scaffold(
appBar: AppBar(
title: const Text('Reorderable Items'),
leading: IconButton(
icon: const Icon(Icons.delete_sweep, color: Colors.white),
onPressed: () => _showClearAllDialog(context),
tooltip: 'Clear All Items',
),
),
body: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 16.0),
// The total number of list items includes both the actual data items
// and the interleaved drop zones. For N items, there are N+1 drop zones.
// So, itemCount = N (items) + (N+1) (drop zones) = 2N + 1.
itemCount: items.length * 2 + 1,
itemBuilder: (BuildContext context, int i) {
if (i.isEven) {
// Even indices represent the drop zones.
// `dropIndex` indicates the position where an item would be inserted.
// e.g., i=0 -> dropIndex=0 (before item 0), i=2 -> dropIndex=1 (before item 1), etc.
final int dropIndex = i ~/ 2;
// Flags for current dragging state and target evaluation.
final bool isDraggingActive = _draggingItemIndex != null;
// Check if this drop zone corresponds to the effective original position
// of the item being dragged. Dropping here would result in no change.
// If the item is removed from oldIndex, and newIndex is after oldIndex,
// newIndex is decremented. So, original position can be oldIndex or oldIndex+1.
final bool isOriginalPosition = _draggingItemIndex != null &&
(dropIndex == _draggingItemIndex ||
dropIndex == _draggingItemIndex! + 1);
// This determines if this drop zone is a valid candidate for dropping
// (i.e., dragging is active AND it's not the original position of the dragged item).
final bool isValidDropCandidate =
isDraggingActive && !isOriginalPosition;
return DragTarget<int>(
key: ValueKey('drop_zone_$dropIndex'), // Provide a unique key for each DragTarget
onWillAcceptWithDetails: (DragTargetDetails<int> details) {
// No setState here. Only return true if this target will accept.
return isValidDropCandidate;
},
onLeave: (Object? data) {
// No setState here.
},
onAcceptWithDetails: (DragTargetDetails<int> details) {
// This callback is called when a draggable is dropped onto this DragTarget.
// Only perform reorder if it's a valid drop candidate (not original position).
if (isValidDropCandidate) {
listItemsData.reorderItems(
details.data, dropIndex); // Perform the reorder operation.
}
// Clear dragging states after drop. Defer this setState to avoid conflicts
// with ongoing framework updates (e.g., mouse_tracker assertion errors).
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) { // Ensure the widget is still mounted before calling setState
setState(() {
_draggingItemIndex = null;
});
}
});
},
builder: (
BuildContext context,
List<int?> candidateData, // This tells us if something is hovering
List<dynamic> rejectedData,
) {
// Now, isHoveringThisDropZone is derived from candidateData.
// If candidateData is not empty, it means a draggable is hovering over THIS specific DragTarget.
final bool isHoveringThisDropZone = candidateData.isNotEmpty;
// This determines if the animated placeholder (larger height) should show.
// It shows if it's a valid candidate AND the draggable is hovering over it.
final bool showAnimatedDropPlaceholder =
isValidDropCandidate && isHoveringThisDropZone;
BoxDecoration? dropZoneDecoration;
if (showAnimatedDropPlaceholder) {
dropZoneDecoration = BoxDecoration(
borderRadius: BorderRadius.circular(16.0),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[
Colors.blue.shade100.withAlpha((255 * 0.8).round()),
Colors.blue.shade300.withAlpha((255 * 0.8).round())
],
),
boxShadow: <BoxShadow>[
BoxShadow(
color: Colors.blue.withAlpha((255 * 0.4).round()),
blurRadius: 12.0,
spreadRadius: 3.0,
offset: const Offset(0, 6),
),
],
border: Border.all(
color: Colors.blue.shade600,
width: 2.5,
),
);
} else if (isValidDropCandidate) {
dropZoneDecoration = BoxDecoration(
borderRadius: BorderRadius.circular(12.0),
color: Colors.blue.shade50.withAlpha((255 * 0.6).round()),
boxShadow: <BoxShadow>[
BoxShadow(
color: Colors.grey.withAlpha((255 * 0.15).round()),
blurRadius: 6.0,
spreadRadius: 1.0,
offset: const Offset(0, 2),
),
],
border: Border.all(
color: Colors.blue.shade200,
width: 1.0,
style: BorderStyle.solid,
),
);
}
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
// Height is 80.0 if hovered and valid, 20.0 for active but not hovered, otherwise 0.0.
height: showAnimatedDropPlaceholder
? 80.0
: (isValidDropCandidate ? 20.0 : 0.0),
margin: EdgeInsets.symmetric(
horizontal: isValidDropCandidate ? 16.0 : 0.0,
vertical: isValidDropCandidate ? 8.0 : 0.0),
decoration: dropZoneDecoration,
alignment: Alignment.center,
child: showAnimatedDropPlaceholder
? const Text(
'Drop Here',
style: TextStyle(
color: Colors.white,
fontSize: 18.0,
fontWeight: FontWeight.bold),
)
: null,
);
},
);
} else {
// Odd indices represent the actual list items.
final int itemIndex = i ~/ 2;
final String itemText = items[itemIndex];
// Provide a unique key for each Draggable widget.
// Using ValueKey with the item's content (itemText) is common for lists of strings.
// This allows Flutter to efficiently re-identify and move widgets when the underlying list changes.
return Draggable<int>(
key: ValueKey(itemText), // Essential for stable list items
data: itemIndex,
onDragStarted: () {
// When dragging starts, set the dragging item index.
setState(() {
_draggingItemIndex = itemIndex;
});
},
onDragEnd: (DraggableDetails details) {
// When dragging ends, clear the dragging states. This setState is intentional.
// It happens after the drag is fully completed and processed.
setState(() {
_draggingItemIndex = null;
});
},
// The widget displayed in the original position when NOT dragging.
// This widget instance is also used to create the feedback visual.
child: _ListItemCard(
key: ValueKey(itemText), // Same key as Draggable for consistency
itemText: itemText,
itemIndex: itemIndex,
),
// The widget displayed when dragging, which floats under the user's finger.
feedback: Material(
elevation: 12.0, // Higher elevation for feedback
borderRadius: BorderRadius.circular(16.0),
child: _ListItemFeedbackCard(
key: ValueKey('${itemText}_feedback'), // Distinct key for the feedback widget
itemText: itemText,
),
),
// The widget displayed in the original position WHEN dragging.
// By providing a distinct widget here (even an invisible one),
// we ensure that the RenderObject for the original slot is separate
// from any RenderObjects implicitly managed by the feedback.
childWhenDragging: Opacity(
opacity: 0.0, // Make it invisible while dragging
// Keep the same visual layout so that the space is reserved.
child: _ListItemCard(
key: ValueKey(itemText), // Same key as `child` to preserve identity
itemText: itemText,
itemIndex: itemIndex,
),
),
);
}
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddItemDialog(context),
backgroundColor: Colors.blue.shade600,
child: const Icon(Icons.add, color: Colors.white),
),
);
}
}
/// A widget to display an individual item in the list with fancy styling.
class _ListItemCard extends StatelessWidget {
final String itemText;
final int itemIndex;
const _ListItemCard({required this.itemText, required this.itemIndex, super.key});
@override
Widget build(BuildContext context) {
return Card(
elevation: 4.0, // Subtle shadow
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.0), // Rounded corners
),
margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
clipBehavior: Clip.antiAlias, // Ensures content respects rounded corners
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[Colors.white, Colors.blue.shade50], // Subtle gradient
),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: <Widget>[
Expanded(
child: Text(
itemText,
style: const TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.w500,
color: Colors.blueGrey,
),
),
),
IconButton(
icon: const Icon(Icons.remove_circle_outline, color: Colors.red),
onPressed: () {
Provider.of<ListItemsData>(context, listen: false)
.removeItemAtIndex(itemIndex);
},
),
const Icon(Icons.drag_handle, color: Colors.grey),
],
),
),
),
);
}
}
/// A widget to display the feedback (dragging visual) for an individual item.
class _ListItemFeedbackCard extends StatelessWidget {
final String itemText;
const _ListItemFeedbackCard({required this.itemText, super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: MediaQuery.of(context).size.width, // Match screen width
child: Card(
elevation: 12.0, // Higher elevation for dragging feedback
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.0),
),
margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
clipBehavior: Clip.antiAlias,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[
Colors.blue.shade100,
Colors.blue.shade300
], // More pronounced gradient
),
boxShadow: <BoxShadow>[
BoxShadow(
color: Colors.blue.withAlpha((255 * 0.6).round()),
blurRadius: 15.0,
spreadRadius: 3.0,
offset: const Offset(0, 6),
),
],
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: <Widget>[
Expanded(
child: Text(
itemText,
style: const TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.w600,
color: Colors.white, // White text for better contrast
),
),
),
const Icon(Icons.drag_handle, color: Colors.white70),
],
),
),
),
),
);
}
}
// Main entry point for the Flutter application
void main() {
runApp(const MyApp());
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment