I'm trying to create a Kanban application using Flutter to manage tasks and projects. I would like to know how to implement a Kanban board with columns such as "To Do", "In Progress", and "Done" and allow users to reorder tasks by dragging and dropping between columns. I've started a Flutter project, but I need a guide or code example to implement this feature. Can anyone provide guidance or code examples? Thank you very much!
How to Create a Kanban App with Flutter and Enable Drag-and-Drop Feature?
648 Views Asked by Rezaldy Abidin At
3
There are 3 best solutions below
1
On
I've implemented the kanban as follows.
Note: I am using Getx for Routing and state management. and I am not using any package for kanban.
Following is my code snippet with GIF recording.
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:get/get.dart';
import 'package:thrivebay/Constants/colors.dart';
import 'package:thrivebay/Controllers/Kanban/kanban_cntrlr.dart';
import 'package:thrivebay/Controllers/Kanban/tasks_controller.dart';
import 'package:thrivebay/Models/Kanban/kanban.dart';
import 'package:thrivebay/Utils/colors_utils.dart';
import 'package:thrivebay/Utils/datetime.dart';
import 'package:thrivebay/Utils/overlays.dart';
import '../../Data/Firestore/kanban.dart';
import '../../Models/Kanban/task.dart';
import '../Widgets/widgets.dart';
import 'add_task.dart';
import 'add_update_project.dart';
class KanbanDetailsView extends StatefulWidget {
const KanbanDetailsView({Key? key, required this.projectId}) : super(key: key);
final String projectId;
@override
State<KanbanDetailsView> createState() => _KanbanDetailsViewState();
}
class _KanbanDetailsViewState extends State<KanbanDetailsView> {
List<String> testList = [];
ScrollController scrollController = ScrollController();
@override
void initState() {
Get.put(KanbanTasksController(projectId: widget.projectId));
super.initState();
}
int? currentTaskStatusIndex;
Kanban? project;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.darkBackgroundVariant,
appBar: AppBar(
leading: const KBackButton(),
backgroundColor: AppColors.darkPrimary,
title: const Text('Project Detail'),
actions: [
PopupMenuButton<String>(
elevation: 1,
shadowColor: AppColors.white,
child: SizedBox(
height: 25,
width: 40,
child: SvgPicture.asset('assets/icons/more.svg', color: AppColors.white),
),
itemBuilder: (context) => [
PopupMenuItem<String>(
value: 'edit',
child: Row(
children: [
SvgPicture.asset('assets/icons/edit.svg'),
const SizedBox(width: 12),
const Text('Edit Project'),
],
),
),
const PopupMenuDivider(),
PopupMenuItem<String>(
value: 'delete',
child: Row(
children: [
SvgPicture.asset('assets/icons/delete.svg'),
const SizedBox(width: 12),
const Text('Delete Project'),
],
),
),
],
onSelected: (value) {
if (value == 'edit') {
Get.to(() => AddUpdateProject(project: project));
} else if (value == 'delete') {
showOkCancelAlertDialog(
context: context,
title: 'Delete Project?',
message: 'Are you sure you want to delete this project?',
okLabel: 'Delete',
isDestructiveAction: true,
).then((value) async {
if (value == OkCancelResult.ok) {
await kOverlayWithAsync(asyncFunction: () async {
await KanbanFirestore.deleteProject(widget.projectId);
});
Get.back();
}
});
}
},
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(12.0),
child: GetBuilder<KanbanController>(
builder: (cntrlr) {
project = cntrlr.projects.firstWhereOrNull((element) => element.projectId == widget.projectId);
if (project == null) {
return const Center(child: CircularProgressIndicator());
} else {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
project!.title,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 10),
Text(
project!.description,
maxLines: 4,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(color: AppColors.darkLight),
),
const SizedBox(height: 20),
KDetailInputRow(
iconPath: 'assets/icons/clock.svg',
lable: 'Created time',
child: Text(
project!.createdAt!.formattedDateTime,
style: Theme.of(context).textTheme.titleSmall,
),
),
const SizedBox(height: 12),
KDetailInputRow(
iconPath: 'assets/icons/date.svg',
lable: 'Due date',
child: Text(
project?.dueDate != null ? project!.dueDate!.formattedDateTime : 'Continuous',
style: Theme.of(context).textTheme.titleSmall,
),
),
const SizedBox(height: 12),
KDetailInputRow(
iconPath: 'assets/icons/progress.svg',
lable: 'Progress',
child: Row(
children: [
Expanded(
child: KProgressWidget(color: project!.theme.toColor, progress: project!.progress)),
const SizedBox(width: 12),
Text('${project?.progress.ceil()}%', style: Theme.of(context).textTheme.titleMedium),
],
),
),
const SizedBox(height: 12),
KDetailInputRow(
iconPath: 'assets/icons/priority.svg',
lable: 'Priority',
child: Text(
project!.priority?.lable ?? 'None',
style: Theme.of(context).textTheme.titleSmall!.copyWith(
color:
project!.priority?.lable != null ? project!.priority!.color : AppColors.darkAccent),
),
),
const SizedBox(height: 12),
KDetailInputRow(
iconPath: 'assets/icons/theme.svg',
lable: 'Theme',
child: Align(
alignment: Alignment.centerLeft,
child: CircleAvatar(
radius: 12,
backgroundColor: project!.theme.toColor.lighter,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: CircleAvatar(backgroundColor: project!.theme.toColor),
),
),
),
),
],
);
}
},
),
),
const Divider(),
Expanded(
child: SingleChildScrollView(
controller: scrollController,
scrollDirection: Axis.horizontal,
child: GetBuilder<KanbanTasksController>(builder: (cntrlr) {
print(cntrlr.tasks.length);
return Row(
children: List.generate(
taskTitles.length,
(index) {
List<KanbanTask> tasks =
cntrlr.tasks.where((element) => element.status == taskTitles[index]).toList();
return DragTarget(
onAccept: (details) {},
onAcceptWithDetails: (details) {
cntrlr.changeTaskStatus((details.data as String?)!, taskTitles[index]);
setState(() {
currentTaskStatusIndex = null;
});
},
onLeave: (details) {
setState(() {
currentTaskStatusIndex = null;
});
},
onMove: (details) {
if (currentTaskStatusIndex != index) {
setState(() {
currentTaskStatusIndex = index;
});
}
},
onWillAccept: (details) {
return true;
},
builder: (BuildContext context, List<Object?> candidateData, List<dynamic> rejectedData) {
return Container(
margin: const EdgeInsets.all(12),
width: Get.width * 0.7,
decoration: BoxDecoration(
color: currentTaskStatusIndex == index ? AppColors.darkSurface : AppColors.darkBackground,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
contentPadding: const EdgeInsets.fromLTRB(15, 0, 10, 0),
title: Text(taskTitles[index].lableWithEmoji!),
trailing: PopupMenuButton<String>(
itemBuilder: (context) {
return [
PopupMenuItem<String>(
value: 'add',
child: Row(
children: [
SvgPicture.asset('assets/icons/add.svg'),
const SizedBox(width: 12),
const Text('Add a task'),
],
),
),
const PopupMenuDivider(),
PopupMenuItem<String>(
value: 'delete',
child: Row(
children: [
SvgPicture.asset('assets/icons/delete.svg'),
const SizedBox(width: 12),
const Text('Delete All'),
],
),
),
];
},
onSelected: (value) {
if (value == 'add') {
Get.to(() =>
TaskCardDetailView(status: taskTitles[index], projectId: widget.projectId));
} else if (value == 'delete') {}
},
),
),
const Divider(height: 1, color: AppColors.darkBackgroundSecondary),
Expanded(
child: MediaQuery.removePadding(
context: context,
removeBottom: true,
removeTop: true,
child: ListView.separated(
separatorBuilder: (context, index) => const SizedBox(height: 0),
shrinkWrap: true,
itemCount: tasks.length,
itemBuilder: (BuildContext context, int index) {
final task = tasks[index];
return Listener(
onPointerMove: (PointerMoveEvent event) {
if (event.delta.dx != 0) {
scrollController.jumpTo(scrollController.offset + event.delta.dx * 2.5);
}
},
child: LongPressDraggable(
data: task.id!,
feedback: Transform.rotate(
angle: 0.05,
child: Material(
type: MaterialType.transparency,
child: KTaskCard(task: task, widget: widget),
),
),
childWhenDragging: Opacity(
opacity: 0.6,
child: KTaskCard(task: task, widget: widget),
),
child: KTaskCard(
task: task,
widget: widget,
onDone: () {
cntrlr.changeTaskStatus(task.id!, TaskStatus.done);
},
),
),
);
},
),
),
),
ListTile(
leading: const Icon(Icons.add),
title: const Text('Add a task'),
splashColor: AppColors.darkPrimary,
onTap: () {
Get.to(
() => TaskCardDetailView(status: taskTitles[index], projectId: widget.projectId),
);
},
),
],
),
);
},
);
},
),
);
}),
),
)
],
),
);
}
}
class KTaskCard extends StatelessWidget {
const KTaskCard({
super.key,
required this.task,
required this.widget,
this.onDone,
});
final KanbanTask task;
final KanbanDetailsView widget;
final void Function()? onDone;
// final TaskStatus status;
@override
Widget build(BuildContext context) {
return Container(
width: Get.width * 0.7,
margin: const EdgeInsets.fromLTRB(10, 8, 10, 0),
// padding: const EdgeInsets.all(8.0),
decoration: BoxDecoration(
color: AppColors.darkBackgroundSecondary,
borderRadius: BorderRadius.circular(8),
),
child: ListTile(
visualDensity: const VisualDensity(vertical: -4),
contentPadding: const EdgeInsets.only(left: 15),
title: Text(
task.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall!.copyWith(color: AppColors.white),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (task.status != TaskStatus.done)
IconButton(
onPressed: onDone,
icon: const Icon(Icons.check),
),
IconButton(
onPressed: () {
Get.to(() => TaskCardDetailView(status: task.status, projectId: widget.projectId, task: task));
},
icon: SvgPicture.asset('assets/icons/edit.svg'),
),
],
),
),
);
}
}
List<TaskStatus> taskTitles = [
TaskStatus.todo,
TaskStatus.inProgress,
TaskStatus.blocked,
TaskStatus.done,
];

There's a specific package for this named: kanban_board
It is a customizable kanban board, which can be used to reorder items and list with drag and drop.