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 AI and Automation in Web Development.

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.