Building a Real-Time Collaborative Design App with Flutter and Supabase

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...
Real-Time Collaborative Design App Cover Image

Building a real-time collaborative application offers exciting potential to create engaging and interactive experiences for users. In this article, we will walk you through the process of creating a real-time collaborative design app using Flutter and Supabase. This app will allow multiple users to draw and interact with design elements on a shared canvas, similar to tools like Figma.

Overview of the Figma Clone App

We will build an interactive design canvas app where users can collaborate in real-time. Key features include:

  • Drawing shapes (circles and rectangles)
  • Moving shapes around
  • Syncing cursor positions and design objects in real-time
  • Persisting the canvas state in a Postgres database

Although we might not build a complete Figma clone, the principles and functionality will form a strong foundation for a collaborative design canvas.

Setting Up the App

Create a Blank Flutter Application

Begin by creating a blank Flutter app. We will focus on web support for this example since it involves cursor interactions.

flutter create canvas --empty --platforms=web

Install the Dependencies

We need two dependencies:

  • supabase_flutter: for real-time communication and storing canvas data.
  • uuid: for generating unique identifiers.

Add the dependencies:

flutter pub add supabase_flutter uuid

Setup the Supabase Project

Create a new Supabase project by visiting database.new. Once the project is ready, run the following SQL from the SQL editor to set up the table and RLS policies:

create table canvas_objects (
  id uuid primary key default gen_random_uuid() not null,
  "object" jsonb not null,
  created_at timestamp with time zone default timezone('utc'::text, now()) not null
);
 
alter table canvas_objects enable row level security;
 
create policy select_canvas_objects on canvas_objects as permissive for select to anon using (true);
 
create policy insert_canvas_objects on canvas_objects as permissive for insert to anon with check (true);
 
create policy update_canvas_objects on canvas_objects as permissive for update to anon using (true);

Building the Figma Clone App

Step 1: Initialize Supabase

Initialize Supabase in lib/main.dart. Replace YOUR_SUPABASE_URL and YOUR_SUPABASE_ANON_KEY with your Supabase project credentials:

import 'package:canvas/canvas/canvas_page.dart';
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
 
void main() async {
  Supabase.initialize(
    url: 'YOUR_SUPABASE_URL',
    anonKey: 'YOUR_SUPABASE_ANON_KEY',
  );
  runApp(const MyApp());
}
 
final supabase = Supabase.instance.client;
 
class MyApp extends StatelessWidget {
  const MyApp({super.key});
 
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Figma Clone',
      debugShowCheckedModeBanner: false,
      home: CanvasPage(),
    );
  }
}

Step 2: Create the Constants File

Create lib/utils/constants.dart to organize the app’s constants:

abstract class Constants {
  /// Name of the Realtime channel
  static const String channelName = 'canvas';
 
  /// Name of the broadcast event
  static const String broadcastEventName = 'canvas';
}

Step 3: Create the Data Model

Create lib/canvas/canvas_object.dart and add the following to define the data models for cursors and shapes:

import 'dart:convert';
import 'dart:math';
import 'dart:ui';
import 'package:uuid/uuid.dart';
 
/// Extension method to create random colors
extension RandomColor on Color {
  static Color getRandom() {
    return Color((Random().nextDouble() * 0xFFFFFF).toInt()).withOpacity(1.0);
  }
 
  static Color getRandomFromId(String id) {
    final seed = utf8.encode(id).reduce((value, element) => value + element);
    return Color((Random(seed).nextDouble() * 0xFFFFFF).toInt()).withOpacity(1.0);
  }
}
 
/// Base class for synchronized objects
abstract class SyncedObject {
  final String id;
  SyncedObject({required this.id});
  factory SyncedObject.fromJson(Map json) {
    final objectType = json['object_type'];
    if (objectType == UserCursor.type) {
      return UserCursor.fromJson(json);
    } else {
      return CanvasObject.fromJson(json);
    }
  }
  Map toJson();
}
 
/// Model for user cursors
class UserCursor extends SyncedObject {
  static String type = 'cursor';
  final Offset position;
  final Color color;
 
  UserCursor({required super.id, required this.position}) : color = RandomColor.getRandomFromId(id);
 
