From 45dc3c3256f04be5e101c4d95e6870a76a845678 Mon Sep 17 00:00:00 2001 From: Jei-sKappa Date: Mon, 19 May 2025 11:28:56 +0200 Subject: [PATCH 1/6] refactor: shared document_state --- .../lib/shared/document_state.dart | 44 +++++++++++++++++ .../flutter_example/lib/todo_list/state.dart | 47 +++---------------- .../lib/todo_list/todo_list.dart | 22 +++++---- 3 files changed, 63 insertions(+), 50 deletions(-) create mode 100644 packages/crdt_lf/flutter_example/lib/shared/document_state.dart diff --git a/packages/crdt_lf/flutter_example/lib/shared/document_state.dart b/packages/crdt_lf/flutter_example/lib/shared/document_state.dart new file mode 100644 index 0000000..3f0b10d --- /dev/null +++ b/packages/crdt_lf/flutter_example/lib/shared/document_state.dart @@ -0,0 +1,44 @@ +import 'dart:async'; + +import 'package:crdt_lf/crdt_lf.dart'; +import 'package:crdt_lf_flutter_example/shared/network.dart'; +import 'package:flutter/material.dart'; + +abstract class DocumentState extends ChangeNotifier { + DocumentState(this._document, this._network) { + _listenToNetworkChanges(); + _listenToLocalChanges(); + } + + final CRDTDocument _document; + final Network _network; + + StreamSubscription? _networkChanges; + StreamSubscription? _localChanges; + + void _listenToNetworkChanges() { + _networkChanges = _network + .stream(_document.peerId) + .listen(_applyNetworkChanges); + } + + void _listenToLocalChanges() { + _localChanges = _document.localChanges.listen(_sendChange); + } + + void _applyNetworkChanges(Change change) { + _document.applyChange(change); + notifyListeners(); + } + + void _sendChange(Change change) { + _network.sendChange(change); + } + + @override + void dispose() { + _networkChanges?.cancel(); + _localChanges?.cancel(); + super.dispose(); + } +} diff --git a/packages/crdt_lf/flutter_example/lib/todo_list/state.dart b/packages/crdt_lf/flutter_example/lib/todo_list/state.dart index 90407f3..660a239 100644 --- a/packages/crdt_lf/flutter_example/lib/todo_list/state.dart +++ b/packages/crdt_lf/flutter_example/lib/todo_list/state.dart @@ -1,46 +1,18 @@ -import 'dart:async'; - import 'package:crdt_lf/crdt_lf.dart'; +import 'package:crdt_lf_flutter_example/shared/document_state.dart'; import 'package:crdt_lf_flutter_example/shared/network.dart'; -import 'package:flutter/material.dart'; -class DocumentState extends ChangeNotifier { - DocumentState._(this._document, this._handler, this._network) { - _listenToNetworkChanges(); - _listenToLocalChanges(); - } +class TodoDocumentState extends DocumentState { + TodoDocumentState._(CRDTDocument document, this._handler, Network network) + : super(document, network); - factory DocumentState.create(PeerId author, {required Network network}) { + factory TodoDocumentState.create(PeerId author, {required Network network}) { final document = CRDTDocument(peerId: author); final handler = CRDTListHandler(document, 'todo-list'); - return DocumentState._(document, handler, network); + return TodoDocumentState._(document, handler, network); } - final CRDTDocument _document; final CRDTListHandler _handler; - final Network _network; - - StreamSubscription? _networkChanges; - StreamSubscription? _localChanges; - - void _listenToNetworkChanges() { - _networkChanges = _network - .stream(_document.peerId) - .listen(_applyNetworkChanges); - } - - void _listenToLocalChanges() { - _localChanges = _document.localChanges.listen(_sendChange); - } - - void _applyNetworkChanges(Change change) { - _document.applyChange(change); - notifyListeners(); - } - - void _sendChange(Change change) { - _network.sendChange(change); - } void addTodo(String todo) { _handler.insert(0, todo); @@ -53,11 +25,4 @@ class DocumentState extends ChangeNotifier { } List get todos => _handler.value; - - @override - void dispose() { - _networkChanges?.cancel(); - _localChanges?.cancel(); - super.dispose(); - } } diff --git a/packages/crdt_lf/flutter_example/lib/todo_list/todo_list.dart b/packages/crdt_lf/flutter_example/lib/todo_list/todo_list.dart index 53cf224..b5350c1 100644 --- a/packages/crdt_lf/flutter_example/lib/todo_list/todo_list.dart +++ b/packages/crdt_lf/flutter_example/lib/todo_list/todo_list.dart @@ -16,16 +16,20 @@ class TodoList extends StatelessWidget { Widget build(BuildContext context) { return AppLayout( example: 'Todo List', - leftBody: ChangeNotifierProvider( + leftBody: ChangeNotifierProvider( create: - (context) => - DocumentState.create(author1, network: context.read()), + (context) => TodoDocumentState.create( + author1, + network: context.read(), + ), child: TodoDocument(author: author1), ), - rightBody: ChangeNotifierProvider( + rightBody: ChangeNotifierProvider( create: - (context) => - DocumentState.create(author2, network: context.read()), + (context) => TodoDocumentState.create( + author2, + network: context.read(), + ), child: TodoDocument(author: author2), ), ); @@ -44,7 +48,7 @@ class TodoDocument extends StatelessWidget { builder: (BuildContext dialogContext) { return AddItemDialog( onAdd: (text) { - context.read().addTodo(text); + context.read().addTodo(text); }, ); }, @@ -54,7 +58,7 @@ class TodoDocument extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - body: Consumer( + body: Consumer( builder: (context, state, child) { if (state.todos.isEmpty) { return const Center( @@ -83,7 +87,7 @@ class TodoDocument extends StatelessWidget { icon: const Icon(Icons.delete_outline), tooltip: 'Delete Todo', onPressed: () { - context.read().removeTodo(index); + context.read().removeTodo(index); }, ), ); From ded0365ddcc0b18eb253192c5791ca6be5f3d6e6 Mon Sep 17 00:00:00 2001 From: Jei-sKappa Date: Mon, 19 May 2025 14:26:28 +0200 Subject: [PATCH 2/6] feat: whiteboard flutter example --- .../crdt_lf/flutter_example/lib/main.dart | 9 +- .../lib/shared/document_state.dart | 2 + .../lib/whiteboard/canvas.dart | 35 +++++++ .../lib/whiteboard/painter.dart | 94 +++++++++++++++++++ .../flutter_example/lib/whiteboard/state.dart | 88 +++++++++++++++++ .../lib/whiteboard/stroke.dart | 47 ++++++++++ .../lib/whiteboard/whiteboard.dart | 88 +++++++++++++++++ 7 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 packages/crdt_lf/flutter_example/lib/whiteboard/canvas.dart create mode 100644 packages/crdt_lf/flutter_example/lib/whiteboard/painter.dart create mode 100644 packages/crdt_lf/flutter_example/lib/whiteboard/state.dart create mode 100644 packages/crdt_lf/flutter_example/lib/whiteboard/stroke.dart create mode 100644 packages/crdt_lf/flutter_example/lib/whiteboard/whiteboard.dart diff --git a/packages/crdt_lf/flutter_example/lib/main.dart b/packages/crdt_lf/flutter_example/lib/main.dart index d145d40..4bcda75 100644 --- a/packages/crdt_lf/flutter_example/lib/main.dart +++ b/packages/crdt_lf/flutter_example/lib/main.dart @@ -1,5 +1,6 @@ import 'package:crdt_lf_flutter_example/shared/network.dart'; import 'package:crdt_lf_flutter_example/todo_list/todo_list.dart'; +import 'package:crdt_lf_flutter_example/whiteboard/whiteboard.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -22,6 +23,7 @@ class MyApp extends StatelessWidget { routes: { '/': (context) => const Examples(), 'todo-list': (context) => const TodoList(), + 'whiteboard': (context) => const Whiteboard(), }, ), ); @@ -45,7 +47,12 @@ class Examples extends StatelessWidget { leading: const SizedBox(), title: const Text('CRDT LF Examples'), ), - body: ListView(children: [_listTile(context, 'Todo List', 'todo-list')]), + body: ListView( + children: [ + _listTile(context, 'Todo List', 'todo-list'), + _listTile(context, 'Whiteboard', 'whiteboard'), + ], + ), ); } } diff --git a/packages/crdt_lf/flutter_example/lib/shared/document_state.dart b/packages/crdt_lf/flutter_example/lib/shared/document_state.dart index 3f0b10d..149dbb1 100644 --- a/packages/crdt_lf/flutter_example/lib/shared/document_state.dart +++ b/packages/crdt_lf/flutter_example/lib/shared/document_state.dart @@ -13,6 +13,8 @@ abstract class DocumentState extends ChangeNotifier { final CRDTDocument _document; final Network _network; + PeerId get peerId => _document.peerId; + StreamSubscription? _networkChanges; StreamSubscription? _localChanges; diff --git a/packages/crdt_lf/flutter_example/lib/whiteboard/canvas.dart b/packages/crdt_lf/flutter_example/lib/whiteboard/canvas.dart new file mode 100644 index 0000000..ece8cfb --- /dev/null +++ b/packages/crdt_lf/flutter_example/lib/whiteboard/canvas.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'stroke.dart'; + +class WhiteboardPainter extends CustomPainter { + final List strokes; + + WhiteboardPainter({required this.strokes}); + + @override + void paint(Canvas canvas, Size size) { + final paint = + Paint() + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + + for (final stroke in strokes) { + paint.color = stroke.color; + paint.strokeWidth = stroke.width; + + if (stroke.points.isNotEmpty) { + final path = Path(); + path.moveTo(stroke.points.first.dx, stroke.points.first.dy); + for (var i = 1; i < stroke.points.length; i++) { + path.lineTo(stroke.points[i].dx, stroke.points[i].dy); + } + canvas.drawPath(path, paint); + } + } + } + + @override + bool shouldRepaint(covariant WhiteboardPainter oldDelegate) { + return oldDelegate.strokes != strokes; + } +} diff --git a/packages/crdt_lf/flutter_example/lib/whiteboard/painter.dart b/packages/crdt_lf/flutter_example/lib/whiteboard/painter.dart new file mode 100644 index 0000000..3bcd942 --- /dev/null +++ b/packages/crdt_lf/flutter_example/lib/whiteboard/painter.dart @@ -0,0 +1,94 @@ +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'stroke.dart'; + +class WhiteboardPainter extends CustomPainter { + WhiteboardPainter({required this.strokes}); + + List strokes; + + @override + void paint(Canvas canvas, Size size) { + // Draw the background + _drawGrid(canvas, size); + + // Draw the strokes + for (final stroke in strokes) { + final points = stroke.points; + + if (points.isEmpty) continue; + final paint = stroke.getPaint(); + + if (stroke.points.length == 1) { + paint.style = PaintingStyle.fill; + final center = stroke.points.first; + final radius = stroke.width / 2; + canvas.drawCircle(center, radius, paint); + continue; + } + + final path = stroke.toPath(); + canvas.drawPath(path, paint); + } + } + + void _drawGrid(Canvas canvas, Size size) { + const gridStrokeWidth = 1.0; + final gridSpacing = size.width / 32; + + final gridPaint = + Paint() + ..color = Colors.grey.shade500 + ..strokeWidth = gridStrokeWidth; + + // Horizontal lines for main grid + for (double y = 0; y <= size.height; y += gridSpacing) { + canvas.drawLine(Offset(0, y), Offset(size.width, y), gridPaint); + } + + // Vertical lines for main grid + for (double x = 0; x <= size.width; x += gridSpacing) { + canvas.drawLine(Offset(x, 0), Offset(x, size.height), gridPaint); + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => + !listEquals((oldDelegate as WhiteboardPainter).strokes, strokes); +} + +extension _StrokeX on Stroke { + Paint getPaint() { + return Paint() + ..color = color + ..strokeWidth = max(1.0, width) + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + } + + Path toPath() { + final path = Path(); + + // Add the first point to the path + final firstPoint = points.first; + path.moveTo(firstPoint.dx, firstPoint.dy); + + // Add subsequent points using quadratic Bezier curves + for (int i = 1; i < points.length - 1; i++) { + final p1 = points[i]; + final p2 = points[i + 1]; + + path.quadraticBezierTo( + p1.dx, + p1.dy, + (p1.dx + p2.dx) / 2, + (p1.dy + p2.dy) / 2, + ); + } + + return path; + } +} diff --git a/packages/crdt_lf/flutter_example/lib/whiteboard/state.dart b/packages/crdt_lf/flutter_example/lib/whiteboard/state.dart new file mode 100644 index 0000000..d762748 --- /dev/null +++ b/packages/crdt_lf/flutter_example/lib/whiteboard/state.dart @@ -0,0 +1,88 @@ +import 'package:crdt_lf/crdt_lf.dart'; +import 'package:crdt_lf_flutter_example/shared/document_state.dart'; +import 'package:crdt_lf_flutter_example/shared/network.dart'; +import 'package:crdt_lf_flutter_example/whiteboard/stroke.dart'; +import 'package:flutter/material.dart'; + +class WhiteboardDocumentState extends DocumentState { + WhiteboardDocumentState._( + CRDTDocument document, + this._handler, + this._peerFeedbackHandler, + Network network, + ) : super(document, network); + + factory WhiteboardDocumentState.create( + PeerId author, { + required Network network, + }) { + final document = CRDTDocument(peerId: author); + final handler = CRDTMapHandler(document, 'whiteboard'); + final peerFeedbackHandler = CRDTMapHandler( + document, + 'whiteboard_feedback', + ); + return WhiteboardDocumentState._( + document, + handler, + peerFeedbackHandler, + network, + ); + } + + final CRDTMapHandler _handler; + final CRDTMapHandler _peerFeedbackHandler; + + void createStrokeFeedback( + Offset offset, { + Color color = Colors.black, + double strokeWidth = 5.0, + }) { + final strokeFeedback = Stroke( + // Temporary ID + id: DateTime.now().microsecondsSinceEpoch.toString(), + points: [offset], + color: color, + width: strokeWidth, + ); + + _peerFeedbackHandler.set(peerId.id, strokeFeedback); + notifyListeners(); + } + + void updateStrokeFeedback(Offset offset) { + final strokeFeedback = _peerFeedbackHandler.value[peerId.id]; + + if (strokeFeedback == null) return; + + final updatedStrokeFeedback = strokeFeedback.copyWith( + points: [...strokeFeedback.points, offset], + ); + + _peerFeedbackHandler.set(peerId.id, updatedStrokeFeedback); + notifyListeners(); + } + + void addStroke() { + final strokeFeedback = _peerFeedbackHandler.value[peerId.id]; + + if (strokeFeedback == null) return; + + _handler.set(strokeFeedback.id, strokeFeedback); + _peerFeedbackHandler.set(peerId.id, null); + notifyListeners(); + } + + void removeStroke(StrokeId id) { + _handler.delete(id); + notifyListeners(); + } + + List get strokes => _handler.value.values.toList(); + + List get strokesWithFeedbacks => [ + ..._handler.value.values, + for (final strokeFeedback in _peerFeedbackHandler.value.values) + if (strokeFeedback != null) strokeFeedback, + ]; +} diff --git a/packages/crdt_lf/flutter_example/lib/whiteboard/stroke.dart b/packages/crdt_lf/flutter_example/lib/whiteboard/stroke.dart new file mode 100644 index 0000000..b7c6223 --- /dev/null +++ b/packages/crdt_lf/flutter_example/lib/whiteboard/stroke.dart @@ -0,0 +1,47 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +typedef StrokeId = String; + +@immutable +class Stroke { + final StrokeId id; + final List points; + final Color color; + final double width; + + const Stroke({ + required this.id, + required this.points, + required this.color, + required this.width, + }); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is Stroke && + other.id == id && + other.color == color && + other.width == width && + listEquals(other.points, points); + } + + @override + int get hashCode => Object.hash(id, points, color, width); + + Stroke copyWith({List? points, Color? color, double? strokeWidth}) { + return Stroke( + id: id, + points: points ?? this.points, + color: color ?? this.color, + width: strokeWidth ?? width, + ); + } + + @override + String toString() { + return 'Stroke(id: $id, points: $points, color: $color, strokeWidth: $width)'; + } +} diff --git a/packages/crdt_lf/flutter_example/lib/whiteboard/whiteboard.dart b/packages/crdt_lf/flutter_example/lib/whiteboard/whiteboard.dart new file mode 100644 index 0000000..e42a0ee --- /dev/null +++ b/packages/crdt_lf/flutter_example/lib/whiteboard/whiteboard.dart @@ -0,0 +1,88 @@ +import 'package:crdt_lf/crdt_lf.dart'; +import 'package:crdt_lf_flutter_example/shared/layout.dart'; +import 'package:crdt_lf_flutter_example/shared/network.dart'; +import 'package:crdt_lf_flutter_example/whiteboard/state.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'painter.dart'; + +final author1 = PeerId.parse('79a716de-176e-4347-ba6e-1d9a2de02e17'); +final author2 = PeerId.parse('79a716de-176e-4347-ba6e-1d9a2de02e18'); + +class Whiteboard extends StatelessWidget { + const Whiteboard({super.key}); + + @override + Widget build(BuildContext context) { + return AppLayout( + example: 'Whiteboard', + leftBody: ChangeNotifierProvider( + create: + (context) => WhiteboardDocumentState.create( + author1, + network: context.read(), + ), + child: WhiteboardDocument(author: author1, strokeColor: Colors.blue), + ), + rightBody: ChangeNotifierProvider( + create: + (context) => WhiteboardDocumentState.create( + author2, + network: context.read(), + ), + child: WhiteboardDocument(author: author2, strokeColor: Colors.red), + ), + ); + } +} + +class WhiteboardDocument extends StatelessWidget { + const WhiteboardDocument({ + super.key, + required this.author, + required this.strokeColor, + }); + + final PeerId author; + final Color strokeColor; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: MouseRegion( + cursor: SystemMouseCursors.precise, + child: Consumer( + builder: (context, state, _) { + return GestureDetector( + onPanStart: (details) { + state.createStrokeFeedback( + details.localPosition, + color: strokeColor, + ); + }, + onPanUpdate: (details) { + state.updateStrokeFeedback(details.localPosition); + }, + onPanEnd: (details) { + state.updateStrokeFeedback(details.localPosition); + state.addStroke(); + }, + child: CustomPaint( + painter: WhiteboardPainter(strokes: state.strokesWithFeedbacks), + // Added child to CustomPaint for hit testing + child: Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.black, width: 1.0), + color: Colors.transparent, + ), + width: double.infinity, + height: double.infinity, + ), + ), + ); + }, + ), + ), + ); + } +} From 3f482d88c7bd545331baad343b05c4dcb02f8cdf Mon Sep 17 00:00:00 2001 From: Jei-sKappa Date: Mon, 19 May 2025 15:40:10 +0200 Subject: [PATCH 3/6] feat: network delay --- .../crdt_lf/flutter_example/lib/shared/layout.dart | 14 ++++++++++++-- .../flutter_example/lib/shared/network.dart | 13 ++++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/crdt_lf/flutter_example/lib/shared/layout.dart b/packages/crdt_lf/flutter_example/lib/shared/layout.dart index 59da88e..bc20e57 100644 --- a/packages/crdt_lf/flutter_example/lib/shared/layout.dart +++ b/packages/crdt_lf/flutter_example/lib/shared/layout.dart @@ -22,11 +22,21 @@ class AppLayout extends StatelessWidget { ); } - Widget _switch() { + Widget _networkActions() { return Consumer( builder: (context, network, __) { return Row( children: [ + Text('Network delay: ${network.networkDelay.inMilliseconds}ms'), + Slider( + value: network.networkDelay.inMilliseconds.toDouble(), + min: 0, + max: 2000, + divisions: 20, + onChanged: (value) { + network.setNetworkDelay(Duration(milliseconds: value.toInt())); + }, + ), const Text('Sync: '), Switch( value: network.isOnline, @@ -50,7 +60,7 @@ class AppLayout extends StatelessWidget { appBar: AppBar( leading: _leading(context), title: Text('CRDT LF: $example'), - actions: [_switch()], + actions: [_networkActions()], ), body: Row( children: [ diff --git a/packages/crdt_lf/flutter_example/lib/shared/network.dart b/packages/crdt_lf/flutter_example/lib/shared/network.dart index f67da45..9590c69 100644 --- a/packages/crdt_lf/flutter_example/lib/shared/network.dart +++ b/packages/crdt_lf/flutter_example/lib/shared/network.dart @@ -19,6 +19,9 @@ class Network extends ChangeNotifier { bool _isOnline = false; bool get isOnline => _isOnline; + Duration _networkDelay = Duration.zero; + Duration get networkDelay => _networkDelay; + /// Sets the online status of the network. /// /// If transitioning to online, sends any queued offline changes. @@ -48,14 +51,22 @@ class Network extends ChangeNotifier { } } + void setNetworkDelay(Duration delay) { + _networkDelay = delay; + notifyListeners(); + } + /// Sends a change into the network, attributed to the sender. /// /// If the network is online, the change is broadcast immediately. /// If offline, the change is queued and sent when the network comes back online. /// Listeners will receive this change unless their listener ID matches the senderId. - void sendChange(Change change) { + Future sendChange(Change change) async { final changeTuple = (change.author, change); if (_isOnline) { + // Simulate network delay + if (_networkDelay > Duration.zero) await Future.delayed(_networkDelay); + _changesController.add(changeTuple); } else { _offlineQueue.add(changeTuple); From 9caeb02062fa70987368c7ddeb4b73cee4900a7f Mon Sep 17 00:00:00 2001 From: Jei-sKappa Date: Mon, 19 May 2025 16:05:00 +0200 Subject: [PATCH 4/6] feat: pointer feedback --- .../lib/whiteboard/pointer_feedback.dart | 36 ++++++++++++ .../flutter_example/lib/whiteboard/state.dart | 42 +++++++++++++- .../lib/whiteboard/whiteboard.dart | 56 +++++++++++++------ 3 files changed, 114 insertions(+), 20 deletions(-) create mode 100644 packages/crdt_lf/flutter_example/lib/whiteboard/pointer_feedback.dart diff --git a/packages/crdt_lf/flutter_example/lib/whiteboard/pointer_feedback.dart b/packages/crdt_lf/flutter_example/lib/whiteboard/pointer_feedback.dart new file mode 100644 index 0000000..b0998b6 --- /dev/null +++ b/packages/crdt_lf/flutter_example/lib/whiteboard/pointer_feedback.dart @@ -0,0 +1,36 @@ +import 'package:crdt_lf/crdt_lf.dart'; +import 'package:flutter/material.dart'; + +@immutable +class PointerFeedback { + const PointerFeedback({ + required this.offset, + required this.color, + required this.peerId, + }); + + final Offset offset; + final Color color; + final PeerId peerId; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is PointerFeedback && + other.offset == offset && + other.color == color && + other.peerId == peerId; + } + + @override + int get hashCode => Object.hash(offset, color, peerId); + + PointerFeedback copyWith({Offset? offset, Color? color}) { + return PointerFeedback( + offset: offset ?? this.offset, + color: color ?? this.color, + peerId: peerId, + ); + } +} diff --git a/packages/crdt_lf/flutter_example/lib/whiteboard/state.dart b/packages/crdt_lf/flutter_example/lib/whiteboard/state.dart index d762748..655ced3 100644 --- a/packages/crdt_lf/flutter_example/lib/whiteboard/state.dart +++ b/packages/crdt_lf/flutter_example/lib/whiteboard/state.dart @@ -1,6 +1,7 @@ import 'package:crdt_lf/crdt_lf.dart'; import 'package:crdt_lf_flutter_example/shared/document_state.dart'; import 'package:crdt_lf_flutter_example/shared/network.dart'; +import 'package:crdt_lf_flutter_example/whiteboard/pointer_feedback.dart'; import 'package:crdt_lf_flutter_example/whiteboard/stroke.dart'; import 'package:flutter/material.dart'; @@ -9,6 +10,7 @@ class WhiteboardDocumentState extends DocumentState { CRDTDocument document, this._handler, this._peerFeedbackHandler, + this._peerPointerFeedbackHandler, Network network, ) : super(document, network); @@ -22,16 +24,38 @@ class WhiteboardDocumentState extends DocumentState { document, 'whiteboard_feedback', ); + final peerPointerFeedbackHandler = CRDTMapHandler( + document, + 'whiteboard_pointer_feedback', + ); return WhiteboardDocumentState._( document, handler, peerFeedbackHandler, + peerPointerFeedbackHandler, network, ); } final CRDTMapHandler _handler; final CRDTMapHandler _peerFeedbackHandler; + final CRDTMapHandler _peerPointerFeedbackHandler; + + void setPointerFeedback(Offset offset, {Color color = Colors.black}) { + final pointer = PointerFeedback( + offset: offset, + color: color, + peerId: peerId, + ); + + _peerPointerFeedbackHandler.set(pointer.peerId.id, pointer); + notifyListeners(); + } + + void removePointerFeedback() { + _peerPointerFeedbackHandler.set(peerId.id, null); + notifyListeners(); + } void createStrokeFeedback( Offset offset, { @@ -60,6 +84,15 @@ class WhiteboardDocumentState extends DocumentState { ); _peerFeedbackHandler.set(peerId.id, updatedStrokeFeedback); + + // Update also the pointer feedback + final pointerFeedback = _peerPointerFeedbackHandler.value[peerId.id]; + if (pointerFeedback != null) { + _peerPointerFeedbackHandler.set( + peerId.id, + pointerFeedback.copyWith(offset: offset), + ); + } notifyListeners(); } @@ -82,7 +115,12 @@ class WhiteboardDocumentState extends DocumentState { List get strokesWithFeedbacks => [ ..._handler.value.values, - for (final strokeFeedback in _peerFeedbackHandler.value.values) - if (strokeFeedback != null) strokeFeedback, + ..._peerFeedbackHandler.value.values.whereType(), ]; + + List get remotePointerFeedbacks => + _peerPointerFeedbackHandler.value.values + .whereType() + .where((feedback) => feedback.peerId != peerId) + .toList(); } diff --git a/packages/crdt_lf/flutter_example/lib/whiteboard/whiteboard.dart b/packages/crdt_lf/flutter_example/lib/whiteboard/whiteboard.dart index e42a0ee..ec73a9b 100644 --- a/packages/crdt_lf/flutter_example/lib/whiteboard/whiteboard.dart +++ b/packages/crdt_lf/flutter_example/lib/whiteboard/whiteboard.dart @@ -49,11 +49,17 @@ class WhiteboardDocument extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - body: MouseRegion( - cursor: SystemMouseCursors.precise, - child: Consumer( - builder: (context, state, _) { - return GestureDetector( + body: Consumer( + builder: (context, state, _) { + return MouseRegion( + onHover: (event) { + state.setPointerFeedback(event.localPosition, color: strokeColor); + }, + onExit: (event) { + state.removePointerFeedback(); + }, + cursor: SystemMouseCursors.precise, + child: GestureDetector( onPanStart: (details) { state.createStrokeFeedback( details.localPosition, @@ -67,21 +73,35 @@ class WhiteboardDocument extends StatelessWidget { state.updateStrokeFeedback(details.localPosition); state.addStroke(); }, - child: CustomPaint( - painter: WhiteboardPainter(strokes: state.strokesWithFeedbacks), - // Added child to CustomPaint for hit testing - child: Container( - decoration: BoxDecoration( - border: Border.all(color: Colors.black, width: 1.0), - color: Colors.transparent, + child: Stack( + children: [ + CustomPaint( + painter: WhiteboardPainter(strokes: state.strokesWithFeedbacks), + // Added child to CustomPaint for hit testing + child: Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.black, width: 1.0), + color: Colors.transparent, + ), + width: double.infinity, + height: double.infinity, + ), ), - width: double.infinity, - height: double.infinity, - ), + for (final pointer in state.remotePointerFeedbacks) + Positioned( + left: pointer.offset.dx - 4, // hack to center the icon + top: pointer.offset.dy - 20, // hack to center the icon + child: Icon( + Icons.edit, + color: pointer.color, + size: 24.0, + ), + ), + ], ), - ); - }, - ), + ), + ); + }, ), ); } From 94aa711da75482cf7324670821b95de86d816151 Mon Sep 17 00:00:00 2001 From: Jei-sKappa Date: Mon, 19 May 2025 16:44:35 +0200 Subject: [PATCH 5/6] fix: no need to notify pointer update --- packages/crdt_lf/flutter_example/lib/whiteboard/state.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/crdt_lf/flutter_example/lib/whiteboard/state.dart b/packages/crdt_lf/flutter_example/lib/whiteboard/state.dart index 655ced3..f95a389 100644 --- a/packages/crdt_lf/flutter_example/lib/whiteboard/state.dart +++ b/packages/crdt_lf/flutter_example/lib/whiteboard/state.dart @@ -49,12 +49,10 @@ class WhiteboardDocumentState extends DocumentState { ); _peerPointerFeedbackHandler.set(pointer.peerId.id, pointer); - notifyListeners(); } void removePointerFeedback() { _peerPointerFeedbackHandler.set(peerId.id, null); - notifyListeners(); } void createStrokeFeedback( From c28802d2d73bed688681c547a04aa4679fc73adf Mon Sep 17 00:00:00 2001 From: Jei-sKappa Date: Mon, 19 May 2025 16:59:08 +0200 Subject: [PATCH 6/6] style: format --- .../flutter_example/lib/whiteboard/whiteboard.dart | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/crdt_lf/flutter_example/lib/whiteboard/whiteboard.dart b/packages/crdt_lf/flutter_example/lib/whiteboard/whiteboard.dart index ec73a9b..d5f1b37 100644 --- a/packages/crdt_lf/flutter_example/lib/whiteboard/whiteboard.dart +++ b/packages/crdt_lf/flutter_example/lib/whiteboard/whiteboard.dart @@ -76,7 +76,9 @@ class WhiteboardDocument extends StatelessWidget { child: Stack( children: [ CustomPaint( - painter: WhiteboardPainter(strokes: state.strokesWithFeedbacks), + painter: WhiteboardPainter( + strokes: state.strokesWithFeedbacks, + ), // Added child to CustomPaint for hit testing child: Container( decoration: BoxDecoration( @@ -91,11 +93,7 @@ class WhiteboardDocument extends StatelessWidget { Positioned( left: pointer.offset.dx - 4, // hack to center the icon top: pointer.offset.dy - 20, // hack to center the icon - child: Icon( - Icons.edit, - color: pointer.color, - size: 24.0, - ), + child: Icon(Icons.edit, color: pointer.color, size: 24.0), ), ], ),