Created
November 5, 2025 17:54
-
-
Save tock-dev/b92e417f3e3048bab723d268d0103956 to your computer and use it in GitHub Desktop.
A simple To-Do list app made with Gemini in DartPad
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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