  UserCursor.fromJson(Map json)
      : position = Offset(json['position']['x'], json['position']['y']),
        color = RandomColor.getRandomFromId(json['id']),
        super(id: json['id']);
 
  @override
  Map toJson() {
    return {
      'object_type': type,
      'id': id,
      'position': {'x': position.dx, 'y': position.dy},
    };
  }
}
 
/// Base model for design objects
abstract class CanvasObject extends SyncedObject {
  final Color color;
  CanvasObject({required super.id, required this.color});
 
  factory CanvasObject.fromJson(Map json) {
    if (json['object_type'] == CanvasCircle.type) {
      return CanvasCircle.fromJson(json);
    } else if (json['object_type'] == CanvasRectangle.type) {
      return CanvasRectangle.fromJson(json);
    } else {
      throw UnimplementedError('Unknown object_type: ${json['object_type']}');
    }
  }
 
  bool intersectsWith(Offset point);
  CanvasObject copyWith();
  CanvasObject move(Offset delta);
}
 
/// Model for circle shapes
class CanvasCircle extends CanvasObject {
  static String type = 'circle';
  final Offset center;
  final double radius;
 
  CanvasCircle({required super.id, required super.color, required this.radius, required this.center});
  CanvasCircle.fromJson(Map json)
    : radius = json['radius'],
      center = Offset(json['center']['x'], json['center']['y']),
      super(id: json['id'], color: Color(json['color']));
  
  CanvasCircle.createNew(this.center) : radius = 0, super(id: const Uuid().v4(), color: RandomColor.getRandom());
  
  @override
  Map toJson() {
    return {
      'object_type': type,
      'id': id,
      'color': color.value,
      'center': {'x': center.dx, 'y': center.dy},
      'radius': radius,
    };
  }
 
  @override
  CanvasCircle copyWith({double? radius, Offset? center, Color? color}) {
    return CanvasCircle(
      radius: radius ?? this.radius,
      center: center ?? this.center,
      id: id,
      color: color ?? this.color,
    );
  }
 
  @override
  bool intersectsWith(Offset point) {
    final centerToPointerDistance = (point - center).distance;
    return radius > centerToPointerDistance;
  }
 
  @override
  CanvasCircle move(Offset delta) {
    return copyWith(center: center + delta);
  }
}
 
/// Model for rectangle shapes
class CanvasRectangle extends CanvasObject {
  static String type = 'rectangle';
  final Offset topLeft;
  final Offset bottomRight;
  
  CanvasRectangle({required super.id, required super.color, required this.topLeft, required this.bottomRight});
  CanvasRectangle.fromJson(Map json)
    : bottomRight = Offset(json['bottom_right']['x'], json['bottom_right']['y']),
      topLeft = Offset(json['top_left']['x'], json['top_left']['y']),
      super(id: json['id'], color: Color(json['color']));
  
  CanvasRectangle.createNew(Offset startingPoint)
    : topLeft = startingPoint,
      bottomRight = startingPoint,
      super(color: RandomColor.getRandom(), id: const Uuid().v4());
  
  @override
  Map toJson() {
    return {
      'object_type': type,
      'id': id,
      'color': color.value,
      'top_left': {'x': topLeft.dx, 'y': topLeft.dy},
      'bottom_right': {'x': bottomRight.dx, 'y': bottomRight.dy},
    };
  }
 
  @override
  CanvasRectangle copyWith({Offset? topLeft, Offset? bottomRight, Color? color}) {
    return CanvasRectangle(
      topLeft: topLeft ?? this.topLeft,
      bottomRight: bottomRight ?? this.bottomRight,
      id: id,
      color: color ?? this.color,
    );
  }
 
  @override
  bool intersectsWith(Offset point) {
    final minX = min(topLeft.dx, bottomRight.dx);
    final maxX = max(topLeft.dx, bottomRight.dx);
    final minY = min(topLeft.dy, bottomRight.dy);
    final maxY = max(topLeft.dy, bottomRight.dy);
    return minX < point.dx && point.dx < maxX && minY < point.dy && point.dy < maxY;
  }
 
  @override
  CanvasRectangle move(Offset delta) {
    return copyWith(
      topLeft: topLeft + delta,
      bottomRight: bottomRight + delta,
    );
  }
}

Step 4: Create the Custom Painter

Create lib/canvas/canvas_painter.dart to handle custom painting:

