From 5f3b5d7d8c51de68c79a57835d75c48386b56858 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 25 Aug 2026 12:06:45 +0200 Subject: [PATCH] feat(sample): let the sample app target another Stream backend at runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logging in to a different Stream app was only possible for the API key, and only through "Advanced Options"; the base URL could be reached solely via the e2e-only debug override. That left no way to point an already built deployment — the hosted web demo — at another environment. - Add a "Base URL" field to Advanced Options, threading it through AuthController.connect into the client and persisting it alongside the other credentials. Changing it rebuilds the client, since the URL is fixed at construction. - Prefill the API key field with the configured app. - Skip the `stream_chat_flutter_sample_app` predefined filter whenever a custom API key or base URL is in use — it is a saved query that exists only on the demo app, so it 404s everywhere else — and fall back to a plain members filter. - Keep STREAM_API_KEY / STREAM_BASE_URL dart-defines as the compile-time defaults for local runs, and document both paths in the README. Verified on the web build with no dart-defines: entering a staging key, user and base URL at runtime connects and loads channels, and the predefined filter is skipped automatically. Production defaults are unchanged — the demo users still load through the predefined filter. Co-Authored-By: Claude Opus 5 --- sample_app/README.md | 27 ++++++++++++ sample_app/lib/auth/auth_controller.dart | 28 ++++++++++++- .../lib/pages/advanced_options_page.dart | 42 ++++++++++++++++++- sample_app/lib/pages/choose_user_page.dart | 1 + sample_app/lib/utils/app_config.dart | 33 ++++++++++++++- sample_app/lib/widgets/channel_list.dart | 12 +++++- 6 files changed, 137 insertions(+), 6 deletions(-) diff --git a/sample_app/README.md b/sample_app/README.md index cfd1dafbb1..8eeee06460 100644 --- a/sample_app/README.md +++ b/sample_app/README.md @@ -50,6 +50,33 @@ The app connects to a demo Stream environment out of the box — no configuratio The default API key and demo user credentials live in `lib/utils/app_config.dart`. You can override the API key at runtime via the **Advanced Options** button on the login screen; the key is stored securely and used for subsequent sessions. +#### Targeting a different Stream app + +To point the sample app at another backend — a staging environment, or your own +Stream app — open **Advanced Options** on the login screen and fill in: + +| Field | Notes | +| --- | --- | +| **Chat API Key** | Prefilled with the app's configured key. | +| **User ID** / **User Token** | A user on that app, with a token minted from its secret. The demo users on the login screen are signed for the demo app only. | +| **Base URL** | Optional. HTTP base URL of the Stream API; the WebSocket URL is derived from it. Leave empty for the SDK's own endpoint. | + +Because these are entered at runtime, this works on an already-built +deployment — including the [hosted web demo](https://getstream.github.io/stream-chat-flutter/). + +Whenever a custom API key or base URL is in use, the channel list drops the +`stream_chat_flutter_sample_app` predefined filter — a saved query that exists +only on the demo app — and uses a plain "channels I am a member of" filter +instead. + +For local runs the same two settings have compile-time defaults: + +```bash +flutter run \ + --dart-define=STREAM_API_KEY=your-api-key \ + --dart-define=STREAM_BASE_URL=https://chat-edge-us-east1-ce1.gcp.stream-io-api.com +``` + ### Push Notifications Push notifications require a Firebase project. Add your `google-services.json` (Android) and/or `GoogleService-Info.plist` (iOS) to the respective platform directories. See the [push notifications guide](https://getstream.io/chat/docs/sdk/flutter/advanced-guides/push-notifications/) for full setup instructions. diff --git a/sample_app/lib/auth/auth_controller.dart b/sample_app/lib/auth/auth_controller.dart index f1cab45351..415f4bcca7 100644 --- a/sample_app/lib/auth/auth_controller.dart +++ b/sample_app/lib/auth/auth_controller.dart @@ -16,6 +16,7 @@ bool get platformSupportsPersistenceCredentials => !CurrentPlatform.isWeb && !Cu const kStreamApiKey = 'STREAM_API_KEY'; const kStreamUserId = 'STREAM_USER_ID'; const kStreamToken = 'STREAM_TOKEN'; +const kStreamBaseUrlKey = 'STREAM_BASE_URL'; // Firebase on both platforms: raw APNs payloads lack the FCM metadata that // `firebase_messaging.onMessageOpenedApp` needs to fire on tap. @@ -60,6 +61,7 @@ class StreamConnectionOverride { StreamChatClient _buildStreamChatClient( String apiKey, { + String? baseUrl, StreamConnectionOverride? connectionOverride, }) { final logLevel = connectionOverride != null ? Level.OFF : (kDebugMode ? Level.INFO : Level.SEVERE); @@ -73,7 +75,7 @@ StreamChatClient _buildStreamChatClient( return error is StreamChatNetworkError && error.isRetriable; }, ), - baseURL: connectionOverride?.baseURL, + baseURL: connectionOverride?.baseURL ?? baseUrl, baseWsUrl: connectionOverride?.baseWsUrl, // e2e only: lets the harness simulate a full network outage by failing // every HTTP request (paired with the WebSocket close from @@ -143,6 +145,18 @@ class AuthController extends ValueNotifier { /// The active client, or `null` before the first [connect]. StreamChatClient? get client => _client; + /// Whether the session is pointed at something other than the app's own + /// defaults — a custom API key or base URL entered in "Advanced Options". + /// + /// Features that depend on server-side configuration only present on the + /// demo app (the channel list's predefined filter) switch to a portable + /// equivalent when this is true. + bool get usingCustomBackend { + final apiKey = _activeApiKey ?? kDefaultStreamApiKey; + final baseUrl = _activeBaseUrl ?? ''; + return apiKey != kDefaultStreamApiKey || baseUrl != kStreamBaseUrl; + } + @visibleForTesting StreamConnectionOverride? debugConnectionOverride; @@ -162,6 +176,7 @@ class AuthController extends ValueNotifier { bool debugForceOffline = false; String? _activeApiKey; + String? _activeBaseUrl; PushTokenManager? _pushTokenManager; /// Restores a previous session from secure storage, if any. @@ -177,6 +192,7 @@ class AuthController extends ValueNotifier { final apiKey = await secureStorage.read(key: kStreamApiKey); final userId = await secureStorage.read(key: kStreamUserId); final token = await secureStorage.read(key: kStreamToken); + final baseUrl = await secureStorage.read(key: kStreamBaseUrlKey); if (userId == null || token == null) return; try { @@ -184,6 +200,7 @@ class AuthController extends ValueNotifier { apiKey: apiKey ?? kDefaultStreamApiKey, user: User(id: userId), token: token, + baseUrl: (baseUrl ?? '').isEmpty ? null : baseUrl, persistCredentials: false, ); } catch (e, stk) { @@ -202,20 +219,25 @@ class AuthController extends ValueNotifier { required String apiKey, required User user, required String token, + String? baseUrl, bool persistCredentials = true, }) async { value = const Authenticating(); - if (_client != null && _activeApiKey != apiKey) { + // The base URL is baked into the client at construction, so a change to it + // needs a fresh client just as much as a change of app does. + if (_client != null && (_activeApiKey != apiKey || _activeBaseUrl != baseUrl)) { await _client!.dispose(); _client = null; } final client = _client ??= _buildStreamChatClient( apiKey, + baseUrl: baseUrl, connectionOverride: debugConnectionOverride, ); _activeApiKey = apiKey; + _activeBaseUrl = baseUrl; try { final ownUser = await client.connectUser(user, token); @@ -226,6 +248,7 @@ class AuthController extends ValueNotifier { secureStorage.write(key: kStreamApiKey, value: apiKey), secureStorage.write(key: kStreamUserId, value: user.id), secureStorage.write(key: kStreamToken, value: token), + secureStorage.write(key: kStreamBaseUrlKey, value: baseUrl ?? ''), ]); } @@ -290,6 +313,7 @@ class AuthController extends ValueNotifier { await _client?.dispose(); _client = null; _activeApiKey = null; + _activeBaseUrl = null; debugConnectionOverride = null; debugConnectivityStream = null; debugForceOffline = false; diff --git a/sample_app/lib/pages/advanced_options_page.dart b/sample_app/lib/pages/advanced_options_page.dart index ff37ad2e7c..ad9f0f9f5b 100644 --- a/sample_app/lib/pages/advanced_options_page.dart +++ b/sample_app/lib/pages/advanced_options_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:sample_app/auth/auth_controller.dart'; import 'package:sample_app/routes/routes.dart'; +import 'package:sample_app/utils/app_config.dart'; import 'package:sample_app/widgets/stream_version.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -15,7 +16,9 @@ class AdvancedOptionsPage extends StatefulWidget { class _AdvancedOptionsPageState extends State { final _formKey = GlobalKey(); - final TextEditingController _apiKeyController = TextEditingController(); + // Prefilled with the configured app so pointing the sample app at another + // backend only needs a user id and token typed in. + final TextEditingController _apiKeyController = TextEditingController(text: kDefaultStreamApiKey); String? _apiKeyError; final TextEditingController _userIdController = TextEditingController(); @@ -26,6 +29,10 @@ class _AdvancedOptionsPageState extends State { final TextEditingController _usernameController = TextEditingController(); + // Lets an already-built deployment (the web demo) be pointed at another + // environment without recompiling. + final TextEditingController _baseUrlController = TextEditingController(text: kStreamBaseUrl); + bool loading = false; @override @@ -34,6 +41,7 @@ class _AdvancedOptionsPageState extends State { _userIdController.dispose(); _userTokenController.dispose(); _usernameController.dispose(); + _baseUrlController.dispose(); super.dispose(); } @@ -46,6 +54,7 @@ class _AdvancedOptionsPageState extends State { final userId = _userIdController.text; final userToken = _userTokenController.text; final username = _usernameController.text; + final baseUrl = _baseUrlController.text.trim(); loading = true; showDialog( @@ -79,6 +88,7 @@ class _AdvancedOptionsPageState extends State { }, ), token: userToken, + baseUrl: baseUrl.isEmpty ? null : baseUrl, ); } catch (e) { debugPrint(e.toString()); @@ -261,6 +271,36 @@ class _AdvancedOptionsPageState extends State { labelText: 'Username (optional)', ), ), + const SizedBox(height: 8), + TextFormField( + controller: _baseUrlController, + textInputAction: TextInputAction.done, + keyboardType: TextInputType.url, + autocorrect: false, + style: TextStyle( + fontSize: 14, + color: context.streamColorScheme.textPrimary, + ), + decoration: InputDecoration( + labelStyle: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: context.streamColorScheme.textSecondary, + ), + border: UnderlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + fillColor: context.streamColorScheme.backgroundSurface, + filled: true, + labelText: 'Base URL (optional)', + hintText: 'https://chat.stream-io-api.com', + hintStyle: TextStyle( + fontSize: 14, + color: context.streamColorScheme.textTertiary, + ), + ), + ), const Spacer(), ElevatedButton( style: ButtonStyle( diff --git a/sample_app/lib/pages/choose_user_page.dart b/sample_app/lib/pages/choose_user_page.dart index ab7958e1bd..b99d9cedd7 100644 --- a/sample_app/lib/pages/choose_user_page.dart +++ b/sample_app/lib/pages/choose_user_page.dart @@ -91,6 +91,7 @@ class ChooseUserPage extends StatelessWidget { apiKey: kDefaultStreamApiKey, user: user, token: token, + baseUrl: kStreamBaseUrl.isEmpty ? null : kStreamBaseUrl, ); } finally { // Pop the progress dialog regardless of outcome. diff --git a/sample_app/lib/utils/app_config.dart b/sample_app/lib/utils/app_config.dart index 0956c8a58d..584ae581b3 100644 --- a/sample_app/lib/utils/app_config.dart +++ b/sample_app/lib/utils/app_config.dart @@ -1,6 +1,37 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -const kDefaultStreamApiKey = 'kv7mcsxr24p8'; +/// The Stream app the sample app connects to. +/// +/// Defaults to Stream's public demo app. Point the sample app at another +/// Stream app — a staging environment, or your own — at launch: +/// `--dart-define=STREAM_API_KEY=`. +/// +/// [defaultUsers] are signed for the demo app only, so a different key also +/// needs a user supplied through the "Advanced Options" login screen. +const kDefaultStreamApiKey = String.fromEnvironment( + 'STREAM_API_KEY', + defaultValue: 'kv7mcsxr24p8', +); + +/// Default base URL for the Stream API: +/// `--dart-define=STREAM_BASE_URL=https://chat-edge-us-east1-ce1.gcp.stream-io-api.com`. +/// +/// Empty — the default — uses the SDK's own endpoint. The WebSocket URL is +/// derived from this, so only the HTTP base URL needs to be supplied. +/// +/// This is only the starting value: the "Advanced Options" login screen can +/// set it at runtime, which is what already-built deployments (the web demo) +/// have to use. +const kStreamBaseUrl = String.fromEnvironment('STREAM_BASE_URL'); + +/// Name of the server-side predefined filter backing the channel list. +/// +/// Predefined filters are saved queries that live on the Stream app, so this +/// one exists only on the demo app. It is skipped automatically whenever a +/// custom API key or base URL is in use — see +/// [AuthController.usingCustomBackend] — falling back to a plain "channels I +/// am a member of" filter. +const kChannelListPredefinedFilter = 'stream_chat_flutter_sample_app'; final defaultUsers = { 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoic2FsdmF0b3JlIn0.pgiJz7sIc7iP29BHKFwe3nLm5-OaR_1l2P-SlgiC9a8': diff --git a/sample_app/lib/widgets/channel_list.dart b/sample_app/lib/widgets/channel_list.dart index bde0f972f5..eb83c20ff5 100644 --- a/sample_app/lib/widgets/channel_list.dart +++ b/sample_app/lib/widgets/channel_list.dart @@ -4,7 +4,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:go_router/go_router.dart'; +import 'package:sample_app/auth/auth_controller.dart'; import 'package:sample_app/routes/routes.dart'; +import 'package:sample_app/utils/app_config.dart'; import 'package:sample_app/widgets/channel_detail_sheet.dart'; import 'package:sample_app/widgets/search_text_field.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -55,10 +57,16 @@ class _ChannelList extends State { ], ); if (searchQuery.isNotEmpty) _messageSearchListController.search(searchQuery); + // The predefined filter is a saved query living on the demo Stream app, so + // it 404s against any other backend. Anyone who pointed the app elsewhere + // gets the equivalent client-side filter instead. + final userId = _streamChat.currentUser!.id; + final predefinedFilter = authController.usingCustomBackend ? null : kChannelListPredefinedFilter; _channelListController = StreamChannelListController( client: _streamChat.client, - predefinedFilter: 'stream_chat_flutter_sample_app', - filterValues: {'user_id': _streamChat.currentUser!.id}, + filter: predefinedFilter == null ? Filter.in_('members', [userId]) : null, + predefinedFilter: predefinedFilter, + filterValues: predefinedFilter == null ? null : {'user_id': userId}, limit: 30, ); }