DEV Community

kanta13jp1
kanta13jp1

Posted on

Building an MS Project-Style Gantt Chart in Flutter Web — CustomPaint with Synchronized Scrolling

Building an MS Project-Style Gantt Chart in Flutter Web

The Goal

Visualize WBS (Work Breakdown Structure) progress with a synchronized task list on the left and a scrollable timeline on the right — like MS Project, but in Flutter Web.

Architecture

Row(
  children: [
    // Left pane: task names and assignees (fixed width)
    SizedBox(width: 300, child: TaskListPane()),
    // Right pane: timeline bars (horizontally scrollable)
    Expanded(child: TimelinePane()),
  ],
)
Enter fullscreen mode Exit fullscreen mode

Two ScrollController instances linked together keep vertical scroll synchronized between panes.

Implementation

Synchronized Scrolling

class _GanttChartPageState extends State<GanttChartPage> {
  final _leftVertical = ScrollController();
  final _rightVertical = ScrollController();

  @override
  void initState() {
    super.initState();
    _leftVertical.addListener(() {
      if (_rightVertical.offset != _leftVertical.offset) {
        _rightVertical.jumpTo(_leftVertical.offset);
      }
    });
    _rightVertical.addListener(() {
      if (_leftVertical.offset != _rightVertical.offset) {
        _leftVertical.jumpTo(_rightVertical.offset);
      }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Drawing Gantt Bars with CustomPaint

class GanttBarPainter extends CustomPainter {
  final List<WbsTask> tasks;
  final DateTime startDate;
  final double dayWidth;

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint();

    for (int i = 0; i < tasks.length; i++) {
      final task = tasks[i];
      final y = i * 40.0 + 8;
      final startX = task.startDate.difference(startDate).inDays * dayWidth;
      final barWidth = task.duration.inDays * dayWidth;

      // Background bar
      paint.color = _progressColor(task.progressRate).withAlpha(80);
      canvas.drawRRect(
        RRect.fromRectAndRadius(
          Rect.fromLTWH(startX, y, barWidth, 24),
          const Radius.circular(4),
        ),
        paint,
      );

      // Progress fill
      paint.color = _progressColor(task.progressRate);
      canvas.drawRRect(
        RRect.fromRectAndRadius(
          Rect.fromLTWH(startX, y, barWidth * task.progressRate / 100, 24),
          const Radius.circular(4),
        ),
        paint,
      );
    }
  }

  Color _progressColor(int progress) {
    if (progress >= 100) return const Color(0xFF4CAF50); // done: green
    if (progress >= 50) return const Color(0xFFFF9800);  // in progress: orange
    return const Color(0xFFFF5722);                       // behind: red
  }

  @override
  bool shouldRepaint(GanttBarPainter old) =>
      old.tasks != tasks || old.startDate != startDate;
}
Enter fullscreen mode Exit fullscreen mode

WbsTask Model

class WbsTask {
  final String id;
  final String title;
  final String? assignee;
  final DateTime startDate;
  final Duration duration;
  final int progressRate; // 0-100
  final List<String> dependencies;
}
Enter fullscreen mode Exit fullscreen mode

Supabase Schema

CREATE TABLE wbs_tasks (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  title text NOT NULL,
  start_date date NOT NULL,
  end_date date NOT NULL,
  progress_rate int DEFAULT 0 CHECK (progress_rate BETWEEN 0 AND 100),
  dependencies uuid[],
  user_id uuid REFERENCES auth.users(id)
);
Enter fullscreen mode Exit fullscreen mode

Gotchas

shouldRepaint performance

Returning true always causes per-frame repaints. Check if data actually changed:

@override
bool shouldRepaint(GanttBarPainter old) =>
    old.tasks != tasks || old.startDate != startDate;
Enter fullscreen mode Exit fullscreen mode

Alignment between header and bars

The date header (month/day labels) and the actual bar positions must use the same startDate and dayWidth constant. Compute both in the parent widget and pass them down.

Conclusion

Flutter Web's CustomPaint handles complex visualizations that existing widgets can't. For Gantt charts with custom grid layouts, painting from scratch gives you full control — and the performance is fine for typical WBS sizes (< 200 tasks).


Building in public: https://my-web-app-b67f4.web.app/

Flutter #CustomPaint #buildinpublic #WBS

Top comments (0)