Presence System
InstantDBโs presence system enables real-time collaboration features like cursors, typing indicators, reactions, and user avatars. Itโs perfect for building collaborative applications.
Room-Based Presence
The modern approach uses room-based APIs for better organization and scoping:
Joining a Room
class CollaborativeEditor extends StatefulWidget { @override State<CollaborativeEditor> createState() => _CollaborativeEditorState();}
class _CollaborativeEditorState extends State<CollaborativeEditor> { String? _userId; String? _userName; InstantRoom? _room;
@override void initState() { super.initState(); _initializePresence(); }
void _initializePresence() { final db = InstantProvider.of(context); final currentUser = db.auth.currentUser.value;
// Use authenticated user or generate anonymous identity if (currentUser != null) { _userId = currentUser.id; _userName = currentUser.email; } else { _userId = db.getAnonymousUserId(); _userName = 'Guest ${_userId!.substring(_userId!.length - 4)}'; }
// Join room with initial presence data _room = db.presence.joinRoom('editor-room', initialPresence: { 'userName': _userName, 'status': 'editing', 'avatar': _generateAvatar(_userName!), }); }}
Basic Presence Operations
// Update user presenceawait _room!.setPresence({ 'status': 'typing', 'lastSeen': DateTime.now().millisecondsSinceEpoch,});
// Update cursor positionawait _room!.updateCursor(x: 100, y: 200);
// Set typing indicatorawait _room!.setTyping(true);
// Send a reactionawait _room!.sendReaction('๐', metadata: { 'x': mouseX, 'y': mouseY, 'message': 'Great idea!',});
Displaying Presence Data
User Avatars
Show all connected users in a room:
class UserAvatars extends StatelessWidget { final InstantRoom room;
const UserAvatars({super.key, required this.room});
@override Widget build(BuildContext context) { return Watch((context) { final presence = room.getPresence().value;
final users = presence.entries .where((entry) => entry.value.data['status'] == 'online') .take(5) .toList();
return Row( children: [ ...users.map((entry) { final user = entry.value.data; return Padding( padding: const EdgeInsets.only(right: 8), child: CircleAvatar( radius: 16, backgroundColor: _getUserColor(user['userName']), child: Text( _getInitials(user['userName'] ?? '?'), style: const TextStyle( fontSize: 12, fontWeight: FontWeight.bold, color: Colors.white, ), ), ), ); }),
if (presence.length > 5) CircleAvatar( radius: 16, backgroundColor: Colors.grey, child: Text( '+${presence.length - 5}', style: const TextStyle(fontSize: 10, color: Colors.white), ), ), ], ); }); }}
Live Cursors
Display real-time cursor positions:
class CursorOverlay extends StatelessWidget { final InstantRoom room; final Widget child;
const CursorOverlay({super.key, required this.room, required this.child});
@override Widget build(BuildContext context) { return Stack( children: [ child,
// Cursor layer Watch((context) { final cursors = room.getCursors().value;
return Stack( children: cursors.entries.map((entry) { final cursor = entry.value; final userName = cursor.data['userName'] ?? 'Unknown';
return Positioned( left: cursor.x, top: cursor.y, child: _CursorWidget( userName: userName, color: _getUserColor(userName), ), ); }).toList(), ); }), ], ); }}
class _CursorWidget extends StatelessWidget { final String userName; final Color color;
const _CursorWidget({required this.userName, required this.color});
@override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Cursor pointer CustomPaint( size: const Size(20, 20), painter: CursorPainter(color: color), ),
// User name label Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(4), ), child: Text( userName, style: const TextStyle( fontSize: 12, color: Colors.white, fontWeight: FontWeight.w500, ), ), ), ], ); }}
Typing Indicators
Show whoโs currently typing:
class TypingIndicator extends StatelessWidget { final InstantRoom room;
const TypingIndicator({super.key, required this.room});
@override Widget build(BuildContext context) { return Watch((context) { final typing = room.getTyping().value;
if (typing.isEmpty) { return const SizedBox.shrink(); }
final typingUsers = typing.entries .map((entry) => entry.value.data['userName'] as String? ?? 'Someone') .toList();
String text; if (typingUsers.length == 1) { text = '${typingUsers.first} is typing...'; } else if (typingUsers.length == 2) { text = '${typingUsers.first} and ${typingUsers.last} are typing...'; } else { text = '${typingUsers.length} people are typing...'; }
return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: Colors.grey.shade100, borderRadius: BorderRadius.circular(16), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Colors.grey.shade600), ), ), const SizedBox(width: 8), Text( text, style: TextStyle( fontSize: 12, color: Colors.grey.shade600, fontStyle: FontStyle.italic, ), ), ], ), ); }); }}
Reactions
Display floating reactions:
class ReactionsOverlay extends StatelessWidget { final InstantRoom room; final Widget child;
const ReactionsOverlay({super.key, required this.room, required this.child});
@override Widget build(BuildContext context) { return Stack( children: [ child,
// Reactions layer Watch((context) { final reactions = room.getReactions().value;
return Stack( children: reactions.map((reaction) { final metadata = reaction.data['metadata'] as Map<String, dynamic>?; final x = metadata?['x']?.toDouble() ?? 0.0; final y = metadata?['y']?.toDouble() ?? 0.0;
return Positioned( left: x, top: y, child: AnimatedReaction( emoji: reaction.data['reaction'] as String? ?? 'โค๏ธ', onComplete: () { // Reaction animation completed }, ), ); }).toList(), ); }), ], ); }}
class AnimatedReaction extends StatefulWidget { final String emoji; final VoidCallback onComplete;
const AnimatedReaction({ super.key, required this.emoji, required this.onComplete, });
@override State<AnimatedReaction> createState() => _AnimatedReactionState();}
class _AnimatedReactionState extends State<AnimatedReaction> with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation<double> _scaleAnimation; late Animation<double> _opacityAnimation; late Animation<Offset> _positionAnimation;
@override void initState() { super.initState(); _controller = AnimationController( duration: const Duration(seconds: 2), vsync: this, );
_scaleAnimation = Tween<double>(begin: 0.5, end: 1.2).animate( CurvedAnimation(parent: _controller, curve: Curves.elasticOut), );
_opacityAnimation = Tween<double>(begin: 1.0, end: 0.0).animate( CurvedAnimation( parent: _controller, curve: const Interval(0.7, 1.0, curve: Curves.easeOut), ), );
_positionAnimation = Tween<Offset>( begin: Offset.zero, end: const Offset(0, -50), ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
_controller.forward().then((_) => widget.onComplete()); }
@override Widget build(BuildContext context) { return AnimatedBuilder( animation: _controller, builder: (context, child) { return Transform.translate( offset: _positionAnimation.value, child: Transform.scale( scale: _scaleAnimation.value, child: Opacity( opacity: _opacityAnimation.value, child: Text( widget.emoji, style: const TextStyle(fontSize: 24), ), ), ), ); }, ); }
@override void dispose() { _controller.dispose(); super.dispose(); }}
Topic-Based Messaging
Use topics for structured communication within rooms:
class ChatRoom extends StatefulWidget { @override State<ChatRoom> createState() => _ChatRoomState();}
class _ChatRoomState extends State<ChatRoom> { InstantRoom? _room; final List<ChatMessage> _messages = []; StreamSubscription? _chatSubscription;
@override void initState() { super.initState(); _initializeRoom(); }
void _initializeRoom() { final db = InstantProvider.of(context);
_room = db.presence.joinRoom('chat-room', initialPresence: { 'userName': 'Current User', 'status': 'online', });
// Subscribe to chat messages _chatSubscription = _room!.subscribeTopic('chat').listen((data) { final message = ChatMessage.fromJson(data); setState(() { _messages.add(message); }); }); }
void _sendMessage(String text) { if (text.trim().isEmpty) return;
final message = { 'id': DateTime.now().millisecondsSinceEpoch.toString(), 'text': text.trim(), 'userName': 'Current User', 'timestamp': DateTime.now().millisecondsSinceEpoch, };
_room!.publishTopic('chat', message); }}
Advanced Presence Features
Presence with Custom Data
Store custom data in presence:
// Rich presence dataawait _room!.setPresence({ 'userName': 'Alice', 'status': 'editing', 'currentDocument': documentId, 'tool': 'text', 'mood': '๐', 'location': { 'section': 'introduction', 'paragraph': 3, },});
Temporary Presence Events
Send temporary events that donโt persist:
// Temporary events (reactions, notifications)await _room!.sendReaction('๐', metadata: { 'achievement': 'Document completed!', 'x': 200, 'y': 100,});
// These disappear after a short time
Presence Cleanup
Always clean up presence when leaving:
@overridevoid dispose() { _room?.setPresence({'status': 'offline'}); _room = null; _chatSubscription?.cancel(); super.dispose();}
Best Practices
1. Initialize Presence Early
Set up presence as soon as users enter collaborative spaces:
@overridevoid initState() { super.initState(); // Initialize presence immediately _initializePresence();}
2. Throttle Updates
Avoid excessive presence updates:
Timer? _cursorTimer;
void _updateCursor(double x, double y) { _cursorTimer?.cancel(); _cursorTimer = Timer(const Duration(milliseconds: 100), () { _room?.updateCursor(x: x, y: y); });}
3. Handle Anonymous Users
Provide good defaults for anonymous users:
String _generateGuestName(String userId) { return 'Guest ${userId.substring(userId.length - 4)}';}
4. Cleanup Resources
Always clean up when leaving:
void _leaveRoom() { _room?.setPresence({'status': 'offline'}); db.presence.leaveRoom('room-id');}
Presence UI Patterns
Status Indicators
Widget buildStatusIndicator(String status) { Color color; IconData icon;
switch (status) { case 'online': color = Colors.green; icon = Icons.circle; break; case 'typing': color = Colors.blue; icon = Icons.edit; break; case 'away': color = Colors.orange; icon = Icons.schedule; break; default: color = Colors.grey; icon = Icons.circle_outlined; }
return Icon(icon, color: color, size: 12);}
User Count Badge
Widget buildUserCount(int count) { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: Colors.blue, borderRadius: BorderRadius.circular(12), ), child: Text( '$count online', style: const TextStyle(color: Colors.white, fontSize: 12), ), );}
Next Steps
Learn more about building collaborative features:
- Collaborative Features - Complete collaboration patterns
- Real-time Sync - Understanding data synchronization
- Flutter Widgets - Reactive UI patterns
- Advanced Topics - Optimizing presence performance