import 'package:canvas/canvas/canvas_object.dart';
import 'package:flutter/material.dart';
 
class CanvasPainter extends CustomPainter {
  final Map userCursors;
  final Map canvasObjects;
 
  CanvasPainter({required this.userCursors, required this.canvasObjects});
 
  @override
  void paint(Canvas canvas, Size size) {
    // Draw canvas objects
    for (final canvasObject in canvasObjects.values) {
      if (canvasObject is CanvasCircle) {
        final position = canvasObject.center;
        final radius = canvasObject.radius;
        canvas.drawCircle(position, radius, Paint()..color = canvasObject.color);
      } else if (canvasObject is CanvasRectangle) {
        final position = canvasObject.topLeft;
        final bottomRight = canvasObject.bottomRight;
        canvas.drawRect(
          Rect.fromLTRB(position.dx, position.dy, bottomRight.dx, bottomRight.dy),
          Paint()..color = canvasObject.color,
        );
      }
    }
 
    // Draw cursors
    for (final userCursor in userCursors.values) {
      final position = userCursor.position;
      canvas.drawPath(
        Path()
          ..moveTo(position.dx, position.dy)
          ..lineTo(position.dx + 14.29, position.dy + 44.84)
          ..lineTo(position.dx + 20.35, position.dy + 25.93)
          ..lineTo(position.dx + 39.85, position.dy + 24.51)
          ..lineTo(position.dx, position.dy),
        Paint()..color = userCursor.color,
      );
    }
  }
 
  @override
  bool shouldRepaint(CanvasPainter oldPainter) => true;
}

Step 5: Create the Canvas Page

In lib/canvas/canvas_page.dart, implement the logic for the canvas page:

import 'dart:math';
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:canvas/utils/constants.dart';
import 'package:canvas/canvas/canvas_object.dart';
import 'package:canvas/canvas/canvas_painter.dart';
import 'package:canvas/main.dart';
 
/// Different input modes users can perform
enum _DrawMode {
  pointer(iconData: Icons.pan_tool_alt),
  circle(iconData: Icons.circle_outlined),
  rectangle(iconData: Icons.rectangle_outlined);
 
  const _DrawMode({required this.iconData});
 
  final IconData iconData;
}
 
/// Interactive art board page to draw and collaborate with other users.
class CanvasPage extends StatefulWidget {
  const CanvasPage({super.key});
 
  @override
  State createState() => _CanvasPageState();
}
 
class _CanvasPageState extends State {
  final Map _userCursors = {};
  final Map _canvasObjects = {};
  late final RealtimeChannel _canvasChannel;
  late final String _myId;
  _DrawMode _currentMode = _DrawMode.pointer;
  String? _currentlyDrawingObjectId;
  Offset? _panStartPoint;
  Offset _cursorPosition = const Offset(0, 0);
 
  @override
  void initState() {
    super.initState();
    _initialize();
  }
 
  Future _initialize() async {
    _myId = const Uuid().v4();
    _canvasChannel = supabase
      .channel(Constants.channelName)
      .onBroadcast(
        event: Constants.broadcastEventName,
        callback: (payload) {
          final cursor = UserCursor.fromJson(payload['cursor']);
          _userCursors[cursor.id] = cursor;
 
          if (payload['object'] != null) {
            final object = CanvasObject.fromJson(payload['object']);
            _canvasObjects[object.id] = object;
          }
          setState(() {});
        },
      )
      .subscribe();
 
    final initialData = await supabase
      .from('canvas_objects')
      .select()
      .order('created_at', ascending: true);
    for (final canvasObjectData in initialData) {
      final canvasObject = CanvasObject.fromJson(canvasObjectData['object']);
      _canvasObjects[canvasObject.id] = canvasObject;
    }
    setState(() {});
  }
 
  Future _syncCanvasObject(Offset cursorPosition) {
    final myCursor = UserCursor(
      position: cursorPosition,
      id: _myId,
    );
    return _canvasChannel.sendBroadcastMessage(
      event: Constants.broadcastEventName,
      payload: {
        'cursor': myCursor.toJson(),
        if (_currentlyDrawingObjectId != null)
          'object': _canvasObjects[_currentlyDrawingObjectId]?.toJson(),
      },
    );
  }
 
