From 76e208371cfd28d3254c3045c828f45313a7a928 Mon Sep 17 00:00:00 2001 From: Tyler Date: Tue, 13 Aug 2024 21:14:16 +0900 Subject: [PATCH] example: Update Flutter Figma clone app (#28593) Add authorizations to Flutter figma clone --- .../realtime/flutter-figma-clone/.metadata | 25 +- .../realtime/flutter-figma-clone/README.md | 3 +- .../lib/auth/login_page.dart | 46 +++ .../lib/canvas/canvas_page.dart | 274 +++++++++++++----- .../lib/canvas/canvas_painter.dart | 2 +- .../flutter-figma-clone/lib/main.dart | 53 +++- .../lib/{canvas => models}/canvas_object.dart | 0 .../lib/models/profile.dart | 17 ++ .../lib/models/project.dart | 25 ++ .../lib/project/projects_page.dart | 110 +++++++ .../lib/utils/constants.dart | 3 - .../realtime/flutter-figma-clone/pubspec.lock | 224 +++++++++++--- .../realtime/flutter-figma-clone/pubspec.yaml | 3 +- .../migrations/20240118161520_canvas.sql | 4 - .../migrations/20240808072352_auth.sql | 87 ++++++ .../flutter-figma-clone/test/widget_test.dart | 30 ++ .../flutter-figma-clone/web/index.html | 27 +- .../flutter-figma-clone/web/manifest.json | 4 +- 18 files changed, 760 insertions(+), 177 deletions(-) create mode 100644 examples/realtime/flutter-figma-clone/lib/auth/login_page.dart rename examples/realtime/flutter-figma-clone/lib/{canvas => models}/canvas_object.dart (100%) create mode 100644 examples/realtime/flutter-figma-clone/lib/models/profile.dart create mode 100644 examples/realtime/flutter-figma-clone/lib/models/project.dart create mode 100644 examples/realtime/flutter-figma-clone/lib/project/projects_page.dart create mode 100644 examples/realtime/flutter-figma-clone/supabase/migrations/20240808072352_auth.sql create mode 100644 examples/realtime/flutter-figma-clone/test/widget_test.dart diff --git a/examples/realtime/flutter-figma-clone/.metadata b/examples/realtime/flutter-figma-clone/.metadata index 5e4a304849c..68b331fb191 100644 --- a/examples/realtime/flutter-figma-clone/.metadata +++ b/examples/realtime/flutter-figma-clone/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "b0366e0a3f089e15fd89c97604ab402fe26b724c" + revision: "761747bfc538b5af34aa0d3fac380f1bc331ec49" channel: "stable" project_type: app @@ -13,26 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: b0366e0a3f089e15fd89c97604ab402fe26b724c - base_revision: b0366e0a3f089e15fd89c97604ab402fe26b724c - - platform: android - create_revision: b0366e0a3f089e15fd89c97604ab402fe26b724c - base_revision: b0366e0a3f089e15fd89c97604ab402fe26b724c - - platform: ios - create_revision: b0366e0a3f089e15fd89c97604ab402fe26b724c - base_revision: b0366e0a3f089e15fd89c97604ab402fe26b724c - - platform: linux - create_revision: b0366e0a3f089e15fd89c97604ab402fe26b724c - base_revision: b0366e0a3f089e15fd89c97604ab402fe26b724c - - platform: macos - create_revision: b0366e0a3f089e15fd89c97604ab402fe26b724c - base_revision: b0366e0a3f089e15fd89c97604ab402fe26b724c + create_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49 + base_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49 - platform: web - create_revision: b0366e0a3f089e15fd89c97604ab402fe26b724c - base_revision: b0366e0a3f089e15fd89c97604ab402fe26b724c - - platform: windows - create_revision: b0366e0a3f089e15fd89c97604ab402fe26b724c - base_revision: b0366e0a3f089e15fd89c97604ab402fe26b724c + create_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49 + base_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49 # User provided section diff --git a/examples/realtime/flutter-figma-clone/README.md b/examples/realtime/flutter-figma-clone/README.md index 27b5ddab133..69691ad71e4 100644 --- a/examples/realtime/flutter-figma-clone/README.md +++ b/examples/realtime/flutter-figma-clone/README.md @@ -2,8 +2,9 @@ ![Supabase Figma Clone](https://raw.githubusercontent.com/dshukertjr/examples/main/.github/images/design.png 'Supabase Figma Clone') -First of all, Figma clone is an over statement 😂 This app demonstrates the real-time communication capabilities of [Supabase](https://supabase.com) by creating an interactive design board where you can draw some shapes and move them around while seeing each other's cursors. +This app demonstrates the real-time communication capabilities of [Supabase](https://supabase.com) by creating an interactive design board where you can draw some shapes and move them around while seeing each other's cursors. +- Authorized Realtime channel - Cursor position sharing in real-time - Drawing shapes in real-time diff --git a/examples/realtime/flutter-figma-clone/lib/auth/login_page.dart b/examples/realtime/flutter-figma-clone/lib/auth/login_page.dart new file mode 100644 index 00000000000..759f2c65558 --- /dev/null +++ b/examples/realtime/flutter-figma-clone/lib/auth/login_page.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:supabase_auth_ui/supabase_auth_ui.dart'; + +class LoginPage extends StatelessWidget { + const LoginPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Login')), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: SupaEmailAuth( + onSignUpComplete: (AuthResponse response) async { + context.go('/projects'); + }, + onSignInComplete: (AuthResponse response) { + context.go('/projects'); + }, + metadataFields: [ + MetaDataField( + label: 'Username', + key: 'username', + validator: (val) { + if (val == null) { + return 'Please enter a username'; + } + if (RegExp(r'^[A-Za-z0-9_]{3,24}$').hasMatch(val)) { + return null; + } else { + return 'Invalid username'; + } + }, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/examples/realtime/flutter-figma-clone/lib/canvas/canvas_page.dart b/examples/realtime/flutter-figma-clone/lib/canvas/canvas_page.dart index 2135c8fa398..e88474d6b85 100644 --- a/examples/realtime/flutter-figma-clone/lib/canvas/canvas_page.dart +++ b/examples/realtime/flutter-figma-clone/lib/canvas/canvas_page.dart @@ -1,12 +1,13 @@ import 'dart:math'; -import 'package:canvas/canvas/canvas_object.dart'; import 'package:canvas/canvas/canvas_painter.dart'; import 'package:canvas/main.dart'; +import 'package:canvas/models/canvas_object.dart'; +import 'package:canvas/models/profile.dart'; +import 'package:canvas/models/project.dart'; import 'package:canvas/utils/constants.dart'; import 'package:flutter/material.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; -import 'package:uuid/uuid.dart'; +import 'package:supabase_auth_ui/supabase_auth_ui.dart'; /// Different input modes users can perform enum _DrawMode { @@ -27,7 +28,12 @@ enum _DrawMode { /// Interactive art board page to draw and collaborate with other users. class CanvasPage extends StatefulWidget { - const CanvasPage({super.key}); + const CanvasPage( + this.projectId, { + super.key, + }); + + final String projectId; @override State createState() => _CanvasPageState(); @@ -43,8 +49,8 @@ class _CanvasPageState extends State { /// Supabase realtime channel to communicate to other clients late final RealtimeChannel _canvasChanel; - /// Randomly generated UUID for the user - late final String _myId; + /// Username of each users + String? _myUsername; /// Whether the user is using the pointer to move things around, or in drawing mode. _DrawMode _currentMode = _DrawMode.pointer; @@ -58,21 +64,51 @@ class _CanvasPageState extends State { /// Cursor position of the user. Offset _cursorPosition = const Offset(0, 0); + Project? _project; + + final TextEditingController _addUserController = TextEditingController(); + @override void initState() { super.initState(); _initialize(); } + @override + void dispose() { + _canvasChanel.unsubscribe(); + _addUserController.dispose(); + super.dispose(); + } + Future _initialize() async { - // Generate a random UUID for the user. - // We could replace this with Supabase auth user ID if we want to make it - // more like Figma. - _myId = const Uuid().v4(); + final projectId = widget.projectId; + // Get the project data + _project = await supabase + .from('projects') + .select('*, profiles(*)') + .eq('id', widget.projectId) + .single() + .withConverter(Project.fromJson); + + // Get the user's username + final profileMap = await supabase + .from('profiles') + .select('username') + .eq('id', supabase.auth.currentUser!.id) + .maybeSingle(); + + _myUsername = profileMap?['username'] as String; // Start listening to broadcast messages to display other users' cursors and objects. _canvasChanel = supabase - .channel(Constants.channelName) + .channel( + projectId, + opts: const RealtimeChannelConfig( + private: true, + ack: true, + ), + ) .onBroadcast( event: Constants.broadcastEventName, callback: (payload) { @@ -85,11 +121,22 @@ class _CanvasPageState extends State { } setState(() {}); }) - .subscribe(); + .subscribe((status, error) { + if (status == RealtimeSubscribeStatus.channelError) { + debugPrint('Error subscribing to channel: $error'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Error connecting'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + }); final initialData = await supabase .from('canvas_objects') .select() + .eq('project_id', widget.projectId) .order('created_at', ascending: true); for (final canvasObjectData in initialData) { final canvasObject = CanvasObject.fromJson(canvasObjectData['object']); @@ -100,12 +147,17 @@ class _CanvasPageState extends State { /// Syncs the user's cursor position and the currently drawing object with /// other users. - Future _syncCanvasObject(Offset cursorPosition) { + /// + /// The return value indicates whether the sync was successful or not. + Future _syncCanvasObject(Offset cursorPosition) async { + if (_myUsername == null) { + return; + } final myCursor = UserCursor( position: cursorPosition, - id: _myId, + id: _myUsername!, ); - return _canvasChanel.sendBroadcastMessage( + await _canvasChanel.sendBroadcastMessage( event: Constants.broadcastEventName, payload: { 'cursor': myCursor.toJson(), @@ -126,25 +178,25 @@ class _CanvasPageState extends State { // Loop through the canvas objects to find if there are any // that intersects with the current mouse position. for (final canvasObject in _canvasObjects.values.toList().reversed) { - if (canvasObject.intersectsWith(details.globalPosition)) { + if (canvasObject.intersectsWith(details.localPosition)) { _currentlyDrawingObjectId = canvasObject.id; break; } } break; case _DrawMode.circle: - final newObject = Circle.createNew(details.globalPosition); + final newObject = Circle.createNew(details.localPosition); _canvasObjects[newObject.id] = newObject; _currentlyDrawingObjectId = newObject.id; break; case _DrawMode.rectangle: - final newObject = Rectangle.createNew(details.globalPosition); + final newObject = Rectangle.createNew(details.localPosition); _canvasObjects[newObject.id] = newObject; _currentlyDrawingObjectId = newObject.id; break; } - _cursorPosition = details.globalPosition; - _panStartPoint = details.globalPosition; + _cursorPosition = details.localPosition; + _panStartPoint = details.localPosition; setState(() {}); } @@ -167,9 +219,9 @@ class _CanvasPageState extends State { _canvasObjects[_currentlyDrawingObjectId!]! as Circle; _canvasObjects[_currentlyDrawingObjectId!] = currentlyDrawingCircle.copyWith( - center: (details.globalPosition + _panStartPoint!) / 2, - radius: min((details.globalPosition.dx - _panStartPoint!.dx).abs(), - (details.globalPosition.dy - _panStartPoint!.dy).abs()) / + center: (details.localPosition + _panStartPoint!) / 2, + radius: min((details.localPosition.dx - _panStartPoint!.dx).abs(), + (details.localPosition.dy - _panStartPoint!.dy).abs()) / 2, ); break; @@ -178,7 +230,7 @@ class _CanvasPageState extends State { case _DrawMode.rectangle: _canvasObjects[_currentlyDrawingObjectId!] = (_canvasObjects[_currentlyDrawingObjectId!] as Rectangle).copyWith( - bottomRight: details.globalPosition, + bottomRight: details.localPosition, ); break; } @@ -186,17 +238,17 @@ class _CanvasPageState extends State { if (_currentlyDrawingObjectId != null) { setState(() {}); } - _cursorPosition = details.globalPosition; + _cursorPosition = details.localPosition; _syncCanvasObject(_cursorPosition); } void onPanEnd(DragEndDetails _) async { - if (_currentlyDrawingObjectId != null) { - _syncCanvasObject(_cursorPosition); - } - final drawnObjectId = _currentlyDrawingObjectId; + if (_currentlyDrawingObjectId != null) { + await _syncCanvasObject(_cursorPosition); + } + setState(() { _panStartPoint = null; _currentlyDrawingObjectId = null; @@ -206,8 +258,10 @@ class _CanvasPageState extends State { if (drawnObjectId == null) { return; } + await supabase.from('canvas_objects').upsert({ 'id': drawnObjectId, + 'project_id': widget.projectId, 'object': _canvasObjects[drawnObjectId]!.toJson(), }); } @@ -215,46 +269,136 @@ class _CanvasPageState extends State { @override Widget build(BuildContext context) { return Scaffold( + appBar: AppBar( + titleTextStyle: const TextStyle(color: Colors.white), + title: Text(_project?.name ?? ''), + leadingWidth: 300, + backgroundColor: Colors.grey[900], + leading: Row( + children: _DrawMode.values + .map((mode) => IconButton( + onPressed: () { + setState(() { + _currentMode = mode; + }); + }, + icon: Icon(mode.iconData), + color: _currentMode == mode ? Colors.green : Colors.white, + )) + .toList(), + ), + // Displays the list of users currently drawing on the canvas + actions: [ + ...[ + ..._userCursors.values.map((e) => e.id), + if (_myUsername != null) _myUsername! + ] + .map( + (id) => Align( + widthFactor: 0.8, + child: CircleAvatar( + backgroundColor: RandomColor.getRandomFromId(id), + child: Text(id.substring(0, 2)), + ), + ), + ) + .toList(), + const SizedBox(width: 8), + TextButton.icon( + style: TextButton.styleFrom(foregroundColor: Colors.white), + onPressed: () { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Share the project'), + content: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + const ListTile( + title: Text('Members'), + ), + ..._project!.profiles + .map((profile) => ListTile( + title: Text(profile.username), + leading: CircleAvatar( + backgroundColor: + RandomColor.getRandomFromId( + profile.username, + ), + child: Text( + profile.username.substring(0, 2)), + ), + )) + .toList(), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _addUserController, + decoration: const InputDecoration( + hintText: 'Add people with username', + ), + ), + ), + TextButton( + onPressed: () async { + final username = _addUserController.text; + final profileMap = await supabase + .from('profiles') + .select() + .eq('username', username) + .maybeSingle(); + if (profileMap == null) { + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar( + content: Text('User not found'))); + return; + } + final profile = + Profile.fromJson(profileMap); + await supabase + .from('project_members') + .insert({ + 'project_id': widget.projectId, + 'profile_id': profile.id, + }); + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${profile.username} has been added'))); + }, + child: const Text('Invite'), + ), + ], + ) + ], + ), + ); + }); + }, + label: const Text('Share'), + icon: const Icon(Icons.lock), + ), + const SizedBox(width: 20), + ]), body: MouseRegion( onHover: (event) { - _syncCanvasObject(event.position); + _syncCanvasObject(event.localPosition); }, - child: Stack( - children: [ - // The main canvas - GestureDetector( - onPanDown: _onPanDown, - onPanUpdate: _onPanUpdate, - onPanEnd: onPanEnd, - child: CustomPaint( - size: MediaQuery.of(context).size, - painter: CanvasPainter( - userCursors: _userCursors, - canvasObjects: _canvasObjects, - ), - ), + child: GestureDetector( + onPanDown: _onPanDown, + onPanUpdate: _onPanUpdate, + onPanEnd: onPanEnd, + child: CustomPaint( + size: MediaQuery.of(context).size, + painter: CanvasPainter( + userCursors: _userCursors, + canvasObjects: _canvasObjects, ), - - // Buttons to change the current mode. - 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(), - ), - ), - ], + ), ), ), ); diff --git a/examples/realtime/flutter-figma-clone/lib/canvas/canvas_painter.dart b/examples/realtime/flutter-figma-clone/lib/canvas/canvas_painter.dart index 7bb635b0e43..51dd45e2e6f 100644 --- a/examples/realtime/flutter-figma-clone/lib/canvas/canvas_painter.dart +++ b/examples/realtime/flutter-figma-clone/lib/canvas/canvas_painter.dart @@ -1,4 +1,4 @@ -import 'package:canvas/canvas/canvas_object.dart'; +import 'package:canvas/models/canvas_object.dart'; import 'package:flutter/material.dart'; class CanvasPainter extends CustomPainter { diff --git a/examples/realtime/flutter-figma-clone/lib/main.dart b/examples/realtime/flutter-figma-clone/lib/main.dart index 0061d63f1ab..69aa230f247 100644 --- a/examples/realtime/flutter-figma-clone/lib/main.dart +++ b/examples/realtime/flutter-figma-clone/lib/main.dart @@ -1,27 +1,64 @@ +import 'package:canvas/auth/login_page.dart'; import 'package:canvas/canvas/canvas_page.dart'; +import 'package:canvas/project/projects_page.dart'; import 'package:flutter/material.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:flutter_web_plugins/url_strategy.dart'; +import 'package:go_router/go_router.dart'; +import 'package:supabase_auth_ui/supabase_auth_ui.dart'; void main() async { - Supabase.initialize( - url: 'http://127.0.0.1:54321', - anonKey: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0', + usePathUrlStrategy(); + + await Supabase.initialize( + url: 'YOUR_SUPABASE_URL', + anonKey: 'YOUR_SUPABASE_ANON_KEY', ); runApp(const MyApp()); } final supabase = Supabase.instance.client; +final _router = GoRouter( + initialLocation: '/signin', + routes: [ + GoRoute( + path: '/signin', + builder: (context, state) => const LoginPage(), + ), + GoRoute( + path: '/projects', + builder: (context, state) => const ProjectsPage(), + ), + GoRoute( + path: '/canvas/:projectId', + builder: (context, state) => + CanvasPage(state.pathParameters['projectId']!), + ), + ], + redirect: (BuildContext context, GoRouterState state) { + final isSignedIn = supabase.auth.currentSession != null; + if (state.path != '/signin') { + if (!isSignedIn) { + return '/signin'; + } + } else { + if (isSignedIn) { + return '/projects'; + } + } + return null; + }, +); + class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { - return const MaterialApp( - title: 'Flutter Demo', + return MaterialApp.router( + title: 'Figma Clone', debugShowCheckedModeBanner: false, - home: CanvasPage(), + routerConfig: _router, ); } } diff --git a/examples/realtime/flutter-figma-clone/lib/canvas/canvas_object.dart b/examples/realtime/flutter-figma-clone/lib/models/canvas_object.dart similarity index 100% rename from examples/realtime/flutter-figma-clone/lib/canvas/canvas_object.dart rename to examples/realtime/flutter-figma-clone/lib/models/canvas_object.dart diff --git a/examples/realtime/flutter-figma-clone/lib/models/profile.dart b/examples/realtime/flutter-figma-clone/lib/models/profile.dart new file mode 100644 index 00000000000..6fd6a08f1d8 --- /dev/null +++ b/examples/realtime/flutter-figma-clone/lib/models/profile.dart @@ -0,0 +1,17 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first + +class Profile { + final String id; + final String username; + + Map toJson() { + return { + 'id': id, + 'username': username, + }; + } + + Profile.fromJson(Map map) + : id = map['id'] as String, + username = map['username'] as String; +} diff --git a/examples/realtime/flutter-figma-clone/lib/models/project.dart b/examples/realtime/flutter-figma-clone/lib/models/project.dart new file mode 100644 index 00000000000..a673f125345 --- /dev/null +++ b/examples/realtime/flutter-figma-clone/lib/models/project.dart @@ -0,0 +1,25 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'package:canvas/models/profile.dart'; + +class Project { + final String id; + final String name; + final List profiles; + + Map toJson() { + return { + 'id': id, + 'name': name, + 'profiles': profiles.map((profile) => profile.toJson()).toList(), + }; + } + + Project.fromJson(Map map) + : id = map['id'] as String, + name = map['name'] as String, + profiles = List.from( + (map['profiles'] as List).map( + (profile) => Profile.fromJson(profile as Map), + ), + ); +} diff --git a/examples/realtime/flutter-figma-clone/lib/project/projects_page.dart b/examples/realtime/flutter-figma-clone/lib/project/projects_page.dart new file mode 100644 index 00000000000..392a2f7fe45 --- /dev/null +++ b/examples/realtime/flutter-figma-clone/lib/project/projects_page.dart @@ -0,0 +1,110 @@ +import 'package:canvas/main.dart'; +import 'package:canvas/models/canvas_object.dart'; +import 'package:canvas/models/project.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class ProjectsPage extends StatelessWidget { + const ProjectsPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + extendBody: true, + appBar: AppBar(title: const Text('Projects')), + body: FutureBuilder>( + future: supabase + .from('projects') + .select('*, profiles(*)') + .order('created_at') + .withConverter((rows) => rows.map(Project.fromJson).toList()), + builder: (context, snapshot) { + if (snapshot.hasError) { + return const Center(child: Text('An error occurred')); + } + if (!snapshot.hasData) { + return const Center(child: CircularProgressIndicator()); + } + final projects = snapshot.data!; + + if (projects.isEmpty) { + return const Center( + child: Text('You do not have any projects yet'), + ); + } + + return Align( + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Wrap( + runAlignment: WrapAlignment.spaceAround, + spacing: 16, + runSpacing: 16, + children: projects.map( + (project) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 300), + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(8), + color: Colors.white, + child: InkWell( + onTap: () { + context.go('/canvas/${project.id}'); + }, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + project.name, + style: Theme.of(context) + .textTheme + .titleMedium! + .copyWith(fontWeight: FontWeight.bold), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: project.profiles + .map( + (profile) => Align( + widthFactor: 0.8, + child: CircleAvatar( + backgroundColor: + RandomColor.getRandomFromId( + profile.username, + ), + child: Text(profile.username + .substring(0, 2)), + ), + ), + ) + .toList(), + ), + ], + ), + ), + ), + ), + ); + }, + ).toList(), + ), + ), + ); + }), + floatingActionButton: ElevatedButton.icon( + onPressed: () async { + final projectId = await supabase.rpc('create_new_project'); + context.go('/canvas/$projectId'); + }, + label: const Text('Create a Project'), + icon: const Icon(Icons.add), + ), + ); + } +} diff --git a/examples/realtime/flutter-figma-clone/lib/utils/constants.dart b/examples/realtime/flutter-figma-clone/lib/utils/constants.dart index 8a79a86a65f..020830073cd 100644 --- a/examples/realtime/flutter-figma-clone/lib/utils/constants.dart +++ b/examples/realtime/flutter-figma-clone/lib/utils/constants.dart @@ -1,7 +1,4 @@ abstract class Constants { - /// Name of the realtime channel - static const String channelName = 'canvas'; - /// Name of the broadcast event static const String broadcastEventName = 'canvas'; } diff --git a/examples/realtime/flutter-figma-clone/pubspec.lock b/examples/realtime/flutter-figma-clone/pubspec.lock index 30e27950e3d..a6daf6caf31 100644 --- a/examples/realtime/flutter-figma-clone/pubspec.lock +++ b/examples/realtime/flutter-figma-clone/pubspec.lock @@ -57,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + email_validator: + dependency: transitive + description: + name: email_validator + sha256: e9a90f27ab2b915a27d7f9c2a7ddda5dd752d6942616ee83529b686fc086221b + url: "https://pub.dev" + source: hosted + version: "2.1.17" fake_async: dependency: transitive description: @@ -112,22 +120,86 @@ packages: description: flutter source: sdk version: "0.0.0" + font_awesome_flutter: + dependency: transitive + description: + name: font_awesome_flutter + sha256: "275ff26905134bcb59417cf60ad979136f1f8257f2f449914b2c3e05bbb4cd6f" + url: "https://pub.dev" + source: hosted + version: "10.7.0" functions_client: dependency: transitive description: name: functions_client - sha256: "9a0ab83a525c8691a6724746e642de755a299afa04158807787364cd9e718001" + sha256: "229648c4c78e0cb13dfb5e7508a3d2e7058f126ba364b94f8199ba4cead96d4e" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.3.1" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: ddc16d34b0d74cb313986918c0f0885a7ba2fc24d8fb8419de75f0015144ccfe + url: "https://pub.dev" + source: hosted + version: "14.2.3" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "0c56c2c5d60d6dfaf9725f5ad4699f04749fb196ee5a70487a46ef184837ccf6" + url: "https://pub.dev" + source: hosted + version: "0.3.0+2" + google_sign_in: + dependency: transitive + description: + name: google_sign_in + sha256: "0b8787cb9c1a68ad398e8010e8c8766bfa33556d2ab97c439fb4137756d7308f" + url: "https://pub.dev" + source: hosted + version: "6.2.1" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: "5a47ebec9af97daf0822e800e4f101c3340b5ebc3f6898cf860c1a71b53cf077" + url: "https://pub.dev" + source: hosted + version: "6.1.28" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: a058c9880be456f21e2e8571c1126eaacd570bdc5b6c6d9d15aea4bdf22ca9fe + url: "https://pub.dev" + source: hosted + version: "5.7.6" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "1f6e5787d7a120cc0359ddf315c92309069171306242e181c09472d1b00a2971" + url: "https://pub.dev" + source: hosted + version: "2.4.5" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: f2b3af0ba52ff59439f18962fca71db860f09507a81da929fc0e719270b35db2 + url: "https://pub.dev" + source: hosted + version: "0.12.3+3" gotrue: dependency: transitive description: name: gotrue - sha256: b6c30fa49dfb243f9c61214651ffe2611704b46fddb520125b5f45610cb9a118 + sha256: a8784341bcc08f88ba7a4b04a40a37059c7e71c315f058d45c31d09e8a951194 url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.8.3" gtk: dependency: transitive description: @@ -136,22 +208,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" - hive: - dependency: transitive - description: - name: hive - sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" - url: "https://pub.dev" - source: hosted - version: "2.2.3" - hive_flutter: - dependency: transitive - description: - name: hive_flutter - sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc - url: "https://pub.dev" - source: hosted - version: "1.1.0" http: dependency: transitive description: @@ -176,6 +232,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + url: "https://pub.dev" + source: hosted + version: "10.0.5" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + url: "https://pub.dev" + source: hosted + version: "3.0.5" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" lints: dependency: transitive description: @@ -184,30 +264,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + url: "https://pub.dev" + source: hosted + version: "1.2.0" matcher: dependency: transitive description: name: matcher - sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb url: "https://pub.dev" source: hosted - version: "0.12.16" + version: "0.12.16+1" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.15.0" mime: dependency: transitive description: @@ -220,10 +308,10 @@ packages: dependency: transitive description: name: path - sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" url: "https://pub.dev" source: hosted - version: "1.8.3" + version: "1.9.0" path_provider: dependency: transitive description: @@ -292,18 +380,18 @@ packages: dependency: transitive description: name: postgrest - sha256: "748ebffffb60b4eaa270955dcf3742a19a2b315344c41ff1b4a0ebcd322b5181" + sha256: "675540a5e93e89fc28cc4ef0999ed2916c00a1c73de73f3a0d5067ce36dd41a2" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.3" realtime_client: dependency: transitive description: name: realtime_client - sha256: "5831636c19802ba936093a35a7c5b745b130e268fa052e84b4b5290139d2ae03" + sha256: a99b7817e203c57ada746e9fe113820410cf84d9029f4310c57737aae890b0f7 url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.2.0" retry: dependency: transitive description: @@ -376,6 +464,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + sign_in_with_apple: + dependency: transitive + description: + name: sign_in_with_apple + sha256: "5c34c601d6910d7db0077063001ccd9d9329ee58b9038ee07153ffcdd76e91bd" + url: "https://pub.dev" + source: hosted + version: "6.1.1" + sign_in_with_apple_platform_interface: + dependency: transitive + description: + name: sign_in_with_apple_platform_interface + sha256: c2ef2ce6273fce0c61acd7e9ff5be7181e33d7aa2b66508b39418b786cca2119 + url: "https://pub.dev" + source: hosted + version: "1.1.0" + sign_in_with_apple_web: + dependency: transitive + description: + name: sign_in_with_apple_web + sha256: c009e9beeb6c376e86aaa154fcc8b4e075d4bad90c56286b9668a51cdb6129ea + url: "https://pub.dev" + source: hosted + version: "2.1.0" sky_engine: dependency: transitive description: flutter @@ -409,10 +521,10 @@ packages: dependency: transitive description: name: storage_client - sha256: b49ff2e1e6738c0ef445546d6ec77040829947f0c7ef0b115acb125656127c83 + sha256: e37f1b9d40f43078d12bd2d1b6b08c2c16fbdbafc58b57bc44922da6ea3f5625 url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.0.2" stream_channel: dependency: transitive description: @@ -433,18 +545,26 @@ packages: dependency: transitive description: name: supabase - sha256: "976baea81ed75e3b5925db13fe0bfed79440e3b2d8d0753e2f58ab402457b467" + sha256: f6d21d0b555aa997d8170afd112407df0aaf44820cbe93b3520e2f7ea3ec0904 url: "https://pub.dev" source: hosted - version: "2.0.5" - supabase_flutter: + version: "2.2.7" + supabase_auth_ui: dependency: "direct main" description: - name: supabase_flutter - sha256: "4bf88de6ff3c7c350863c907f02f02db5f5a112a826c5a6c02de69d52898bf30" + name: supabase_auth_ui + sha256: "2a60c9e513360f062e4aa016760a48a5585c0986f1ff178e22f1fa88534e2f6b" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "0.5.0" + supabase_flutter: + dependency: transitive + description: + name: supabase_flutter + sha256: bcac7745e423ffe4aea86fefc3ccef259f979005c7a40558089c11f8606fd085 + url: "https://pub.dev" + source: hosted + version: "2.5.11" term_glyph: dependency: transitive description: @@ -457,10 +577,10 @@ packages: dependency: transitive description: name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.7.2" typed_data: dependency: transitive description: @@ -549,14 +669,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: f652077d0bdf60abe4c1f6377448e8655008eef28f128bc023f7b5e8dfeb48fc + url: "https://pub.dev" + source: hosted + version: "14.2.4" web: dependency: transitive description: name: web - sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 + sha256: "4188706108906f002b3a293509234588823c8c979dc83304e229ff400c996b05" url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "0.4.2" web_socket_channel: dependency: transitive description: @@ -585,10 +713,10 @@ packages: dependency: transitive description: name: yet_another_json_isolate - sha256: e727502a2640d65b4b8a8a6cb48af9dd0cbe644ba4b3ee667c7f4afa0c1d6069 + sha256: "381dd593e4199fe41a7b6467cef77a41d91fa8ef41465e135f12c99a76be463b" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.0.1" sdks: - dart: ">=3.2.3 <4.0.0" - flutter: ">=3.16.0" + dart: ">=3.4.0 <4.0.0" + flutter: ">=3.22.0" diff --git a/examples/realtime/flutter-figma-clone/pubspec.yaml b/examples/realtime/flutter-figma-clone/pubspec.yaml index 4f07284c841..0f83d8d280d 100644 --- a/examples/realtime/flutter-figma-clone/pubspec.yaml +++ b/examples/realtime/flutter-figma-clone/pubspec.yaml @@ -12,8 +12,9 @@ dependencies: flutter: sdk: flutter - supabase_flutter: ^2.3.0 + supabase_auth_ui: ^0.5.0 uuid: ^4.3.3 + go_router: ^14.2.3 dev_dependencies: flutter_test: diff --git a/examples/realtime/flutter-figma-clone/supabase/migrations/20240118161520_canvas.sql b/examples/realtime/flutter-figma-clone/supabase/migrations/20240118161520_canvas.sql index 5f308c656f2..db743ce17cc 100644 --- a/examples/realtime/flutter-figma-clone/supabase/migrations/20240118161520_canvas.sql +++ b/examples/realtime/flutter-figma-clone/supabase/migrations/20240118161520_canvas.sql @@ -4,7 +4,3 @@ create table canvas_objects ( 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); \ No newline at end of file diff --git a/examples/realtime/flutter-figma-clone/supabase/migrations/20240808072352_auth.sql b/examples/realtime/flutter-figma-clone/supabase/migrations/20240808072352_auth.sql new file mode 100644 index 00000000000..837a25cf9b3 --- /dev/null +++ b/examples/realtime/flutter-figma-clone/supabase/migrations/20240808072352_auth.sql @@ -0,0 +1,87 @@ +create table public.profiles ( + id uuid primary key references auth.users(id) on delete cascade, + username text unique not null, + created_at timestamp with time zone default timezone('utc'::text, now()) not null + + -- username should be 3 to 24 characters long containing alphabets, numbers and underscores + constraint username_validation check (username ~* '^[A-Za-z0-9_]{3,24}$') +); + +-- Function to create a new row in profiles table upon signup +-- Also copies the username value from metadata +create or replace function handle_new_user() returns trigger as $$ + begin + insert into public.profiles(id, username) + values(new.id, new.raw_user_meta_data->>'username'); + + return new; + end; +$$ language plpgsql security definer; + +-- Trigger to call `handle_new_user` when new user signs up +create trigger on_auth_user_created + after insert on auth.users + for each row + execute function handle_new_user(); + +create table if not exists public.projects ( + id uuid primary key default gen_random_uuid(), + name text not null default 'new project', + created_at timestamp with time zone default timezone('utc'::text, now()) not null +); + +create table if not exists public.project_members ( + profile_id uuid references public.profiles(id) on delete cascade not null, + project_id uuid references public.projects(id) on delete cascade not null, + created_at timestamp with time zone default timezone('utc' :: text, now()) not null, + primary key (profile_id, project_id) +); + +alter table public.canvas_objects +add column project_id uuid references public.projects(id) on delete cascade not null; + +-- Returns true if the signed in user is a member of the project +create or replace function is_project_member(project_id uuid) +returns boolean as $$ + select exists( + select 1 + from project_members + where project_id = is_project_member.project_id and profile_id = auth.uid() + ); +$$ language sql security definer; + +alter table public.profiles enable row level security; +create policy "Public profiles are viewable by everyone." on public.profiles for select using (true); + +alter table public.projects enable row level security; +create policy "Users can view projects that they have joined" on public.projects for select using (is_project_member(id)); + +alter table public.project_members enable row level security; +create policy "Participants of the project can view other participants." on public.project_members for select using (is_project_member(project_id)); +create policy "Participants of the project can insert new users." on public.project_members for insert with check (is_project_member(project_id)); + +alter table public.canvas_objects enable row level security; +create policy "Users can view canvas_objects of projects they are in." on public.canvas_objects for select using (is_project_member(project_id)); +create policy "Users can insert canvas_objects of projects they are in." on public.canvas_objects for insert with check (is_project_member(project_id)); +create policy "Users can update canvas_objects of projects they are in." on public.canvas_objects for update using (is_project_member(project_id)) with check (is_project_member(project_id)); +create policy "Users can delete canvas_objects of projects they are in." on public.canvas_objects for delete using (is_project_member(project_id)); + +-- Creates a new project and inserts the caller as a participant +create or replace function create_new_project() returns uuid as $$ + declare + new_project_id uuid; + begin + -- Create a new project + insert into public.projects default values + returning id into new_project_id; + + -- Insert the caller user into the new project + insert into public.project_members (profile_id, project_id) + values (auth.uid(), new_project_id); + + return new_project_id; + end +$$ language plpgsql security definer; + +create policy "Project members can receive presence and broadcast messages." on realtime.messages for select using (is_project_member(realtime.topic()::uuid)); +create policy "Project members can send presence and broadcast messages." on realtime.messages for insert with check (is_project_member(realtime.topic()::uuid)); diff --git a/examples/realtime/flutter-figma-clone/test/widget_test.dart b/examples/realtime/flutter-figma-clone/test/widget_test.dart new file mode 100644 index 00000000000..9b1ded134f2 --- /dev/null +++ b/examples/realtime/flutter-figma-clone/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:canvas/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} diff --git a/examples/realtime/flutter-figma-clone/web/index.html b/examples/realtime/flutter-figma-clone/web/index.html index e0c9b701ea4..80ce435acf7 100644 --- a/examples/realtime/flutter-figma-clone/web/index.html +++ b/examples/realtime/flutter-figma-clone/web/index.html @@ -23,37 +23,16 @@ - + - design + canvas - - - - - + diff --git a/examples/realtime/flutter-figma-clone/web/manifest.json b/examples/realtime/flutter-figma-clone/web/manifest.json index 2c8de133299..aad92e819ea 100644 --- a/examples/realtime/flutter-figma-clone/web/manifest.json +++ b/examples/realtime/flutter-figma-clone/web/manifest.json @@ -1,6 +1,6 @@ { - "name": "design", - "short_name": "design", + "name": "canvas", + "short_name": "canvas", "start_url": ".", "display": "standalone", "background_color": "#0175C2",