How to Create a Kanban App with Flutter and Enable Drag-and-Drop Feature?

648 Views Asked by At

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!

3

There are 3 best solutions below

1
Dharam Budh On

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.

1
Mohammad Fallah On

See the video link below. The video explains how to implement Kanban in Flutter.

video link

1
DholaSain 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.

enter image description here

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,
];