  void _onPanDown(DragDownDetails details) {
    switch (_currentMode) {
      case _DrawMode.pointer:
        for (final canvasObject in _canvasObjects.values.toList().reversed) {
          if (canvasObject.intersectsWith(details.globalPosition)) {
            _currentlyDrawingObjectId = canvasObject.id;
            break;
          }
        }
        break;
      case _DrawMode.circle:
        final newObject = CanvasCircle.createNew(details.globalPosition);
        _canvasObjects[newObject.id] = newObject;
        _currentlyDrawingObjectId = newObject.id;
        break;
      case _DrawMode.rectangle:
        final newObject = CanvasRectangle.createNew(details.globalPosition);
        _canvasObjects[newObject.id] = newObject;
        _currentlyDrawingObjectId = newObject.id;
        break;
    }
    _cursorPosition = details.globalPosition;
    _panStartPoint = details.globalPosition;
    setState(() {});
  }
 
  void _onPanUpdate(DragUpdateDetails details) {
    switch (_currentMode) {
      case _DrawMode.pointer:
        if (_currentlyDrawingObjectId != null) {
          _canvasObjects[_currentlyDrawingObjectId!] = 
            _canvasObjects[_currentlyDrawingObjectId!]!.move(details.delta);
        }
        break;
 
      case _DrawMode.circle:
        final currentlyDrawingCircle = _canvasObjects[_currentlyDrawingObjectId!]! as CanvasCircle;
        _canvasObjects[_currentlyDrawingObjectId!] = currentlyDrawingCircle.copyWith(
          center: (details.globalPosition + _panStartPoint!) / 2,
          radius: min((details.globalPosition.dx - _panStartPoint!.dx).abs(),
                      (details.globalPosition.dy - _panStartPoint!.dy).abs()) / 2,
        );
        break;
 
      case _DrawMode.rectangle:
        _canvasObjects[_currentlyDrawingObjectId!] = 
          (_canvasObjects[_currentlyDrawingObjectId!] as CanvasRectangle).copyWith(
            bottomRight: details.globalPosition,
          );
        break;
    }
 
    if (_currentlyDrawingObjectId != null) {
      setState(() {});
    }
    _cursorPosition = details.globalPosition;
    _syncCanvasObject(_cursorPosition);
  }
 
  void onPanEnd(DragEndDetails _) async {
    if (_currentlyDrawingObjectId != null) {
      _syncCanvasObject(_cursorPosition);
    }
 
    final drawnObjectId = _currentlyDrawingObjectId;
 
    setState(() {
      _panStartPoint = null;
      _currentlyDrawingObjectId = null;
    });
    
    if (drawnObjectId != null) {
      await supabase.from('canvas_objects').upsert({
        'id': drawnObjectId,
        'object': _canvasObjects[drawnObjectId]!.toJson(),
      });
    }
  }
 
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: MouseRegion(
        onHover: (event) {
          _syncCanvasObject(event.position);
        },
        child: Stack(
          children: [
            GestureDetector(
              onPanDown: _onPanDown,
              onPanUpdate: _onPanUpdate,
              onPanEnd: onPanEnd,
              child: CustomPaint(
                size: MediaQuery.of(context).size,
                painter: CanvasPainter(
                  userCursors: _userCursors,
                  canvasObjects: _canvasObjects,
                ),
              ),
            ),
            Positioned(
              top: 0,
              left: 0,
              child: Row(
                children: _DrawMode.values.map((mode) => IconButton(
                  iconSize: 48,
                  onPressed: () {
                    setState(() {
                      _currentMode = mode;
                    });
                  },
                  icon: Icon(mode.iconData),
                  color: _currentMode == mode ? Colors.green : null,
                )).toList(),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Step 6: Run the Application

Execute flutter run and run the app in your browser. Open the app in different browsers to test real-time interactions.

Conclusion

In this article, we demonstrated how to build a collaborative design app using Flutter and Supabase. We explored real-time communication with Supabase’s Realtime Broadcast and used Flutter’s CustomPainter to render design elements on a canvas. While this setup covers the basics, it provides a strong foundation for creating more advanced collaborative applications.

Resources


Want to read more blog posts? Check out our latest blog post on Mastering the Art of DevOps: A Guide to Bridging the Gap Between Development and Operations.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.