From 6bbfd13b1e753a752cc50059e1c393a6afe6e2b3 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 20 Jul 2026 10:49:58 +0200 Subject: [PATCH 1/3] docs(examples): add realtime Postgres Changes, Broadcast and Presence example --- examples/README.md | 9 +- examples/realtime_room/.gitignore | 6 + examples/realtime_room/README.md | 50 +++ examples/realtime_room/analysis_options.yaml | 1 + examples/realtime_room/lib/main.dart | 413 ++++++++++++++++++ examples/realtime_room/lib/models.dart | 38 ++ examples/realtime_room/lib/room_channel.dart | 159 +++++++ .../realtime_room/lib/room_repository.dart | 51 +++ examples/realtime_room/pubspec.yaml | 25 ++ examples/realtime_room/web/index.html | 13 + .../20240602000000_realtime_room_example.sql | 46 ++ examples/supabase/seed.sql | 6 + pubspec.yaml | 1 + 13 files changed, 814 insertions(+), 4 deletions(-) create mode 100644 examples/realtime_room/.gitignore create mode 100644 examples/realtime_room/README.md create mode 100644 examples/realtime_room/analysis_options.yaml create mode 100644 examples/realtime_room/lib/main.dart create mode 100644 examples/realtime_room/lib/models.dart create mode 100644 examples/realtime_room/lib/room_channel.dart create mode 100644 examples/realtime_room/lib/room_repository.dart create mode 100644 examples/realtime_room/pubspec.yaml create mode 100644 examples/realtime_room/web/index.html create mode 100644 examples/supabase/migrations/20240602000000_realtime_room_example.sql diff --git a/examples/README.md b/examples/README.md index 9cc22477d..b0cd6ccbd 100644 --- a/examples/README.md +++ b/examples/README.md @@ -3,10 +3,11 @@ A collection of small apps that each show off a feature of `supabase_flutter`, all sharing a single local Supabase instance. -| Example | What it shows | -| -------------------------------- | ------------------------------------------ | -| [`database_crud`](database_crud) | Database CRUD with PostgREST (`from(...)`) | -| [`passkeys`](passkeys) | Passkey (WebAuthn) sign in and management | +| Example | What it shows | +| -------------------------------- | --------------------------------------------------- | +| [`database_crud`](database_crud) | Database CRUD with PostgREST (`from(...)`) | +| [`realtime_room`](realtime_room) | Realtime Postgres Changes, Broadcast and Presence | +| [`passkeys`](passkeys) | Passkey (WebAuthn) sign in and management | ## Quick start diff --git a/examples/realtime_room/.gitignore b/examples/realtime_room/.gitignore new file mode 100644 index 000000000..f85557248 --- /dev/null +++ b/examples/realtime_room/.gitignore @@ -0,0 +1,6 @@ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +build/ +pubspec.lock +pubspec_overrides.yaml diff --git a/examples/realtime_room/README.md b/examples/realtime_room/README.md new file mode 100644 index 000000000..0630368f1 --- /dev/null +++ b/examples/realtime_room/README.md @@ -0,0 +1,50 @@ +# Realtime: Postgres Changes, Broadcast and Presence + +A live chat room that shows all three of Supabase Realtime's features working +together on a single channel: + +- **Postgres Changes** stream inserts and deletes on the `messages` table, so the + chat log stays in sync across every open window without re-fetching + (`onPostgresChanges`). +- **Broadcast** relays ephemeral "typing" pings that are never written to the + database, only forwarded to the other clients (`sendBroadcastMessage`, + `onBroadcast`). +- **Presence** tracks who is currently in the room and surfaces the live roster + (`track`, `onPresenceSync`, `presenceState`). + +The realtime subscription lives in +[`lib/room_channel.dart`](lib/room_channel.dart), which wraps a single +`supabase.channel(...)` and exposes the three features as plain Dart streams. The +database reads and writes live in +[`lib/room_repository.dart`](lib/room_repository.dart). Both are kept separate +from the UI so they are easy to read and to drive from an integration test. + +To keep the focus on the Supabase calls, the UI uses plain `setState`. A larger +app would typically reach for a state management solution (for example Riverpod, +Bloc or Provider) instead. + +The `messages` table, its realtime publication and the sample data come from the +shared Supabase config in [`../supabase`](../supabase): schema in +`migrations/20240602000000_realtime_room_example.sql`, seed rows in `seed.sql`. +Realtime itself is part of the always-on local stack, so no `config.toml` change +is needed. + +## Running + +From the `examples` directory, run the launcher and pick `realtime_room`: + +```bash +./run.sh +``` + +Open the printed URL in a second browser window (or run it again on another +device) with a different display name to watch messages, typing indicators and +the online roster update live between them. + +Or run it directly against any project: + +```bash +flutter run \ + --dart-define=SUPABASE_URL=https://YOUR_PROJECT.supabase.co \ + --dart-define=SUPABASE_PUBLISHABLE_KEY=YOUR_PUBLISHABLE_KEY +``` diff --git a/examples/realtime_room/analysis_options.yaml b/examples/realtime_room/analysis_options.yaml new file mode 100644 index 000000000..10becad33 --- /dev/null +++ b/examples/realtime_room/analysis_options.yaml @@ -0,0 +1 @@ +include: package:supabase_lints/analysis_options_flutter.yaml diff --git a/examples/realtime_room/lib/main.dart b/examples/realtime_room/lib/main.dart new file mode 100644 index 000000000..02791d101 --- /dev/null +++ b/examples/realtime_room/lib/main.dart @@ -0,0 +1,413 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import 'models.dart'; +import 'room_channel.dart'; +import 'room_repository.dart'; + +const supabaseUrl = String.fromEnvironment('SUPABASE_URL'); +const supabasePublishableKey = String.fromEnvironment( + 'SUPABASE_PUBLISHABLE_KEY', +); + +final messengerKey = GlobalKey(); + +Future main() async { + await Supabase.initialize( + url: supabaseUrl, + publishableKey: supabasePublishableKey, + ); + runApp(const RealtimeRoomApp()); +} + +SupabaseClient get supabase => Supabase.instance.client; + +class RealtimeRoomApp extends StatelessWidget { + const RealtimeRoomApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Supabase Realtime Room', + scaffoldMessengerKey: messengerKey, + theme: ThemeData(colorSchemeSeed: Colors.deepPurple, useMaterial3: true), + home: const JoinPage(), + ); + } +} + +/// Asks for a display name before joining the room. Open the example in a second +/// window with a different name to see the realtime features in action. +class JoinPage extends StatefulWidget { + const JoinPage({super.key}); + + @override + State createState() => _JoinPageState(); +} + +class _JoinPageState extends State { + final _username = TextEditingController(); + + @override + void dispose() { + _username.dispose(); + super.dispose(); + } + + void _join() { + final username = _username.text.trim(); + if (username.isEmpty) return; + unawaited( + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => RoomPage(username: username), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Realtime room')), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _username, + autofocus: true, + textInputAction: TextInputAction.go, + onSubmitted: (_) => _join(), + decoration: const InputDecoration( + labelText: 'Display name', + hintText: 'e.g. Ada', + ), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: _join, + child: const Text('Join room'), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// The live room: a chat log kept in sync with Postgres Changes, an online +/// roster from Presence and typing indicators sent over Broadcast. +class RoomPage extends StatefulWidget { + const RoomPage({required this.username, super.key}); + + final String username; + + @override + State createState() => _RoomPageState(); +} + +class _RoomPageState extends State { + final _repository = RoomRepository(supabase); + final _input = TextEditingController(); + final _scroll = ScrollController(); + + late final RoomChannel _channel = _repository.joinRoom( + username: widget.username, + ); + final List> _subscriptions = []; + + List _messages = []; + List _onlineUsers = []; + final Set _typingUsers = {}; + final Map _typingTimers = {}; + Timer? _typingThrottle; + bool _loading = true; + bool _sending = false; + + @override + void initState() { + super.initState(); + unawaited(_init()); + } + + @override + void dispose() { + for (final subscription in _subscriptions) { + unawaited(subscription.cancel()); + } + for (final timer in _typingTimers.values) { + timer.cancel(); + } + _typingThrottle?.cancel(); + unawaited(_channel.dispose()); + _input.dispose(); + _scroll.dispose(); + super.dispose(); + } + + /// Loads the existing messages, then joins the channel and wires the realtime + /// streams into the UI. + Future _init() async { + // Start listening before subscribing so no early event is missed. + _subscriptions.addAll([ + _channel.onMessageInserted.listen(_addMessage), + _channel.onMessageDeleted.listen(_removeMessage), + _channel.onlineUsers.listen( + (users) => setState(() => _onlineUsers = users), + ), + _channel.onTyping.listen(_showTyping), + ]); + + try { + final messages = await _repository.fetchMessages(); + if (mounted) setState(() => _messages = messages); + await _channel.subscribe(); + } catch (error) { + _showError(error); + } finally { + if (mounted) setState(() => _loading = false); + } + _scrollToBottom(); + } + + /// Appends a streamed message, ignoring one already in the list (a row could + /// arrive both from the initial fetch and the insert stream). + void _addMessage(Message message) { + if (_messages.any((existing) => existing.id == message.id)) return; + setState(() => _messages = [..._messages, message]); + _scrollToBottom(); + } + + void _removeMessage(String id) { + setState(() => _messages.removeWhere((message) => message.id == id)); + } + + /// Shows ` is typing` for a short while, resetting the timer each time a + /// fresh ping arrives from that user. + void _showTyping(String name) { + _typingTimers[name]?.cancel(); + setState(() => _typingUsers.add(name)); + _typingTimers[name] = Timer(const Duration(seconds: 2), () { + if (mounted) setState(() => _typingUsers.remove(name)); + }); + } + + /// Throttles typing pings so a burst of keystrokes sends at most one broadcast + /// per second. + void _onInputChanged(String _) { + if (_typingThrottle?.isActive ?? false) return; + _typingThrottle = Timer(const Duration(seconds: 1), () {}); + unawaited(_channel.sendTyping()); + } + + Future _send() async { + final content = _input.text.trim(); + if (content.isEmpty || _sending) return; + setState(() => _sending = true); + try { + await _repository.sendMessage( + username: widget.username, + content: content, + ); + // The inserted row comes back through the Postgres Changes stream, which + // is what adds it to the list, so there's nothing to add here. + _input.clear(); + } catch (error) { + _showError(error); + } finally { + if (mounted) setState(() => _sending = false); + } + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scroll.hasClients) { + _scroll.jumpTo(_scroll.position.maxScrollExtent); + } + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Realtime room'), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(40), + child: _OnlineBar(users: _onlineUsers), + ), + ), + body: Column( + children: [ + Expanded( + child: _loading + ? const Center(child: CircularProgressIndicator()) + : _messages.isEmpty + ? const Center(child: Text('No messages yet. Say hi!')) + : ListView.builder( + controller: _scroll, + padding: const EdgeInsets.all(12), + itemCount: _messages.length, + itemBuilder: (context, index) { + final message = _messages[index]; + return _MessageTile( + message: message, + isMine: message.username == widget.username, + onDelete: () => _repository.deleteMessage(message.id), + ); + }, + ), + ), + _TypingIndicator(usernames: _typingUsers), + const Divider(height: 1), + _Composer( + controller: _input, + enabled: !_sending, + onChanged: _onInputChanged, + onSend: _send, + ), + ], + ), + ); + } +} + +class _OnlineBar extends StatelessWidget { + const _OnlineBar({required this.users}); + + final List users; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 40, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12), + children: [ + const Icon(Icons.circle, size: 10, color: Colors.greenAccent), + const SizedBox(width: 8), + for (final user in users) + Padding( + padding: const EdgeInsets.only(right: 8), + child: Chip( + label: Text(user.username), + visualDensity: VisualDensity.compact, + ), + ), + ], + ), + ); + } +} + +class _MessageTile extends StatelessWidget { + const _MessageTile({ + required this.message, + required this.isMine, + required this.onDelete, + }); + + final Message message; + final bool isMine; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(message.username), + subtitle: Text(message.content), + trailing: isMine + ? IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: 'Delete', + onPressed: onDelete, + ) + : null, + ); + } +} + +class _TypingIndicator extends StatelessWidget { + const _TypingIndicator({required this.usernames}); + + final Set usernames; + + @override + Widget build(BuildContext context) { + if (usernames.isEmpty) return const SizedBox(height: 20); + final names = usernames.join(', '); + final verb = usernames.length == 1 ? 'is' : 'are'; + return SizedBox( + height: 20, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + '$names $verb typing…', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ); + } +} + +class _Composer extends StatelessWidget { + const _Composer({ + required this.controller, + required this.enabled, + required this.onChanged, + required this.onSend, + }); + + final TextEditingController controller; + final bool enabled; + final ValueChanged onChanged; + final VoidCallback onSend; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Expanded( + child: TextField( + controller: controller, + enabled: enabled, + onChanged: onChanged, + onSubmitted: (_) => onSend(), + textInputAction: TextInputAction.send, + decoration: const InputDecoration( + hintText: 'Message', + isDense: true, + ), + ), + ), + const SizedBox(width: 8), + IconButton.filled( + onPressed: enabled ? onSend : null, + icon: const Icon(Icons.send), + ), + ], + ), + ); + } +} + +void _showError(Object error) { + final message = error is PostgrestException + ? error.message + : error.toString(); + messengerKey.currentState?.showSnackBar( + SnackBar(content: Text(message), backgroundColor: Colors.red), + ); +} diff --git a/examples/realtime_room/lib/models.dart b/examples/realtime_room/lib/models.dart new file mode 100644 index 000000000..b85fdabe6 --- /dev/null +++ b/examples/realtime_room/lib/models.dart @@ -0,0 +1,38 @@ +/// A chat message stored in the `messages` table. New rows are streamed to every +/// client in the room through realtime Postgres Changes. +class Message { + const Message({ + required this.id, + required this.username, + required this.content, + required this.createdAt, + }); + + factory Message.fromJson(Map json) => Message( + id: json['id'] as String, + username: json['username'] as String, + content: json['content'] as String, + createdAt: DateTime.parse(json['created_at'] as String), + ); + + final String id; + final String username; + final String content; + final DateTime createdAt; +} + +/// Someone currently connected to the room, surfaced through realtime Presence. +/// +/// The fields come from the arbitrary payload this example tracks with +/// [RoomChannel.subscribe]; presence lets each client attach any JSON it likes. +class OnlineUser { + const OnlineUser({required this.username, required this.onlineAt}); + + factory OnlineUser.fromPresence(Map payload) => OnlineUser( + username: payload['username'] as String, + onlineAt: DateTime.parse(payload['online_at'] as String), + ); + + final String username; + final DateTime onlineAt; +} diff --git a/examples/realtime_room/lib/room_channel.dart b/examples/realtime_room/lib/room_channel.dart new file mode 100644 index 000000000..98b3e6f66 --- /dev/null +++ b/examples/realtime_room/lib/room_channel.dart @@ -0,0 +1,159 @@ +import 'dart:async'; + +import 'package:supabase_flutter/supabase_flutter.dart'; + +import 'models.dart'; + +/// A single realtime channel for the room, wrapping the three realtime features +/// this example demonstrates: +/// +/// * **Postgres Changes** stream inserts and deletes on the `messages` table, so +/// the chat log stays in sync without re-fetching. +/// * **Broadcast** relays ephemeral "typing" pings that are never written to the +/// database, only forwarded to the other connected clients. +/// * **Presence** tracks who is currently in the room and exposes the live +/// roster. +/// +/// The channel is created but not connected in the constructor; call [subscribe] +/// to join and [dispose] to leave and release the streams. +class RoomChannel { + RoomChannel({ + required SupabaseClient client, + required this.username, + this.roomName = 'public-room', + }) : _client = client, + _channel = client.channel( + roomName, + // `self: true` echoes our own broadcast and presence events back to us, + // so this client also shows up in its own roster. + // + // `replicationReady: true` asks the server to emit a system event once + // the replication backing Postgres Changes is live. Without it, a row + // inserted right after joining can be missed, because replication is + // set up asynchronously after the join is confirmed. + opts: const RealtimeChannelConfig(self: true, replicationReady: true), + ); + + final SupabaseClient _client; + final RealtimeChannel _channel; + + /// Name shared with the other clients through broadcast and presence. + final String username; + + /// The channel topic. Every client that joins the same room name shares its + /// messages, typing pings and presence. + final String roomName; + + final _messageInserted = StreamController.broadcast(); + final _messageDeleted = StreamController.broadcast(); + final _typing = StreamController.broadcast(); + final _onlineUsers = StreamController>.broadcast(); + + /// A message someone added to the room (from a Postgres Changes insert event). + Stream get onMessageInserted => _messageInserted.stream; + + /// The id of a message someone removed (from a Postgres Changes delete event). + Stream get onMessageDeleted => _messageDeleted.stream; + + /// The username of another client that is currently typing (from a broadcast + /// event). Our own typing pings are filtered out. + Stream get onTyping => _typing.stream; + + /// The current room roster (recomputed on every presence sync event). + Stream> get onlineUsers => _onlineUsers.stream; + + static const _typingEvent = 'typing'; + + /// Registers the realtime listeners and joins the channel. Completes once the + /// server confirms both the subscription and that Postgres Changes replication + /// is live, so a message sent right afterwards is guaranteed to stream back. + Future subscribe() { + final ready = Completer(); + + _channel + // Postgres Changes: new rows in the `messages` table. + .onPostgresChanges( + event: PostgresChangeEvent.insert, + schema: 'public', + table: 'messages', + callback: (payload) => + _messageInserted.add(Message.fromJson(payload.newRecord)), + ) + // Postgres Changes: deleted rows. The delete payload carries the removed + // row under `oldRecord`. + .onPostgresChanges( + event: PostgresChangeEvent.delete, + schema: 'public', + table: 'messages', + callback: (payload) => + _messageDeleted.add(payload.oldRecord['id'] as String), + ) + // Broadcast: a transient "typing" ping from another client. + .onBroadcast( + event: _typingEvent, + callback: (payload) { + final name = payload['username'] as String; + if (name != username) _typing.add(name); + }, + ) + // Presence: fires whenever the roster changes. Read the full state back + // and map it to the connected users. + .onPresenceSync((_) => _onlineUsers.add(_currentUsers())) + // The replication-ready signal requested with `replicationReady: true`. + // It arrives after the join, once Postgres Changes is actually + // streaming, so this is what completes [subscribe]. + .onSystemEvents((payload) { + final system = RealtimeSystemPayload.fromJson( + Map.from(payload as Map), + ); + if (system.extension != 'system' || ready.isCompleted) return; + if (system.status == 'ok') { + ready.complete(); + } else { + ready.completeError(Exception(system.message)); + } + }) + .subscribe((status, error) { + if (status == RealtimeSubscribeStatus.subscribed) { + // Announce ourselves to the room now that we're connected. The + // payload is arbitrary JSON the other clients read back as presence. + unawaited( + _channel.track({ + 'username': username, + 'online_at': DateTime.now().toIso8601String(), + }), + ); + } else if (error != null && !ready.isCompleted) { + ready.completeError(error); + } + }); + + return ready.future; + } + + /// Broadcasts a "typing" ping to the other clients. This never touches the + /// database, which is what makes broadcast the right fit for high-frequency, + /// throwaway signals. + Future sendTyping() => _channel.sendBroadcastMessage( + event: _typingEvent, + payload: {'username': username}, + ); + + /// Flattens the presence state into one [OnlineUser] per connected client. + List _currentUsers() { + return _channel + .presenceState() + .expand((state) => state.presences) + .map((presence) => OnlineUser.fromPresence(presence.payload)) + .toList(); + } + + /// Leaves the room (which also untracks our presence) and closes the streams. + Future dispose() async { + await _client.removeChannel(_channel); + await _messageInserted.close(); + await _messageDeleted.close(); + await _typing.close(); + await _onlineUsers.close(); + } +} diff --git a/examples/realtime_room/lib/room_repository.dart b/examples/realtime_room/lib/room_repository.dart new file mode 100644 index 000000000..769b8f423 --- /dev/null +++ b/examples/realtime_room/lib/room_repository.dart @@ -0,0 +1,51 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; + +import 'models.dart'; +import 'room_channel.dart'; + +/// Database reads and writes for the room's chat history. +/// +/// The persistent part of the example (loading and mutating the `messages` +/// table) lives here; the live realtime subscription lives in [RoomChannel]. +/// Both take a [SupabaseClient] so the UI stays thin and each part is easy to +/// drive from an integration test. +class RoomRepository { + RoomRepository(this._client); + + final SupabaseClient _client; + + /// SELECT the existing messages once, oldest first, to seed the chat log + /// before realtime starts streaming new ones. + Future> fetchMessages() async { + final rows = await _client.from('messages').select().order('created_at'); + return rows.map(Message.fromJson).toList(); + } + + /// INSERT a message and return the stored row. + /// + /// The insert is all that's needed to update every other client: the + /// `messages` table is in the realtime publication, so the server streams this + /// row to every subscribed [RoomChannel] as a Postgres Changes insert event. + Future sendMessage({ + required String username, + required String content, + }) async { + final row = await _client + .from('messages') + .insert({'username': username, 'content': content}) + .select() + .single(); + return Message.fromJson(row); + } + + /// DELETE a message, which likewise streams a delete event to every client. + Future deleteMessage(String id) async { + await _client.from('messages').delete().eq('id', id); + } + + /// Opens a realtime [RoomChannel] for [username] carrying the Postgres + /// Changes, Broadcast and Presence streams for the room. Call + /// [RoomChannel.subscribe] to join and [RoomChannel.dispose] to leave. + RoomChannel joinRoom({required String username}) => + RoomChannel(client: _client, username: username); +} diff --git a/examples/realtime_room/pubspec.yaml b/examples/realtime_room/pubspec.yaml new file mode 100644 index 000000000..c4ed8b8a3 --- /dev/null +++ b/examples/realtime_room/pubspec.yaml @@ -0,0 +1,25 @@ +name: realtime_room_example +description: Demonstrates realtime Postgres Changes, Broadcast and Presence using supabase_flutter. + +publish_to: 'none' + +version: 1.0.0+1 + +environment: + sdk: '>=3.9.0 <4.0.0' + flutter: '>=3.35.0' + +resolution: workspace + +dependencies: + flutter: + sdk: flutter + supabase_flutter: ^2.17.0 + +dev_dependencies: + supabase_lints: ^0.1.1 + flutter_test: + sdk: flutter + +flutter: + uses-material-design: true diff --git a/examples/realtime_room/web/index.html b/examples/realtime_room/web/index.html new file mode 100644 index 000000000..697d352b1 --- /dev/null +++ b/examples/realtime_room/web/index.html @@ -0,0 +1,13 @@ + + + + + + + + Supabase Realtime Room + + + + + diff --git a/examples/supabase/migrations/20240602000000_realtime_room_example.sql b/examples/supabase/migrations/20240602000000_realtime_room_example.sql new file mode 100644 index 000000000..f003744c9 --- /dev/null +++ b/examples/supabase/migrations/20240602000000_realtime_room_example.sql @@ -0,0 +1,46 @@ +-- Schema for the "realtime: Postgres Changes, Broadcast and Presence" example. +-- +-- A single `messages` table backs the room's chat log. The example streams +-- inserts and deletes on this table to every connected client through Postgres +-- Changes. Broadcast (typing pings) and Presence (the online roster) are +-- transient and need no tables of their own. + +create table public.messages ( + id uuid primary key default gen_random_uuid(), + username text not null, + content text not null, + created_at timestamptz not null default now() +); + +create index messages_created_at_idx on public.messages (created_at); + +-- Stream row changes on this table to subscribed realtime clients by adding it +-- to the realtime publication. Without this, Postgres Changes emits nothing. +alter publication supabase_realtime add table public.messages; + +-- Include the full previous row in delete (and update) change payloads. By +-- default only the primary key is sent on delete; the example only needs the +-- id, but this keeps the payload complete. +alter table public.messages replica identity full; + +-- The example runs unauthenticated with the publishable (anon) key. Enable row +-- level security and add permissive policies so anyone can read and post to the +-- shared room. A real app would scope these to the signed in user instead. +-- +-- Realtime also checks the select policy before streaming a change to a role, so +-- "anyone can read" is what lets the anon key receive Postgres Changes here. +alter table public.messages enable row level security; + +create policy "Anyone can read messages" + on public.messages for select using (true); + +create policy "Anyone can post messages" + on public.messages for insert with check (true); + +create policy "Anyone can delete messages" + on public.messages for delete using (true); + +-- Row level security decides which rows a role may touch, but the role still +-- needs table privileges. Grant the publishable (anon) key read, insert and +-- delete on messages so the demo works without signing in. +grant select, insert, delete on public.messages to anon, authenticated; diff --git a/examples/supabase/seed.sql b/examples/supabase/seed.sql index 3df90668b..baf8e0e93 100644 --- a/examples/supabase/seed.sql +++ b/examples/supabase/seed.sql @@ -13,3 +13,9 @@ insert into public.tasks (project_id, title, is_complete, priority) values ('00000000-0000-0000-0000-000000000002', 'Fix login on Android', false, 3), ('00000000-0000-0000-0000-000000000003', 'Book flights', false, 2), ('00000000-0000-0000-0000-000000000003', 'Water the plants', true, 1); + +-- Sample data for the realtime room example. + +insert into public.messages (username, content) values + ('supabase', 'Welcome to the room! Open this example in a second window to chat live.'), + ('supabase', 'Messages stream over Postgres Changes, typing pings over Broadcast and the roster over Presence.'); diff --git a/pubspec.yaml b/pubspec.yaml index af7286837..93be0b953 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,7 @@ workspace: - examples/database_crud - examples/launcher - examples/passkeys + - examples/realtime_room dev_dependencies: melos: ^8.2.1 From 43cc9580331279d6b4937b0d4c0ed06ccf195060 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 20 Jul 2026 11:19:50 +0200 Subject: [PATCH 2/3] fix(examples): harden realtime room UI against subscribe hangs and silent delete failures --- examples/realtime_room/lib/main.dart | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/examples/realtime_room/lib/main.dart b/examples/realtime_room/lib/main.dart index 02791d101..defb3f189 100644 --- a/examples/realtime_room/lib/main.dart +++ b/examples/realtime_room/lib/main.dart @@ -170,7 +170,9 @@ class _RoomPageState extends State { try { final messages = await _repository.fetchMessages(); if (mounted) setState(() => _messages = messages); - await _channel.subscribe(); + // Bound the join so a dropped connection surfaces as an error instead of + // leaving the spinner up forever waiting for the replication-ready event. + await _channel.subscribe().timeout(const Duration(seconds: 30)); } catch (error) { _showError(error); } finally { @@ -228,9 +230,20 @@ class _RoomPageState extends State { } } + /// Deletes a message and reports any failure, like [_send]. The row leaves the + /// list through the Postgres Changes delete stream, so there's nothing to + /// remove here on success. + Future _delete(String id) async { + try { + await _repository.deleteMessage(id); + } catch (error) { + _showError(error); + } + } + void _scrollToBottom() { WidgetsBinding.instance.addPostFrameCallback((_) { - if (_scroll.hasClients) { + if (mounted && _scroll.hasClients) { _scroll.jumpTo(_scroll.position.maxScrollExtent); } }); @@ -262,7 +275,7 @@ class _RoomPageState extends State { return _MessageTile( message: message, isMine: message.username == widget.username, - onDelete: () => _repository.deleteMessage(message.id), + onDelete: () => _delete(message.id), ); }, ), From 563f67298765f68868d5b4ab06269d988b950788 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 20 Jul 2026 11:08:50 +0200 Subject: [PATCH 3/3] test(examples): add end-to-end test for the realtime room example --- examples/realtime_room/README.md | 17 +++ .../integration_test/room_test.dart | 132 ++++++++++++++++++ examples/realtime_room/pubspec.yaml | 2 + pubspec.lock | 31 ++++ 4 files changed, 182 insertions(+) create mode 100644 examples/realtime_room/integration_test/room_test.dart diff --git a/examples/realtime_room/README.md b/examples/realtime_room/README.md index 0630368f1..354e614b5 100644 --- a/examples/realtime_room/README.md +++ b/examples/realtime_room/README.md @@ -48,3 +48,20 @@ flutter run \ --dart-define=SUPABASE_URL=https://YOUR_PROJECT.supabase.co \ --dart-define=SUPABASE_PUBLISHABLE_KEY=YOUR_PUBLISHABLE_KEY ``` + +## Integration test + +[`integration_test/room_test.dart`](integration_test/room_test.dart) is an +end-to-end test that drives the app widgets against the local stack: it joins the +room, posts a message through the composer and asserts it appears (Postgres +Changes), checks the roster (Presence) and the typing indicator (Broadcast) using +a second client as another user, then deletes a message through the UI. + +With the local stack running, pass the same defines the app uses and run it on a +device (integration tests need one, so `-d macos`, an emulator or a real device): + +```bash +flutter test integration_test/room_test.dart -d macos \ + --dart-define=SUPABASE_URL=http://localhost:54321 \ + --dart-define=SUPABASE_PUBLISHABLE_KEY=YOUR_LOCAL_PUBLISHABLE_KEY +``` diff --git a/examples/realtime_room/integration_test/room_test.dart b/examples/realtime_room/integration_test/room_test.dart new file mode 100644 index 000000000..8b9226cb6 --- /dev/null +++ b/examples/realtime_room/integration_test/room_test.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:realtime_room_example/main.dart'; +import 'package:realtime_room_example/room_repository.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +const supabaseUrl = String.fromEnvironment('SUPABASE_URL'); +const supabasePublishableKey = String.fromEnvironment( + 'SUPABASE_PUBLISHABLE_KEY', +); + +/// End-to-end test that drives the real app widgets against the local Supabase +/// stack, exercising all three realtime features through the UI: Postgres +/// Changes (a message round-tripping through the database), Presence (the online +/// roster) and Broadcast (the typing indicator). +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + await Supabase.initialize( + url: supabaseUrl, + publishableKey: supabasePublishableKey, + ); + }); + + tearDownAll(() async { + await Supabase.instance.dispose(); + }); + + tearDown(() async { + // Remove whatever the test posted so it can run repeatedly. + await Supabase.instance.client.from('messages').delete().inFilter( + 'username', + [ + 'alice', + 'bob', + ], + ); + }); + + testWidgets('joins, chats and sees other users live', (tester) async { + await tester.pumpWidget(const RealtimeRoomApp()); + + // Join screen: enter a display name and open the room. + await tester.enterText(find.byType(TextField), 'alice'); + await tester.tap(find.text('Join room')); + await tester.pumpAndSettle(); + + // Wait for the loading spinner to clear. The room only finishes loading once + // Postgres Changes replication is live, so a message sent after this is + // guaranteed to stream back rather than being missed during setup. + await _pumpUntilGone(tester, find.byType(CircularProgressIndicator)); + + // Presence: once subscribed, our own name appears in the roster. + await _pumpUntil(tester, find.widgetWithText(Chip, 'alice')); + + // Postgres Changes: a message sent through the composer round-trips through + // the database and comes back on the realtime stream, so finding it on + // screen proves the insert path end to end. + await tester.enterText( + find.widgetWithText(TextField, 'Message'), + 'hello from alice', + ); + await tester.tap(find.byIcon(Icons.send)); + await _pumpUntil(tester, find.text('hello from alice')); + + // A second client stands in for another person in the room. + final bobClient = SupabaseClient(supabaseUrl, supabasePublishableKey); + addTearDown(bobClient.dispose); + final bobRepository = RoomRepository(bobClient); + final bob = bobRepository.joinRoom(username: 'bob'); + addTearDown(bob.dispose); + await bob.subscribe(); + + // Presence: bob shows up in alice's roster. + await _pumpUntil(tester, find.widgetWithText(Chip, 'bob')); + + // Broadcast: bob's typing ping shows in alice's UI. + await bob.sendTyping(); + await _pumpUntil(tester, find.textContaining('bob is typing')); + + // Postgres Changes: a message bob posts appears in alice's list too. + await bobRepository.sendMessage(username: 'bob', content: 'hi alice'); + await _pumpUntil(tester, find.text('hi alice')); + + // Postgres Changes (delete): removing alice's own message through the UI + // deletes the row and streams the removal back, so it leaves the list. + final aliceMessage = find.ancestor( + of: find.text('hello from alice'), + matching: find.byType(ListTile), + ); + await tester.tap( + find.descendant( + of: aliceMessage, + matching: find.byIcon(Icons.delete_outline), + ), + ); + await _pumpUntilGone(tester, find.text('hello from alice')); + }); +} + +/// Pumps frames until [finder] matches at least one widget or [timeout] elapses. +/// +/// Realtime updates arrive asynchronously over the network, so the UI can't be +/// settled with `pumpAndSettle`; this polls the widget tree instead. +Future _pumpUntil( + WidgetTester tester, + Finder finder, { + Duration timeout = const Duration(seconds: 30), +}) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + await tester.pump(const Duration(milliseconds: 100)); + if (finder.evaluate().isNotEmpty) return; + } + fail('Timed out waiting for: $finder'); +} + +/// The inverse of [_pumpUntil]: pumps until [finder] matches nothing. +Future _pumpUntilGone( + WidgetTester tester, + Finder finder, { + Duration timeout = const Duration(seconds: 30), +}) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + await tester.pump(const Duration(milliseconds: 100)); + if (finder.evaluate().isEmpty) return; + } + fail('Timed out waiting for it to disappear: $finder'); +} diff --git a/examples/realtime_room/pubspec.yaml b/examples/realtime_room/pubspec.yaml index c4ed8b8a3..e190fa5a1 100644 --- a/examples/realtime_room/pubspec.yaml +++ b/examples/realtime_room/pubspec.yaml @@ -20,6 +20,8 @@ dev_dependencies: supabase_lints: ^0.1.1 flutter_test: sdk: flutter + integration_test: + sdk: flutter flutter: uses-material-design: true diff --git a/pubspec.lock b/pubspec.lock index 4af6b79cf..d9b31859a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -350,6 +350,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_lints: dependency: transitive description: @@ -376,6 +381,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" glob: dependency: transitive description: @@ -424,6 +434,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + integration_test: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" io: dependency: transitive description: @@ -933,6 +948,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" term_glyph: dependency: transitive description: @@ -1109,6 +1132,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" webkit_inspection_protocol: dependency: transitive description: