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, ); }