diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 8c8717f531..7e78dc7844 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -5,6 +5,7 @@ - Added support for predefined filters for `QueryChannels` on `StreamChatClient` (`StreamChatClient.queryChannels` and `StreamChatClient.queryChannelsWithResult`). - Added `ChatPersistenceClient.queryChannelStates` and `ChatPersistenceClient.saveChannelQueries` as the unified read/write methods for channel-query persistence. Both accept standard and predefined-filter parameters and internally dispatch. - Added an `upsert` flag to `ChannelClientState.updateMessage` and `ChannelClientState.updateThreadInfo` (defaults to `true`). Pass `false` to update a message only if it's already loaded in the state, skipping unknown messages instead of adding them. +- Added `StreamChatNetworkError.type` (a `StreamChatNetworkErrorType` capturing the transport failure kind — connection error, timeout, cancellation, etc.). 🔄 Changed @@ -14,10 +15,14 @@ ⚠️ Deprecated - Deprecated `ChatPersistenceClient.getChannelStates` and `ChatPersistenceClient.updateChannelQueries` in favor of the unified `queryChannelStates` / `saveChannelQueries`. The deprecated methods stay overridable so downstream subclasses keep working unchanged. +- Deprecated `StreamChatNetworkError.isRequestCancelledError` in favor of `type == StreamChatNetworkErrorType.cancel`. 🐞 Fixed - Fixed a deprecation warning from `equatable` causing CI analysis to fail. +- Fixed `StreamWebSocketError.toString()` using a `WebSocketError(...)` prefix instead of the class name; it now also includes `code`, and `StreamChatNetworkError.toString()` now surfaces the transport `type` when known. +- Fixed `Channel.name`/`image`/`extraData` setters throwing after a *failed* initialization; they now only throw once the channel is successfully initialized. +- Fixed `Channel.initialized` staying errored after a failed init; it now reflects a subsequent successful (re)initialization. - `ComparableField` now folds diacritics/ligatures and ignores case when comparing strings, so `SortOption` on fields like `name` no longer pushes lowercase or non-ASCII names (`jhon`, `Łukasz`, `Øystein`) to the end of client-sorted lists ([#2601](https://github.com/GetStream/stream-chat-flutter/issues/2601)). - Fixed `message.updated` and soft `message.deleted` events being incorrectly upserted into `ChannelState.messages` (and thread reply lists) when they targeted a message outside the currently loaded window. diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index dd228c26c4..de0d4df28b 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -111,7 +111,7 @@ class Channel { /// /// {@macro name} set name(String? name) { - if (_initializedCompleter.isCompleted) { + if (_isInitialized) { throw StateError( 'Once the channel is initialized you should use `channel.updateName` ' 'to update the channel name', @@ -124,7 +124,7 @@ class Channel { /// /// {@macro image} set image(String? image) { - if (_initializedCompleter.isCompleted) { + if (_isInitialized) { throw StateError( 'Once the channel is initialized you should use `channel.updateImage` ' 'to update the channel image', @@ -134,7 +134,7 @@ class Channel { } set extraData(Map extraData) { - if (_initializedCompleter.isCompleted) { + if (_isInitialized) { throw StateError( 'Once the channel is initialized you should use `channel.update` ' 'to update channel data', @@ -518,7 +518,7 @@ class Channel { StreamChatClient get client => _client; final StreamChatClient _client; - final Completer _initializedCompleter = Completer(); + Completer _initializedCompleter = Completer(); /// True if this is initialized. /// @@ -526,6 +526,9 @@ class Channel { /// [Channel.fromState]. Future get initialized => _initializedCompleter.future; + // Whether the channel is successfully initialized and not disposed. + bool get _isInitialized => _initializedCompleter.isCompleted && state != null; + final _cancelableAttachmentUploadRequest = {}; final _messageAttachmentsUploadCompleter = >{}; @@ -659,7 +662,8 @@ class Channel { ); } }).catchError((e, stk) { - if (e is StreamChatNetworkError && e.isRequestCancelledError) { + if (e is StreamChatNetworkError && + e.type == StreamChatNetworkErrorType.cancel) { client.logger.info('Attachment ${it.id} upload cancelled'); // remove attachment from message if cancelled. @@ -1830,6 +1834,14 @@ class Channel { PaginationParams? watchersPagination, bool preferOffline = false, }) async { + // A prior failed init left the completer errored; reset it so this attempt + // owns the `initialized` result. Must stay before the first `await` so a + // caller reading `initialized` right after `query()`/`watch()` begins sees + // the fresh completer. + if (_initializedCompleter.isCompleted && this.state == null) { + _initializedCompleter = Completer(); + } + ChannelState? channelState; try { @@ -2162,7 +2174,7 @@ class Channel { } void _checkInitialized() { - if (_initializedCompleter.isCompleted && state != null) return; + if (_isInitialized) return; throw StateError( "Channel $cid hasn't been initialized yet or has been disposed. " diff --git a/packages/stream_chat/lib/src/core/error/stream_chat_error.dart b/packages/stream_chat/lib/src/core/error/stream_chat_error.dart index a20c25efba..9df4a882ed 100644 --- a/packages/stream_chat/lib/src/core/error/stream_chat_error.dart +++ b/packages/stream_chat/lib/src/core/error/stream_chat_error.dart @@ -4,17 +4,22 @@ import 'package:equatable/equatable.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; +/// Base class for all errors surfaced by the Stream Chat SDK. /// +/// See also: +/// +/// * [StreamChatNetworkError], raised by failed HTTP requests. +/// * [StreamWebSocketError], raised on the realtime connection. // `EquatableMixin` is deprecated in equatable ≥2.1.0 in favour of // `with Equatable`, but switching would change the mixin type in the class // hierarchy (e.g. `error is EquatableMixin` → false), which is a breaking // change under semver. Migrate this when the next major version is cut. // ignore: deprecated_member_use class StreamChatError with EquatableMixin implements Exception { - /// + /// Creates a new [StreamChatError] with the given [message]. const StreamChatError(this.message); - /// Error message + /// A human-readable description of what went wrong. final String message; @override @@ -24,22 +29,22 @@ class StreamChatError with EquatableMixin implements Exception { String toString() => 'StreamChatError(message: $message)'; } -/// +/// An error received over the realtime (WebSocket) connection. class StreamWebSocketError extends StreamChatError { - /// + /// Creates a new [StreamWebSocketError] with the given [message]. const StreamWebSocketError( super.message, { this.data, }); - /// + /// Creates a [StreamWebSocketError] from a Stream error payload. factory StreamWebSocketError.fromStreamError(Map error) { final data = ErrorResponse.fromJson(error); final message = data.message ?? ''; return StreamWebSocketError(message, data: data); } - /// + /// Creates a [StreamWebSocketError] from a [WebSocketChannelException]. factory StreamWebSocketError.fromWebSocketChannelError( WebSocketChannelException error, ) { @@ -47,20 +52,20 @@ class StreamWebSocketError extends StreamChatError { return StreamWebSocketError(message); } - /// + /// The structured error returned by the server, if any. + final ErrorResponse? data; + + /// The Stream error code, if one was provided. int? get code => data?.code; - /// + /// The [ChatErrorCode] for this error, or null if unrecognised. ChatErrorCode? get errorCode { final code = this.code; if (code == null) return null; return chatErrorCodeFromCode(code); } - /// Response body. please refer to [ErrorResponse]. - final ErrorResponse? data; - - /// + /// Whether the operation can be retried. bool get isRetriable => data == null; @override @@ -69,37 +74,44 @@ class StreamWebSocketError extends StreamChatError { @override String toString() { var params = 'message: $message'; + if (code case final code?) params = 'code: $code, $params'; if (data != null) params += ', data: $data'; - return 'WebSocketError($params)'; + return 'StreamWebSocketError($params)'; } } -/// +/// An error raised when a network request to Stream fails. class StreamChatNetworkError extends StreamChatError { - /// + /// Creates a [StreamChatNetworkError] for a known [errorCode]. StreamChatNetworkError( ChatErrorCode errorCode, { int? statusCode, this.data, StackTrace? stacktrace, - this.isRequestCancelledError = false, + @Deprecated('Set type to StreamChatNetworkErrorType.cancel instead') + bool? isRequestCancelledError, + this.type = StreamChatNetworkErrorType.unknown, }) : code = errorCode.code, statusCode = statusCode ?? data?.statusCode, stackTrace = stacktrace ?? StackTrace.current, + _isRequestCancelledError = isRequestCancelledError, super(errorCode.message); - /// + /// Creates a [StreamChatNetworkError] from raw values. StreamChatNetworkError.raw({ required this.code, required String message, this.statusCode, this.data, StackTrace? stacktrace, - this.isRequestCancelledError = false, + @Deprecated('Set type to StreamChatNetworkErrorType.cancel instead') + bool? isRequestCancelledError, + this.type = StreamChatNetworkErrorType.unknown, }) : stackTrace = stacktrace ?? StackTrace.current, + _isRequestCancelledError = isRequestCancelledError, super(message); - /// + /// Creates a [StreamChatNetworkError] from a [DioException]. factory StreamChatNetworkError.fromDioException(DioException exception) { final response = exception.response; ErrorResponse? errorResponse; @@ -118,37 +130,49 @@ class StreamChatNetworkError extends StreamChatError { statusCode: errorResponse?.statusCode ?? response?.statusCode, data: errorResponse, stacktrace: exception.stackTrace, - isRequestCancelledError: exception.type == DioExceptionType.cancel, + type: _networkErrorTypeFromDio(exception.type), ); } - /// Error code + /// The Stream error code. See [ChatErrorCode]. final int code; - /// HTTP status code + /// The HTTP status code of the response, if any. final int? statusCode; - /// Response body. please refer to [ErrorResponse]. + /// The structured error returned by the server, if any. final ErrorResponse? data; - /// True, in case the error is due to a cancelled network request. - final bool isRequestCancelledError; + /// The kind of transport failure that caused this error. + /// + /// Defaults to [StreamChatNetworkErrorType.unknown] when it can't be + /// determined. + final StreamChatNetworkErrorType type; /// The optional stack trace attached to the error. final StackTrace? stackTrace; - /// + /// Whether the request was cancelled before it completed. + @Deprecated('Use type == StreamChatNetworkErrorType.cancel instead') + bool get isRequestCancelledError => + _isRequestCancelledError ?? type == StreamChatNetworkErrorType.cancel; + final bool? _isRequestCancelledError; + + /// The [ChatErrorCode] for this error, or null if unrecognised. ChatErrorCode? get errorCode => chatErrorCodeFromCode(code); - /// + /// Whether the operation can be retried. bool get isRetriable => data == null; @override - List get props => [...super.props, code, statusCode]; + List get props => [...super.props, code, statusCode, type]; @override String toString({bool printStackTrace = false}) { var params = 'code: $code, message: $message'; + if (type != StreamChatNetworkErrorType.unknown) { + params += ', type: ${type.name}'; + } if (statusCode != null) params += ', statusCode: $statusCode'; if (data != null) params += ', data: $data'; var msg = 'StreamChatNetworkError($params)'; @@ -159,3 +183,56 @@ class StreamChatNetworkError extends StreamChatError { return msg; } } + +/// The kind of transport failure that caused a [StreamChatNetworkError]. +/// +/// Lets callers tell a lost connection apart from a timeout, a cancellation, +/// or a server response — for example, to show a tailored error message. +enum StreamChatNetworkErrorType { + /// The server could not be reached (e.g. no internet connection). + connectionError, + + /// Opening the connection timed out. + connectionTimeout, + + /// Sending the request timed out. + sendTimeout, + + /// Receiving the response timed out. + receiveTimeout, + + /// Transforming the response (e.g. background JSON decoding) timed out. + transformTimeout, + + /// The server responded with a non-success status. + badResponse, + + /// The request was cancelled before it completed. + cancel, + + /// The connection's certificate could not be validated. + badCertificate, + + /// The failure could not be attributed to a specific transport cause. + unknown, +} + +StreamChatNetworkErrorType _networkErrorTypeFromDio(DioExceptionType type) { + return switch (type) { + DioExceptionType.connectionError => + StreamChatNetworkErrorType.connectionError, + DioExceptionType.connectionTimeout => + StreamChatNetworkErrorType.connectionTimeout, + DioExceptionType.sendTimeout => StreamChatNetworkErrorType.sendTimeout, + DioExceptionType.receiveTimeout => + StreamChatNetworkErrorType.receiveTimeout, + DioExceptionType.transformTimeout => + StreamChatNetworkErrorType.transformTimeout, + DioExceptionType.badResponse => StreamChatNetworkErrorType.badResponse, + DioExceptionType.cancel => StreamChatNetworkErrorType.cancel, + DioExceptionType.badCertificate => + StreamChatNetworkErrorType.badCertificate, + // DioExceptionType.unknown and any future dio types map to unknown. + _ => StreamChatNetworkErrorType.unknown, + }; +} diff --git a/packages/stream_chat/test/src/client/channel_test.dart b/packages/stream_chat/test/src/client/channel_test.dart index 1a75b4448c..6504547537 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel_test.dart @@ -114,6 +114,43 @@ void main() { expect(newChannelInstance.name, newName); expect(newChannelInstance.extraData['name'], newName); }); + + test('setters remain usable after a failed watch()', () async { + // Make initialization fail. + when( + () => client.queryChannel( + channelType, + channelId: any(named: 'channelId'), + channelData: any(named: 'channelData'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + + // A failed watch() also completes `initialized` with the error. Attach + // the expectation up-front so that error has a listener the moment it + // occurs and isn't reported as an unhandled async error. + final initializedFailure = expectLater( + channel.initialized, + throwsA(isA()), + ); + + await expectLater( + channel.watch(), + throwsA(isA()), + ); + await initializedFailure; + + // Init never *succeeded*, so the raw setters must still work. Previously + // they threw because the completer was merely `isCompleted` (it had + // completed with an error). + expect(() => channel.name = 'New name', returnsNormally); + expect(channel.name, 'New name'); + }); }); group('Initialized Channel with Persistence', () { @@ -507,7 +544,7 @@ void main() { (_) async => throw StreamChatNetworkError.raw( code: 0, message: 'Request cancelled', - isRequestCancelledError: true, + type: StreamChatNetworkErrorType.cancel, ), ); @@ -561,7 +598,7 @@ void main() { (_) async => throw StreamChatNetworkError.raw( code: 0, message: 'Request cancelled', - isRequestCancelledError: true, + type: StreamChatNetworkErrorType.cancel, ), ); @@ -638,7 +675,7 @@ void main() { (_) async => throw StreamChatNetworkError.raw( code: 0, message: 'Request cancelled', - isRequestCancelledError: true, + type: StreamChatNetworkErrorType.cancel, ), ); @@ -710,7 +747,7 @@ void main() { (_) async => throw StreamChatNetworkError.raw( code: 0, message: 'Request cancelled', - isRequestCancelledError: true, + type: StreamChatNetworkErrorType.cancel, ), ); @@ -2951,6 +2988,65 @@ void main() { )).called(1); }); + test( + 'a successful retry after a failed init reconciles ' + '`initialized` and `state`', () async { + final freshChannel = Channel(client, channelType, channelId); + addTearDown(freshChannel.dispose); + + var attempts = 0; + when( + () => client.queryChannel( + channelType, + channelId: channelId, + watch: true, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).thenAnswer((_) async { + if (++attempts == 1) { + throw StreamChatNetworkError(ChatErrorCode.inputError); + } + return _generateChannelState(channelId, channelType); + }); + + // First init fails: `initialized` errors and `state` stays null. + // Attach the expectation before watch() so the error is handled. + final firstInit = expectLater( + freshChannel.initialized, + throwsA(isA()), + ); + await expectLater( + freshChannel.watch(), + throwsA(isA()), + ); + await firstInit; + expect(freshChannel.state, isNull); + + // Retrying resets the completer; the successful watch initializes the + // channel and `initialized`/`state` agree again. + await freshChannel.watch(); + expect(freshChannel.state, isNotNull); + await expectLater(freshChannel.initialized, completion(isTrue)); + + // Two query attempts were made: the failed init and the retry. Verify + // them here so the shared `client` mock's interaction count doesn't + // leak into later tests in this group. + verify( + () => client.queryChannel( + channelType, + channelId: channelId, + watch: true, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).called(2); + }); + test('should rethrow if `.query` throws', () async { when(() => client.queryChannel( channelType, diff --git a/packages/stream_chat/test/src/core/error/stream_chat_error_test.dart b/packages/stream_chat/test/src/core/error/stream_chat_error_test.dart index c95afd3cb4..f9da231ed0 100644 --- a/packages/stream_chat/test/src/core/error/stream_chat_error_test.dart +++ b/packages/stream_chat/test/src/core/error/stream_chat_error_test.dart @@ -3,6 +3,11 @@ import 'package:stream_chat/src/core/api/responses.dart'; import 'package:stream_chat/src/core/error/error.dart'; import 'package:test/test.dart'; +DioException _dioException(DioExceptionType type) => DioException( + requestOptions: RequestOptions(path: 'test-path'), + type: type, + ); + void main() { group('StreamChatError', () { test('should match if message is same', () { @@ -45,9 +50,15 @@ void main() { expect( error.toString(), - 'WebSocketError(message: $message, data: $data)', + 'StreamWebSocketError(code: ${data.code}, message: $message, ' + 'data: $data)', ); }); + + test('`.toString` omits code when absent', () { + const error = StreamWebSocketError('boom'); + expect(error.toString(), 'StreamWebSocketError(message: boom)'); + }); }); group('StreamChatNetworkError', () { @@ -191,5 +202,60 @@ void main() { 'message: ${errorCode.message})', ); }); + + test('`.toString` includes the transport type when known', () { + final error = StreamChatNetworkError.fromDioException( + DioException( + requestOptions: RequestOptions(path: '/'), + type: DioExceptionType.connectionError, + ), + ); + + expect(error.toString(), contains('type: connectionError')); + }); + + group('.type from .fromDioException maps dio types 1:1', () { + const cases = { + DioExceptionType.connectionError: + StreamChatNetworkErrorType.connectionError, + DioExceptionType.connectionTimeout: + StreamChatNetworkErrorType.connectionTimeout, + DioExceptionType.sendTimeout: StreamChatNetworkErrorType.sendTimeout, + DioExceptionType.receiveTimeout: + StreamChatNetworkErrorType.receiveTimeout, + DioExceptionType.transformTimeout: + StreamChatNetworkErrorType.transformTimeout, + DioExceptionType.badResponse: StreamChatNetworkErrorType.badResponse, + DioExceptionType.cancel: StreamChatNetworkErrorType.cancel, + DioExceptionType.badCertificate: + StreamChatNetworkErrorType.badCertificate, + DioExceptionType.unknown: StreamChatNetworkErrorType.unknown, + }; + + for (final MapEntry(key: dioType, value: expected) in cases.entries) { + test('$dioType -> $expected', () { + final error = StreamChatNetworkError.fromDioException( + _dioException(dioType), + ); + expect(error.type, expected); + }); + } + }); + + test('.type defaults to unknown for non-network errors', () { + final error = StreamChatNetworkError(ChatErrorCode.internalSystemError); + expect(error.type, StreamChatNetworkErrorType.unknown); + }); + + test('.type is part of equality', () { + final error = StreamChatNetworkError.raw(code: 1, message: 'msg'); + final error2 = StreamChatNetworkError.raw( + code: 1, + message: 'msg', + type: StreamChatNetworkErrorType.connectionTimeout, + ); + + expect(error, isNot(error2)); + }); }); } diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index ff000f87e0..92f15c5b1c 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -3,9 +3,12 @@ ✅ Added - Added a `LastMessagePredicate` typedef for the `ChannelLastMessageText.lastMessagePredicate` filter. +- Added an `errorSubtitle` to `StreamScrollViewErrorWidget`, which now falls back to the generic error copy (title and description) when values aren't provided. 🐞 Fixed +- Fixed the default `StreamChannel` loading and error states not being themed or localized; `StreamChat` now installs themed, connection-aware defaults, overridable per `StreamChannel` or via `DefaultStreamChannelBuilders`. +- Fixed the default list/scroll-view error states (channel, message, member, user, thread, poll-vote, search, and photo) showing raw or fixed errors; they are now connection-aware (no internet / slow connection), falling back to each view's specific error text. - Fixed link preview enrichment failing for uppercase URL schemes (e.g. `HTTPS://`) by normalizing the scheme before enriching. - Fixed `StreamMessageListView` firing `markThreadRead` on a reply-less parent, which produced a guaranteed 404 every time the thread view was opened before the first reply. - Fixed shadowed messages not hidden in channel list items. diff --git a/packages/stream_chat_flutter/lib/src/localization/translations.dart b/packages/stream_chat_flutter/lib/src/localization/translations.dart index ce5989e7df..eb7313944c 100644 --- a/packages/stream_chat_flutter/lib/src/localization/translations.dart +++ b/packages/stream_chat_flutter/lib/src/localization/translations.dart @@ -152,6 +152,25 @@ abstract class Translations { /// The error shown when something went wrong String get somethingWentWrongError; + /// The title shown when there is no internet connection. + String get connectionErrorTitle; + + /// The description shown when there is no internet connection. + String get connectionErrorDescription; + + /// The title shown when the connection is too slow or the request timed out. + String get slowConnectionErrorTitle; + + /// The description shown when the connection is too slow or the request + /// timed out. + String get slowConnectionErrorDescription; + + /// The title shown for a generic, uncategorised error. + String get genericErrorTitle; + + /// The description shown for a generic, uncategorised error. + String get genericErrorDescription; + /// The label for "OK" String get okLabel; @@ -717,6 +736,26 @@ class DefaultTranslations implements Translations { @override String get somethingWentWrongError => 'Something went wrong'; + @override + String get connectionErrorTitle => 'No Internet Connection'; + + @override + String get connectionErrorDescription => + 'Please check your internet connection'; + + @override + String get slowConnectionErrorTitle => 'Slow Internet Connection'; + + @override + String get slowConnectionErrorDescription => + 'There seems to be a problem with your internet connection'; + + @override + String get genericErrorTitle => 'Error'; + + @override + String get genericErrorDescription => 'Oops, something went wrong'; + @override String get addMoreFilesLabel => 'Add more files'; diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart index 80de539e96..4b64fa3a50 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart @@ -14,6 +14,7 @@ import 'package:stream_chat_flutter/src/message_list_view/unread_indicator_butto import 'package:stream_chat_flutter/src/message_list_view/unread_messages_separator.dart'; import 'package:stream_chat_flutter/src/message_widget/ephemeral_message.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; +import 'package:stream_chat_flutter/src/utils/network_error_text.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Spacing Types (These are properties of a message to help inform the decision @@ -531,16 +532,23 @@ class _StreamMessageListViewState extends State { messageListController: _messageListController, parentMessage: widget.parentMessage, errorBuilder: widget.errorBuilder ?? - (BuildContext context, Object error) => Center( - child: Text( - context.translations.genericErrorText, - style: _streamTheme.textTheme.footnote.copyWith( - color: _streamTheme.colorTheme.textHighEmphasis - // ignore: deprecated_member_use - .withOpacity(0.5), - ), + (BuildContext context, Object error) { + final text = resolveNetworkErrorText( + context, + error, + fallbackTitle: context.translations.genericErrorText, + ); + return Center( + child: Text( + text.title, + style: _streamTheme.textTheme.footnote.copyWith( + color: _streamTheme.colorTheme.textHighEmphasis + // ignore: deprecated_member_use + .withOpacity(0.5), ), ), + ); + }, ), ), ); diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_grid_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_grid_view.dart index e391451647..9ba96f0aa2 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_grid_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_grid_view.dart @@ -4,6 +4,7 @@ import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_wid import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_indicator.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; +import 'package:stream_chat_flutter/src/utils/network_error_text.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Default grid delegate for [StreamChannelGridView]. @@ -383,14 +384,23 @@ class StreamChannelGridView extends StatelessWidget { const Center( child: StreamScrollViewLoadingWidget(), ), - errorBuilder: (context, error) => - errorBuilder?.call(context, error) ?? - Center( - child: StreamScrollViewErrorWidget( - errorTitle: Text(context.translations.loadingChannelsError), - onRetryPressed: controller.refresh, - ), + errorBuilder: (context, error) { + if (errorBuilder?.call(context, error) case final builder?) { + return builder; + } + + final translations = context.translations; + final text = resolveNetworkErrorText(context, error, + fallbackTitle: translations.loadingChannelsError); + + return Center( + child: StreamScrollViewErrorWidget( + errorTitle: Text(text.title), + errorSubtitle: Text(text.description), + onRetryPressed: controller.refresh, ), + ); + }, ); } } diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_view.dart index 3541963b3f..4d111ee14d 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_view.dart @@ -4,6 +4,7 @@ import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_wid import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_indicator.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; +import 'package:stream_chat_flutter/src/utils/network_error_text.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Default separator builder for [StreamChannelListView]. @@ -354,14 +355,23 @@ class StreamChannelListView extends StatelessWidget { const Center( child: StreamScrollViewLoadingWidget(), ), - errorBuilder: (context, error) => - errorBuilder?.call(context, error) ?? - Center( - child: StreamScrollViewErrorWidget( - errorTitle: Text(context.translations.loadingChannelsError), - onRetryPressed: controller.refresh, - ), + errorBuilder: (context, error) { + if (errorBuilder?.call(context, error) case final builder?) { + return builder; + } + + final translations = context.translations; + final text = resolveNetworkErrorText(context, error, + fallbackTitle: translations.loadingChannelsError); + + return Center( + child: StreamScrollViewErrorWidget( + errorTitle: Text(text.title), + errorSubtitle: Text(text.description), + onRetryPressed: controller.refresh, ), + ); + }, ); } } diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/draft_scroll_view/stream_draft_list_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/draft_scroll_view/stream_draft_list_view.dart index 1c59029017..09c2582ed1 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/draft_scroll_view/stream_draft_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/draft_scroll_view/stream_draft_list_view.dart @@ -4,6 +4,7 @@ import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_wid import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_indicator.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; +import 'package:stream_chat_flutter/src/utils/network_error_text.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Default separator builder for [StreamDraftListView]. @@ -347,14 +348,23 @@ class StreamDraftListView extends StatelessWidget { const Center( child: StreamScrollViewLoadingWidget(), ), - errorBuilder: (context, error) => - errorBuilder?.call(context, error) ?? - Center( - child: StreamScrollViewErrorWidget( - errorTitle: Text(context.translations.loadingMessagesError), - onRetryPressed: controller.refresh, - ), + errorBuilder: (context, error) { + if (errorBuilder?.call(context, error) case final builder?) { + return builder; + } + + final translations = context.translations; + final text = resolveNetworkErrorText(context, error, + fallbackTitle: translations.loadingMessagesError); + + return Center( + child: StreamScrollViewErrorWidget( + errorTitle: Text(text.title), + errorSubtitle: Text(text.description), + onRetryPressed: controller.refresh, ), + ); + }, ); } } diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/member_scroll_view/stream_member_grid_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/member_scroll_view/stream_member_grid_view.dart index adf97a7860..ce3130bd34 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/member_scroll_view/stream_member_grid_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/member_scroll_view/stream_member_grid_view.dart @@ -4,6 +4,7 @@ import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_wid import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_indicator.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; +import 'package:stream_chat_flutter/src/utils/network_error_text.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Default grid delegate for [StreamMemberGridView]. @@ -384,14 +385,23 @@ class StreamMemberGridView extends StatelessWidget { const Center( child: StreamScrollViewLoadingWidget(), ), - errorBuilder: (context, error) => - errorBuilder?.call(context, error) ?? - Center( - child: StreamScrollViewErrorWidget( - errorTitle: Text(context.translations.loadingUsersError), - onRetryPressed: controller.refresh, - ), + errorBuilder: (context, error) { + if (errorBuilder?.call(context, error) case final builder?) { + return builder; + } + + final translations = context.translations; + final text = resolveNetworkErrorText(context, error, + fallbackTitle: translations.loadingUsersError); + + return Center( + child: StreamScrollViewErrorWidget( + errorTitle: Text(text.title), + errorSubtitle: Text(text.description), + onRetryPressed: controller.refresh, ), + ); + }, ); } } diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/member_scroll_view/stream_member_list_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/member_scroll_view/stream_member_list_view.dart index fcdad5379e..34322674ac 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/member_scroll_view/stream_member_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/member_scroll_view/stream_member_list_view.dart @@ -4,6 +4,7 @@ import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_wid import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_indicator.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; +import 'package:stream_chat_flutter/src/utils/network_error_text.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Default separator builder for [StreamMemberListView]. @@ -351,13 +352,22 @@ class StreamMemberListView extends StatelessWidget { const Center( child: StreamScrollViewLoadingWidget(), ), - errorBuilder: (context, error) => - errorBuilder?.call(context, error) ?? - Center( - child: StreamScrollViewErrorWidget( - errorTitle: Text(context.translations.loadingUsersError), - onRetryPressed: controller.refresh, - ), + errorBuilder: (context, error) { + if (errorBuilder?.call(context, error) case final builder?) { + return builder; + } + + final translations = context.translations; + final text = resolveNetworkErrorText(context, error, + fallbackTitle: translations.loadingUsersError); + + return Center( + child: StreamScrollViewErrorWidget( + errorTitle: Text(text.title), + errorSubtitle: Text(text.description), + onRetryPressed: controller.refresh, ), + ); + }, ); } diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_grid_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_grid_view.dart index e7a939f942..3d47efbabe 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_grid_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_grid_view.dart @@ -4,6 +4,7 @@ import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_wid import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_indicator.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; +import 'package:stream_chat_flutter/src/utils/network_error_text.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Default grid delegate for [StreamMessageSearchGridView]. @@ -352,13 +353,23 @@ class StreamMessageSearchGridView extends StatelessWidget { const Center( child: StreamScrollViewLoadingWidget(), ), - errorBuilder: (context, error) => - errorBuilder?.call(context, error) ?? - Center( - child: StreamScrollViewErrorWidget( - onRetryPressed: controller.refresh, - ), + errorBuilder: (context, error) { + if (errorBuilder?.call(context, error) case final builder?) { + return builder; + } + + final translations = context.translations; + final text = resolveNetworkErrorText(context, error, + fallbackTitle: translations.loadingMessagesError); + + return Center( + child: StreamScrollViewErrorWidget( + errorTitle: Text(text.title), + errorSubtitle: Text(text.description), + onRetryPressed: controller.refresh, ), + ); + }, ); } } diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_list_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_list_view.dart index 2f4c75468f..2e5701e38a 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_list_view.dart @@ -4,6 +4,7 @@ import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_wid import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_indicator.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; +import 'package:stream_chat_flutter/src/utils/network_error_text.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Default separator builder for [StreamMessageSearchListView]. @@ -356,14 +357,23 @@ class StreamMessageSearchListView extends StatelessWidget { const Center( child: StreamScrollViewLoadingWidget(), ), - errorBuilder: (context, error) => - errorBuilder?.call(context, error) ?? - Center( - child: StreamScrollViewErrorWidget( - errorTitle: Text(context.translations.loadingMessagesError), - onRetryPressed: controller.refresh, - ), + errorBuilder: (context, error) { + if (errorBuilder?.call(context, error) case final builder?) { + return builder; + } + + final translations = context.translations; + final text = resolveNetworkErrorText(context, error, + fallbackTitle: translations.loadingMessagesError); + + return Center( + child: StreamScrollViewErrorWidget( + errorTitle: Text(text.title), + errorSubtitle: Text(text.description), + onRetryPressed: controller.refresh, ), + ); + }, ); } } diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/photo_gallery/stream_photo_gallery.dart b/packages/stream_chat_flutter/lib/src/scroll_view/photo_gallery/stream_photo_gallery.dart index dcee2b76a4..1b4817ce3f 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/photo_gallery/stream_photo_gallery.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/photo_gallery/stream_photo_gallery.dart @@ -7,6 +7,7 @@ import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_wid import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_indicator.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; +import 'package:stream_chat_flutter/src/utils/network_error_text.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Default grid delegate for [StreamPhotoGallery]. @@ -398,13 +399,21 @@ class StreamPhotoGallery extends StatelessWidget { ); }, errorBuilder: (context, error) { - return errorBuilder?.call(context, error) ?? - Center( - child: StreamScrollViewErrorWidget( - errorTitle: Text(context.translations.genericErrorText), - onRetryPressed: controller.refresh, - ), - ); + if (errorBuilder?.call(context, error) case final builder?) { + return builder; + } + + final translations = context.translations; + final text = resolveNetworkErrorText(context, error, + fallbackTitle: translations.genericErrorText); + + return Center( + child: StreamScrollViewErrorWidget( + errorTitle: Text(text.title), + errorSubtitle: Text(text.description), + onRetryPressed: controller.refresh, + ), + ); }, ); } diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/poll_vote_scroll_view/stream_poll_vote_list_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/poll_vote_scroll_view/stream_poll_vote_list_view.dart index 13aecdb471..5e4add49a9 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/poll_vote_scroll_view/stream_poll_vote_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/poll_vote_scroll_view/stream_poll_vote_list_view.dart @@ -10,6 +10,7 @@ import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_chat_flutter/src/utils/network_error_text.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; /// Default separator builder for [StreamPollVoteListView]. @@ -357,13 +358,22 @@ class StreamPollVoteListView extends StatelessWidget { const Center( child: StreamScrollViewLoadingWidget(), ), - errorBuilder: (context, error) => - errorBuilder?.call(context, error) ?? - Center( - child: StreamScrollViewErrorWidget( - errorTitle: Text(context.translations.loadingPollVotesError), - onRetryPressed: controller.refresh, - ), + errorBuilder: (context, error) { + if (errorBuilder?.call(context, error) case final builder?) { + return builder; + } + + final translations = context.translations; + final text = resolveNetworkErrorText(context, error, + fallbackTitle: translations.loadingPollVotesError); + + return Center( + child: StreamScrollViewErrorWidget( + errorTitle: Text(text.title), + errorSubtitle: Text(text.description), + onRetryPressed: controller.refresh, ), + ); + }, ); } diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_empty_widget.dart b/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_empty_widget.dart index 6fe8b8524a..00aed319c6 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_empty_widget.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_empty_widget.dart @@ -36,26 +36,34 @@ class StreamScrollViewEmptyWidget extends StatelessWidget { @override Widget build(BuildContext context) { final chatThemeData = StreamChatTheme.of(context); + final textTheme = chatThemeData.textTheme; + final colorTheme = chatThemeData.colorTheme; - final emptyIcon = AnimatedSwitcher( - duration: kThemeChangeDuration, - child: this.emptyIcon, + final effectiveTitleStyle = emptyTitleStyle ?? textTheme.headline; + + final icon = IconTheme.merge( + data: IconThemeData(size: 32, color: colorTheme.textLowEmphasis), + child: emptyIcon, ); final emptyTitleText = AnimatedDefaultTextStyle( - style: emptyTitleStyle ?? chatThemeData.textTheme.headline, + style: effectiveTitleStyle, duration: kThemeChangeDuration, child: emptyTitle, ); - return Column( - mainAxisSize: mainAxisSize, - mainAxisAlignment: mainAxisAlignment, - crossAxisAlignment: crossAxisAlignment, - children: [ - emptyIcon, - emptyTitleText, - ], + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 40, + ), + child: Column( + spacing: 8, + mainAxisSize: mainAxisSize, + mainAxisAlignment: mainAxisAlignment, + crossAxisAlignment: crossAxisAlignment, + children: [icon, emptyTitleText], + ), ); } } diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_error_widget.dart b/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_error_widget.dart index 3dafd08931..235c833f79 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_error_widget.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_error_widget.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// A widget that is displayed when a [StreamScrollView] encounters an error @@ -10,6 +9,8 @@ class StreamScrollViewErrorWidget extends StatelessWidget { super.key, this.errorTitle, this.errorTitleStyle, + this.errorSubtitle, + this.errorSubtitleStyle, this.errorIcon, this.retryButtonText, this.retryButtonTextStyle, @@ -20,15 +21,28 @@ class StreamScrollViewErrorWidget extends StatelessWidget { }); /// The title of the error. + /// + /// Defaults to a generic localized error title. final Widget? errorTitle; /// The style of the title. final TextStyle? errorTitleStyle; + /// An optional supporting description shown below the title. + /// + /// When no [errorTitle] is supplied, this defaults to a generic localized + /// description so the out-of-the-box error state matches the design. + final Widget? errorSubtitle; + + /// The style of the subtitle. + final TextStyle? errorSubtitleStyle; + /// The icon to display when the list shows error. final Widget? errorIcon; /// The text to display in the retry button. + /// + /// Defaults to a localized "Try Again" label. final Widget? retryButtonText; /// The style of the retryButtonText. @@ -49,44 +63,77 @@ class StreamScrollViewErrorWidget extends StatelessWidget { @override Widget build(BuildContext context) { final chatThemeData = StreamChatTheme.of(context); + final textTheme = chatThemeData.textTheme; + final colorTheme = chatThemeData.colorTheme; + final translations = context.translations; - final errorIcon = AnimatedSwitcher( - duration: kThemeChangeDuration, - child: this.errorIcon ?? - Icon( - Icons.error_outline_rounded, - size: 148, - color: chatThemeData.colorTheme.disabled, - ), + final icon = IconTheme.merge( + data: IconThemeData(size: 32, color: colorTheme.textLowEmphasis), + child: errorIcon ?? const Icon(Icons.error_outline_rounded), ); - final titleText = AnimatedDefaultTextStyle( - style: errorTitleStyle ?? chatThemeData.textTheme.headline, - duration: kThemeChangeDuration, - child: errorTitle ?? const Empty(), - ); + final effectiveErrorTitle = + errorTitle ?? Text(translations.genericErrorTitle); + // The generic subtitle only pairs with the generic title, not a custom one. + final resolvedSubtitle = errorSubtitle ?? + (errorTitle == null + ? Text(translations.genericErrorDescription) + : null); + final effectiveTitleStyle = errorTitleStyle ?? + textTheme.headline.copyWith(color: colorTheme.textHighEmphasis); + final effectiveSubtitleStyle = errorSubtitleStyle ?? + textTheme.body.copyWith(color: colorTheme.textLowEmphasis); + final effectiveRetryButtonText = + retryButtonText ?? Text(translations.tryAgainLabel); - final retryButtonText = AnimatedDefaultTextStyle( - style: errorTitleStyle ?? - chatThemeData.textTheme.headline.copyWith( - color: Colors.white, - ), + final title = AnimatedDefaultTextStyle( + style: effectiveTitleStyle, + textAlign: TextAlign.center, duration: kThemeChangeDuration, - child: this.retryButtonText ?? Text(context.translations.retryLabel), + child: effectiveErrorTitle, ); - return Column( - mainAxisSize: mainAxisSize, - mainAxisAlignment: mainAxisAlignment, - crossAxisAlignment: crossAxisAlignment, - children: [ - errorIcon, - titleText, - ElevatedButton( - onPressed: onRetryPressed, - child: retryButtonText, + Widget? subtitle; + if (resolvedSubtitle != null) { + subtitle = Padding( + padding: const EdgeInsets.only(top: 4), + child: AnimatedDefaultTextStyle( + style: effectiveSubtitleStyle, + textAlign: TextAlign.center, + duration: kThemeChangeDuration, + child: resolvedSubtitle, ), - ], + ); + } + + final retryButton = OutlinedButton( + onPressed: onRetryPressed, + style: OutlinedButton.styleFrom( + textStyle: retryButtonTextStyle ?? textTheme.bodyBold, + foregroundColor: colorTheme.accentPrimary, + disabledForegroundColor: colorTheme.disabled, + ), + child: effectiveRetryButtonText, + ); + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 40, + ), + child: Column( + mainAxisSize: mainAxisSize, + mainAxisAlignment: mainAxisAlignment, + crossAxisAlignment: crossAxisAlignment, + children: [ + icon, + const SizedBox(height: 8), + title, + if (subtitle != null) subtitle, + const SizedBox(height: 16), + retryButton, + ], + ), ); } } diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_load_more_indicator.dart b/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_load_more_indicator.dart index 7258522590..458cb9b329 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_load_more_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_load_more_indicator.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; /// A widget that shows a loading indicator when the user is near the bottom of /// the list. @@ -17,9 +18,14 @@ class StreamScrollViewLoadMoreIndicator extends StatelessWidget { final double width; @override - Widget build(BuildContext context) => SizedBox( - height: height, - width: width, - child: const CircularProgressIndicator.adaptive(), - ); + Widget build(BuildContext context) { + final colorTheme = StreamChatTheme.of(context).colorTheme; + return SizedBox( + height: height, + width: width, + child: CircularProgressIndicator.adaptive( + valueColor: AlwaysStoppedAnimation(colorTheme.accentPrimary), + ), + ); + } } diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_loading_widget.dart b/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_loading_widget.dart index 958eb80dc4..e171d992f8 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_loading_widget.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_loading_widget.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; /// A widget that is displayed while the [StreamScrollView] is loading. class StreamScrollViewLoadingWidget extends StatelessWidget { @@ -16,9 +17,20 @@ class StreamScrollViewLoadingWidget extends StatelessWidget { final double width; @override - Widget build(BuildContext context) => SizedBox( + Widget build(BuildContext context) { + final colorTheme = StreamChatTheme.of(context).colorTheme; + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 40, + ), + child: SizedBox( height: height, width: width, - child: const CircularProgressIndicator.adaptive(), - ); + child: CircularProgressIndicator.adaptive( + valueColor: AlwaysStoppedAnimation(colorTheme.accentPrimary), + ), + ), + ); + } } diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_view.dart index b8b2d293e3..4386ec9c2d 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_view.dart @@ -4,6 +4,7 @@ import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_wid import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_indicator.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; +import 'package:stream_chat_flutter/src/utils/network_error_text.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Default separator builder for [StreamThreadListView]. @@ -347,14 +348,23 @@ class StreamThreadListView extends StatelessWidget { const Center( child: StreamScrollViewLoadingWidget(), ), - errorBuilder: (context, error) => - errorBuilder?.call(context, error) ?? - Center( - child: StreamScrollViewErrorWidget( - errorTitle: Text(context.translations.loadingMessagesError), - onRetryPressed: controller.refresh, - ), + errorBuilder: (context, error) { + if (errorBuilder?.call(context, error) case final builder?) { + return builder; + } + + final translations = context.translations; + final text = resolveNetworkErrorText(context, error, + fallbackTitle: translations.loadingMessagesError); + + return Center( + child: StreamScrollViewErrorWidget( + errorTitle: Text(text.title), + errorSubtitle: Text(text.description), + onRetryPressed: controller.refresh, ), + ); + }, ); } } diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_grid_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_grid_view.dart index 5aac833955..f679e0666b 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_grid_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_grid_view.dart @@ -4,6 +4,7 @@ import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_wid import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_indicator.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; +import 'package:stream_chat_flutter/src/utils/network_error_text.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Default grid delegate for [StreamUserGridView]. @@ -381,14 +382,23 @@ class StreamUserGridView extends StatelessWidget { const Center( child: StreamScrollViewLoadingWidget(), ), - errorBuilder: (context, error) => - errorBuilder?.call(context, error) ?? - Center( - child: StreamScrollViewErrorWidget( - errorTitle: Text(context.translations.loadingUsersError), - onRetryPressed: controller.refresh, - ), + errorBuilder: (context, error) { + if (errorBuilder?.call(context, error) case final builder?) { + return builder; + } + + final translations = context.translations; + final text = resolveNetworkErrorText(context, error, + fallbackTitle: translations.loadingUsersError); + + return Center( + child: StreamScrollViewErrorWidget( + errorTitle: Text(text.title), + errorSubtitle: Text(text.description), + onRetryPressed: controller.refresh, ), + ); + }, ); } } diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_list_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_list_view.dart index df2530aad0..0853133429 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_list_view.dart @@ -4,6 +4,7 @@ import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_wid import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_indicator.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; +import 'package:stream_chat_flutter/src/utils/network_error_text.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Default separator builder for [StreamUserListView]. @@ -351,14 +352,23 @@ class StreamUserListView extends StatelessWidget { const Center( child: StreamScrollViewLoadingWidget(), ), - errorBuilder: (context, error) => - errorBuilder?.call(context, error) ?? - Center( - child: StreamScrollViewErrorWidget( - errorTitle: Text(context.translations.loadingUsersError), - onRetryPressed: controller.refresh, - ), + errorBuilder: (context, error) { + if (errorBuilder?.call(context, error) case final builder?) { + return builder; + } + + final translations = context.translations; + final text = resolveNetworkErrorText(context, error, + fallbackTitle: translations.loadingUsersError); + + return Center( + child: StreamScrollViewErrorWidget( + errorTitle: Text(text.title), + errorSubtitle: Text(text.description), + onRetryPressed: controller.refresh, ), + ); + }, ); } diff --git a/packages/stream_chat_flutter/lib/src/stream_chat.dart b/packages/stream_chat_flutter/lib/src/stream_chat.dart index f074251512..8fce8a6dd1 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat.dart @@ -3,6 +3,9 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_portal/flutter_portal.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; +import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; +import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; +import 'package:stream_chat_flutter/src/utils/network_error_text.dart'; import 'package:stream_chat_flutter/src/video/vlc/vlc_manager.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -179,10 +182,14 @@ class StreamChatState extends State { onBackgroundEventReceived: widget.onBackgroundEventReceived, backgroundKeepAlive: widget.backgroundKeepAlive, connectivityStream: widget.connectivityStream, - child: Builder( - builder: (context) { - return widget.child ?? const Empty(); - }, + child: DefaultStreamChannelBuilders( + errorBuilder: _defaultChannelErrorBuilder, + loadingBuilder: _defaultChannelLoadingBuilder, + child: Builder( + builder: (context) { + return widget.child ?? const Empty(); + }, + ), ), ), ); @@ -220,3 +227,39 @@ class StreamChatState extends State { super.didChangeDependencies(); } } + +// Installed by [StreamChat] as the default [StreamChannel] loading state, so +// app-provided channels show a themed indicator on the app background. +Widget _defaultChannelLoadingBuilder(BuildContext context) { + return Material( + color: StreamChatTheme.of(context).colorTheme.appBg, + child: const Center( + child: StreamScrollViewLoadingWidget(), + ), + ); +} + +// Installed by [StreamChat] as the default [StreamChannel] error state, so +// app-provided channels show a themed, localized widget instead of a raw error. +// Both builders are stable top-level tear-offs so DefaultStreamChannelBuilders +// can compare them by identity in updateShouldNotify. +Widget _defaultChannelErrorBuilder( + BuildContext context, + Object error, + StackTrace? stackTrace, +) { + final translations = context.translations; + final text = resolveNetworkErrorText(context, error); + + return Material( + color: StreamChatTheme.of(context).colorTheme.appBg, + child: Center( + child: StreamScrollViewErrorWidget( + errorTitle: Text(text.title), + errorSubtitle: Text(text.description), + retryButtonText: Text(translations.tryAgainLabel), + onRetryPressed: () => StreamChannel.of(context).retry(), + ), + ), + ); +} diff --git a/packages/stream_chat_flutter/lib/src/utils/network_error_text.dart b/packages/stream_chat_flutter/lib/src/utils/network_error_text.dart new file mode 100644 index 0000000000..f50fac6ba8 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/utils/network_error_text.dart @@ -0,0 +1,40 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// Resolves a title and description for [error] based on its transport-level +/// [StreamChatNetworkError.type]. +/// +/// Connection failures map to the "no internet" copy and timeouts to the +/// "slow connection" copy. Everything else falls back to [fallbackTitle] / +/// [fallbackDescription] when provided, otherwise to the generic localized +/// copy. +({String title, String description}) resolveNetworkErrorText( + BuildContext context, + Object? error, { + String? fallbackTitle, + String? fallbackDescription, +}) { + final translations = context.translations; + return switch (error) { + StreamChatNetworkError(type: StreamChatNetworkErrorType.connectionError) => + ( + title: translations.connectionErrorTitle, + description: translations.connectionErrorDescription, + ), + StreamChatNetworkError( + type: StreamChatNetworkErrorType.connectionTimeout || + StreamChatNetworkErrorType.sendTimeout || + StreamChatNetworkErrorType.receiveTimeout + ) => + ( + title: translations.slowConnectionErrorTitle, + description: translations.slowConnectionErrorDescription, + ), + _ => ( + title: fallbackTitle ?? translations.genericErrorTitle, + description: + fallbackDescription ?? translations.genericErrorDescription, + ), + }; +} diff --git a/packages/stream_chat_flutter/test/src/localization/default_translations_test.dart b/packages/stream_chat_flutter/test/src/localization/default_translations_test.dart index 068d798243..c4e475eff4 100644 --- a/packages/stream_chat_flutter/test/src/localization/default_translations_test.dart +++ b/packages/stream_chat_flutter/test/src/localization/default_translations_test.dart @@ -39,6 +39,12 @@ void main() { ); expect(translations.emptyMessagesText, isNotNull); expect(translations.genericErrorText, isNotNull); + expect(translations.connectionErrorTitle, isNotNull); + expect(translations.connectionErrorDescription, isNotNull); + expect(translations.slowConnectionErrorTitle, isNotNull); + expect(translations.slowConnectionErrorDescription, isNotNull); + expect(translations.genericErrorTitle, isNotNull); + expect(translations.genericErrorDescription, isNotNull); expect(translations.loadingMessagesError, isNotNull); expect(translations.resultCountText(3), isNotNull); expect(translations.messageDeletedText, isNotNull); diff --git a/packages/stream_chat_flutter/test/src/stream_chat_default_channel_error_test.dart b/packages/stream_chat_flutter/test/src/stream_chat_default_channel_error_test.dart new file mode 100644 index 0000000000..138d7cae08 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/stream_chat_default_channel_error_test.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'mocks.dart'; + +void main() { + late MockClient client; + late MockClientState clientState; + + setUp(() { + client = MockClient(); + clientState = MockClientState(); + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); + }); + + StreamChatNetworkError dioError(DioExceptionType type) => + StreamChatNetworkError.fromDioException( + DioException( + requestOptions: RequestOptions(path: '/'), + type: type, + ), + ); + + // Pumps StreamChat and renders the default error state it installs for the + // given [error], via DefaultStreamChannelBuilders.errorBuilderOf. + Future pumpDefaultError(WidgetTester tester, Object error) { + return tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: Scaffold( + body: Builder( + builder: (context) { + final errorBuilder = + DefaultStreamChannelBuilders.errorBuilderOf(context); + return errorBuilder(context, error, null); + }, + ), + ), + ), + ), + ); + } + + group('default channel error builder installed by StreamChat', () { + testWidgets('shows the no-internet copy for connection errors', + (tester) async { + await pumpDefaultError( + tester, dioError(DioExceptionType.connectionError)); + + expect(find.byType(StreamScrollViewErrorWidget), findsOneWidget); + expect(find.text('No Internet Connection'), findsOneWidget); + expect( + find.text('Please check your internet connection'), findsOneWidget); + expect(find.text('Try Again'), findsOneWidget); + }); + + testWidgets('shows the slow-connection copy for timeouts', (tester) async { + await pumpDefaultError(tester, dioError(DioExceptionType.receiveTimeout)); + + expect(find.text('Slow Internet Connection'), findsOneWidget); + expect( + find.text('There seems to be a problem with your internet connection'), + findsOneWidget, + ); + }); + + testWidgets('shows a generic message and never the raw error', + (tester) async { + final error = StreamChatNetworkError.raw( + code: -1, + message: 'super secret internal failure', + statusCode: 500, + ); + + await pumpDefaultError(tester, error); + + expect(find.text('Error'), findsOneWidget); + expect(find.text('Oops, something went wrong'), findsOneWidget); + expect(find.textContaining('StreamChatNetworkError'), findsNothing); + expect( + find.textContaining('super secret internal failure'), findsNothing); + }); + }); + + testWidgets( + 'StreamChat installs a themed default channel loading builder', + (tester) async { + Color? expectedBackground; + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: Scaffold( + body: Builder( + builder: (context) { + expectedBackground = + StreamChatTheme.of(context).colorTheme.appBg; + final loadingBuilder = + DefaultStreamChannelBuilders.loadingBuilderOf(context); + return loadingBuilder(context); + }, + ), + ), + ), + ), + ); + + // The installed default shows a spinner on the themed app background. + expect(find.byType(CircularProgressIndicator), findsOneWidget); + final material = tester.widget( + find + .ancestor( + of: find.byType(CircularProgressIndicator), + matching: find.byType(Material), + ) + .first, + ); + expect(material.color, expectedBackground); + }, + ); +} diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index 705925fc0b..d881a09a0c 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -3,11 +3,14 @@ ✅ Added - Added support for predefined filters on `StreamChannelListController`. +- Added `StreamChannelState.retry()` to re-run a failed channel initialization, for use as the retry action in `StreamChannel.errorBuilder`. +- Added `DefaultStreamChannelBuilders`, an inherited widget that supplies default loading and error builders to descendant `StreamChannel`s (resolved via `loadingBuilderOf`/`errorBuilderOf`). 🐞 Fixed - Fixed `StreamChannelListController` not handling `notification.channel_deleted` event. - Fixed backwards pagination not working if channel was never opened. +- Fixed `StreamChannel`'s default error state exposing raw error details; it now shows a safe error state (icon, message, and a Try Again button wired to `retry()`) that adapts to the failure type. ## 9.26.0 diff --git a/packages/stream_chat_flutter_core/lib/src/stream_channel.dart b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart index 84de7b98ac..4b1db8ae35 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_channel.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart @@ -24,14 +24,6 @@ typedef ErrorWidgetBuilder = Widget Function( StackTrace? stackTrace, ); -Color _getDefaultBackgroundColor(BuildContext context) { - final brightness = Theme.of(context).brightness; - return switch (brightness) { - Brightness.light => const Color(0xfff7f7f8), - Brightness.dark => const Color(0xff000000), - }; -} - /// Widget used to provide information about the channel to the widget tree /// /// Use [StreamChannel.of] to get the current [StreamChannelState] instance. @@ -44,8 +36,8 @@ class StreamChannel extends StatefulWidget { required this.channel, this.showLoading = true, this.initialMessageId, - this.errorBuilder = _defaultErrorBuilder, - this.loadingBuilder = _defaultLoadingBuilder, + this.errorBuilder = _resolveErrorBuilder, + this.loadingBuilder = _resolveLoadingBuilder, }) : _shouldPosition = true; /// Exposes a [channel] to descendants without repositioning the loaded @@ -67,8 +59,8 @@ class StreamChannel extends StatefulWidget { required this.channel, }) : showLoading = false, initialMessageId = null, - errorBuilder = _defaultErrorBuilder, - loadingBuilder = _defaultLoadingBuilder, + errorBuilder = _resolveErrorBuilder, + loadingBuilder = _resolveLoadingBuilder, _shouldPosition = false; /// The child of the widget @@ -83,53 +75,35 @@ class StreamChannel extends StatefulWidget { /// If passed the channel will load from this particular message. final String? initialMessageId; - /// Widget builder used in case the channel is initialising. + /// Widget builder used while the channel is initialising. + /// + /// Defaults to a builder that resolves the nearest + /// [DefaultStreamChannelBuilders], falling back to a built-in loading + /// indicator. final WidgetBuilder loadingBuilder; - /// Widget builder used in case an error occurs while building the channel. + /// Widget builder used when an error occurs while building the channel. + /// + /// Defaults to a builder that resolves the nearest + /// [DefaultStreamChannelBuilders], falling back to a built-in error widget. final ErrorWidgetBuilder errorBuilder; // Whether to position the loaded window on mount (initialMessageId, // last-read, or latest). Only false for StreamChannel.value. final bool _shouldPosition; - static Widget _defaultLoadingBuilder(BuildContext context) { - final backgroundColor = _getDefaultBackgroundColor(context); - return Material( - color: backgroundColor, - child: const Center( - child: CircularProgressIndicator.adaptive(), - ), - ); + static Widget _resolveLoadingBuilder(BuildContext context) { + final builder = DefaultStreamChannelBuilders.loadingBuilderOf(context); + return builder(context); } - static Widget _defaultErrorBuilder( + static Widget _resolveErrorBuilder( BuildContext context, Object error, StackTrace? stackTrace, ) { - final backgroundColor = _getDefaultBackgroundColor(context); - - Object? unwrapParallelError(Object error) { - if (error case ParallelWaitError(:final List errors)) { - return errors.firstWhereOrNull((it) => it != null)?.error; - } - - return error; - } - - final exception = unwrapParallelError(error); - return Material( - color: backgroundColor, - child: Center( - child: switch (exception) { - DioException(type: DioExceptionType.badResponse) => - Text(exception.message ?? 'Bad response'), - DioException() => const Text('Check your connection and retry'), - _ => Text(exception.toString()), - }, - ), - ); + final builder = DefaultStreamChannelBuilders.errorBuilderOf(context); + return builder(context, error, stackTrace); } /// Finds the [StreamChannelState] from the closest [StreamChannel] ancestor @@ -841,6 +815,28 @@ class StreamChannelState extends State { return _queryAtMessage(); } + /// Retries initializing the channel after a failure. + /// + /// Re-runs the channel initialization and rebuilds so a previously failed + /// load (e.g. due to a network error) can recover. This is the retry action + /// to wire into [StreamChannel.errorBuilder]. + void retry() { + if (!mounted) return; + setState(_initializeChannel); // Rebuild to show the loading state again. + } + + void _initializeChannel() { + // Order matters: `_maybeInitChannel()` triggers the (re)query that resets + // `channel.initialized` after a failed init, so it must run before + // `channel.initialized` is read here — otherwise a retry would await the + // stale, already-errored completer. + // + // `..ignore()` avoids an unhandled error if the future fails before the + // FutureBuilder resubscribes (e.g. on retry's deferred rebuild). + _channelInitFuture = Future.wait([_maybeInitChannel(), channel.initialized]) + ..ignore(); + } + Future _maybeInitChannel() async { // If the channel doesn't have an CID yet, it hasn't been created on the // server so we don't need to initialize it. @@ -899,7 +895,7 @@ class StreamChannelState extends State { @override void initState() { super.initState(); - _channelInitFuture = [_maybeInitChannel(), channel.initialized].wait; + _initializeChannel(); } @override @@ -908,7 +904,7 @@ class StreamChannelState extends State { if (oldWidget.channel.cid != widget.channel.cid || oldWidget.initialMessageId != widget.initialMessageId) { // Re-initialize channel if the channel CID or initial message ID changes. - _channelInitFuture = [_maybeInitChannel(), channel.initialized].wait; + _initializeChannel(); } } @@ -926,19 +922,159 @@ class StreamChannelState extends State { child: FutureBuilder( future: _channelInitFuture, builder: (context, snapshot) { + // Gate the error on `done`: the snapshot keeps the stale error across + // a retry, so checking it first would hide the loading state. + if (snapshot.connectionState != ConnectionState.done) { + if (widget.showLoading) return widget.loadingBuilder(context); + return widget.child; // return child directly if loading is disabled + } + if (snapshot.hasError) { final error = snapshot.error!; final stackTrace = snapshot.stackTrace; return widget.errorBuilder(context, error, stackTrace); } - if (snapshot.connectionState != ConnectionState.done) { - if (widget.showLoading) return widget.loadingBuilder(context); - } - return widget.child; }, ), ); } } + +/// Provides default builders to descendant [StreamChannel]s that don't +/// specify their own [StreamChannel.loadingBuilder] / [StreamChannel.errorBuilder]. +/// +/// Lets a higher layer (e.g. `stream_chat_flutter`'s `StreamChat`) supply +/// themed, localized loading and error states for every [StreamChannel] +/// beneath it, without each call site passing them. +class DefaultStreamChannelBuilders extends InheritedWidget { + /// Creates a new instance of [DefaultStreamChannelBuilders]. + const DefaultStreamChannelBuilders({ + super.key, + this.loadingBuilder, + this.errorBuilder, + required super.child, + }); + + /// Default builder for the channel-initializing state. + final WidgetBuilder? loadingBuilder; + + /// Default builder for the channel-initialization error state. + final ErrorWidgetBuilder? errorBuilder; + + /// Resolves the loading builder from the closest + /// [DefaultStreamChannelBuilders] above [context], falling back to the + /// built-in loading indicator. + static WidgetBuilder loadingBuilderOf(BuildContext context) { + final scope = context + .dependOnInheritedWidgetOfExactType(); + return scope?.loadingBuilder ?? _defaultLoadingBuilder; + } + + /// Resolves the error builder from the closest [DefaultStreamChannelBuilders] + /// above [context], falling back to the built-in error widget. + static ErrorWidgetBuilder errorBuilderOf(BuildContext context) { + final scope = context + .dependOnInheritedWidgetOfExactType(); + return scope?.errorBuilder ?? _defaultErrorBuilder; + } + + @override + bool updateShouldNotify(DefaultStreamChannelBuilders oldWidget) { + return loadingBuilder != oldWidget.loadingBuilder || + errorBuilder != oldWidget.errorBuilder; + } + + static Color _getDefaultBackgroundColor(BuildContext context) { + final brightness = Theme.of(context).brightness; + return switch (brightness) { + Brightness.light => const Color(0xfff7f7f8), + Brightness.dark => const Color(0xff000000), + }; + } + + static Widget _defaultLoadingBuilder(BuildContext context) { + final backgroundColor = _getDefaultBackgroundColor(context); + return Material( + color: backgroundColor, + child: const Center( + child: CircularProgressIndicator.adaptive(), + ), + ); + } + + static Widget _defaultErrorBuilder( + BuildContext context, + Object error, + StackTrace? stackTrace, + ) { + final backgroundColor = _getDefaultBackgroundColor(context); + + // Raw, unlocalized fallback: this core widget has no design system or + // translations, so copy/styling are hardcoded. Apps can override with + // [StreamChannel.errorBuilder] for a themed, localized state. + final (title, message) = switch (error) { + StreamChatNetworkError( + type: StreamChatNetworkErrorType.connectionError + ) => + ( + 'No Internet Connection', + 'Please check your internet connection', + ), + StreamChatNetworkError( + type: StreamChatNetworkErrorType.connectionTimeout || + StreamChatNetworkErrorType.sendTimeout || + StreamChatNetworkErrorType.receiveTimeout + ) => + ( + 'Slow Internet Connection', + 'There seems to be a problem with your internet connection', + ), + _ => ('Error', 'Oops, something went wrong'), + }; + + return Material( + color: backgroundColor, + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 40, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline_rounded, size: 32), + const SizedBox(height: 8), + Text( + title, + textAlign: TextAlign.center, + style: + const TextStyle(fontSize: 16, fontWeight: FontWeight.w400), + ), + const SizedBox(height: 4), + Text( + message, + textAlign: TextAlign.center, + style: + const TextStyle(fontSize: 14, fontWeight: FontWeight.w400), + ), + const SizedBox(height: 16), + OutlinedButton( + onPressed: () => StreamChannel.maybeOf(context)?.retry(), + style: OutlinedButton.styleFrom( + textStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + child: const Text('Try Again'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/packages/stream_chat_flutter_core/test/stream_channel_test.dart b/packages/stream_chat_flutter_core/test/stream_channel_test.dart index b9b75a2ec5..b262f69979 100644 --- a/packages/stream_chat_flutter_core/test/stream_channel_test.dart +++ b/packages/stream_chat_flutter_core/test/stream_channel_test.dart @@ -1,5 +1,7 @@ // ignore_for_file: lines_longer_than_80_chars +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -317,12 +319,11 @@ void main() { // Important: Making initialization fail with a direct error when(nonInitializedMockChannel.watch).thenThrow('Failed to connect'); - // A widget with custom error handler + // A widget relying on the default error builder. final testWidget = MaterialApp( home: Scaffold( body: StreamChannel( channel: nonInitializedMockChannel, - // The default error builder will show the error message child: const Text('Child Widget'), ), ), @@ -336,8 +337,9 @@ void main() { // Wait for error to occur await tester.pumpAndSettle(); - // Should show error widget with the error message - expect(find.text('Failed to connect'), findsOneWidget); + // Should show a safe, generic error message instead of the raw error. + expect(find.text('Failed to connect'), findsNothing); + expect(find.text('Oops, something went wrong'), findsOneWidget); expect(find.text('Child Widget'), findsNothing); }, ); @@ -375,6 +377,291 @@ void main() { }, ); + testWidgets( + 'errorBuilder receives the unwrapped error, not a ParallelWaitError', + (tester) async { + final channel = NonInitializedMockChannel(); + when(() => channel.cid).thenReturn('test:channel'); + final networkError = StreamChatNetworkError.fromDioException( + DioException( + requestOptions: RequestOptions(path: 'test'), + type: DioExceptionType.connectionError, + ), + ); + when(channel.watch).thenThrow(networkError); + + Object? capturedError; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StreamChannel( + channel: channel, + errorBuilder: (_, error, __) { + capturedError = error; + return const Text('Custom Error'); + }, + child: const Text('Child Widget'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // The builder gets the real cause, not the aggregate wrapper. + expect(capturedError, isNot(isA())); + expect(capturedError, same(networkError)); + }, + ); + + testWidgets( + 'default errorBuilder never renders the raw error and shows a generic message', + (tester) async { + final channel = NonInitializedMockChannel(); + when(() => channel.cid).thenReturn('test:channel'); + final rawError = StreamChatNetworkError.raw( + code: -1, + message: 'super secret internal failure', + statusCode: 500, + ); + when(channel.watch).thenThrow(rawError); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StreamChannel( + channel: channel, + child: const Text('Child Widget'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // The raw error string must not leak to the UI. + expect(find.textContaining('StreamChatNetworkError'), findsNothing); + expect( + find.textContaining('super secret internal failure'), findsNothing); + expect(find.text('Oops, something went wrong'), findsOneWidget); + }, + ); + + testWidgets( + 'default errorBuilder shows a connection message for connection errors', + (tester) async { + final channel = NonInitializedMockChannel(); + when(() => channel.cid).thenReturn('test:channel'); + when(channel.watch).thenThrow( + StreamChatNetworkError.fromDioException( + DioException( + requestOptions: RequestOptions(path: 'test'), + type: DioExceptionType.connectionError, + ), + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StreamChannel( + channel: channel, + child: const Text('Child Widget'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.text('No Internet Connection'), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'retry() re-runs initialization and clears the error on success', + (tester) async { + final channel = NonInitializedMockChannel(); + when(() => channel.cid).thenReturn('test:channel'); + var attempts = 0; + when(channel.watch).thenAnswer((_) async { + attempts++; + if (attempts == 1) { + throw StreamChatNetworkError.raw(code: -1, message: 'boom'); + } + return const ChannelState(); + }); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StreamChannel( + channel: channel, + child: const Text('Child Widget'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // The error state is shown and the child is hidden. + expect(find.text('Oops, something went wrong'), findsOneWidget); + expect(find.text('Child Widget'), findsNothing); + + // Retrying re-runs init; the second watch succeeds and the child renders. + tester.state(find.byType(StreamChannel)).retry(); + await tester.pumpAndSettle(); + + expect(find.text('Child Widget'), findsOneWidget); + expect(find.text('Oops, something went wrong'), findsNothing); + expect(attempts, 2); + }, + ); + + testWidgets( + 'retry() shows the loading state again instead of the stale error', + (tester) async { + final channel = NonInitializedMockChannel(); + when(() => channel.cid).thenReturn('test:channel'); + Completer? secondWatch; + var attempts = 0; + when(channel.watch).thenAnswer((_) { + attempts++; + if (attempts == 1) { + return Future.error( + StreamChatNetworkError.raw(code: -1, message: 'boom'), + ); + } + secondWatch = Completer(); + return secondWatch!.future; + }); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StreamChannel( + channel: channel, + child: const Text('Child Widget'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Oops, something went wrong'), findsOneWidget); + + // Retry while the second attempt is still in flight. + tester.state(find.byType(StreamChannel)).retry(); + await tester.pump(); + + // The loading state is shown again — not the stale error snapshot that + // FutureBuilder retains across the future reassignment. + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text('Oops, something went wrong'), findsNothing); + + // Completing the in-flight watch renders the child. + secondWatch!.complete(const ChannelState()); + await tester.pumpAndSettle(); + expect(find.text('Child Widget'), findsOneWidget); + }, + ); + + testWidgets( + 'retry into a persistent failure surfaces the error without leaking', + (tester) async { + final channel = NonInitializedMockChannel(); + when(() => channel.cid).thenReturn('test:channel'); + // Always fails, so the retry fails again. + when(channel.watch).thenThrow( + StreamChatNetworkError.raw(code: -1, message: 'boom'), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StreamChannel( + channel: channel, + child: const Text('Child Widget'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Oops, something went wrong'), findsOneWidget); + + // Retry while still failing. The init future errors before the deferred + // rebuild resubscribes; without the `..ignore()` guard this leaks an + // unhandled async error, which the test binding reports as a failure. + tester.state(find.byType(StreamChannel)).retry(); + await tester.pumpAndSettle(); + + // Error UI still shown, and no unhandled error was thrown. + expect(find.text('Oops, something went wrong'), findsOneWidget); + expect(find.text('Child Widget'), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'uses DefaultStreamChannelBuilders.errorBuilder when none is provided', + (tester) async { + final channel = NonInitializedMockChannel(); + when(() => channel.cid).thenReturn('test:channel'); + when(channel.watch).thenThrow( + StreamChatNetworkError.raw(code: -1, message: 'boom'), + ); + + await tester.pumpWidget( + MaterialApp( + home: DefaultStreamChannelBuilders( + errorBuilder: (_, __, ___) => const Text('Inherited Error'), + child: Scaffold( + body: StreamChannel( + channel: channel, + child: const Text('Child Widget'), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // The inherited default is used, not the built-in one. + expect(find.text('Inherited Error'), findsOneWidget); + expect(find.text('Oops, something went wrong'), findsNothing); + }, + ); + + testWidgets( + 'explicit errorBuilder wins over DefaultStreamChannelBuilders', + (tester) async { + final channel = NonInitializedMockChannel(); + when(() => channel.cid).thenReturn('test:channel'); + when(channel.watch).thenThrow( + StreamChatNetworkError.raw(code: -1, message: 'boom'), + ); + + await tester.pumpWidget( + MaterialApp( + home: DefaultStreamChannelBuilders( + errorBuilder: (_, __, ___) => const Text('Inherited Error'), + child: Scaffold( + body: StreamChannel( + channel: channel, + errorBuilder: (_, __, ___) => const Text('Explicit Error'), + child: const Text('Child Widget'), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Explicit Error'), findsOneWidget); + expect(find.text('Inherited Error'), findsNothing); + }, + ); + testWidgets( 'should query channel at specific message when initialMessageId is provided', (tester) async { diff --git a/packages/stream_chat_localizations/CHANGELOG.md b/packages/stream_chat_localizations/CHANGELOG.md index 2d819a654d..0c124e35cc 100644 --- a/packages/stream_chat_localizations/CHANGELOG.md +++ b/packages/stream_chat_localizations/CHANGELOG.md @@ -1,6 +1,7 @@ ## 9.27.0 - Updated `stream_chat_flutter` dependency to [`9.27.0`](https://pub.dev/packages/stream_chat_flutter/changelog). +- Added connection-error translations (`connectionErrorTitle`/`Description`, `slowConnectionErrorTitle`/`Description`, `genericErrorTitle`/`Description`) for all supported locales. ## 9.26.0 diff --git a/packages/stream_chat_localizations/example/lib/add_new_lang.dart b/packages/stream_chat_localizations/example/lib/add_new_lang.dart index ce38cf422e..1fd82a3068 100644 --- a/packages/stream_chat_localizations/example/lib/add_new_lang.dart +++ b/packages/stream_chat_localizations/example/lib/add_new_lang.dart @@ -330,6 +330,26 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { @override String get tryAgainLabel => 'Try Again'; + @override + String get connectionErrorTitle => 'No Internet Connection'; + + @override + String get connectionErrorDescription => + 'Please check your internet connection'; + + @override + String get slowConnectionErrorTitle => 'Slow Internet Connection'; + + @override + String get slowConnectionErrorDescription => + 'There seems to be a problem with your internet connection'; + + @override + String get genericErrorTitle => 'Error'; + + @override + String get genericErrorDescription => 'Oops, something went wrong'; + @override String membersCountText(int count) { if (count == 1) return '1 Member'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart index a71ec593a2..c899966fbf 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart @@ -167,6 +167,26 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { @override String get somethingWentWrongError => 'Alguna cosa ha anat malament'; + @override + String get connectionErrorTitle => 'Sense connexió a Internet'; + + @override + String get connectionErrorDescription => + 'Comprova la teva connexió a Internet'; + + @override + String get slowConnectionErrorTitle => 'Connexió a Internet lenta'; + + @override + String get slowConnectionErrorDescription => + 'Sembla que hi ha un problema amb la teva connexió a Internet'; + + @override + String get genericErrorTitle => 'Error'; + + @override + String get genericErrorDescription => 'Vaja, alguna cosa ha anat malament'; + @override String get addMoreFilesLabel => 'Afegir més fitxers'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart index 6ca4153625..0fafcf5580 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart @@ -157,6 +157,26 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { @override String get somethingWentWrongError => 'Etwas ist schief gelaufen'; + @override + String get connectionErrorTitle => 'Keine Internetverbindung'; + + @override + String get connectionErrorDescription => + 'Bitte überprüfe deine Internetverbindung'; + + @override + String get slowConnectionErrorTitle => 'Langsame Internetverbindung'; + + @override + String get slowConnectionErrorDescription => + 'Es scheint ein Problem mit deiner Internetverbindung zu geben'; + + @override + String get genericErrorTitle => 'Fehler'; + + @override + String get genericErrorDescription => 'Hoppla, etwas ist schief gelaufen'; + @override String get addMoreFilesLabel => 'Weitere Dateien hinzufügen'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart index f26fd20a4e..c4e998a536 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart @@ -164,6 +164,26 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { @override String get somethingWentWrongError => 'Something went wrong'; + @override + String get connectionErrorTitle => 'No Internet Connection'; + + @override + String get connectionErrorDescription => + 'Please check your internet connection'; + + @override + String get slowConnectionErrorTitle => 'Slow Internet Connection'; + + @override + String get slowConnectionErrorDescription => + 'There seems to be a problem with your internet connection'; + + @override + String get genericErrorTitle => 'Error'; + + @override + String get genericErrorDescription => 'Oops, something went wrong'; + @override String get addMoreFilesLabel => 'Add more files'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart index e1587597c7..6f88a435c9 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart @@ -168,6 +168,25 @@ class StreamChatLocalizationsEs extends GlobalStreamChatLocalizations { @override String get somethingWentWrongError => 'Algo ha salido mal'; + @override + String get connectionErrorTitle => 'Sin conexión a Internet'; + + @override + String get connectionErrorDescription => 'Comprueba tu conexión a Internet'; + + @override + String get slowConnectionErrorTitle => 'Conexión a Internet lenta'; + + @override + String get slowConnectionErrorDescription => + 'Parece que hay un problema con tu conexión a Internet'; + + @override + String get genericErrorTitle => 'Error'; + + @override + String get genericErrorDescription => 'Vaya, algo ha salido mal'; + @override String get addMoreFilesLabel => 'Añadir más archivos'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart index 7d210044ed..9df6581fe1 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart @@ -167,6 +167,26 @@ class StreamChatLocalizationsFr extends GlobalStreamChatLocalizations { @override String get somethingWentWrongError => 'Quelque chose a mal tourné'; + @override + String get connectionErrorTitle => 'Pas de connexion Internet'; + + @override + String get connectionErrorDescription => + 'Veuillez vérifier votre connexion Internet'; + + @override + String get slowConnectionErrorTitle => 'Connexion Internet lente'; + + @override + String get slowConnectionErrorDescription => + 'Il semble y avoir un problème avec votre connexion Internet'; + + @override + String get genericErrorTitle => 'Erreur'; + + @override + String get genericErrorDescription => 'Oups, quelque chose a mal tourné'; + @override String get addMoreFilesLabel => "Ajouter d'autres fichiers"; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart index e4120b3782..bc1f8ba481 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart @@ -162,6 +162,25 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { @override String get somethingWentWrongError => 'लोड करने में समस्या'; + @override + String get connectionErrorTitle => 'इंटरनेट कनेक्शन नहीं है'; + + @override + String get connectionErrorDescription => 'कृपया अपना इंटरनेट कनेक्शन जांचें'; + + @override + String get slowConnectionErrorTitle => 'धीमा इंटरनेट कनेक्शन'; + + @override + String get slowConnectionErrorDescription => + 'ऐसा लगता है कि आपके इंटरनेट कनेक्शन में कोई समस्या है'; + + @override + String get genericErrorTitle => 'त्रुटि'; + + @override + String get genericErrorDescription => 'ओह, कुछ गलत हो गया'; + @override String get addMoreFilesLabel => 'और फ़ाइलें जोड़ें'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart index 878a2a6b8a..dfb8f90a0e 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart @@ -171,6 +171,26 @@ Il file è troppo grande per essere caricato. Il limite è di $limitInMB MB.'''; @override String get somethingWentWrongError => 'Qualcosa è andato storto'; + @override + String get connectionErrorTitle => 'Nessuna connessione a Internet'; + + @override + String get connectionErrorDescription => + 'Controlla la tua connessione a Internet'; + + @override + String get slowConnectionErrorTitle => 'Connessione a Internet lenta'; + + @override + String get slowConnectionErrorDescription => + 'Sembra esserci un problema con la tua connessione a Internet'; + + @override + String get genericErrorTitle => 'Errore'; + + @override + String get genericErrorDescription => 'Ops, qualcosa è andato storto'; + @override String get addMoreFilesLabel => 'Aggiungi altri file'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart index fea76688a1..e0088f6e01 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart @@ -159,6 +159,24 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { @override String get somethingWentWrongError => 'エラーが発生しました'; + @override + String get connectionErrorTitle => 'インターネット接続がありません'; + + @override + String get connectionErrorDescription => 'インターネット接続を確認してください'; + + @override + String get slowConnectionErrorTitle => 'インターネット接続が遅いです'; + + @override + String get slowConnectionErrorDescription => 'インターネット接続に問題があるようです'; + + @override + String get genericErrorTitle => 'エラー'; + + @override + String get genericErrorDescription => 'おっと、問題が発生しました'; + @override String get addMoreFilesLabel => 'ファイルの追加'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart index 9d4156d2d8..6a2fb2659b 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart @@ -159,6 +159,24 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { @override String get somethingWentWrongError => '뭔가 잘못됐습느다'; + @override + String get connectionErrorTitle => '인터넷 연결 없음'; + + @override + String get connectionErrorDescription => '인터넷 연결을 확인해 주세요'; + + @override + String get slowConnectionErrorTitle => '인터넷 연결이 느림'; + + @override + String get slowConnectionErrorDescription => '인터넷 연결에 문제가 있는 것 같습니다'; + + @override + String get genericErrorTitle => '오류'; + + @override + String get genericErrorDescription => '앗, 문제가 발생했습니다'; + @override String get addMoreFilesLabel => '파일을 추가함'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart index 814867f8d8..46aaf8f3b1 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart @@ -160,6 +160,25 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { @override String get somethingWentWrongError => 'Noe gikk galt'; + @override + String get connectionErrorTitle => 'Ingen internettforbindelse'; + + @override + String get connectionErrorDescription => 'Sjekk internettforbindelsen din'; + + @override + String get slowConnectionErrorTitle => 'Treg internettforbindelse'; + + @override + String get slowConnectionErrorDescription => + 'Det ser ut til å være et problem med internettforbindelsen din'; + + @override + String get genericErrorTitle => 'Feil'; + + @override + String get genericErrorDescription => 'Oi, noe gikk galt'; + @override String get addMoreFilesLabel => 'Legg til flere filer'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart index ddc189736f..837de109ff 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart @@ -163,6 +163,26 @@ class StreamChatLocalizationsPt extends GlobalStreamChatLocalizations { @override String get somethingWentWrongError => 'Algo deu errado'; + @override + String get connectionErrorTitle => 'Sem conexão com a Internet'; + + @override + String get connectionErrorDescription => + 'Verifique sua conexão com a Internet'; + + @override + String get slowConnectionErrorTitle => 'Conexão com a Internet lenta'; + + @override + String get slowConnectionErrorDescription => + 'Parece haver um problema com sua conexão com a Internet'; + + @override + String get genericErrorTitle => 'Erro'; + + @override + String get genericErrorDescription => 'Ops, algo deu errado'; + @override String get addMoreFilesLabel => 'Adicionar mais arquivos'; diff --git a/packages/stream_chat_localizations/test/translations_test.dart b/packages/stream_chat_localizations/test/translations_test.dart index 8c043e3787..1f5901d5f4 100644 --- a/packages/stream_chat_localizations/test/translations_test.dart +++ b/packages/stream_chat_localizations/test/translations_test.dart @@ -156,6 +156,12 @@ void main() { expect(localizations.searchingForNetworkText, isNotNull); expect(localizations.offlineLabel, isNotNull); expect(localizations.tryAgainLabel, isNotNull); + expect(localizations.connectionErrorTitle, isNotNull); + expect(localizations.connectionErrorDescription, isNotNull); + expect(localizations.slowConnectionErrorTitle, isNotNull); + expect(localizations.slowConnectionErrorDescription, isNotNull); + expect(localizations.genericErrorTitle, isNotNull); + expect(localizations.genericErrorDescription, isNotNull); // 1 member expect(localizations.membersCountText(1), isNotNull); // 3 members