diff --git a/.github/workflows/stream_flutter_workflow.yml b/.github/workflows/stream_flutter_workflow.yml index af57f49f6..b63933bb7 100644 --- a/.github/workflows/stream_flutter_workflow.yml +++ b/.github/workflows/stream_flutter_workflow.yml @@ -8,6 +8,11 @@ on: pull_request: paths: - 'packages/**' + types: + - opened + - reopened + - synchronize + - ready_for_review push: branches: - master diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 9a4f508d7..3abd5f22f 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -1,3 +1,45 @@ +## 6.1.0 + +๐Ÿž Fixed + +- [[#1355]](https://github.com/GetStream/stream-chat-flutter/issues/1355) Fixed error while hiding channel and clearing + message history. +- [[#1525]](https://github.com/GetStream/stream-chat-flutter/issues/1525) Fixed removing message not removing quoted + message reference. + +โœ… Added + +- Expose `ChannelMute` class. [#1473](https://github.com/GetStream/stream-chat-flutter/issues/1473) +- Added synchronization to the `StreamChatClient.sync` + api. [#1392](https://github.com/GetStream/stream-chat-flutter/issues/1392) +- Added support for `StreamChatClient.chatApiInterceptors` to add custom interceptors to the API client. + [#1265](https://github.com/GetStream/stream-chat-flutter/issues/1265). + + ```dart + final client = StreamChatClient( + chatApiInterceptors: [ + InterceptorsWrapper( + onRequest: (options, handler) { + // Do something before request is sent. + return handler.next(options); + }, + onResponse: (response, handler) { + // Do something with response data + return handler.next(response); + }, + onError: (DioError e, handler) { + // Do something with response error + return handler.next(e); + }, + ), + ], + ); + ``` + +๐Ÿ”„ Changed + +- Updated `dart` sdk environment range to support `3.0.0`. + ## 6.0.0 ๐Ÿž Fixed diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index 7d8515c73..3b249fcaa 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -746,6 +746,7 @@ class Channel { state!.deleteMessage( message.copyWith( type: 'deleted', + deletedAt: message.deletedAt ?? DateTime.now(), status: MessageSendingStatus.sent, ), hardDelete: hardDelete, @@ -997,26 +998,24 @@ class Channel { ) async { final type = reaction.type; - final reactionCounts = {...message.reactionCounts ?? {}}; + final reactionCounts = {...?message.reactionCounts}; if (reactionCounts.containsKey(type)) { reactionCounts.update(type, (value) => value - 1); } - final reactionScores = {...message.reactionScores ?? {}}; + final reactionScores = {...?message.reactionScores}; if (reactionScores.containsKey(type)) { reactionScores.update(type, (value) => value - 1); } - final latestReactions = [...message.latestReactions ?? []] - ..removeWhere((r) => - r.userId == reaction.userId && - r.type == reaction.type && - r.messageId == reaction.messageId); + final latestReactions = [...?message.latestReactions]..removeWhere((r) => + r.userId == reaction.userId && + r.type == reaction.type && + r.messageId == reaction.messageId); - final ownReactions = message.ownReactions - ?..removeWhere((r) => - r.userId == reaction.userId && - r.type == reaction.type && - r.messageId == reaction.messageId); + final ownReactions = [...?message.ownReactions]..removeWhere((r) => + r.userId == reaction.userId && + r.type == reaction.type && + r.messageId == reaction.messageId); final newMessage = message.copyWith( reactionCounts: reactionCounts..removeWhere((_, value) => value == 0), @@ -1485,19 +1484,11 @@ class Channel { /// will be removed for the user. Future hide({bool clearHistory = false}) async { _checkInitialized(); - final response = await _client.hideChannel( + return _client.hideChannel( id!, type, clearHistory: clearHistory, ); - if (clearHistory) { - state!.truncate(); - final cid = _cid; - if (cid != null) { - await _client.chatPersistenceClient?.deleteMessageByCid(cid); - } - } - return response; } /// Removes the hidden status for the channel. @@ -1938,11 +1929,9 @@ class ChannelClientState { void _listenMessageDeleted() { _subscriptions.add(_channel.on(EventType.messageDeleted).listen((event) { final message = event.message!; - if (event.hardDelete == true) { - removeMessage(message); - } else { - updateMessage(message); - } + final hardDelete = event.hardDelete ?? false; + + deleteMessage(message, hardDelete: hardDelete); })); } @@ -1967,18 +1956,35 @@ class ChannelClientState { /// Updates the [message] in the state if it exists. Adds it otherwise. void updateMessage(Message message) { + // Regular messages, which are shown in channel. if (message.parentId == null || message.showInChannel == true) { - final newMessages = [...messages]; + var newMessages = [...messages]; final oldIndex = newMessages.indexWhere((m) => m.id == message.id); if (oldIndex != -1) { - Message? m; + var updatedMessage = message; + // Add quoted message to the message if it is not present. if (message.quotedMessageId != null && message.quotedMessage == null) { final oldMessage = newMessages[oldIndex]; - m = message.copyWith( + updatedMessage = updatedMessage.copyWith( quotedMessage: oldMessage.quotedMessage, ); } - newMessages[oldIndex] = m ?? message; + newMessages[oldIndex] = updatedMessage; + + // Update quoted message reference for every message if available. + newMessages = [...newMessages].map((it) { + // Early return if the message doesn't have a quoted message. + if (it.quotedMessageId != message.id) return it; + + // Setting it to null will remove the quoted message from the message + // So, we are setting the same message but with the deleted state. + return it.copyWith( + quotedMessage: updatedMessage.copyWith( + type: 'deleted', + deletedAt: updatedMessage.deletedAt ?? DateTime.now(), + ), + ); + }).toList(); } else { newMessages.add(message); } @@ -2007,6 +2013,7 @@ class ChannelClientState { ); } + // Thread messages, which are shown in thread page. if (message.parentId != null) { updateThreadInfo(message.parentId!, [message]); } @@ -2036,9 +2043,22 @@ class ChannelClientState { } // Remove regular message, thread message shown in channel - final allMessages = [...messages]; + var updatedMessages = [...messages]..removeWhere((e) => e.id == message.id); + + // Remove quoted message reference from every message if available. + updatedMessages = [...updatedMessages].map((it) { + // Early return if the message doesn't have a quoted message. + if (it.quotedMessageId != message.id) return it; + + // Setting it to null will remove the quoted message from the message. + return it.copyWith( + quotedMessage: null, + quotedMessageId: null, + ); + }).toList(); + _channelState = _channelState.copyWith( - messages: allMessages..removeWhere((e) => e.id == message.id), + messages: updatedMessages, ); } diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 4f2dbda3d..7627cf1df 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -32,6 +32,7 @@ import 'package:stream_chat/src/event_type.dart'; import 'package:stream_chat/src/ws/connection_status.dart'; import 'package:stream_chat/src/ws/websocket.dart'; import 'package:stream_chat/version.dart'; +import 'package:synchronized/extension.dart'; /// Handler function used for logging records. Function requires a single /// [LogRecord] as the only parameter. @@ -72,6 +73,7 @@ class StreamChatClient { WebSocket? ws, AttachmentFileUploaderProvider attachmentFileUploaderProvider = StreamAttachmentFileUploader.new, + Iterable? chatApiInterceptors, }) { logger.info('Initiating new StreamChatClient'); @@ -90,6 +92,7 @@ class StreamChatClient { connectionIdManager: _connectionIdManager, attachmentFileUploaderProvider: attachmentFileUploaderProvider, logger: detachedLogger('๐Ÿ•ธ๏ธ'), + interceptors: chatApiInterceptors, ); _ws = ws ?? @@ -488,37 +491,40 @@ class StreamChatClient { /// Get the events missed while offline to sync the offline storage /// Will automatically fetch [cids] and [lastSyncedAt] if [persistenceEnabled] - Future sync({List? cids, DateTime? lastSyncAt}) async { - cids ??= await _chatPersistenceClient?.getChannelCids(); - if (cids == null || cids.isEmpty) { - return; - } + Future sync({List? cids, DateTime? lastSyncAt}) { + return synchronized(() async { + final channels = cids ?? await _chatPersistenceClient?.getChannelCids(); + if (channels == null || channels.isEmpty) { + return; + } - lastSyncAt ??= await _chatPersistenceClient?.getLastSyncAt(); - if (lastSyncAt == null) { - return; - } + final syncAt = + lastSyncAt ?? await _chatPersistenceClient?.getLastSyncAt(); + if (syncAt == null) { + return; + } - try { - final res = await _chatApi.general.sync(cids, lastSyncAt); - final events = res.events - ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); - - for (final event in events) { - logger.fine('event.type: ${event.type}'); - final messageText = event.message?.text; - if (messageText != null) { - logger.fine('event.message.text: $messageText'); + try { + final res = await _chatApi.general.sync(channels, syncAt); + final events = res.events + ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + + for (final event in events) { + logger.fine('event.type: ${event.type}'); + final messageText = event.message?.text; + if (messageText != null) { + logger.fine('event.message.text: $messageText'); + } + handleEvent(event); } - handleEvent(event); - } - final now = DateTime.now(); - _lastSyncedAt = now; - _chatPersistenceClient?.updateLastSyncAt(now); - } catch (e, stk) { - logger.severe('Error during sync', e, stk); - } + final now = DateTime.now(); + _lastSyncedAt = now; + _chatPersistenceClient?.updateLastSyncAt(now); + } catch (e, stk) { + logger.severe('Error during sync', e, stk); + } + }); } final _queryChannelsStreams = >>{}; @@ -1567,7 +1573,7 @@ class ClientState { _client.on(EventType.channelHidden).listen((event) async { final eventChannel = event.channel!; await _client.chatPersistenceClient?.deleteChannels([eventChannel.cid]); - channels[eventChannel.cid]?.dispose(); + channels.remove(eventChannel.cid)?.dispose(); }), ); } @@ -1606,7 +1612,7 @@ class ClientState { .listen((Event event) async { final eventChannel = event.channel!; await _client.chatPersistenceClient?.deleteChannels([eventChannel.cid]); - channels[eventChannel.cid]?.dispose(); + channels.remove(eventChannel.cid)?.dispose(); }), ); } @@ -1713,9 +1719,9 @@ class ClientState { _unreadChannelsController.close(); _totalUnreadCountController.close(); - final channels = this.channels.values.toList(); + final channels = [...this.channels.keys]; for (final channel in channels) { - channel.dispose(); + this.channels.remove(channel)?.dispose(); } _channelsController.close(); } diff --git a/packages/stream_chat/lib/src/core/api/stream_chat_api.dart b/packages/stream_chat/lib/src/core/api/stream_chat_api.dart index 7e725204d..73b6a23a8 100644 --- a/packages/stream_chat/lib/src/core/api/stream_chat_api.dart +++ b/packages/stream_chat/lib/src/core/api/stream_chat_api.dart @@ -1,3 +1,4 @@ +import 'package:dio/dio.dart'; import 'package:logging/logging.dart'; import 'package:stream_chat/src/core/api/attachment_file_uploader.dart'; import 'package:stream_chat/src/core/api/call_api.dart'; @@ -26,6 +27,7 @@ class StreamChatApi { AttachmentFileUploaderProvider attachmentFileUploaderProvider = StreamAttachmentFileUploader.new, Logger? logger, + Iterable? interceptors, }) : _fileUploaderProvider = attachmentFileUploaderProvider, _client = client ?? StreamHttpClient( @@ -34,6 +36,7 @@ class StreamChatApi { tokenManager: tokenManager, connectionIdManager: connectionIdManager, logger: logger, + interceptors: interceptors, ); final StreamHttpClient _client; diff --git a/packages/stream_chat/lib/src/core/http/interceptor/logging_interceptor.dart b/packages/stream_chat/lib/src/core/http/interceptor/logging_interceptor.dart index 6b07495de..72fd70d03 100644 --- a/packages/stream_chat/lib/src/core/http/interceptor/logging_interceptor.dart +++ b/packages/stream_chat/lib/src/core/http/interceptor/logging_interceptor.dart @@ -101,8 +101,7 @@ class LoggingInterceptor extends Interceptor { options.data as Map?, header: 'Body', ); - } - if (data is FormData) { + } else if (data is FormData) { final formDataMap = {} ..addEntries(data.fields) ..addEntries(data.files); @@ -163,7 +162,7 @@ class LoggingInterceptor extends Interceptor { _logPrintResponse('โ•‘'); _printResponse(_logPrintResponse, response); _logPrintResponse('โ•‘'); - _logPrintResponse('โ•š'); + _printLine(_logPrintResponse, 'โ•š'); } super.onResponse(response, handler); } diff --git a/packages/stream_chat/lib/src/core/http/stream_http_client.dart b/packages/stream_chat/lib/src/core/http/stream_http_client.dart index 4a13fbaf5..99e72339f 100644 --- a/packages/stream_chat/lib/src/core/http/stream_http_client.dart +++ b/packages/stream_chat/lib/src/core/http/stream_http_client.dart @@ -25,6 +25,7 @@ class StreamHttpClient { TokenManager? tokenManager, ConnectionIdManager? connectionIdManager, Logger? logger, + Iterable? interceptors, }) : _options = options ?? const StreamHttpClientOptions(), httpClient = dio ?? Dio() { httpClient @@ -45,20 +46,25 @@ class StreamHttpClient { if (tokenManager != null) AuthInterceptor(this, tokenManager), if (connectionIdManager != null) ConnectionIdInterceptor(connectionIdManager), - if (logger != null && logger.level != Level.OFF) - LoggingInterceptor( - requestHeader: true, - logPrint: (step, message) { - switch (step) { - case InterceptStep.request: - return logger.info(message); - case InterceptStep.response: - return logger.info(message); - case InterceptStep.error: - return logger.severe(message); - } - }, - ), + ...interceptors ?? + [ + // Add a default logging interceptor if no interceptors are + // provided. + if (logger != null && logger.level != Level.OFF) + LoggingInterceptor( + requestHeader: true, + logPrint: (step, message) { + switch (step) { + case InterceptStep.request: + return logger.info(message); + case InterceptStep.response: + return logger.info(message); + case InterceptStep.error: + return logger.severe(message); + } + }, + ), + ], ]); } diff --git a/packages/stream_chat/lib/src/core/models/own_user.dart b/packages/stream_chat/lib/src/core/models/own_user.dart index 60d152780..cfa7f79a5 100644 --- a/packages/stream_chat/lib/src/core/models/own_user.dart +++ b/packages/stream_chat/lib/src/core/models/own_user.dart @@ -1,5 +1,4 @@ import 'package:json_annotation/json_annotation.dart'; -import 'package:stream_chat/src/core/models/channel_mute.dart'; import 'package:stream_chat/src/core/util/serializer.dart'; import 'package:stream_chat/stream_chat.dart'; diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index fa5e727e4..1e0df28c6 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -3,6 +3,7 @@ library stream_chat; export 'package:async/async.dart'; export 'package:dio/src/cancel_token.dart'; export 'package:dio/src/dio_error.dart'; +export 'package:dio/src/dio_mixin.dart' show Interceptor, InterceptorsWrapper; export 'package:dio/src/multipart_file.dart'; export 'package:dio/src/options.dart'; export 'package:dio/src/options.dart' show ProgressCallback; @@ -19,11 +20,13 @@ export 'src/core/api/responses.dart'; export 'src/core/api/stream_chat_api.dart' show PushProvider; export 'src/core/api/stream_chat_api.dart'; export 'src/core/error/error.dart'; +export 'src/core/http/interceptor/logging_interceptor.dart'; export 'src/core/models/action.dart'; export 'src/core/models/attachment.dart'; export 'src/core/models/attachment_file.dart'; export 'src/core/models/channel_config.dart'; export 'src/core/models/channel_model.dart'; +export 'src/core/models/channel_mute.dart'; export 'src/core/models/channel_state.dart'; export 'src/core/models/command.dart'; export 'src/core/models/device.dart'; diff --git a/packages/stream_chat/lib/version.dart b/packages/stream_chat/lib/version.dart index 19a6844b0..a2567eda3 100644 --- a/packages/stream_chat/lib/version.dart +++ b/packages/stream_chat/lib/version.dart @@ -3,4 +3,4 @@ import 'package:stream_chat/src/client/client.dart'; /// Current package version /// Used in [StreamChatClient] to build the `x-stream-client` header // ignore: constant_identifier_names -const PACKAGE_VERSION = '6.0.0'; +const PACKAGE_VERSION = '6.1.0'; diff --git a/packages/stream_chat/pubspec.yaml b/packages/stream_chat/pubspec.yaml index f0bbc763d..1f2ae6a50 100644 --- a/packages/stream_chat/pubspec.yaml +++ b/packages/stream_chat/pubspec.yaml @@ -1,12 +1,12 @@ name: stream_chat homepage: https://getstream.io/ description: The official Dart client for Stream Chat, a service for building chat applications. -version: 6.0.0 +version: 6.1.0 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues environment: - sdk: '>=2.17.0 <3.0.0' + sdk: '>=2.17.0 <4.0.0' dependencies: async: ^2.10.0 @@ -22,6 +22,7 @@ dependencies: mime: ^1.0.4 rate_limiter: ^1.0.0 rxdart: ^0.27.7 + synchronized: ^3.0.0 uuid: ^3.0.7 web_socket_channel: ^2.3.0 diff --git a/packages/stream_chat/test/src/core/http/stream_http_client_test.dart b/packages/stream_chat/test/src/core/http/stream_http_client_test.dart index 4854b98f8..51533b115 100644 --- a/packages/stream_chat/test/src/core/http/stream_http_client_test.dart +++ b/packages/stream_chat/test/src/core/http/stream_http_client_test.dart @@ -86,41 +86,73 @@ void main() { }, ); - test('loggingInterceptor should be added if logger is provided', () { - const apiKey = 'api-key'; - final client = StreamHttpClient( - apiKey, - logger: Logger('test-logger'), - ); + group('loggingInterceptor', () { + test('should be added if logger is provided', () { + const apiKey = 'api-key'; + final client = StreamHttpClient( + apiKey, + logger: Logger('test-logger'), + ); - expect( - client.httpClient.interceptors.whereType().length, - 1, - ); - }); + expect( + client.httpClient.interceptors.whereType().length, + 1, + ); + }); - test('loggingInterceptor should log requests', () async { - const apiKey = 'api-key'; - final logger = MockLogger(); - final client = StreamHttpClient(apiKey, logger: logger); + test('should not be added if logger.level is OFF', () { + const apiKey = 'api-key'; + final client = StreamHttpClient( + apiKey, + logger: Logger.detached('test-logger')..level = Level.OFF, + ); - try { - await client.get('path'); - } catch (_) {} + expect( + client.httpClient.interceptors.whereType().length, + 0, + ); + }); - verify(() => logger.info(any())).called(greaterThan(0)); - }); + test('should not be added if `interceptors` are provided', () { + const apiKey = 'api-key'; + final client = StreamHttpClient( + apiKey, + logger: Logger.detached('test-logger'), + interceptors: [ + // Sample Interceptor. + InterceptorsWrapper(), + ], + ); - test('loggingInterceptor should log error', () async { - const apiKey = 'api-key'; - final logger = MockLogger(); - final client = StreamHttpClient(apiKey, logger: logger); + expect( + client.httpClient.interceptors.whereType().length, + 0, + ); + }); - try { - await client.get('path'); - } catch (_) {} + test('should log requests', () async { + const apiKey = 'api-key'; + final logger = MockLogger(); + final client = StreamHttpClient(apiKey, logger: logger); + + try { + await client.get('path'); + } catch (_) {} + + verify(() => logger.info(any())).called(greaterThan(0)); + }); + + test('should log error', () async { + const apiKey = 'api-key'; + final logger = MockLogger(); + final client = StreamHttpClient(apiKey, logger: logger); + + try { + await client.get('path'); + } catch (_) {} - verify(() => logger.severe(any())).called(greaterThan(0)); + verify(() => logger.severe(any())).called(greaterThan(0)); + }); }); test('`.close` should close the dio client', () async { diff --git a/packages/stream_chat/test/src/core/models/own_user_test.dart b/packages/stream_chat/test/src/core/models/own_user_test.dart index e0dc7801e..73df4b4ea 100644 --- a/packages/stream_chat/test/src/core/models/own_user_test.dart +++ b/packages/stream_chat/test/src/core/models/own_user_test.dart @@ -1,5 +1,4 @@ import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat/src/core/models/channel_mute.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:test/test.dart'; diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index dea279237..f32ef2d2b 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,23 +1,100 @@ +## 6.1.0 + +๐Ÿž Fixed + +- [[#1502]](https://github.com/GetStream/stream-chat-flutter/issues/1502) Fixed `isOnlyEmoji` method Detects Single + Hangul + Consonants as Emoji. +- [[#1505]](https://github.com/GetStream/stream-chat-flutter/issues/1505) Fixed Message bubble disappears for Hangul + Consonants. +- [[#1476]](https://github.com/GetStream/stream-chat-flutter/issues/1476) Fixed `UserAvatarTransform.userAvatarBuilder` + works only for otherUser. +- [[#1490]](https://github.com/GetStream/stream-chat-flutter/issues/1490) Fixed `editMessageInputBuilder` property not + used in message edit widget. +- [[#1523]](https://github.com/GetStream/stream-chat-flutter/issues/1523) Fixed `StreamMessageThemeData` not being + applied correctly. +- [[#1525]](https://github.com/GetStream/stream-chat-flutter/issues/1525) Fixed `StreamQuotedMessageWidget` message for + deleted messages not being shown correctly. +- [[#1529]](https://github.com/GetStream/stream-chat-flutter/issues/1529) Fixed `ClipboardData` requires non-nullable + string as text on Flutter 3.10. +- [[#1533]](https://github.com/GetStream/stream-chat-flutter/issues/1533) Fixed `StreamMessageListView` messages grouped + incorrectly w.r.t. timestamp. +- [[#1532]](https://github.com/GetStream/stream-chat-flutter/issues/1532) Fixed `StreamMessageWidget` actions dialog + backdrop filter is cut off by safe area. + +โœ… Added + +- Added `MessageTheme.urlAttachmentHostStyle`, `MessageTheme.urlAttachmentTitleStyle`, and + `MessageTheme.urlAttachmentTextStyle` to customize the style of the url attachment. +- Added `StreamMessageInput.ogPreviewFilter` to allow users to filter out the og preview + links. [#1338](https://github.com/GetStream/stream-chat-flutter/issues/1338) + + ```dart + StreamMessageInput( + ogPreviewFilter: (matchedUri, messageText) { + final url = matchedUri.toString(); + if (url.contains('giphy.com')) { + // Return false to prevent the OG preview from being built. + return false; + } + // Return true to build the OG preview. + return true; + ), + ``` + +- Added `StreamMessageInput.hintGetter` to allow users to customize the hint text of the message + input. [#1401](https://github.com/GetStream/stream-chat-flutter/issues/1401) + + ```dart + StreamMessageInput( + hintGetter: (context, hintType) { + switch (hintType) { + case HintType.searchGif: + return 'Custom Search Giphy'; + case HintType.addACommentOrSend: + return 'Custom Add a comment or send'; + case HintType.slowModeOn: + return 'Custom Slow mode is on'; + case HintType.writeAMessage: + return 'Custom Write a message'; + } + }, + ), + ``` + +- Added `StreamMessageListView.shrinkWrap` to allow users to shrink wrap the message list view. + +๐Ÿ”„ Changed + +- Updated `dart` sdk environment range to support `3.0.0`. +- Deprecated `MessageTheme.linkBackgroundColor` in favor of `MessageTheme.urlAttachmentBackgroundColor`. +- Updated `stream_chat_flutter_core` dependency + to [`6.1.0`](https://pub.dev/packages/stream_chat_flutter_core/changelog). + ## 6.0.0 ๐Ÿž Fixed -- [#1456](https://github.com/GetStream/stream-chat-flutter/issues/1456) Fixed logic for showing that a message was read using sending indicator. -- [#1462](https://github.com/GetStream/stream-chat-flutter/issues/1462) Fixed support for iPad in the share button for images. -- [#1475](https://github.com/GetStream/stream-chat-flutter/issues/1475) Fixed typo to fix compilation. +- [[#1456]](https://github.com/GetStream/stream-chat-flutter/issues/1456) Fixed logic for showing that a message was + read using sending indicator. +- [[#1462]](https://github.com/GetStream/stream-chat-flutter/issues/1462) Fixed support for iPad in the share button for + images. +- [[#1475]](https://github.com/GetStream/stream-chat-flutter/issues/1475) Fixed typo to fix compilation. โœ… Added - Now it is possible to customize the max lines of the title of a url attachment. Before it was always 1 line. -- Added `attachmentActionsModalBuilder` parameter to `StreamMessageWidget` that allows to customize `AttachmentActionsModal`. -- Added `StreamMessageInput.sendMessageKeyPredicate` and `StreamMessageInput.clearQuotedMessageKeyPredicate` to customize the keys used to send and clear the quoted message. +- Added `attachmentActionsModalBuilder` parameter to `StreamMessageWidget` that allows to + customize `AttachmentActionsModal`. +- Added `StreamMessageInput.sendMessageKeyPredicate` and `StreamMessageInput.clearQuotedMessageKeyPredicate` to + customize the keys used to send and clear the quoted message. ๐Ÿ”„ Changed - Updated dependencies to resolvable versions. ๐Ÿš€ Improved -- + - Improved draw of reaction options. [#1455](https://github.com/GetStream/stream-chat-flutter/pull/1455) ## 5.3.0 @@ -27,17 +104,22 @@ - Updated `photo_manager` dependency to `^2.5.2` ๐Ÿž Fixed -- [[#1424]](https://github.com/GetStream/stream-chat-flutter/issues/1424) Fixed a render issue when showing messages starting with 4 whitespaces. + +- [[#1424]](https://github.com/GetStream/stream-chat-flutter/issues/1424) Fixed a render issue when showing messages + starting with 4 whitespaces. - Fixed a bug where the `AttachmentPickerBottomSheet` was not able to identify the mobile browser. - Fixed uploading files on Windows - fixed temp file path. โœ… Added + - New `noPhotoOrVideoLabel` displayed when there is no files to choose. ## 5.2.0 โœ… Added -- Added a new `bottomRowBuilderWithDefaultWidget` parameter to `StreamMessageWidget` which contains a third parameter (default `BottomRow` widget with `copyWith` method available) to allow easier customization. + +- Added a new `bottomRowBuilderWithDefaultWidget` parameter to `StreamMessageWidget` which contains a third parameter ( + default `BottomRow` widget with `copyWith` method available) to allow easier customization. ๐Ÿ”„ Changed @@ -47,14 +129,20 @@ - Updated `dart_vlc` dependency to `^0.4.0` - Updated `file_picker` dependency to `^5.2.4` - Deprecated `StreamMessageWidget.bottomRowBuilder` in favor of `StreamMessageWidget.bottomRowBuilderWithDefaultWidget`. -- Deprecated `StreamMessageWidget.deletedBottomRowBuilder` in favor of `StreamMessageWidget.bottomRowBuilderWithDefaultWidget`. +- Deprecated `StreamMessageWidget.deletedBottomRowBuilder` in favor + of `StreamMessageWidget.bottomRowBuilderWithDefaultWidget`. - Deprecated `StreamMessageWidget.usernameBuilder` in favor of `StreamMessageWidget.bottomRowBuilderWithDefaultWidget`. ๐Ÿž Fixed -- [[#1379]](https://github.com/GetStream/stream-chat-flutter/issues/1379) Fixed "Issues with photo attachments on web", where the cached image attachment would not render while uploading. -- Fix render overflow issue with `MessageSearchListTileTitle`. It now uses `Text.rich` instead of `Row`. Better default behaviour and allows `TextOverflow`. -- [[1346]](https://github.com/GetStream/stream-chat-flutter/issues/1346) Fixed a render issue while uploading video on web. -- [[#1347]](https://github.com/GetStream/stream-chat-flutter/issues/1347) `onReply` not working in `AttachmentActionsModal` which is used by `StreamImageAttachment` and `StreamImageGroup`. + +- [[#1379]](https://github.com/GetStream/stream-chat-flutter/issues/1379) Fixed "Issues with photo attachments on web", + where the cached image attachment would not render while uploading. +- Fix render overflow issue with `MessageSearchListTileTitle`. It now uses `Text.rich` instead of `Row`. Better default + behaviour and allows `TextOverflow`. +- [[1346]](https://github.com/GetStream/stream-chat-flutter/issues/1346) Fixed a render issue while uploading video on + web. +- [[#1347]](https://github.com/GetStream/stream-chat-flutter/issues/1347) `onReply` not working + in `AttachmentActionsModal` which is used by `StreamImageAttachment` and `StreamImageGroup`. ## 5.1.0 diff --git a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart index c62472caa..1ea7733e5 100644 --- a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart @@ -11,6 +11,7 @@ import 'package:stream_chat_flutter/scrollable_positioned_list/src/indexed_key.d import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_listener.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_notifier.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/src/scroll_view.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/wrapping.dart'; /// A list of widgets similar to [ListView], except scroll control /// and position reporting is based on index rather than pixel offset. @@ -35,28 +36,20 @@ class PositionedList extends StatefulWidget { this.alignment = 0, this.scrollDirection = Axis.vertical, this.reverse = false, + this.shrinkWrap = false, this.physics, this.padding, this.cacheExtent, this.semanticChildCount, - this.findChildIndexCallback, this.addSemanticIndexes = true, this.addRepaintBoundaries = true, this.addAutomaticKeepAlives = true, - this.keyboardDismissBehavior, - }) : assert((positionedIndex == 0) || (positionedIndex < itemCount), - 'positionedIndex cannot be 0 and must be smaller than itemCount'); - - /// Called to find the new index of a child based on its key in case of - /// reordering. - /// - /// If not provided, a child widget may not map to its existing [RenderObject] - /// when the order in which children are returned from [builder] changes. - /// This may result in state-loss. - /// - /// This callback should take an input [Key], and it should return the - /// index of the child element with that associated key, or null if not found. - final ChildIndexGetter? findChildIndexCallback; + this.findChildIndexCallback, + this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, + }) : assert( + (positionedIndex == 0) || (positionedIndex < itemCount), + 'positionedIndex must be 0 or a value less than itemCount', + ); /// Number of items the [itemBuilder] can produce. final int itemCount; @@ -98,6 +91,15 @@ class PositionedList extends StatefulWidget { /// See [ScrollView.reverse]. final bool reverse; + /// {@template flutter.widgets.scroll_view.shrinkWrap} + /// Whether the extent of the scroll view in the [scrollDirection] should be + /// determined by the contents being viewed. + /// + /// Defaults to false. + /// + /// See [ScrollView.shrinkWrap]. + final bool shrinkWrap; + /// How the scroll view should respond to user input. /// /// For example, determines how the scroll view continues to animate after the @@ -132,9 +134,22 @@ class PositionedList extends StatefulWidget { /// See [SliverChildBuilderDelegate.addAutomaticKeepAlives]. final bool addAutomaticKeepAlives; - /// [ScrollViewKeyboardDismissBehavior] the defines how this [PositionedList] will - /// dismiss the keyboard automatically. - final ScrollViewKeyboardDismissBehavior? keyboardDismissBehavior; + /// Called to find the new index of a child based on its key in case of reordering. + /// + /// If not provided, a child widget may not map to its existing [RenderObject] + /// when the order of children returned from the children builder changes. + /// This may result in state-loss. + /// + /// This callback should take an input [Key], and it should return the + /// index of the child element with that associated key, or null if not found. + /// + /// See [SliverChildBuilderDelegate.findChildIndexCallback]. + final ChildIndexGetter? findChildIndexCallback; + + /// Defines how this [ScrollView] will dismiss the keyboard automatically. + /// + /// See [ScrollView.keyboardDismissBehavior]. + final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; @override State createState() => _PositionedListState(); @@ -175,12 +190,13 @@ class _PositionedListState extends State { anchor: widget.alignment, center: _centerKey, controller: scrollController, - keyboardDismissBehavior: widget.keyboardDismissBehavior, scrollDirection: widget.scrollDirection, reverse: widget.reverse, cacheExtent: widget.cacheExtent, physics: widget.physics, + shrinkWrap: widget.shrinkWrap, semanticChildCount: widget.semanticChildCount ?? widget.itemCount, + keyboardDismissBehavior: widget.keyboardDismissBehavior, slivers: [ if (widget.positionedIndex > 0) SliverPadding( @@ -196,9 +212,9 @@ class _PositionedListState extends State { ? widget.positionedIndex : widget.positionedIndex * 2, addSemanticIndexes: false, - findChildIndexCallback: widget.findChildIndexCallback, addRepaintBoundaries: widget.addRepaintBoundaries, addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + findChildIndexCallback: widget.findChildIndexCallback, ), ), ), @@ -213,10 +229,10 @@ class _PositionedListState extends State { index + widget.positionedIndex * 2, ), childCount: widget.itemCount != 0 ? 1 : 0, - findChildIndexCallback: widget.findChildIndexCallback, addSemanticIndexes: false, addRepaintBoundaries: widget.addRepaintBoundaries, addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + findChildIndexCallback: widget.findChildIndexCallback, ), ), ), @@ -234,10 +250,10 @@ class _PositionedListState extends State { childCount: widget.separatorBuilder == null ? widget.itemCount - widget.positionedIndex - 1 : 2 * (widget.itemCount - widget.positionedIndex - 1), - findChildIndexCallback: widget.findChildIndexCallback, addSemanticIndexes: false, addRepaintBoundaries: widget.addRepaintBoundaries, addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + findChildIndexCallback: widget.findChildIndexCallback, ), ), ), @@ -319,25 +335,33 @@ class _PositionedListState extends State { if (!updateScheduled) { updateScheduled = true; SchedulerBinding.instance.addPostFrameCallback((_) { - if (registeredElements.value == null) { + final elements = registeredElements.value; + if (elements == null) { updateScheduled = false; return; } final positions = []; - RenderViewport? viewport; - for (final element in registeredElements.value!) { - final box = element.renderObject as RenderBox?; - viewport ??= RenderAbstractViewport.of(box) as RenderViewport?; - if (viewport == null || box == null) { - break; + RenderViewportBase? viewport; + for (final element in elements) { + final box = element.renderObject! as RenderBox; + viewport ??= RenderAbstractViewport.of(box) as RenderViewportBase?; + var anchor = 0.0; + if (viewport is RenderViewport) { + anchor = viewport.anchor; } - final key = element.widget.key as IndexedKey; + + if (viewport is CustomRenderViewport) { + anchor = viewport.anchor; + } + + final key = element.widget.key! as IndexedKey; + // Skip this element if `box` has never been laid out. + if (!box.hasSize) continue; if (widget.scrollDirection == Axis.vertical) { - final reveal = viewport.getOffsetToReveal(box, 0).offset; + final reveal = viewport!.getOffsetToReveal(box, 0).offset; if (!reveal.isFinite) continue; - final itemOffset = reveal - - viewport.offset.pixels + - viewport.anchor * viewport.size.height; + final itemOffset = + reveal - viewport.offset.pixels + anchor * viewport.size.height; positions.add(ItemPosition( index: key.index, itemLeadingEdge: itemOffset.round() / @@ -348,6 +372,7 @@ class _PositionedListState extends State { } else { final itemOffset = box.localToGlobal(Offset.zero, ancestor: viewport).dx; + if (!itemOffset.isFinite) continue; positions.add(ItemPosition( index: key.index, itemLeadingEdge: (widget.reverse diff --git a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scroll_view.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scroll_view.dart index 13a7fdd39..911512495 100644 --- a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scroll_view.dart +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scroll_view.dart @@ -5,13 +5,14 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/src/viewport.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/wrapping.dart'; -/// {@template custom_scroll_view} -/// A version of [CustomScrollView] that does not constrict the extents +/// {@template unbounded_custom_scroll_view} +/// A version of [CustomScrollView] that allows does not constrict the extents /// to be within 0 and 1. See [CustomScrollView] for more information. /// {@endtemplate} class UnboundedCustomScrollView extends CustomScrollView { - /// {@macro custom_scroll_view} + /// {@macro unbounded_custom_scroll_view} const UnboundedCustomScrollView({ super.key, super.scrollDirection, @@ -19,19 +20,19 @@ class UnboundedCustomScrollView extends CustomScrollView { super.controller, super.primary, super.physics, - super.shrinkWrap, + bool shrinkWrap = false, super.center, double anchor = 0.0, super.cacheExtent, super.slivers, super.semanticChildCount, super.dragStartBehavior, - ScrollViewKeyboardDismissBehavior? keyboardDismissBehavior, - }) : _anchor = anchor, - super( - keyboardDismissBehavior: keyboardDismissBehavior ?? - ScrollViewKeyboardDismissBehavior.manual, - ); + super.keyboardDismissBehavior, + }) : _shrinkWrap = shrinkWrap, + _anchor = anchor, + super(shrinkWrap: false); + + final bool _shrinkWrap; // [CustomScrollView] enforces constraints on [CustomScrollView.anchor], so // we need our own version. @@ -49,11 +50,14 @@ class UnboundedCustomScrollView extends CustomScrollView { AxisDirection axisDirection, List slivers, ) { - if (shrinkWrap) { - return ShrinkWrappingViewport( + if (_shrinkWrap) { + return CustomShrinkWrappingViewport( axisDirection: axisDirection, offset: offset, slivers: slivers, + cacheExtent: cacheExtent, + center: center, + anchor: anchor, ); } return UnboundedViewport( diff --git a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scrollable_positioned_list.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scrollable_positioned_list.dart index 5d307158b..b3785154c 100644 --- a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scrollable_positioned_list.dart +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scrollable_positioned_list.dart @@ -37,6 +37,7 @@ class ScrollablePositionedList extends StatefulWidget { required this.itemBuilder, super.key, this.itemScrollController, + this.shrinkWrap = false, ItemPositionsListener? itemPositionsListener, this.initialScrollIndex = 0, this.initialAlignment = 0, @@ -50,7 +51,7 @@ class ScrollablePositionedList extends StatefulWidget { this.addRepaintBoundaries = true, this.minCacheExtent, this.findChildIndexCallback, - this.keyboardDismissBehavior, + this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, }) : itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?, separatorBuilder = null; @@ -61,6 +62,7 @@ class ScrollablePositionedList extends StatefulWidget { required this.itemBuilder, required IndexedWidgetBuilder this.separatorBuilder, super.key, + this.shrinkWrap = false, this.itemScrollController, ItemPositionsListener? itemPositionsListener, this.initialScrollIndex = 0, @@ -75,24 +77,9 @@ class ScrollablePositionedList extends StatefulWidget { this.addRepaintBoundaries = true, this.minCacheExtent, this.findChildIndexCallback, - this.keyboardDismissBehavior, + this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, }) : itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?; - /// Called to find the new index of a child based on its key in case of - /// reordering. - /// - /// If not provided, a child widget may not map to its existing [RenderObject] - /// when the order in which children are returned from [builder] changes. - /// This may result in state-loss. - /// - /// This callback should take an input [Key], and it should return the - /// index of the child element with that associated key, or null if not found. - final ChildIndexGetter? findChildIndexCallback; - - /// [ScrollViewKeyboardDismissBehavior] the defines how this [PositionedList] will - /// dismiss the keyboard automatically. - final ScrollViewKeyboardDismissBehavior? keyboardDismissBehavior; - /// Number of items the [itemBuilder] can produce. final int itemCount; @@ -131,6 +118,15 @@ class ScrollablePositionedList extends StatefulWidget { /// See [ScrollView.reverse]. final bool reverse; + /// {@template flutter.widgets.scroll_view.shrinkWrap} + /// Whether the extent of the scroll view in the [scrollDirection] should be + /// determined by the contents being viewed. + /// + /// Defaults to false. + /// + /// See [ScrollView.shrinkWrap]. + final bool shrinkWrap; + /// How the scroll view should respond to user input. /// /// For example, determines how the scroll view continues to animate after the @@ -171,6 +167,23 @@ class ScrollablePositionedList extends StatefulWidget { /// cache extent. final double? minCacheExtent; + /// Called to find the new index of a child based on its key in case of reordering. + /// + /// If not provided, a child widget may not map to its existing [RenderObject] + /// when the order of children returned from the children builder changes. + /// This may result in state-loss. + /// + /// This callback should take an input [Key], and it should return the + /// index of the child element with that associated key, or null if not found. + /// + /// See [SliverChildBuilderDelegate.findChildIndexCallback]. + final ChildIndexGetter? findChildIndexCallback; + + /// Defines how this [ScrollView] will dismiss the keyboard automatically. + /// + /// See [ScrollView.keyboardDismissBehavior]. + final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; + @override State createState() => _ScrollablePositionedListState(); } @@ -233,11 +246,15 @@ class ItemScrollController { Curve curve = Curves.linear, List opacityAnimationWeights = const [40, 20, 40], }) { - assert(_scrollableListState != null, '_scrollableListState cannot be null'); - assert(opacityAnimationWeights.length == 3, - 'opacityAnimationWeights.length is not equal to 3'); - assert(duration > Duration.zero, - 'duration needs to be bigger than Duration.zero'); + assert( + _scrollableListState != null, + '''ScrollController must be attached to a ScrollablePositionedList to scroll.''', + ); + assert( + opacityAnimationWeights.length == 3, + 'opacityAnimationWeights must have exactly three elements.', + ); + assert(duration > Duration.zero, 'Duration must be greater than zero.'); return _scrollableListState!._scrollTo( index: index, alignment: alignment, @@ -249,7 +266,9 @@ class ItemScrollController { void _attach(_ScrollablePositionedListState scrollableListState) { assert( - _scrollableListState == null, '_scrollableListState needs to be null'); + _scrollableListState == null, + '''ScrollController must not be attached to multiple ScrollablePositionedLists.''', + ); _scrollableListState = scrollableListState; } @@ -273,11 +292,12 @@ class _ScrollablePositionedListState extends State bool _isTransitioning = false; + AnimationController? _animationController; + @override void initState() { super.initState(); - final ItemPosition? initialPosition = - PageStorage.of(context).readState(context); + final initialPosition = PageStorage.of(context).readState(context); primary ..target = initialPosition?.index ?? widget.initialScrollIndex ..alignment = initialPosition?.itemLeadingEdge ?? widget.initialAlignment; @@ -301,6 +321,7 @@ class _ScrollablePositionedListState extends State .removeListener(_updatePositions); secondary.itemPositionsNotifier.itemPositions .removeListener(_updatePositions); + _animationController?.dispose(); super.dispose(); } @@ -329,84 +350,90 @@ class _ScrollablePositionedListState extends State } @override - Widget build(BuildContext context) => LayoutBuilder( - builder: (context, constraints) { - final cacheExtent = _cacheExtent(constraints); - return GestureDetector( - onPanDown: (_) => _stopScroll(canceled: true), - excludeFromSemantics: true, - child: Stack( - children: [ + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final cacheExtent = _cacheExtent(constraints); + return GestureDetector( + onPanDown: (_) => _stopScroll(canceled: true), + excludeFromSemantics: true, + child: Stack( + children: [ + PostMountCallback( + key: primary.key, + callback: startAnimationCallback, + child: FadeTransition( + opacity: ReverseAnimation(opacity), + child: NotificationListener( + onNotification: (_) => _isTransitioning, + child: PositionedList( + itemBuilder: widget.itemBuilder, + separatorBuilder: widget.separatorBuilder, + itemCount: widget.itemCount, + positionedIndex: primary.target, + controller: primary.scrollController, + itemPositionsNotifier: primary.itemPositionsNotifier, + scrollDirection: widget.scrollDirection, + reverse: widget.reverse, + cacheExtent: cacheExtent, + alignment: primary.alignment, + physics: widget.physics, + shrinkWrap: widget.shrinkWrap, + addSemanticIndexes: widget.addSemanticIndexes, + semanticChildCount: widget.semanticChildCount, + padding: widget.padding, + addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + addRepaintBoundaries: widget.addRepaintBoundaries, + findChildIndexCallback: widget.findChildIndexCallback, + keyboardDismissBehavior: widget.keyboardDismissBehavior, + ), + ), + ), + ), + if (_isTransitioning) PostMountCallback( - key: primary.key, + key: secondary.key, callback: startAnimationCallback, child: FadeTransition( - opacity: ReverseAnimation(opacity), + opacity: opacity, child: NotificationListener( - onNotification: (_) => _isTransitioning, + onNotification: (_) => false, child: PositionedList( - keyboardDismissBehavior: widget.keyboardDismissBehavior, itemBuilder: widget.itemBuilder, separatorBuilder: widget.separatorBuilder, itemCount: widget.itemCount, - positionedIndex: primary.target, - controller: primary.scrollController, - itemPositionsNotifier: primary.itemPositionsNotifier, + itemPositionsNotifier: secondary.itemPositionsNotifier, + positionedIndex: secondary.target, + controller: secondary.scrollController, scrollDirection: widget.scrollDirection, reverse: widget.reverse, cacheExtent: cacheExtent, - alignment: primary.alignment, + alignment: secondary.alignment, physics: widget.physics, + shrinkWrap: widget.shrinkWrap, addSemanticIndexes: widget.addSemanticIndexes, semanticChildCount: widget.semanticChildCount, padding: widget.padding, addAutomaticKeepAlives: widget.addAutomaticKeepAlives, addRepaintBoundaries: widget.addRepaintBoundaries, findChildIndexCallback: widget.findChildIndexCallback, + keyboardDismissBehavior: widget.keyboardDismissBehavior, ), ), ), ), - if (_isTransitioning) - PostMountCallback( - key: secondary.key, - callback: startAnimationCallback, - child: FadeTransition( - opacity: opacity, - child: NotificationListener( - onNotification: (_) => false, - child: PositionedList( - keyboardDismissBehavior: - widget.keyboardDismissBehavior, - itemBuilder: widget.itemBuilder, - separatorBuilder: widget.separatorBuilder, - itemCount: widget.itemCount, - itemPositionsNotifier: - secondary.itemPositionsNotifier, - positionedIndex: secondary.target, - controller: secondary.scrollController, - scrollDirection: widget.scrollDirection, - reverse: widget.reverse, - cacheExtent: cacheExtent, - alignment: secondary.alignment, - physics: widget.physics, - addSemanticIndexes: widget.addSemanticIndexes, - semanticChildCount: widget.semanticChildCount, - padding: widget.padding, - addAutomaticKeepAlives: widget.addAutomaticKeepAlives, - addRepaintBoundaries: widget.addRepaintBoundaries, - ), - ), - ), - ), - ], - ), - ); - }, - ); + ], + ), + ); + }, + ); + } double _cacheExtent(BoxConstraints constraints) => max( - constraints.maxHeight * _screenScrollCount, + (widget.scrollDirection == Axis.vertical + ? constraints.maxHeight + : constraints.maxWidth) * + _screenScrollCount, widget.minCacheExtent ?? 0, ); @@ -434,16 +461,19 @@ class _ScrollablePositionedListState extends State index = widget.itemCount - 1; } if (_isTransitioning) { + final scrollCompleter = Completer(); _stopScroll(canceled: true); - SchedulerBinding.instance.addPostFrameCallback((_) { - _startScroll( + SchedulerBinding.instance.addPostFrameCallback((_) async { + await _startScroll( index: index, alignment: alignment, duration: duration, curve: curve, opacityAnimationWeights: opacityAnimationWeights, ); + scrollCompleter.complete(); }); + await scrollCompleter.future; } else { await _startScroll( index: index, @@ -486,10 +516,11 @@ class _ScrollablePositionedListState extends State startAnimationCallback = () { SchedulerBinding.instance.addPostFrameCallback((_) { startAnimationCallback = () {}; - - opacity.parent = _opacityAnimation(opacityAnimationWeights).animate( - AnimationController(vsync: this, duration: duration)..forward(), - ); + _animationController?.dispose(); + _animationController = + AnimationController(vsync: this, duration: duration)..forward(); + opacity.parent = _opacityAnimation(opacityAnimationWeights) + .animate(_animationController!); secondary.scrollController.jumpTo(-direction * (_screenScrollCount * primary.scrollController.position.viewportDimension - @@ -532,17 +563,19 @@ class _ScrollablePositionedListState extends State } } - setState(() { - if (opacity.value >= 0.5) { - // Secondary [ListView] is more visible than the primary; make it the - // new primary. - final temp = primary; - primary = secondary; - secondary = temp; - } - _isTransitioning = false; - opacity.parent = const AlwaysStoppedAnimation(0); - }); + if (mounted) { + setState(() { + if (opacity.value >= 0.5) { + // Secondary [ListView] is more visible than the primary; make it the + // new primary. + final temp = primary; + primary = secondary; + secondary = temp; + } + _isTransitioning = false; + opacity.parent = const AlwaysStoppedAnimation(0); + }); + } } Animatable _opacityAnimation(List opacityAnimationWeights) { diff --git a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/viewport.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/viewport.dart index 7d2d6b9fd..aac9acc9e 100644 --- a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/viewport.dart +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/viewport.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// ignore_for_file: lines_longer_than_80_chars - import 'dart:math' as math; import 'package:flutter/rendering.dart'; @@ -15,7 +13,7 @@ import 'package:flutter/widgets.dart'; /// Version of [Viewport] with some modifications to how extents are /// computed to allow scroll extents outside 0 to 1. See [Viewport] /// for more information. -/// description +/// {@endtemplate} class UnboundedViewport extends Viewport { /// {@macro unbounded_viewport} UnboundedViewport({ @@ -37,15 +35,16 @@ class UnboundedViewport extends Viewport { double get anchor => _anchor; @override - RenderViewport createRenderObject(BuildContext context) => - UnboundedRenderViewport( - axisDirection: axisDirection, - crossAxisDirection: crossAxisDirection ?? - Viewport.getDefaultCrossAxisDirection(context, axisDirection), - anchor: anchor, - offset: offset, - cacheExtent: cacheExtent, - ); + RenderViewport createRenderObject(BuildContext context) { + return UnboundedRenderViewport( + axisDirection: axisDirection, + crossAxisDirection: crossAxisDirection ?? + Viewport.getDefaultCrossAxisDirection(context, axisDirection), + anchor: anchor, + offset: offset, + cacheExtent: cacheExtent, + ); + } } /// A render object that is bigger on the inside. @@ -137,14 +136,20 @@ class UnboundedRenderViewport extends RenderViewport { @override void performLayout() { if (center == null) { - assert(firstChild == null, 'firstChild cannot be null'); + assert( + firstChild == null, + 'A RenderViewport with no center render object must have no children.', + ); _minScrollExtent = 0.0; _maxScrollExtent = 0.0; _hasVisualOverflow = false; offset.applyContentDimensions(0, 0); return; } - assert(center!.parent == this, 'center.parent cannot be equal to this'); + assert( + center!.parent == this, + '''The "center" property of a RenderViewport must be a child of the viewport.''', + ); late double mainAxisExtent; late double crossAxisExtent; @@ -186,7 +191,7 @@ class UnboundedRenderViewport extends RenderViewport { } while (count < _maxLayoutCycles); assert(() { if (count >= _maxLayoutCycles) { - assert(count != 1, 'count not equal to 1'); + assert(count != 1); throw FlutterError( 'A RenderViewport exceeded its maximum number of layout cycles.\n' 'RenderViewport render objects, during layout, can retry if either their ' @@ -207,7 +212,7 @@ class UnboundedRenderViewport extends RenderViewport { ); } return true; - }(), 'count needs to be bigger than _maxLayoutCycles'); + }()); } double _attemptLayout( @@ -215,11 +220,11 @@ class UnboundedRenderViewport extends RenderViewport { double crossAxisExtent, double correctedOffset, ) { - assert(!mainAxisExtent.isNaN, 'assert mainAxisExtent.isNaN'); - assert(mainAxisExtent >= 0.0, 'assert mainAxisExtent >= 0.0'); - assert(crossAxisExtent.isFinite, 'assert crossAxisExtent.isFinite'); - assert(crossAxisExtent >= 0.0, 'assert crossAxisExtent >= 0.0'); - assert(correctedOffset.isFinite, 'assert correctedOffset.isFinite'); + assert(!mainAxisExtent.isNaN, 'The main axis extent cannot be NaN.'); + assert(mainAxisExtent >= 0.0, 'The main axis extent cannot be negative.'); + assert(crossAxisExtent.isFinite, 'The cross axis extent must be finite.'); + assert(crossAxisExtent >= 0.0, 'The cross axis extent cannot be negative.'); + assert(correctedOffset.isFinite, 'The corrected offset must be finite.'); _minScrollExtent = 0.0; _maxScrollExtent = 0.0; _hasVisualOverflow = false; diff --git a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/wrapping.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/wrapping.dart new file mode 100644 index 000000000..c3a8129ce --- /dev/null +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/wrapping.dart @@ -0,0 +1,1066 @@ +import 'dart:math' as math; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// A widget that is bigger on the inside and shrink wraps its children in the +/// main axis. +/// +/// [ShrinkWrappingViewport] displays a subset of its children according to its +/// own dimensions and the given [offset]. As the offset varies, different +/// children are visible through the viewport. +/// +/// [ShrinkWrappingViewport] differs from [Viewport] in that [Viewport] expands +/// to fill the main axis whereas [ShrinkWrappingViewport] sizes itself to match +/// its children in the main axis. This shrink wrapping behavior is expensive +/// because the children, and hence the viewport, could potentially change size +/// whenever the [offset] changes (e.g., because of a collapsing header). +/// +/// [ShrinkWrappingViewport] cannot contain box children directly. Instead, use +/// a [SliverList], [SliverFixedExtentList], [SliverGrid], or a +/// [SliverToBoxAdapter], for example. +/// +/// See also: +/// +/// * [ListView], [PageView], [GridView], and [CustomScrollView], which combine +/// [Scrollable] and [ShrinkWrappingViewport] into widgets that are easier to +/// use. +/// * [SliverToBoxAdapter], which allows a box widget to be placed inside a +/// sliver context (the opposite of this widget). +/// * [Viewport], a viewport that does not shrink-wrap its contents. +class CustomShrinkWrappingViewport extends CustomViewport { + /// Creates a widget that is bigger on the inside and shrink wraps its + /// children in the main axis. + /// + /// The viewport listens to the [offset], which means you do not need to + /// rebuild this widget when the [offset] changes. + /// + /// The [offset] argument must not be null. + CustomShrinkWrappingViewport({ + super.key, + super.axisDirection, + super.crossAxisDirection, + double anchor = 0.0, + required super.offset, + List? children, + super.center, + super.cacheExtent, + super.slivers, + }) : _anchor = anchor; + + // [Viewport] enforces constraints on [Viewport.anchor], so we need our own + // version. + final double _anchor; + + @override + double get anchor => _anchor; + + @override + CustomRenderShrinkWrappingViewport createRenderObject(BuildContext context) { + return CustomRenderShrinkWrappingViewport( + axisDirection: axisDirection, + crossAxisDirection: crossAxisDirection ?? + Viewport.getDefaultCrossAxisDirection(context, axisDirection), + offset: offset, + anchor: anchor, + cacheExtent: cacheExtent, + ); + } + + @override + void updateRenderObject( + BuildContext context, + CustomRenderShrinkWrappingViewport renderObject, + ) { + renderObject + ..axisDirection = axisDirection + ..crossAxisDirection = crossAxisDirection ?? + Viewport.getDefaultCrossAxisDirection(context, axisDirection) + ..anchor = anchor + ..offset = offset + ..cacheExtent = cacheExtent + ..cacheExtentStyle = cacheExtentStyle + ..clipBehavior = clipBehavior; + } +} + +/// A render object that is bigger on the inside and shrink wraps its children +/// in the main axis. +/// +/// [RenderShrinkWrappingViewport] displays a subset of its children according +/// to its own dimensions and the given [offset]. As the offset varies, different +/// children are visible through the viewport. +/// +/// [RenderShrinkWrappingViewport] differs from [RenderViewport] in that +/// [RenderViewport] expands to fill the main axis whereas +/// [RenderShrinkWrappingViewport] sizes itself to match its children in the +/// main axis. This shrink wrapping behavior is expensive because the children, +/// and hence the viewport, could potentially change size whenever the [offset] +/// changes (e.g., because of a collapsing header). +/// +/// [RenderShrinkWrappingViewport] cannot contain [RenderBox] children directly. +/// Instead, use a [RenderSliverList], [RenderSliverFixedExtentList], +/// [RenderSliverGrid], or a [RenderSliverToBoxAdapter], for example. +/// +/// See also: +/// +/// * [RenderViewport], a viewport that does not shrink-wrap its contents. +/// * [RenderSliver], which explains more about the Sliver protocol. +/// * [RenderBox], which explains more about the Box protocol. +/// * [RenderSliverToBoxAdapter], which allows a [RenderBox] object to be +/// placed inside a [RenderSliver] (the opposite of this class). +class CustomRenderShrinkWrappingViewport extends CustomRenderViewport { + /// Creates a viewport (for [RenderSliver] objects) that shrink-wraps its + /// contents. + /// + /// The [offset] must be specified. For testing purposes, consider passing a + /// [ViewportOffset.zero] or [ViewportOffset.fixed]. + CustomRenderShrinkWrappingViewport({ + super.axisDirection, + required super.crossAxisDirection, + required super.offset, + double anchor = 0.0, + super.children, + super.center, + super.cacheExtent, + }) : _anchor = anchor; + + double _anchor; + + @override + double get anchor => _anchor; + + @override + bool get sizedByParent => false; + + double lastMainAxisExtent = -1; + + @override + set anchor(double value) { + if (value == _anchor) return; + _anchor = value; + markNeedsLayout(); + } + + late double _shrinkWrapExtent; + + /// This value is set during layout based on the [CacheExtentStyle]. + /// + /// When the style is [CacheExtentStyle.viewport], it is the main axis extent + /// of the viewport multiplied by the requested cache extent, which is still + /// expressed in pixels. + double? _calculatedCacheExtent; + + /// While List in a wrapping container, eg. ListView๏ผŒthe mainAxisExtent will + /// be infinite. This time need to change mainAxisExtent to this value. + final double _maxMainAxisExtent = double.maxFinite; + + @override + void performLayout() { + if (center == null) { + assert(firstChild == null, 'center must be null if children are present'); + _minScrollExtent = 0.0; + _maxScrollExtent = 0.0; + _hasVisualOverflow = false; + offset.applyContentDimensions(0, 0); + return; + } + + assert(center!.parent == this, 'center must be a child of the viewport'); + + final constraints = this.constraints; + if (firstChild == null) { + switch (axis) { + case Axis.vertical: + assert( + constraints.hasBoundedWidth, + 'Vertical viewport was given ' + 'unbounded width.\n' + 'Viewports expand in the cross axis to fill their container and ' + 'constrain their children to match their extent in the cross axis. ' + 'In this case, a vertical viewport was given an unlimited amount ' + 'of horizontal space in which to expand.', + ); + size = Size(constraints.maxWidth, constraints.minHeight); + break; + case Axis.horizontal: + assert( + constraints.hasBoundedHeight, + 'Horizontal viewport was given ' + 'unbounded height.\n' + 'Viewports expand in the cross axis to fill their container and ' + 'constrain their children to match their extent in the cross axis. ' + 'In this case, a horizontal viewport was given an unlimited amount ' + 'of vertical space in which to expand.', + ); + size = Size(constraints.minWidth, constraints.maxHeight); + break; + } + offset.applyViewportDimension(0); + _maxScrollExtent = 0.0; + _shrinkWrapExtent = 0.0; + _hasVisualOverflow = false; + offset.applyContentDimensions(0, 0); + return; + } + + double mainAxisExtent; + final double crossAxisExtent; + switch (axis) { + case Axis.vertical: + assert( + constraints.hasBoundedWidth, + 'Vertical viewport was given ' + 'unbounded width.\n' + 'Viewports expand in the cross axis to fill their container and ' + 'constrain their children to match their extent in the cross axis. ' + 'In this case, a vertical viewport was given an unlimited amount ' + 'of horizontal space in which to expand.', + ); + mainAxisExtent = constraints.maxHeight; + crossAxisExtent = constraints.maxWidth; + break; + case Axis.horizontal: + assert( + constraints.hasBoundedHeight, + 'Horizontal viewport was given ' + 'unbounded height.\n' + 'Viewports expand in the cross axis to fill their container and ' + 'constrain their children to match their extent in the cross axis. ' + 'In this case, a horizontal viewport was given an unlimited amount ' + 'of vertical space in which to expand.', + ); + mainAxisExtent = constraints.maxWidth; + crossAxisExtent = constraints.maxHeight; + break; + } + + if (mainAxisExtent.isInfinite) { + mainAxisExtent = _maxMainAxisExtent; + } + + final centerOffsetAdjustment = center!.centerOffsetAdjustment; + + double correction; + double effectiveExtent; + do { + correction = _attemptLayout( + mainAxisExtent, + crossAxisExtent, + offset.pixels + centerOffsetAdjustment, + ); + if (correction != 0.0) { + offset.correctBy(correction); + } else { + switch (axis) { + case Axis.vertical: + effectiveExtent = constraints.constrainHeight(_shrinkWrapExtent); + break; + case Axis.horizontal: + effectiveExtent = constraints.constrainWidth(_shrinkWrapExtent); + break; + } + // *** Difference from [RenderViewport]. + final top = _minScrollExtent + mainAxisExtent * anchor; + final bottom = _maxScrollExtent - mainAxisExtent * (1.0 - anchor); + + final maxScrollOffset = math.max(math.min(0, top), bottom); + final minScrollOffset = math.min(top, maxScrollOffset); + + final didAcceptViewportDimension = + offset.applyViewportDimension(effectiveExtent); + final didAcceptContentDimension = + offset.applyContentDimensions(minScrollOffset, maxScrollOffset); + if (didAcceptViewportDimension && didAcceptContentDimension) { + break; + } + } + } while (true); + switch (axis) { + case Axis.vertical: + size = + constraints.constrainDimensions(crossAxisExtent, effectiveExtent); + break; + case Axis.horizontal: + size = + constraints.constrainDimensions(effectiveExtent, crossAxisExtent); + break; + } + } + + double _attemptLayout( + double mainAxisExtent, + double crossAxisExtent, + double correctedOffset, + ) { + assert(!mainAxisExtent.isNaN, 'The maxExtent of $this has not been set.'); + assert(mainAxisExtent >= 0.0, 'The maxExtent of $this is negative.'); + assert( + crossAxisExtent.isFinite, + 'The crossAxisExtent of $this is not finite.', + ); + assert(crossAxisExtent >= 0.0, 'The crossAxisExtent of $this is negative.'); + assert( + correctedOffset.isFinite, + 'The correctedOffset of $this is not finite.', + ); + _minScrollExtent = 0.0; + _maxScrollExtent = 0.0; + _hasVisualOverflow = false; + _shrinkWrapExtent = 0.0; + + // centerOffset is the offset from the leading edge of the RenderViewport + // to the zero scroll offset (the line between the forward slivers and the + // reverse slivers). + final centerOffset = mainAxisExtent * anchor - correctedOffset; + final reverseDirectionRemainingPaintExtent = + centerOffset.clamp(0.0, mainAxisExtent); + final forwardDirectionRemainingPaintExtent = + (mainAxisExtent - centerOffset).clamp(0.0, mainAxisExtent); + + switch (cacheExtentStyle) { + case CacheExtentStyle.pixel: + _calculatedCacheExtent = cacheExtent; + break; + case CacheExtentStyle.viewport: + _calculatedCacheExtent = mainAxisExtent * cacheExtent!; + break; + } + + final fullCacheExtent = mainAxisExtent + 2 * _calculatedCacheExtent!; + final centerCacheOffset = centerOffset + _calculatedCacheExtent!; + final reverseDirectionRemainingCacheExtent = + centerCacheOffset.clamp(0.0, fullCacheExtent); + final forwardDirectionRemainingCacheExtent = + (fullCacheExtent - centerCacheOffset).clamp(0.0, fullCacheExtent); + + final leadingNegativeChild = childBefore(center!); + + if (leadingNegativeChild != null) { + // negative scroll offsets + final result = layoutChildSequence( + child: leadingNegativeChild, + scrollOffset: math.max(mainAxisExtent, centerOffset) - mainAxisExtent, + overlap: 0, + layoutOffset: forwardDirectionRemainingPaintExtent, + remainingPaintExtent: reverseDirectionRemainingPaintExtent, + mainAxisExtent: mainAxisExtent, + crossAxisExtent: crossAxisExtent, + growthDirection: GrowthDirection.reverse, + advance: childBefore, + remainingCacheExtent: reverseDirectionRemainingCacheExtent, + cacheOrigin: (mainAxisExtent - centerOffset) + .clamp(-_calculatedCacheExtent!, 0.0), + ); + if (result != 0.0) return -result; + } + + // positive scroll offsets + return layoutChildSequence( + child: center, + scrollOffset: math.max(0, -centerOffset), + overlap: leadingNegativeChild == null ? math.min(0, -centerOffset) : 0.0, + layoutOffset: centerOffset >= mainAxisExtent + ? centerOffset + : reverseDirectionRemainingPaintExtent, + remainingPaintExtent: forwardDirectionRemainingPaintExtent, + mainAxisExtent: mainAxisExtent, + crossAxisExtent: crossAxisExtent, + growthDirection: GrowthDirection.forward, + advance: childAfter, + remainingCacheExtent: forwardDirectionRemainingCacheExtent, + cacheOrigin: centerOffset.clamp(-_calculatedCacheExtent!, 0.0), + ); + } + + @override + bool get hasVisualOverflow => _hasVisualOverflow; + + @override + void updateOutOfBandData( + GrowthDirection growthDirection, + SliverGeometry childLayoutGeometry, + ) { + switch (growthDirection) { + case GrowthDirection.forward: + _maxScrollExtent += childLayoutGeometry.scrollExtent; + break; + case GrowthDirection.reverse: + _minScrollExtent -= childLayoutGeometry.scrollExtent; + break; + } + if (childLayoutGeometry.hasVisualOverflow) _hasVisualOverflow = true; + _shrinkWrapExtent += childLayoutGeometry.maxPaintExtent; + growSize = _shrinkWrapExtent; + } + + @override + String labelForChild(int index) => 'child $index'; +} + +/// A widget that is bigger on the inside. +/// +/// [Viewport] is the visual workhorse of the scrolling machinery. It displays a +/// subset of its children according to its own dimensions and the given +/// [offset]. As the offset varies, different children are visible through +/// the viewport. +/// +/// [Viewport] hosts a bidirectional list of slivers, anchored on a [center] +/// sliver, which is placed at the zero scroll offset. The center widget is +/// displayed in the viewport according to the [anchor] property. +/// +/// Slivers that are earlier in the child list than [center] are displayed in +/// reverse order in the reverse [axisDirection] starting from the [center]. For +/// example, if the [axisDirection] is [AxisDirection.down], the first sliver +/// before [center] is placed above the [center]. The slivers that are later in +/// the child list than [center] are placed in order in the [axisDirection]. For +/// example, in the preceding scenario, the first sliver after [center] is +/// placed below the [center]. +/// +/// [Viewport] cannot contain box children directly. Instead, use a +/// [SliverList], [SliverFixedExtentList], [SliverGrid], or a +/// [SliverToBoxAdapter], for example. +/// +/// See also: +/// +/// * [ListView], [PageView], [GridView], and [CustomScrollView], which combine +/// [Scrollable] and [Viewport] into widgets that are easier to use. +/// * [SliverToBoxAdapter], which allows a box widget to be placed inside a +/// sliver context (the opposite of this widget). +/// * [ShrinkWrappingViewport], a variant of [Viewport] that shrink-wraps its +/// contents along the main axis. +abstract class CustomViewport extends MultiChildRenderObjectWidget { + /// Creates a widget that is bigger on the inside. + /// + /// The viewport listens to the [offset], which means you do not need to + /// rebuild this widget when the [offset] changes. + /// + /// The [offset] argument must not be null. + /// + /// The [cacheExtent] must be specified if the [cacheExtentStyle] is + /// not [CacheExtentStyle.pixel]. + CustomViewport({ + super.key, + this.axisDirection = AxisDirection.down, + this.crossAxisDirection, + this.anchor = 0.0, + required this.offset, + this.center, + this.cacheExtent, + this.cacheExtentStyle = CacheExtentStyle.pixel, + this.clipBehavior = Clip.hardEdge, + List slivers = const [], + }) : assert( + center == null || + slivers.where((Widget child) => child.key == center).length == 1, + 'There should be at most one child with the same key as the center child: $center', + ), + assert( + cacheExtentStyle != CacheExtentStyle.viewport || cacheExtent != null, + 'A cacheExtent is required when using cacheExtentStyle.viewport', + ), + super(children: slivers); + + /// The direction in which the [offset]'s [ViewportOffset.pixels] increases. + /// + /// For example, if the [axisDirection] is [AxisDirection.down], a scroll + /// offset of zero is at the top of the viewport and increases towards the + /// bottom of the viewport. + final AxisDirection axisDirection; + + /// The direction in which child should be laid out in the cross axis. + /// + /// If the [axisDirection] is [AxisDirection.down] or [AxisDirection.up], this + /// property defaults to [AxisDirection.left] if the ambient [Directionality] + /// is [TextDirection.rtl] and [AxisDirection.right] if the ambient + /// [Directionality] is [TextDirection.ltr]. + /// + /// If the [axisDirection] is [AxisDirection.left] or [AxisDirection.right], + /// this property defaults to [AxisDirection.down]. + final AxisDirection? crossAxisDirection; + + /// The relative position of the zero scroll offset. + /// + /// For example, if [anchor] is 0.5 and the [axisDirection] is + /// [AxisDirection.down] or [AxisDirection.up], then the zero scroll offset is + /// vertically centered within the viewport. If the [anchor] is 1.0, and the + /// [axisDirection] is [AxisDirection.right], then the zero scroll offset is + /// on the left edge of the viewport. + final double anchor; + + /// Which part of the content inside the viewport should be visible. + /// + /// The [ViewportOffset.pixels] value determines the scroll offset that the + /// viewport uses to select which part of its content to display. As the user + /// scrolls the viewport, this value changes, which changes the content that + /// is displayed. + /// + /// Typically a [ScrollPosition]. + final ViewportOffset offset; + + /// The first child in the [GrowthDirection.forward] growth direction. + /// + /// Children after [center] will be placed in the [axisDirection] relative to + /// the [center]. Children before [center] will be placed in the opposite of + /// the [axisDirection] relative to the [center]. + /// + /// The [center] must be the key of a child of the viewport. + final Key? center; + + /// {@macro flutter.rendering.RenderViewportBase.cacheExtent} + /// + /// See also: + /// + /// * [cacheExtentStyle], which controls the units of the [cacheExtent]. + final double? cacheExtent; + + /// {@macro flutter.rendering.RenderViewportBase.cacheExtentStyle} + final CacheExtentStyle cacheExtentStyle; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.hardEdge]. + final Clip clipBehavior; + + /// Given a [BuildContext] and an [AxisDirection], determine the correct cross + /// axis direction. + /// + /// This depends on the [Directionality] if the `axisDirection` is vertical; + /// otherwise, the default cross axis direction is downwards. + static AxisDirection getDefaultCrossAxisDirection( + BuildContext context, + AxisDirection axisDirection, + ) { + switch (axisDirection) { + case AxisDirection.up: + assert(debugCheckHasDirectionality( + context, + why: + "to determine the cross-axis direction when the viewport has an 'up' axisDirection", + alternative: + "Alternatively, consider specifying the 'crossAxisDirection' argument on the Viewport.", + )); + return textDirectionToAxisDirection(Directionality.of(context)); + case AxisDirection.right: + return AxisDirection.down; + case AxisDirection.down: + assert(debugCheckHasDirectionality( + context, + why: + "to determine the cross-axis direction when the viewport has a 'down' axisDirection", + alternative: + "Alternatively, consider specifying the 'crossAxisDirection' argument on the Viewport.", + )); + return textDirectionToAxisDirection(Directionality.of(context)); + case AxisDirection.left: + return AxisDirection.down; + } + } + + @override + CustomRenderViewport createRenderObject(BuildContext context); + + @override + _ViewportElement createElement() => _ViewportElement(this); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(EnumProperty('axisDirection', axisDirection)) + ..add(EnumProperty( + 'crossAxisDirection', + crossAxisDirection, + defaultValue: null, + )) + ..add(DoubleProperty('anchor', anchor)) + ..add(DiagnosticsProperty('offset', offset)); + if (center != null) { + properties.add(DiagnosticsProperty('center', center)); + } else if (children.isNotEmpty && children.first.key != null) { + properties.add(DiagnosticsProperty( + 'center', + children.first.key, + tooltip: 'implicit', + )); + } + properties + ..add(DiagnosticsProperty('cacheExtent', cacheExtent)) + ..add(DiagnosticsProperty( + 'cacheExtentStyle', + cacheExtentStyle, + )); + } +} + +class _ViewportElement extends MultiChildRenderObjectElement { + /// Creates an element that uses the given widget as its configuration. + _ViewportElement(CustomViewport super.widget); + + @override + CustomViewport get widget => super.widget as CustomViewport; + + @override + CustomRenderViewport get renderObject => + super.renderObject as CustomRenderViewport; + + @override + void mount(Element? parent, dynamic newSlot) { + super.mount(parent, newSlot); + _updateCenter(); + } + + @override + void update(MultiChildRenderObjectWidget newWidget) { + super.update(newWidget); + _updateCenter(); + } + + void _updateCenter() { + if (widget.center != null) { + renderObject.center = children + .singleWhere((Element element) => element.widget.key == widget.center) + .renderObject as RenderSliver?; + } else if (children.isNotEmpty) { + renderObject.center = children.first.renderObject as RenderSliver?; + } else { + renderObject.center = null; + } + } + + @override + void debugVisitOnstageChildren(ElementVisitor visitor) { + children.where((Element e) { + final renderSliver = e.renderObject! as RenderSliver; + return renderSliver.geometry!.visible; + }).forEach(visitor); + } +} + +class CustomSliverPhysicalContainerParentData + extends SliverPhysicalContainerParentData { + /// The position of the child relative to the zero scroll offset. + /// + /// The number of pixels from from the zero scroll offset of the parent sliver + /// (the line at which its [SliverConstraints.scrollOffset] is zero) to the + /// side of the child closest to that offset. A [layoutOffset] can be null + /// when it cannot be determined. The value will be set after layout. + /// + /// In a typical list, this does not change as the parent is scrolled. + /// + /// Defaults to null. + double? layoutOffset; + + GrowthDirection? growthDirection; +} + +/// A render object that is bigger on the inside. +/// +/// [RenderViewport] is the visual workhorse of the scrolling machinery. It +/// displays a subset of its children according to its own dimensions and the +/// given [offset]. As the offset varies, different children are visible through +/// the viewport. +/// +/// [RenderViewport] hosts a bidirectional list of slivers, anchored on a +/// [center] sliver, which is placed at the zero scroll offset. The center +/// widget is displayed in the viewport according to the [anchor] property. +/// +/// Slivers that are earlier in the child list than [center] are displayed in +/// reverse order in the reverse [axisDirection] starting from the [center]. For +/// example, if the [axisDirection] is [AxisDirection.down], the first sliver +/// before [center] is placed above the [center]. The slivers that are later in +/// the child list than [center] are placed in order in the [axisDirection]. For +/// example, in the preceding scenario, the first sliver after [center] is +/// placed below the [center]. +/// +/// [RenderViewport] cannot contain [RenderBox] children directly. Instead, use +/// a [RenderSliverList], [RenderSliverFixedExtentList], [RenderSliverGrid], or +/// a [RenderSliverToBoxAdapter], for example. +/// +/// See also: +/// +/// * [RenderSliver], which explains more about the Sliver protocol. +/// * [RenderBox], which explains more about the Box protocol. +/// * [RenderSliverToBoxAdapter], which allows a [RenderBox] object to be +/// placed inside a [RenderSliver] (the opposite of this class). +/// * [RenderShrinkWrappingViewport], a variant of [RenderViewport] that +/// shrink-wraps its contents along the main axis. +abstract class CustomRenderViewport + extends RenderViewportBase { + /// Creates a viewport for [RenderSliver] objects. + /// + /// If the [center] is not specified, then the first child in the `children` + /// list, if any, is used. + /// + /// The [offset] must be specified. For testing purposes, consider passing a + /// [ViewportOffset.zero] or [ViewportOffset.fixed]. + CustomRenderViewport({ + super.axisDirection, + required super.crossAxisDirection, + required super.offset, + double anchor = 0.0, + List? children, + RenderSliver? center, + super.cacheExtent, + super.cacheExtentStyle, + super.clipBehavior, + }) : assert( + anchor >= 0.0 && anchor <= 1.0, + 'Anchor must be between 0.0 and 1.0.', + ), + assert( + cacheExtentStyle != CacheExtentStyle.viewport || cacheExtent != null, + 'A cacheExtent is required when using CacheExtentStyle.viewport.', + ), + _center = center { + addAll(children); + if (center == null && firstChild != null) _center = firstChild; + } + + /// If a [RenderAbstractViewport] overrides + /// [RenderObject.describeSemanticsConfiguration] to add the [SemanticsTag] + /// [useTwoPaneSemantics] to its [SemanticsConfiguration], two semantics nodes + /// will be used to represent the viewport with its associated scrolling + /// actions in the semantics tree. + /// + /// Two semantics nodes (an inner and an outer node) are necessary to exclude + /// certain child nodes (via the [excludeFromScrolling] tag) from the + /// scrollable area for semantic purposes: The [SemanticsNode]s of children + /// that should be excluded from scrolling will be attached to the outer node. + /// The semantic scrolling actions and the [SemanticsNode]s of scrollable + /// children will be attached to the inner node, which itself is a child of + /// the outer node. + /// + /// See also: + /// + /// * [RenderViewportBase.describeSemanticsConfiguration], which adds this + /// tag to its [SemanticsConfiguration]. + static const SemanticsTag useTwoPaneSemantics = + SemanticsTag('RenderViewport.twoPane'); + + /// When a top-level [SemanticsNode] below a [RenderAbstractViewport] is + /// tagged with [excludeFromScrolling] it will not be part of the scrolling + /// area for semantic purposes. + /// + /// This behavior is only active if the [RenderAbstractViewport] + /// tagged its [SemanticsConfiguration] with [useTwoPaneSemantics]. + /// Otherwise, the [excludeFromScrolling] tag is ignored. + /// + /// As an example, a [RenderSliver] that stays on the screen within a + /// [Scrollable] even though the user has scrolled past it (e.g. a pinned app + /// bar) can tag its [SemanticsNode] with [excludeFromScrolling] to indicate + /// that it should no longer be considered for semantic actions related to + /// scrolling. + static const SemanticsTag excludeFromScrolling = + SemanticsTag('RenderViewport.excludeFromScrolling'); + + @override + void setupParentData(RenderObject child) { + if (child.parentData is! CustomSliverPhysicalContainerParentData) { + child.parentData = CustomSliverPhysicalContainerParentData(); + } + } + + /// The relative position of the zero scroll offset. + /// + /// For example, if [anchor] is 0.5 and the [axisDirection] is + /// [AxisDirection.down] or [AxisDirection.up], then the zero scroll offset is + /// vertically centered within the viewport. If the [anchor] is 1.0, and the + /// [axisDirection] is [AxisDirection.right], then the zero scroll offset is + /// on the left edge of the viewport. + double get anchor; + + set anchor(double value); + + /// The first child in the [GrowthDirection.forward] growth direction. + /// + /// This child that will be at the position defined by [anchor] when the + /// [ViewportOffset.pixels] of [offset] is `0`. + /// + /// Children after [center] will be placed in the [axisDirection] relative to + /// the [center]. Children before [center] will be placed in the opposite of + /// the [axisDirection] relative to the [center]. + /// + /// The [center] must be a child of the viewport. + RenderSliver? get center => _center; + RenderSliver? _center; + + set center(RenderSliver? value) { + if (value == _center) return; + _center = value; + markNeedsLayout(); + } + + @override + bool get sizedByParent => true; + + @override + Size computeDryLayout(BoxConstraints constraints) { + assert(() { + if (!constraints.hasBoundedHeight || !constraints.hasBoundedWidth) { + switch (axis) { + case Axis.vertical: + if (!constraints.hasBoundedHeight) { + throw FlutterError.fromParts([ + ErrorSummary('Vertical viewport was given unbounded height.'), + ErrorDescription( + 'Viewports expand in the scrolling direction to fill their container. ' + 'In this case, a vertical viewport was given an unlimited amount of ' + 'vertical space in which to expand. This situation typically happens ' + 'when a scrollable widget is nested inside another scrollable widget.', + ), + ErrorHint( + 'If this widget is always nested in a scrollable widget there ' + 'is no need to use a viewport because there will always be enough ' + 'vertical space for the children. In this case, consider using a ' + 'Column instead. Otherwise, consider using the "shrinkWrap" property ' + '(or a ShrinkWrappingViewport) to size the height of the viewport ' + 'to the sum of the heights of its children.', + ), + ]); + } + if (!constraints.hasBoundedWidth) { + throw FlutterError( + 'Vertical viewport was given unbounded width.\n' + 'Viewports expand in the cross axis to fill their container and ' + 'constrain their children to match their extent in the cross axis. ' + 'In this case, a vertical viewport was given an unlimited amount of ' + 'horizontal space in which to expand.', + ); + } + break; + case Axis.horizontal: + if (!constraints.hasBoundedWidth) { + throw FlutterError.fromParts([ + ErrorSummary( + 'Horizontal viewport was given unbounded width.', + ), + ErrorDescription( + 'Viewports expand in the scrolling direction to fill their container. ' + 'In this case, a horizontal viewport was given an unlimited amount of ' + 'horizontal space in which to expand. This situation typically happens ' + 'when a scrollable widget is nested inside another scrollable widget.', + ), + ErrorHint( + 'If this widget is always nested in a scrollable widget there ' + 'is no need to use a viewport because there will always be enough ' + 'horizontal space for the children. In this case, consider using a ' + 'Row instead. Otherwise, consider using the "shrinkWrap" property ' + '(or a ShrinkWrappingViewport) to size the width of the viewport ' + 'to the sum of the widths of its children.', + ), + ]); + } + if (!constraints.hasBoundedHeight) { + throw FlutterError( + 'Horizontal viewport was given unbounded height.\n' + 'Viewports expand in the cross axis to fill their container and ' + 'constrain their children to match their extent in the cross axis. ' + 'In this case, a horizontal viewport was given an unlimited amount of ' + 'vertical space in which to expand.', + ); + } + break; + } + } + return true; + }()); + return constraints.biggest; + } + + // Out-of-band data computed during layout. + late double _minScrollExtent; + late double _maxScrollExtent; + bool _hasVisualOverflow = false; + + double growSize = 0; + + @override + bool get hasVisualOverflow => _hasVisualOverflow; + + @override + void updateOutOfBandData( + GrowthDirection growthDirection, + SliverGeometry childLayoutGeometry, + ) { + switch (growthDirection) { + case GrowthDirection.forward: + _maxScrollExtent += childLayoutGeometry.scrollExtent; + break; + case GrowthDirection.reverse: + _minScrollExtent -= childLayoutGeometry.scrollExtent; + break; + } + if (childLayoutGeometry.hasVisualOverflow) _hasVisualOverflow = true; + } + + @override + void updateChildLayoutOffset( + RenderSliver child, + double layoutOffset, + GrowthDirection growthDirection, + ) { + final childParentData = + child.parentData! as CustomSliverPhysicalContainerParentData; + childParentData + ..layoutOffset = layoutOffset + ..growthDirection = growthDirection; + } + + @override + Offset paintOffsetOf(RenderSliver child) { + final childParentData = + child.parentData! as CustomSliverPhysicalContainerParentData; + return computeAbsolutePaintOffset( + child, + childParentData.layoutOffset!, + childParentData.growthDirection!, + ); + } + + @override + double scrollOffsetOf(RenderSliver child, double scrollOffsetWithinChild) { + assert( + child.parent == this, + 'The "child" argument must be a child of this RenderViewport.', + ); + final growthDirection = child.constraints.growthDirection; + switch (growthDirection) { + case GrowthDirection.forward: + var scrollOffsetToChild = 0.0; + var current = center; + while (current != child) { + scrollOffsetToChild += current!.geometry!.scrollExtent; + current = childAfter(current); + } + return scrollOffsetToChild + scrollOffsetWithinChild; + case GrowthDirection.reverse: + var scrollOffsetToChild = 0.0; + var current = childBefore(center!); + while (current != child) { + scrollOffsetToChild -= current!.geometry!.scrollExtent; + current = childBefore(current); + } + return scrollOffsetToChild - scrollOffsetWithinChild; + } + } + + @override + double maxScrollObstructionExtentBefore(RenderSliver child) { + assert( + child.parent == this, + 'The "child" argument must be a child of this RenderViewport.', + ); + final growthDirection = child.constraints.growthDirection; + switch (growthDirection) { + case GrowthDirection.forward: + var pinnedExtent = 0.0; + var current = center; + while (current != child) { + pinnedExtent += current!.geometry!.maxScrollObstructionExtent; + current = childAfter(current); + } + return pinnedExtent; + case GrowthDirection.reverse: + var pinnedExtent = 0.0; + var current = childBefore(center!); + while (current != child) { + pinnedExtent += current!.geometry!.maxScrollObstructionExtent; + current = childBefore(current); + } + return pinnedExtent; + } + } + + @override + void applyPaintTransform(RenderObject child, Matrix4 transform) { + final offset = paintOffsetOf(child as RenderSliver); + transform.translate(offset.dx, offset.dy); + } + + @override + double computeChildMainAxisPosition( + RenderSliver child, + double parentMainAxisPosition, + ) { + final childParentData = + child.parentData! as CustomSliverPhysicalContainerParentData; + switch (applyGrowthDirectionToAxisDirection( + child.constraints.axisDirection, + child.constraints.growthDirection, + )) { + case AxisDirection.down: + case AxisDirection.right: + return parentMainAxisPosition - childParentData.layoutOffset!; + case AxisDirection.up: + return (size.height - parentMainAxisPosition) - + childParentData.layoutOffset!; + case AxisDirection.left: + return (size.width - parentMainAxisPosition) - + childParentData.layoutOffset!; + } + } + + @override + int get indexOfFirstChild { + assert(center != null, 'RenderViewport does not have any children.'); + assert( + center!.parent == this, + 'center is not a child of this RenderViewport', + ); + assert( + firstChild != null, + 'center is the only child of this RenderViewport', + ); + var count = 0; + var child = center; + while (child != firstChild) { + count -= 1; + child = childBefore(child!); + } + return count; + } + + @override + String labelForChild(int index) { + if (index == 0) return 'center child'; + return 'child $index'; + } + + @override + Iterable get childrenInPaintOrder sync* { + if (firstChild == null) return; + var child = firstChild; + while (child != center) { + yield child!; + child = childAfter(child); + } + child = lastChild; + while (true) { + yield child!; + if (child == center) return; + child = childBefore(child); + } + } + + @override + Iterable get childrenInHitTestOrder sync* { + if (firstChild == null) return; + var child = center; + while (child != null) { + yield child; + child = childAfter(child); + } + child = childBefore(center!); + while (child != null) { + yield child; + child = childBefore(child); + } + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('anchor', anchor)); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/url_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/url_attachment.dart index 10170bb15..a8ed00c66 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/url_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/url_attachment.dart @@ -36,8 +36,6 @@ class StreamUrlAttachment extends StatelessWidget { @override Widget build(BuildContext context) { - final chatThemeData = StreamChatTheme.of(context); - return ConstrainedBox( constraints: const BoxConstraints( maxWidth: 400, @@ -79,7 +77,7 @@ class StreamUrlAttachment extends StatelessWidget { borderRadius: const BorderRadius.only( topRight: Radius.circular(16), ), - color: messageTheme.linkBackgroundColor, + color: messageTheme.urlAttachmentBackgroundColor, ), child: Padding( padding: const EdgeInsets.only( @@ -89,9 +87,7 @@ class StreamUrlAttachment extends StatelessWidget { ), child: Text( hostDisplayName, - style: chatThemeData.textTheme.bodyBold.copyWith( - color: chatThemeData.colorTheme.accentPrimary, - ), + style: messageTheme.urlAttachmentHostStyle, ), ), ), @@ -109,14 +105,12 @@ class StreamUrlAttachment extends StatelessWidget { urlAttachment.title!.trim(), maxLines: messageTheme.urlAttachmentTitleMaxLine ?? 1, overflow: TextOverflow.ellipsis, - style: chatThemeData.textTheme.body - .copyWith(fontWeight: FontWeight.w700), + style: messageTheme.urlAttachmentTitleStyle, ), if (urlAttachment.text != null) Text( urlAttachment.text!, - style: chatThemeData.textTheme.body - .copyWith(fontWeight: FontWeight.w400), + style: messageTheme.urlAttachmentTextStyle, ), ], ), diff --git a/packages/stream_chat_flutter/lib/src/message_input/quoted_message_widget.dart b/packages/stream_chat_flutter/lib/src/message_input/quoted_message_widget.dart index 8de663e17..33145852c 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/quoted_message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/quoted_message_widget.dart @@ -22,7 +22,6 @@ class StreamQuotedMessageWidget extends StatelessWidget { this.padding = const EdgeInsets.all(8), this.onTap, this.onQuotedMessageClear, - this.composing = true, }); /// The message @@ -53,9 +52,6 @@ class StreamQuotedMessageWidget extends StatelessWidget { /// Callback for clearing quoted messages. final VoidCallback? onQuotedMessageClear; - /// True if the message is being composed - final bool composing; - @override Widget build(BuildContext context) { final children = [ @@ -63,11 +59,10 @@ class StreamQuotedMessageWidget extends StatelessWidget { child: _QuotedMessage( message: message, textLimit: textLimit, - composing: composing, - onQuotedMessageClear: onQuotedMessageClear, messageTheme: messageTheme, showBorder: showBorder, reverse: reverse, + onQuotedMessageClear: onQuotedMessageClear, attachmentThumbnailBuilders: attachmentThumbnailBuilders, ), ), @@ -104,17 +99,15 @@ class _QuotedMessage extends StatelessWidget { const _QuotedMessage({ required this.message, required this.textLimit, - required this.composing, - required this.onQuotedMessageClear, required this.messageTheme, required this.showBorder, required this.reverse, + this.onQuotedMessageClear, this.attachmentThumbnailBuilders, }); final Message message; final int textLimit; - final bool composing; final VoidCallback? onQuotedMessageClear; final StreamMessageThemeData messageTheme; final bool showBorder; @@ -134,6 +127,8 @@ class _QuotedMessage extends StatelessWidget { bool get _isGiphy => message.attachments.any((element) => element.type == 'giphy'); + bool get _isDeleted => message.isDeleted || message.deletedAt != null; + @override Widget build(BuildContext context) { final isOnlyEmoji = message.text!.isOnlyEmoji; @@ -144,39 +139,54 @@ class _QuotedMessage extends StatelessWidget { msg = msg.copyWith(text: '${msg.text!.substring(0, textLimit - 3)}...'); } - final children = [ - if (composing) - PlatformWidgetBuilder( - web: (context, child) => child, - desktop: (context, child) => child, - child: ClearInputItemButton( - onTap: onQuotedMessageClear, + List children; + if (_isDeleted) { + // Show deleted message text + children = [ + Text( + context.translations.messageDeletedLabel, + style: messageTheme.messageTextStyle?.copyWith( + fontStyle: FontStyle.italic, + color: messageTheme.createdAtStyle?.color, ), ), - if (_hasAttachments) - _ParseAttachments( - message: message, - messageTheme: messageTheme, - attachmentThumbnailBuilders: attachmentThumbnailBuilders, - ), - if (msg.text!.isNotEmpty && !_isGiphy) - Flexible( - child: StreamMessageText( - message: msg, - messageTheme: isOnlyEmoji && _containsText - ? messageTheme.copyWith( - messageTextStyle: messageTheme.messageTextStyle?.copyWith( - fontSize: 32, - ), - ) - : messageTheme.copyWith( - messageTextStyle: messageTheme.messageTextStyle?.copyWith( - fontSize: 12, + ]; + } else { + // Show quoted message + children = [ + if (onQuotedMessageClear != null) + PlatformWidgetBuilder( + web: (context, child) => child, + desktop: (context, child) => child, + child: ClearInputItemButton( + onTap: onQuotedMessageClear, + ), + ), + if (_hasAttachments) + _ParseAttachments( + message: message, + messageTheme: messageTheme, + attachmentThumbnailBuilders: attachmentThumbnailBuilders, + ), + if (msg.text!.isNotEmpty && !_isGiphy) + Flexible( + child: StreamMessageText( + message: msg, + messageTheme: isOnlyEmoji && _containsText + ? messageTheme.copyWith( + messageTextStyle: messageTheme.messageTextStyle?.copyWith( + fontSize: 32, + ), + ) + : messageTheme.copyWith( + messageTextStyle: messageTheme.messageTextStyle?.copyWith( + fontSize: 12, + ), ), - ), + ), ), - ), - ].insertBetween(const SizedBox(width: 8)); + ].insertBetween(const SizedBox(width: 8)); + } return Container( decoration: BoxDecoration( @@ -204,8 +214,8 @@ class _QuotedMessage extends StatelessWidget { } Color? _getBackgroundColor(BuildContext context) { - if (_containsLinkAttachment) { - return messageTheme.linkBackgroundColor; + if (_containsLinkAttachment && !_isDeleted) { + return messageTheme.urlAttachmentBackgroundColor; } return messageTheme.messageBackgroundColor; } diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart index b479b5cea..724535766 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart @@ -25,6 +25,33 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; const _kCommandTrigger = '/'; const _kMentionTrigger = '@'; +/// Signature for the function that determines if a [matchedUri] should be +/// previewed as an OG Attachment. +typedef OgPreviewFilter = bool Function( + Uri matchedUri, + String messageText, +); + +/// Different types of hints that can be shown in [StreamMessageInput]. +enum HintType { + /// Hint for [StreamMessageInput] when the command is enabled and the command + /// is 'giphy'. + searchGif, + + /// Hint for [StreamMessageInput] when there are attachments. + addACommentOrSend, + + /// Hint for [StreamMessageInput] when slow mode is enabled. + slowModeOn, + + /// Hint for [StreamMessageInput] when other conditions are not met. + writeAMessage, +} + +/// Function that returns the hint text for [StreamMessageInput] based on +/// [type]. +typedef HintGetter = String? Function(BuildContext context, HintType type); + /// Inactive state: /// /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_input.png) @@ -114,6 +141,8 @@ class StreamMessageInput extends StatefulWidget { this.sendMessageKeyPredicate = _defaultSendMessageKeyPredicate, this.clearQuotedMessageKeyPredicate = _defaultClearQuotedMessageKeyPredicate, + this.ogPreviewFilter = _defaultOgPreviewFilter, + this.hintGetter = _defaultHintGetter, }); /// The predicate used to send a message on desktop/web @@ -259,6 +288,37 @@ class StreamMessageInput extends StatefulWidget { /// Callback for when the quoted message is cleared final VoidCallback? onQuotedMessageCleared; + /// The filter used to determine if a link should be shown as an OpenGraph + /// preview. + final OgPreviewFilter ogPreviewFilter; + + /// Returns the hint text for the message input. + final HintGetter hintGetter; + + static String? _defaultHintGetter( + BuildContext context, + HintType type, + ) { + switch (type) { + case HintType.searchGif: + return context.translations.searchGifLabel; + case HintType.addACommentOrSend: + return context.translations.addACommentOrSendLabel; + case HintType.slowModeOn: + return context.translations.slowModeOnLabel; + case HintType.writeAMessage: + return context.translations.writeAMessageLabel; + } + } + + static bool _defaultOgPreviewFilter( + Uri matchedUri, + String messageText, + ) { + // Show the preview for all links + return true; + } + static bool _defaultValidator(Message message) => message.text?.isNotEmpty == true || message.attachments.isNotEmpty; @@ -962,18 +1022,20 @@ class StreamMessageInputState extends State leading: true, ); - String _getHint(BuildContext context) { + String? _getHint(BuildContext context) { + HintType hintType; + if (_commandEnabled && _effectiveController.message.command == 'giphy') { - return context.translations.searchGifLabel; - } - if (_effectiveController.attachments.isNotEmpty) { - return context.translations.addACommentOrSendLabel; - } - if (_timeOut != 0) { - return context.translations.slowModeOnLabel; + hintType = HintType.searchGif; + } else if (_effectiveController.attachments.isNotEmpty) { + hintType = HintType.addACommentOrSend; + } else if (_timeOut != 0) { + hintType = HintType.slowModeOn; + } else { + hintType = HintType.writeAMessage; } - return context.translations.writeAMessageLabel; + return widget.hintGetter.call(context, hintType); } String? _lastSearchedContainsUrlText; @@ -990,11 +1052,13 @@ class StreamMessageInputState extends State if (_lastSearchedContainsUrlText == value) return; _lastSearchedContainsUrlText = value; - final matchedUrls = _urlRegex.allMatches(value).toList() - ..removeWhere((it) { - final _parsedMatch = Uri.tryParse(it.group(0) ?? '')?.withScheme; - return _parsedMatch?.host.split('.').last.isValidTLD() == false; - }); + final matchedUrls = _urlRegex.allMatches(value).where((it) { + final _parsedMatch = Uri.tryParse(it.group(0) ?? '')?.withScheme; + if (_parsedMatch == null) return false; + + return _parsedMatch.host.split('.').last.isValidTLD() && + widget.ogPreviewFilter.call(_parsedMatch, value); + }).toList(); // Reset the og attachment if the text doesn't contain any url if (matchedUrls.isEmpty || 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 b6aa25b5e..63fe25a19 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 @@ -71,7 +71,7 @@ enum SpacingType { /// A [StreamChannel] ancestor widget is required in order to provide the /// information about the channels. /// -/// Uses a [ListView.custom] to render the list of channels. +/// Uses a [ScrollablePositionedList] to render the list of channels. /// /// The UI is rendered based on the first ancestor of type [StreamChatTheme]. /// Modify it to change the widget's appearance. @@ -88,8 +88,10 @@ class StreamMessageListView extends StatefulWidget { this.threadBuilder, this.onThreadTap, this.dateDividerBuilder, - this.scrollPhysics = - const ClampingScrollPhysics(), // we need to use ClampingScrollPhysics to avoid the list view to animate and break while loading + // we need to use ClampingScrollPhysics to avoid the list view to bounce + // when we are at the either end of the list view and try to use 'animateTo' + // to animate in the same direction. + this.scrollPhysics = const ClampingScrollPhysics(), this.initialScrollIndex, this.initialAlignment, this.scrollController, @@ -113,6 +115,7 @@ class StreamMessageListView extends StatefulWidget { this.unreadMessagesSeparatorBuilder, this.messageListController, this.reverse = true, + this.shrinkWrap = false, this.paginationLimit = 20, this.paginationLoadingIndicatorBuilder, this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.onDrag, @@ -133,6 +136,14 @@ class StreamMessageListView extends StatefulWidget { /// See [ScrollView.reverse]. final bool reverse; + /// Whether the extent of the scroll view in the [scrollDirection] should be + /// determined by the contents being viewed. + /// + /// Defaults to false. + /// + /// See [ScrollView.shrinkWrap]. + final bool shrinkWrap; + /// Limit used during pagination final int paginationLimit; @@ -271,9 +282,14 @@ class StreamMessageListView extends StatefulWidget { BuildContext context, List spacingTypes, ) { - if (!spacingTypes.contains(SpacingType.defaultSpacing)) { + if (spacingTypes.contains(SpacingType.otherUser)) { + return const SizedBox(height: 8); + } else if (spacingTypes.contains(SpacingType.thread)) { + return const SizedBox(height: 8); + } else if (spacingTypes.contains(SpacingType.timeDiff)) { return const SizedBox(height: 8); } + return const SizedBox(height: 2); } @@ -548,6 +564,7 @@ class _StreamMessageListViewState extends State { physics: widget.scrollPhysics, itemScrollController: _scrollController, reverse: widget.reverse, + shrinkWrap: widget.shrinkWrap, itemCount: itemCount, findChildIndexCallback: (Key key) { final indexedKey = key as IndexedKey; @@ -555,6 +572,10 @@ class _StreamMessageListViewState extends State { if (valueKey != null) { final index = messagesIndex[valueKey.value]; if (index != null) { + // The calculation is as follows: + // * Add 2 to the index retrieved to account for the footer and the bottom loader. + // * Multiply the result by 2 to account for the separators between each pair of items. + // * Subtract 1 to adjust for the 0-based indexing of the list view. return ((index + 2) * 2) - 1; } } @@ -628,29 +649,27 @@ class _StreamMessageListViewState extends State { Widget separator; - final isThread = message.replyCount! > 0; + final isPartOfThread = message.replyCount! > 0 || + message.showInChannel == true; - if (!Jiffy(message.createdAt.toLocal()).isSame( - nextMessage.createdAt.toLocal(), - Units.DAY, - )) { + final createdAt = message.createdAt.toLocal(); + final nextCreatedAt = nextMessage.createdAt.toLocal(); + if (!Jiffy(createdAt).isSame(nextCreatedAt, Units.DAY)) { separator = _buildDateDivider(nextMessage); } else { - final timeDiff = - Jiffy(nextMessage.createdAt.toLocal()).diff( - message.createdAt.toLocal(), + final hasTimeDiff = !Jiffy(createdAt).isSame( + nextCreatedAt, Units.MINUTE, ); final isNextUserSame = message.user!.id == nextMessage.user?.id; final isDeleted = message.isDeleted; - final hasTimeDiff = timeDiff >= 1; final spacingRules = [ if (hasTimeDiff) SpacingType.timeDiff, if (!isNextUserSame) SpacingType.otherUser, - if (isThread) SpacingType.thread, + if (isPartOfThread) SpacingType.thread, if (isDeleted) SpacingType.deleted, ]; @@ -664,7 +683,7 @@ class _StreamMessageListViewState extends State { ); } - if (!isThread && + if (!isPartOfThread && unreadCount > 0 && _oldestUnreadMessage?.id == nextMessage.id) { final unreadMessagesSeparator = @@ -877,6 +896,11 @@ class _StreamMessageListViewState extends State { final currentUserMember = members.firstWhereOrNull((e) => e.user!.id == currentUser!.id); + final hasUrlAttachment = + message.attachments.any((it) => it.ogScrapeUrl != null); + + final borderSide = isOnlyEmoji || hasUrlAttachment ? BorderSide.none : null; + final defaultMessageWidget = StreamMessageWidget( showReplyMessage: false, showResendMessage: false, @@ -901,7 +925,7 @@ class _StreamMessageListViewState extends State { vertical: 8, horizontal: isOnlyEmoji ? 0 : 16.0, ), - borderSide: isMyMessage || isOnlyEmoji ? BorderSide.none : null, + borderSide: borderSide, showUserAvatar: isMyMessage ? DisplayWidget.gone : DisplayWidget.show, messageTheme: isMyMessage ? _streamTheme.ownMessageTheme @@ -1037,10 +1061,10 @@ class _StreamMessageListViewState extends State { final isNextUserSame = nextMessage != null && message.user!.id == nextMessage.user!.id; - num timeDiff = 0; + var hasTimeDiff = false; if (nextMessage != null) { - timeDiff = Jiffy(nextMessage.createdAt.toLocal()).diff( - message.createdAt.toLocal(), + hasTimeDiff = !Jiffy(message.createdAt.toLocal()).isSame( + nextMessage.createdAt.toLocal(), Units.MINUTE, ); } @@ -1057,21 +1081,21 @@ class _StreamMessageListViewState extends State { final showTimeStamp = (!isThreadMessage || _isThreadConversation) && !hasReplies && - (timeDiff >= 1 || !isNextUserSame); + (hasTimeDiff || !isNextUserSame); final showUsername = !isMyMessage && (!isThreadMessage || _isThreadConversation) && !hasReplies && - (timeDiff >= 1 || !isNextUserSame); + (hasTimeDiff || !isNextUserSame); final showUserAvatar = isMyMessage ? DisplayWidget.gone - : (timeDiff >= 1 || !isNextUserSame) + : (hasTimeDiff || !isNextUserSame) ? DisplayWidget.show : DisplayWidget.hide; final showSendingIndicator = - isMyMessage && (index == 0 || timeDiff >= 1 || !isNextUserSame); + isMyMessage && (index == 0 || hasTimeDiff || !isNextUserSame); final showInChannelIndicator = !_isThreadConversation && isThreadMessage; final showThreadReplyIndicator = !_isThreadConversation && hasReplies; @@ -1080,10 +1104,7 @@ class _StreamMessageListViewState extends State { final hasUrlAttachment = message.attachments.any((it) => it.ogScrapeUrl != null); - final borderSide = - isOnlyEmoji || hasUrlAttachment || (isMyMessage && !hasFileAttachment) - ? BorderSide.none - : null; + final borderSide = isOnlyEmoji || hasUrlAttachment ? BorderSide.none : null; final currentUser = StreamChat.of(context).currentUser; final members = StreamChannel.of(context).channel.state?.members ?? []; @@ -1133,7 +1154,7 @@ class _StreamMessageListViewState extends State { bottomLeft: isMyMessage ? Radius.circular(attachmentBorderRadius) : Radius.circular( - (timeDiff >= 1 || !isNextUserSame) && + (hasTimeDiff || !isNextUserSame) && !(hasReplies || isThreadMessage || hasFileAttachment) ? 0 : attachmentBorderRadius, @@ -1141,7 +1162,7 @@ class _StreamMessageListViewState extends State { topRight: Radius.circular(attachmentBorderRadius), bottomRight: isMyMessage ? Radius.circular( - (timeDiff >= 1 || !isNextUserSame) && + (hasTimeDiff || !isNextUserSame) && !(hasReplies || isThreadMessage || hasFileAttachment) ? 0 : attachmentBorderRadius, @@ -1154,7 +1175,7 @@ class _StreamMessageListViewState extends State { bottomLeft: isMyMessage ? const Radius.circular(16) : Radius.circular( - (timeDiff >= 1 || !isNextUserSame) && + (hasTimeDiff || !isNextUserSame) && !(hasReplies || isThreadMessage) ? 0 : 16, @@ -1162,7 +1183,7 @@ class _StreamMessageListViewState extends State { topRight: const Radius.circular(16), bottomRight: isMyMessage ? Radius.circular( - (timeDiff >= 1 || !isNextUserSame) && + (hasTimeDiff || !isNextUserSame) && !(hasReplies || isThreadMessage) ? 0 : 16, diff --git a/packages/stream_chat_flutter/lib/src/message_widget/bottom_row.dart b/packages/stream_chat_flutter/lib/src/message_widget/bottom_row.dart index 289088afb..8d650f5c6 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/bottom_row.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/bottom_row.dart @@ -147,11 +147,10 @@ class BottomRow extends StatelessWidget { @override Widget build(BuildContext context) { if (isDeleted) { - return deletedBottomRowBuilder?.call( - context, - message, - ) ?? - const Offstage(); + final deletedBottomRowBuilder = this.deletedBottomRowBuilder; + if (deletedBottomRowBuilder != null) { + return deletedBottomRowBuilder(context, message); + } } final children = []; diff --git a/packages/stream_chat_flutter/lib/src/message_widget/deleted_message.dart b/packages/stream_chat_flutter/lib/src/message_widget/deleted_message.dart index 8cd16165c..ca82b5b83 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/deleted_message.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/deleted_message.dart @@ -32,7 +32,6 @@ class StreamDeletedMessage extends StatelessWidget { @override Widget build(BuildContext context) { - final chatThemeData = StreamChatTheme.of(context); return Material( color: messageTheme.messageBackgroundColor, shape: shape ?? @@ -40,9 +39,7 @@ class StreamDeletedMessage extends StatelessWidget { borderRadius: borderRadiusGeometry ?? BorderRadius.zero, side: borderSide ?? BorderSide( - color: Theme.of(context).brightness == Brightness.dark - ? chatThemeData.colorTheme.barsBg.withAlpha(24) - : chatThemeData.colorTheme.textHighEmphasis.withAlpha(24), + color: messageTheme.messageBorderColor ?? Colors.transparent, ), ), child: Padding( diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_card.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_card.dart index 2ad2cede2..e7f1d5069 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_card.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/message_card.dart @@ -139,7 +139,8 @@ class _MessageCardState extends State { RoundedRectangleBorder( side: widget.borderSide ?? BorderSide( - color: widget.messageTheme.messageBorderColor ?? Colors.grey, + color: widget.messageTheme.messageBorderColor ?? + Colors.transparent, ), borderRadius: widget.borderRadiusGeometry ?? BorderRadius.zero, ), @@ -215,7 +216,7 @@ class _MessageCardState extends State { } if (widget.hasUrlAttachments) { - return widget.messageTheme.linkBackgroundColor; + return widget.messageTheme.urlAttachmentBackgroundColor; } if (widget.isOnlyEmoji) { diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart index 7d51eb8be..0eb30cfbf 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart @@ -794,8 +794,7 @@ class _StreamMessageWidgetState extends State showUsername || showTimeStamp || showInChannel || - showSendingIndicator || - isDeleted; + showSendingIndicator; /// {@template isPinned} /// Whether [StreamMessageWidget.message] is pinned or not. @@ -1010,7 +1009,8 @@ class _StreamMessageWidgetState extends State title: Text(context.translations.copyMessageLabel), onClick: () { Navigator.of(context, rootNavigator: true).pop(); - Clipboard.setData(ClipboardData(text: widget.message.text)); + final text = widget.message.text; + if (text != null) Clipboard.setData(ClipboardData(text: text)); }, ), if (shouldShowEditAction) ...[ @@ -1035,6 +1035,7 @@ class _StreamMessageWidgetState extends State builder: (_) => EditMessageSheet( message: widget.message, channel: StreamChannel.of(context).channel, + editMessageInputBuilder: widget.editMessageInputBuilder, ), ); }, @@ -1143,6 +1144,7 @@ class _StreamMessageWidgetState extends State showDialog( useRootNavigator: false, context: context, + useSafeArea: false, barrierColor: _streamChatTheme.colorTheme.overlay, builder: (context) => StreamChannel( channel: channel, @@ -1168,8 +1170,10 @@ class _StreamMessageWidgetState extends State ? DisplayWidget.gone : DisplayWidget.show, ), - onCopyTap: (message) => - Clipboard.setData(ClipboardData(text: message.text)), + onCopyTap: (message) { + final text = message.text; + if (text != null) Clipboard.setData(ClipboardData(text: text)); + }, messageTheme: widget.messageTheme, reverse: widget.reverse, showDeleteMessage: shouldShowDeleteAction, diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content.dart index d02a43646..c9e403ad5 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content.dart @@ -379,6 +379,8 @@ class MessageWidgetContent extends StatelessWidget { showUserAvatar == DisplayWidget.show && message.user != null) ...[ UserAvatarTransform( + onUserAvatarTap: onUserAvatarTap, + userAvatarBuilder: userAvatarBuilder, translateUserAvatar: translateUserAvatar, messageTheme: messageTheme, message: message, @@ -431,6 +433,7 @@ class MessageWidgetContent extends StatelessWidget { showDialog( useRootNavigator: false, context: context, + useSafeArea: false, barrierColor: streamChatTheme.colorTheme.overlay, builder: (context) => StreamChannel( channel: channel, diff --git a/packages/stream_chat_flutter/lib/src/message_widget/quoted_message.dart b/packages/stream_chat_flutter/lib/src/message_widget/quoted_message.dart index 028cc443b..f75bdbe74 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/quoted_message.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/quoted_message.dart @@ -65,7 +65,6 @@ class _QuotedMessageState extends State { top: 8, bottom: widget.hasNonUrlAttachments ? 8 : 0, ), - composing: false, ); } } diff --git a/packages/stream_chat_flutter/lib/src/message_widget/reactions/desktop_reactions_builder.dart b/packages/stream_chat_flutter/lib/src/message_widget/reactions/desktop_reactions_builder.dart index bdc10a340..cbc55bdbb 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/reactions/desktop_reactions_builder.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/reactions/desktop_reactions_builder.dart @@ -2,6 +2,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_portal/flutter_portal.dart'; +import 'package:stream_chat_flutter/src/message_widget/reactions/reactions_card.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; // ignore_for_file: cascade_invocations @@ -75,6 +76,7 @@ class _DesktopReactionsBuilderState extends State { @override Widget build(BuildContext context) { final streamChat = StreamChat.of(context); + final currentUser = streamChat.currentUser!; final reactionIcons = StreamChatConfiguration.of(context).reactionIcons; final streamChatTheme = StreamChatTheme.of(context); @@ -83,13 +85,13 @@ class _DesktopReactionsBuilderState extends State { if (widget.shouldShowReactions) { widget.message.latestReactions?.forEach((element) { if (!reactionsMap.containsKey(element.type) || - element.user!.id == streamChat.currentUser?.id) { + element.user!.id == currentUser.id) { reactionsMap[element.type] = element; } }); reactionsList = reactionsMap.values.toList() - ..sort((a, b) => a.user!.id == streamChat.currentUser?.id ? 1 : -1); + ..sort((a, b) => a.user!.id == currentUser.id ? 1 : -1); } return PortalTarget( @@ -114,44 +116,10 @@ class _DesktopReactionsBuilderState extends State { maxWidth: 336, maxHeight: 342, ), - child: Card( - color: streamChatTheme.colorTheme.barsBg, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Text( - '''${widget.message.latestReactions!.length} ${context.translations.messageReactionsLabel}''', - style: streamChatTheme.textTheme.headlineBold, - ), - ), - Flexible( - child: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Wrap( - spacing: 16, - runSpacing: 16, - children: [ - ...widget.message.latestReactions!.map((reaction) { - final reactionIcon = reactionIcons.firstWhereOrNull( - (r) => r.type == reaction.type, - ); - return _StackedReaction( - reaction: reaction, - streamChatTheme: streamChatTheme, - reactionIcon: reactionIcon, - ); - }).toList(), - ], - ), - ), - ), - ], - ), + child: ReactionsCard( + currentUser: currentUser, + message: widget.message, + messageTheme: widget.messageTheme, ), ), ), @@ -163,23 +131,32 @@ class _DesktopReactionsBuilderState extends State { onExit: (event) { setState(() => _showReactionsPopup = !_showReactionsPopup); }, - child: Wrap( - children: [ - ...reactionsList.map((reaction) { - final reactionIcon = reactionIcons.firstWhereOrNull( - (r) => r.type == reaction.type, - ); + child: Padding( + padding: EdgeInsets.symmetric( + vertical: 2, + horizontal: widget.reverse ? 0 : 4, + ), + child: Wrap( + spacing: 4, + runSpacing: 4, + children: [ + ...reactionsList.map((reaction) { + final reactionIcon = reactionIcons.firstWhereOrNull( + (r) => r.type == reaction.type, + ); - return _BottomReaction( - reaction: reaction, - message: widget.message, - borderSide: widget.borderSide, - messageTheme: widget.messageTheme, - reactionIcon: reactionIcon, - streamChatTheme: streamChatTheme, - ); - }).toList(), - ], + return _BottomReaction( + currentUser: currentUser, + reaction: reaction, + message: widget.message, + borderSide: widget.borderSide, + messageTheme: widget.messageTheme, + reactionIcon: reactionIcon, + streamChatTheme: streamChatTheme, + ); + }).toList(), + ], + ), ), ), ); @@ -188,6 +165,7 @@ class _DesktopReactionsBuilderState extends State { class _BottomReaction extends StatelessWidget { const _BottomReaction({ + required this.currentUser, required this.reaction, required this.message, required this.borderSide, @@ -196,6 +174,7 @@ class _BottomReaction extends StatelessWidget { required this.streamChatTheme, }); + final User currentUser; final Reaction reaction; final Message message; final BorderSide? borderSide; @@ -205,7 +184,10 @@ class _BottomReaction extends StatelessWidget { @override Widget build(BuildContext context) { - final userId = StreamChat.of(context).currentUser?.id; + final userId = currentUser.id; + + final backgroundColor = messageTheme?.reactionsBackgroundColor; + return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { @@ -225,37 +207,38 @@ class _BottomReaction extends StatelessWidget { } }, child: Card( - shape: StadiumBorder( + margin: EdgeInsets.zero, + // Setting elevation as null when background color is transparent. + // This is done to avoid shadow when background color is transparent. + elevation: backgroundColor == Colors.transparent ? 0 : null, + shape: RoundedRectangleBorder( side: borderSide ?? BorderSide( - color: messageTheme?.messageBorderColor ?? Colors.grey, + color: messageTheme?.reactionsBorderColor ?? Colors.transparent, ), + borderRadius: BorderRadius.circular(10), ), - color: messageTheme?.messageBackgroundColor, + color: backgroundColor, child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), child: Row( mainAxisSize: MainAxisSize.min, children: [ ConstrainedBox( constraints: BoxConstraints.tight( - const Size.square(16), + const Size.square(14), ), child: reactionIcon?.builder( context, reaction.user?.id == userId, - 16, + 14, ) ?? Icon( Icons.help_outline_rounded, - size: 16, + size: 14, color: reaction.user?.id == userId ? streamChatTheme.colorTheme.accentPrimary - : streamChatTheme.colorTheme.textHighEmphasis - .withOpacity(0.5), + : streamChatTheme.colorTheme.textLowEmphasis, ), ), const SizedBox(width: 4), @@ -280,87 +263,3 @@ class _BottomReaction extends StatelessWidget { properties.add(DiagnosticsProperty('message', message)); } } - -class _StackedReaction extends StatelessWidget { - const _StackedReaction({ - required this.reaction, - required this.streamChatTheme, - required this.reactionIcon, - }); - - final Reaction reaction; - final StreamChatThemeData streamChatTheme; - final StreamReactionIcon? reactionIcon; - - @override - Widget build(BuildContext context) { - final userId = StreamChat.of(context).currentUser?.id; - return SizedBox( - width: 80, - child: Column( - children: [ - Stack( - children: [ - StreamUserAvatar( - user: reaction.user!, - constraints: const BoxConstraints.tightFor( - height: 64, - width: 64, - ), - borderRadius: BorderRadius.circular(32), - ), - Positioned( - bottom: 0, - right: 0, - child: DecoratedBox( - decoration: BoxDecoration( - color: streamChatTheme.colorTheme.inputBg, - border: Border.all( - color: streamChatTheme.colorTheme.barsBg, - width: 2, - ), - shape: BoxShape.circle, - ), - child: Padding( - padding: const EdgeInsets.all(8), - child: reactionIcon?.builder( - context, - reaction.userId == userId, - 16, - ) ?? - Icon( - Icons.help_outline_rounded, - size: 16, - color: reaction.user?.id == userId - ? streamChatTheme.colorTheme.accentPrimary - : streamChatTheme.colorTheme.textHighEmphasis - .withOpacity(0.5), - ), - ), - ), - ), - ], - ), - Text( - userId == reaction.user!.name ? 'You' : reaction.user!.name, - textAlign: TextAlign.center, - ), - ], - ), - ); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add( - DiagnosticsProperty('reaction', reaction), - ); - properties.add( - DiagnosticsProperty( - 'reactionIcon', - reactionIcon, - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/reactions/message_reactions_modal.dart b/packages/stream_chat_flutter/lib/src/message_widget/reactions/message_reactions_modal.dart index 0703ce19f..cd66d08de 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/reactions/message_reactions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/reactions/message_reactions_modal.dart @@ -1,8 +1,8 @@ import 'dart:ui'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/message_widget/reactions/reaction_bubble.dart'; import 'package:stream_chat_flutter/src/message_widget/reactions/reactions_align.dart'; +import 'package:stream_chat_flutter/src/message_widget/reactions/reactions_card.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// {@template streamMessageReactionsModal} @@ -83,9 +83,10 @@ class StreamMessageReactionsModal extends StatelessWidget { ), if (message.latestReactions?.isNotEmpty == true) ...[ const SizedBox(height: 8), - _buildReactionCard( - context, - user, + ReactionsCard( + currentUser: user!, + message: message, + messageTheme: messageTheme, ), ], ], @@ -126,109 +127,4 @@ class StreamMessageReactionsModal extends StatelessWidget { ), ); } - - Widget _buildReactionCard(BuildContext context, User? user) { - final chatThemeData = StreamChatTheme.of(context); - return Card( - color: chatThemeData.colorTheme.barsBg, - clipBehavior: Clip.hardEdge, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - margin: EdgeInsets.zero, - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - context.translations.messageReactionsLabel, - style: chatThemeData.textTheme.headlineBold, - ), - const SizedBox(height: 16), - Flexible( - child: SingleChildScrollView( - child: Wrap( - spacing: 16, - runSpacing: 16, - children: message.latestReactions! - .map((e) => _buildReaction( - e, - user!, - context, - )) - .toList(), - ), - ), - ), - ], - ), - ), - ); - } - - Widget _buildReaction( - Reaction reaction, - User currentUser, - BuildContext context, - ) { - final isCurrentUser = reaction.user?.id == currentUser.id; - final chatThemeData = StreamChatTheme.of(context); - return ConstrainedBox( - constraints: BoxConstraints.loose( - const Size(64, 100), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Stack( - clipBehavior: Clip.none, - children: [ - StreamUserAvatar( - onTap: onUserAvatarTap, - user: reaction.user!, - constraints: const BoxConstraints.tightFor( - height: 64, - width: 64, - ), - onlineIndicatorConstraints: const BoxConstraints.tightFor( - height: 12, - width: 12, - ), - borderRadius: BorderRadius.circular(32), - ), - Positioned( - bottom: 6, - left: isCurrentUser ? -3 : null, - right: isCurrentUser ? -3 : null, - child: Align( - alignment: - reverse ? Alignment.centerRight : Alignment.centerLeft, - child: StreamReactionBubble( - reactions: [reaction], - flipTail: !reverse, - borderColor: - messageTheme.reactionsBorderColor ?? Colors.transparent, - backgroundColor: messageTheme.reactionsBackgroundColor ?? - Colors.transparent, - maskColor: chatThemeData.colorTheme.barsBg, - tailCirclesSpacing: 1, - highlightOwnReactions: false, - ), - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - reaction.user!.name.split(' ')[0], - style: chatThemeData.textTheme.footnoteBold, - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - ], - ), - ); - } } diff --git a/packages/stream_chat_flutter/lib/src/message_widget/reactions/reaction_bubble.dart b/packages/stream_chat_flutter/lib/src/message_widget/reactions/reaction_bubble.dart index c3c3c7627..82e5b34d0 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/reactions/reaction_bubble.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/reactions/reaction_bubble.dart @@ -124,24 +124,22 @@ class StreamReactionBubble extends StatelessWidget { final chatThemeData = StreamChatTheme.of(context); final userId = StreamChat.of(context).currentUser?.id; return Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4, - ), + padding: const EdgeInsets.symmetric(horizontal: 4), child: reactionIcon != null ? ConstrainedBox( - constraints: BoxConstraints.tight(const Size.square(16)), + constraints: BoxConstraints.tight(const Size.square(14)), child: reactionIcon.builder( context, - !highlightOwnReactions || reaction.user?.id == userId, + highlightOwnReactions && reaction.user?.id == userId, 16, ), ) : Icon( Icons.help_outline_rounded, - size: 16, - color: (!highlightOwnReactions || reaction.user?.id == userId) + size: 14, + color: (highlightOwnReactions && reaction.user?.id == userId) ? chatThemeData.colorTheme.accentPrimary - : chatThemeData.colorTheme.textHighEmphasis.withOpacity(0.5), + : chatThemeData.colorTheme.textLowEmphasis, ), ); } diff --git a/packages/stream_chat_flutter/lib/src/message_widget/reactions/reactions_card.dart b/packages/stream_chat_flutter/lib/src/message_widget/reactions/reactions_card.dart new file mode 100644 index 000000000..727b44aff --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget/reactions/reactions_card.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/avatars/user_avatar.dart'; +import 'package:stream_chat_flutter/src/message_widget/reactions/reaction_bubble.dart'; +import 'package:stream_chat_flutter/src/theme/message_theme.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_core/stream_chat_flutter_core.dart'; + +/// {@template reactionsCard} +/// A card that displays the reactions to a message. +/// +/// Used in [StreamMessageReactionsModal] and [DesktopReactionsBuilder]. +/// {@endtemplate} +class ReactionsCard extends StatelessWidget { + /// {@macro reactionsCard} + const ReactionsCard({ + super.key, + required this.currentUser, + required this.message, + required this.messageTheme, + this.onUserAvatarTap, + }); + + /// Current logged in user. + final User currentUser; + + /// Message to display reactions of. + final Message message; + + /// [StreamMessageThemeData] to apply to [message]. + final StreamMessageThemeData messageTheme; + + /// {@macro onUserAvatarTap} + final void Function(User)? onUserAvatarTap; + + @override + Widget build(BuildContext context) { + final chatThemeData = StreamChatTheme.of(context); + return Card( + color: chatThemeData.colorTheme.barsBg, + clipBehavior: Clip.hardEdge, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + context.translations.messageReactionsLabel, + style: chatThemeData.textTheme.headlineBold, + ), + const SizedBox(height: 16), + Flexible( + child: SingleChildScrollView( + child: Wrap( + spacing: 16, + runSpacing: 16, + children: message.latestReactions! + .map((e) => _buildReaction( + e, + currentUser, + context, + )) + .toList(), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildReaction( + Reaction reaction, + User currentUser, + BuildContext context, + ) { + final isCurrentUser = reaction.user?.id == currentUser.id; + final chatThemeData = StreamChatTheme.of(context); + final reverse = !isCurrentUser; + return ConstrainedBox( + constraints: BoxConstraints.loose( + const Size(64, 100), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + StreamUserAvatar( + onTap: onUserAvatarTap, + user: reaction.user!, + constraints: const BoxConstraints.tightFor( + height: 64, + width: 64, + ), + onlineIndicatorConstraints: const BoxConstraints.tightFor( + height: 12, + width: 12, + ), + borderRadius: BorderRadius.circular(32), + ), + Positioned( + bottom: 6, + left: !reverse ? -3 : null, + right: reverse ? -3 : null, + child: Align( + alignment: + reverse ? Alignment.centerRight : Alignment.centerLeft, + child: StreamReactionBubble( + reactions: [reaction], + reverse: !reverse, + flipTail: !reverse, + borderColor: + messageTheme.reactionsBorderColor ?? Colors.transparent, + backgroundColor: messageTheme.reactionsBackgroundColor ?? + Colors.transparent, + maskColor: chatThemeData.colorTheme.barsBg, + tailCirclesSpacing: 1, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + reaction.user!.name.split(' ')[0], + style: chatThemeData.textTheme.footnoteBold, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ], + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/misc/reaction_icon.dart b/packages/stream_chat_flutter/lib/src/misc/reaction_icon.dart index ebccc153b..c1f6c144d 100644 --- a/packages/stream_chat_flutter/lib/src/misc/reaction_icon.dart +++ b/packages/stream_chat_flutter/lib/src/misc/reaction_icon.dart @@ -1,5 +1,14 @@ import 'package:flutter/material.dart'; +/// {@template reactionIconBuilder} +/// Signature for a function that builds a reaction icon. +/// {@endtemplate} +typedef ReactionIconBuilder = Widget Function( + BuildContext context, + bool isHighlighted, + double iconSize, +); + /// {@template streamReactionIcon} /// Reaction icon data /// {@endtemplate} @@ -13,10 +22,6 @@ class StreamReactionIcon { /// Type of reaction final String type; - /// Asset to display for reaction - final Widget Function( - BuildContext, - bool highlighted, - double size, - ) builder; + /// {@macro reactionIconBuilder} + final ReactionIconBuilder builder; } diff --git a/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart b/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart index 76e4120f4..379c1c140 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart @@ -165,7 +165,7 @@ class StreamChatConfigurationData { return StreamSvgIcon.loveReaction( color: highlighted ? theme.colorTheme.accentPrimary - : theme.primaryIconTheme.color!.withOpacity(0.5), + : theme.primaryIconTheme.color, size: size, ); }, @@ -177,7 +177,7 @@ class StreamChatConfigurationData { return StreamSvgIcon.thumbsUpReaction( color: highlighted ? theme.colorTheme.accentPrimary - : theme.primaryIconTheme.color!.withOpacity(0.5), + : theme.primaryIconTheme.color, size: size, ); }, @@ -189,7 +189,7 @@ class StreamChatConfigurationData { return StreamSvgIcon.thumbsDownReaction( color: highlighted ? theme.colorTheme.accentPrimary - : theme.primaryIconTheme.color!.withOpacity(0.5), + : theme.primaryIconTheme.color, size: size, ); }, @@ -201,7 +201,7 @@ class StreamChatConfigurationData { return StreamSvgIcon.lolReaction( color: highlighted ? theme.colorTheme.accentPrimary - : theme.primaryIconTheme.color!.withOpacity(0.5), + : theme.primaryIconTheme.color, size: size, ); }, @@ -213,7 +213,7 @@ class StreamChatConfigurationData { return StreamSvgIcon.wutReaction( color: highlighted ? theme.colorTheme.accentPrimary - : theme.primaryIconTheme.color!.withOpacity(0.5), + : theme.primaryIconTheme.color, size: size, ); }, diff --git a/packages/stream_chat_flutter/lib/src/theme/message_theme.dart b/packages/stream_chat_flutter/lib/src/theme/message_theme.dart index d95ed7bba..6ae2cf884 100644 --- a/packages/stream_chat_flutter/lib/src/theme/message_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/message_theme.dart @@ -20,9 +20,15 @@ class StreamMessageThemeData with Diagnosticable { this.reactionsMaskColor, this.avatarTheme, this.createdAtStyle, - this.linkBackgroundColor, + @Deprecated('Use urlAttachmentBackgroundColor instead') + Color? linkBackgroundColor, + Color? urlAttachmentBackgroundColor, + this.urlAttachmentHostStyle, + this.urlAttachmentTitleStyle, + this.urlAttachmentTextStyle, this.urlAttachmentTitleMaxLine, - }); + }) : urlAttachmentBackgroundColor = + urlAttachmentBackgroundColor ?? linkBackgroundColor; /// Text style for message text final TextStyle? messageTextStyle; @@ -58,9 +64,22 @@ class StreamMessageThemeData with Diagnosticable { final StreamAvatarThemeData? avatarTheme; /// Background color for messages with url attachments. - final Color? linkBackgroundColor; + @Deprecated('Use urlAttachmentBackgroundColor instead') + Color? get linkBackgroundColor => urlAttachmentBackgroundColor; - /// Max number of lines in Url link title + /// Background color for messages with url attachments. + final Color? urlAttachmentBackgroundColor; + + /// Color for url attachment host. + final TextStyle? urlAttachmentHostStyle; + + /// Color for url attachment title. + final TextStyle? urlAttachmentTitleStyle; + + /// Color for url attachment text. + final TextStyle? urlAttachmentTextStyle; + + /// Max number of lines in Url link title. final int? urlAttachmentTitleMaxLine; /// Copy with a theme @@ -76,7 +95,12 @@ class StreamMessageThemeData with Diagnosticable { Color? reactionsBackgroundColor, Color? reactionsBorderColor, Color? reactionsMaskColor, - Color? linkBackgroundColor, + @Deprecated('Use urlAttachmentBackgroundColor instead') + Color? linkBackgroundColor, + Color? urlAttachmentBackgroundColor, + TextStyle? urlAttachmentHostStyle, + TextStyle? urlAttachmentTitleStyle, + TextStyle? urlAttachmentTextStyle, int? urlAttachmentTitleMaxLine, }) { return StreamMessageThemeData( @@ -93,7 +117,15 @@ class StreamMessageThemeData with Diagnosticable { reactionsBackgroundColor ?? this.reactionsBackgroundColor, reactionsBorderColor: reactionsBorderColor ?? this.reactionsBorderColor, reactionsMaskColor: reactionsMaskColor ?? this.reactionsMaskColor, - linkBackgroundColor: linkBackgroundColor ?? this.linkBackgroundColor, + urlAttachmentBackgroundColor: urlAttachmentBackgroundColor ?? + linkBackgroundColor ?? + this.urlAttachmentBackgroundColor, + urlAttachmentHostStyle: + urlAttachmentHostStyle ?? this.urlAttachmentHostStyle, + urlAttachmentTitleStyle: + urlAttachmentTitleStyle ?? this.urlAttachmentTitleStyle, + urlAttachmentTextStyle: + urlAttachmentTextStyle ?? this.urlAttachmentTextStyle, urlAttachmentTitleMaxLine: urlAttachmentTitleMaxLine ?? this.urlAttachmentTitleMaxLine, ); @@ -129,8 +161,23 @@ class StreamMessageThemeData with Diagnosticable { reactionsMaskColor: Color.lerp(a.reactionsMaskColor, b.reactionsMaskColor, t), repliesStyle: TextStyle.lerp(a.repliesStyle, b.repliesStyle, t), - linkBackgroundColor: - Color.lerp(a.linkBackgroundColor, b.linkBackgroundColor, t), + urlAttachmentBackgroundColor: Color.lerp( + a.urlAttachmentBackgroundColor, + b.urlAttachmentBackgroundColor, + t, + ), + urlAttachmentHostStyle: + TextStyle.lerp(a.urlAttachmentHostStyle, b.urlAttachmentHostStyle, t), + urlAttachmentTextStyle: TextStyle.lerp( + a.urlAttachmentTextStyle, + b.urlAttachmentTextStyle, + t, + ), + urlAttachmentTitleStyle: TextStyle.lerp( + a.urlAttachmentTitleStyle, + b.urlAttachmentTitleStyle, + t, + ), ); } @@ -154,7 +201,10 @@ class StreamMessageThemeData with Diagnosticable { reactionsBackgroundColor: other.reactionsBackgroundColor, reactionsBorderColor: other.reactionsBorderColor, reactionsMaskColor: other.reactionsMaskColor, - linkBackgroundColor: other.linkBackgroundColor, + urlAttachmentBackgroundColor: other.urlAttachmentBackgroundColor, + urlAttachmentHostStyle: other.urlAttachmentHostStyle, + urlAttachmentTitleStyle: other.urlAttachmentTitleStyle, + urlAttachmentTextStyle: other.urlAttachmentTextStyle, urlAttachmentTitleMaxLine: other.urlAttachmentTitleMaxLine, ); } @@ -175,7 +225,10 @@ class StreamMessageThemeData with Diagnosticable { reactionsBorderColor == other.reactionsBorderColor && reactionsMaskColor == other.reactionsMaskColor && avatarTheme == other.avatarTheme && - linkBackgroundColor == other.linkBackgroundColor && + urlAttachmentBackgroundColor == other.urlAttachmentBackgroundColor && + urlAttachmentHostStyle == other.urlAttachmentHostStyle && + urlAttachmentTitleStyle == other.urlAttachmentTitleStyle && + urlAttachmentTextStyle == other.urlAttachmentTextStyle && urlAttachmentTitleMaxLine == other.urlAttachmentTitleMaxLine; @override @@ -191,7 +244,10 @@ class StreamMessageThemeData with Diagnosticable { reactionsBorderColor.hashCode ^ reactionsMaskColor.hashCode ^ avatarTheme.hashCode ^ - linkBackgroundColor.hashCode ^ + urlAttachmentBackgroundColor.hashCode ^ + urlAttachmentHostStyle.hashCode ^ + urlAttachmentTitleStyle.hashCode ^ + urlAttachmentTextStyle.hashCode ^ urlAttachmentTitleMaxLine.hashCode; @override @@ -209,7 +265,22 @@ class StreamMessageThemeData with Diagnosticable { ..add(ColorProperty('reactionsBackgroundColor', reactionsBackgroundColor)) ..add(ColorProperty('reactionsBorderColor', reactionsBorderColor)) ..add(ColorProperty('reactionsMaskColor', reactionsMaskColor)) - ..add(ColorProperty('linkBackgroundColor', linkBackgroundColor)) + ..add(ColorProperty( + 'urlAttachmentBackgroundColor', + urlAttachmentBackgroundColor, + )) + ..add(DiagnosticsProperty( + 'urlAttachmentHostStyle', + urlAttachmentHostStyle, + )) + ..add(DiagnosticsProperty( + 'urlAttachmentTitleStyle', + urlAttachmentTitleStyle, + )) + ..add(DiagnosticsProperty( + 'urlAttachmentTextStyle', + urlAttachmentTextStyle, + )) ..add(DiagnosticsProperty( 'urlAttachmentTitleMaxLine', urlAttachmentTitleMaxLine, diff --git a/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart b/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart index 8021406f9..04efaea6d 100644 --- a/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart @@ -126,8 +126,7 @@ class StreamChatThemeData { StreamTextTheme textTheme, ) { final accentColor = colorTheme.accentPrimary; - final iconTheme = - IconThemeData(color: colorTheme.textHighEmphasis.withOpacity(0.5)); + final iconTheme = IconThemeData(color: colorTheme.textLowEmphasis); final channelHeaderTheme = StreamChannelHeaderThemeData( avatarTheme: StreamAvatarThemeData( borderRadius: BorderRadius.circular(20), @@ -184,11 +183,11 @@ class StreamChatThemeData { createdAtStyle: textTheme.footnote.copyWith(color: colorTheme.textLowEmphasis), repliesStyle: textTheme.footnoteBold.copyWith(color: accentColor), - messageBackgroundColor: colorTheme.disabled, + messageBackgroundColor: colorTheme.borders, + messageBorderColor: colorTheme.borders, reactionsBackgroundColor: colorTheme.barsBg, reactionsBorderColor: colorTheme.borders, reactionsMaskColor: colorTheme.appBg, - messageBorderColor: colorTheme.disabled, avatarTheme: StreamAvatarThemeData( borderRadius: BorderRadius.circular(20), constraints: const BoxConstraints.tightFor( @@ -199,11 +198,16 @@ class StreamChatThemeData { messageLinksStyle: TextStyle( color: accentColor, ), - linkBackgroundColor: colorTheme.linkBg, + urlAttachmentBackgroundColor: colorTheme.linkBg, + urlAttachmentHostStyle: textTheme.bodyBold.copyWith(color: accentColor), + urlAttachmentTitleStyle: + textTheme.body.copyWith(fontWeight: FontWeight.w700), + urlAttachmentTextStyle: + textTheme.body.copyWith(fontWeight: FontWeight.w400), ), otherMessageTheme: StreamMessageThemeData( - reactionsBackgroundColor: colorTheme.disabled, - reactionsBorderColor: colorTheme.barsBg, + reactionsBackgroundColor: colorTheme.borders, + reactionsBorderColor: colorTheme.borders, reactionsMaskColor: colorTheme.appBg, messageTextStyle: textTheme.body, createdAtStyle: @@ -223,7 +227,12 @@ class StreamChatThemeData { width: 32, ), ), - linkBackgroundColor: colorTheme.linkBg, + urlAttachmentBackgroundColor: colorTheme.linkBg, + urlAttachmentHostStyle: textTheme.bodyBold.copyWith(color: accentColor), + urlAttachmentTitleStyle: + textTheme.body.copyWith(fontWeight: FontWeight.w700), + urlAttachmentTextStyle: + textTheme.body.copyWith(fontWeight: FontWeight.w400), ), messageInputTheme: StreamMessageInputThemeData( borderRadius: BorderRadius.circular(20), diff --git a/packages/stream_chat_flutter/lib/src/utils/device_segmentation.dart b/packages/stream_chat_flutter/lib/src/utils/device_segmentation.dart index bd65820c5..3f815d0c5 100644 --- a/packages/stream_chat_flutter/lib/src/utils/device_segmentation.dart +++ b/packages/stream_chat_flutter/lib/src/utils/device_segmentation.dart @@ -4,7 +4,7 @@ import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; bool get isWeb => CurrentPlatform.isWeb; /// Returns true if the app is running in a mobile device. -bool get isMobileDevice => CurrentPlatform.isIos || CurrentPlatform.isAndroid; +bool get isMobileDevice => true; /// Returns true if the app is running in a desktop device. bool get isDesktopDevice => @@ -22,7 +22,7 @@ bool get isDesktopVideoPlayerSupported => bool get isMobileDeviceOrWeb => isWeb || isMobileDevice; /// Returns true if the app is running in a desktop or web. -bool get isDesktopDeviceOrWeb => isWeb || isDesktopDevice; +bool get isDesktopDeviceOrWeb => false; /// Returns true if the app is running in a flutter test environment. bool get isTestEnvironment => CurrentPlatform.isFlutterTest; diff --git a/packages/stream_chat_flutter/lib/src/utils/extensions.dart b/packages/stream_chat_flutter/lib/src/utils/extensions.dart index 67b622dba..06b02aae1 100644 --- a/packages/stream_chat_flutter/lib/src/utils/extensions.dart +++ b/packages/stream_chat_flutter/lib/src/utils/extensions.dart @@ -34,7 +34,7 @@ extension StringExtension on String { if (trimmedString.isEmpty) return false; if (trimmedString.characters.length > 3) return false; final emojiRegex = RegExp( - r'^(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])+$', + r'^(\u00a9|\u00ae|\u200d|[\ufe00-\ufe0f]|[\u2600-\u27FF]|[\u2300-\u2bFF]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])+$', multiLine: true, caseSensitive: false, ); diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index 5425efb6f..3a0d5dd17 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -1,12 +1,12 @@ name: stream_chat_flutter homepage: https://github.com/GetStream/stream-chat-flutter description: Stream Chat official Flutter SDK. Build your own chat experience using Dart and Flutter. -version: 6.0.0 +version: 6.1.0 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues environment: - sdk: ">=2.17.0 <3.0.0" + sdk: ">=2.17.0 <4.0.0" flutter: ">=1.20.0" dependencies: @@ -38,7 +38,7 @@ dependencies: rxdart: ^0.27.0 share_plus: ^6.3.0 shimmer: ^2.0.0 - stream_chat_flutter_core: ^6.0.0 + stream_chat_flutter_core: ^6.1.0 synchronized: ^3.0.0 thumblr: ^0.0.4 url_launcher: ^6.1.0 diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart index fb5c9fc27..73568c193 100644 --- a/packages/stream_chat_flutter/test/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart @@ -2,13 +2,12 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:pedantic/pedantic.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; -const screenHeight = 400.0; +const screenHeight = 100.0; const screenWidth = 400.0; const itemWidth = screenWidth / 10.0; const itemCount = 500; @@ -46,6 +45,11 @@ void main() { ); } + final fadeTransitionFinder = find.descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(FadeTransition), + ); + testWidgets('List positioned with 0 at left', (WidgetTester tester) async { final itemPositionsListener = ItemPositionsListener.create(); await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener); @@ -172,7 +176,7 @@ void main() { await tester.pumpAndSettle(); expect(tester.getTopLeft(find.text('Item 100')).dx, 0); - expect(tester.getBottomRight(find.text('Item 109')).dy, screenWidth); + expect(tester.getBottomRight(find.text('Item 109')).dy, screenHeight); expect( itemPositionsListener.itemPositions.value @@ -196,6 +200,31 @@ void main() { 1); }); + testWidgets('Scroll to 20 without fading', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + var fadeTransition = tester.widget(fadeTransitionFinder); + final initialOpacity = fadeTransition.opacity; + + unawaited( + itemScrollController.scrollTo(index: 20, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2); + + fadeTransition = tester.widget(fadeTransitionFinder); + expect(fadeTransition.opacity, initialOpacity); + + await tester.pumpAndSettle(); + + expect(find.text('Item 14'), findsNothing); + expect(find.text('Item 20'), findsOneWidget); + }); + testWidgets('padding test - centered sliver at left', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/positioned_list_test.dart index 9888249cb..96e10a4c1 100644 --- a/packages/stream_chat_flutter/test/scrollable_positioned_list/positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/positioned_list_test.dart @@ -360,4 +360,57 @@ void main() { .itemTrailingEdge, 1); }); + + testWidgets('Does not crash when updated offscreen', + (WidgetTester tester) async { + late StateSetter setState; + var updated = false; + + // There's 0 relayout boundaries in this subtree. + final widget = StatefulBuilder(builder: (context, stateSetter) { + setState = stateSetter; + return Positioned( + left: 0, + right: 0, + child: PositionedList( + shrinkWrap: true, + itemCount: 1, + // When `updated` becomes true this line inserts a + // RenderIndexedSemantics to the render tree. + addSemanticIndexes: updated, + itemBuilder: (context, index) => const SizedBox(height: itemHeight), + )); + }); + + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: Overlay( + initialEntries: [ + OverlayEntry(builder: (context) => widget, maintainState: true), + ], + ), + )); + + // Insert a new opaque OverlayEntry that would prevent the first + // OverlayEntry from doing re-layout. Since there's no relayout boundaries + // in the first OverlayEntry, no dirty RenderObjects in its render subtree + // can update layout. + final newOverlay = OverlayEntry( + builder: (context) => const SizedBox.expand(), + opaque: true, + ); + tester.state(find.byType(Overlay)).insert(newOverlay); + await tester.pump(); + + // Update the list item's render tree. A new RenderObjectElement is + // inflated, registeredElement.renderObject will point to this new + // RenderObjectElement's RenderObject (RenderIndexedSemantics), which has + // never been laid out. + setState(() { + updated = true; + }); + + await tester.pump(); + expect(tester.takeException(), isNull); + }); } diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart index a5af56a81..cb8d07e3a 100644 --- a/packages/stream_chat_flutter/test/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart @@ -2,10 +2,9 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:pedantic/pedantic.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; const screenHeight = 400.0; diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/scrollable_positioned_list_test.dart index ef7157438..55fe0c7ec 100644 --- a/packages/stream_chat_flutter/test/scrollable_positioned_list/scrollable_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/scrollable_positioned_list_test.dart @@ -2,12 +2,12 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:async'; import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:pedantic/pedantic.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/src/scroll_view.dart'; @@ -48,8 +48,10 @@ void main() { itemCount: itemCount, itemScrollController: itemScrollController, itemBuilder: (context, index) { - assert(index >= 0 && index <= itemCount - 1, - '''index needs to be bigger or equal to 0 and smallert than itemCount -1'''); + assert( + index >= 0 && index <= itemCount - 1, + 'index must be in the range of 0 to itemCount - 1', + ); return SizedBox( height: variableHeight ? (itemHeight + (index % 13) * 5) : itemHeight, @@ -71,6 +73,11 @@ void main() { ); } + final fadeTransitionFinder = find.descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(FadeTransition), + ); + testWidgets('List positioned with 0 at top', (WidgetTester tester) async { final itemPositionsListener = ItemPositionsListener.create(); await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener); @@ -394,11 +401,7 @@ void main() { itemScrollController: itemScrollController, itemPositionsListener: itemPositionsListener); - var fadeTransition = tester.widget(find - .descendant( - of: find.byType(ScrollablePositionedList), - matching: find.byType(FadeTransition)) - .last); + var fadeTransition = tester.widget(fadeTransitionFinder); final initialOpacity = fadeTransition.opacity; unawaited( @@ -407,11 +410,7 @@ void main() { await tester.pump(); await tester.pump(scrollDuration ~/ 2); - fadeTransition = tester.widget(find - .descendant( - of: find.byType(ScrollablePositionedList), - matching: find.byType(FadeTransition)) - .last); + fadeTransition = tester.widget(fadeTransitionFinder); expect(fadeTransition.opacity, initialOpacity); await tester.pumpAndSettle(); @@ -456,10 +455,6 @@ void main() { final itemScrollController = ItemScrollController(); await setUpWidgetTest(tester, itemScrollController: itemScrollController); - final fadeTransitionFinder = find.descendant( - of: find.byType(ScrollablePositionedList), - matching: find.byType(FadeTransition)); - unawaited( itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pump(); @@ -533,26 +528,14 @@ void main() { await tester.pump(); await tester.pump(); expect( - tester - .widget(find - .descendant( - of: find.byType(ScrollablePositionedList), - matching: find.byType(FadeTransition)) - .last) - .opacity - .value, - closeTo(0, 0.01)); + tester.widget(fadeTransitionFinder.last).opacity.value, + closeTo(0, 0.01), + ); await tester.pump(scrollDuration + scrollDurationTolerance); expect( - tester - .widget(find - .descendant( - of: find.byType(ScrollablePositionedList), - matching: find.byType(FadeTransition)) - .last) - .opacity - .value, - closeTo(1, 0.01)); + tester.widget(fadeTransitionFinder.last).opacity.value, + closeTo(1, 0.01), + ); expect(find.text('Item 0'), findsOneWidget); expect(tester.getTopLeft(find.text('Item 0')).dy, 0); @@ -610,15 +593,9 @@ void main() { expect(tester.getTopLeft(find.text('Item 10')).dy, 0); expect(tester.getBottomLeft(find.text('Item 19')).dy, screenHeight); expect( - tester - .widget(find - .descendant( - of: find.byType(ScrollablePositionedList), - matching: find.byType(FadeTransition)) - .last) - .opacity - .value, - closeTo(0.5, 0.01)); + tester.widget(fadeTransitionFinder.last).opacity.value, + closeTo(0.5, 0.01), + ); await tester.pumpAndSettle(); }); @@ -899,11 +876,7 @@ void main() { await tester.pump(); expect(tester.getTopLeft(find.text('Item 9')).dy, 0); - final fadeTransition = tester.widget(find - .descendant( - of: find.byType(ScrollablePositionedList), - matching: find.byType(FadeTransition)) - .last); + final fadeTransition = tester.widget(fadeTransitionFinder); expect(fadeTransition.opacity.value, 1.0); await tester.pumpAndSettle(); @@ -923,21 +896,12 @@ void main() { await tester.pump(); expect(tester.getTopLeft(find.text('Item 10')).dy, 0); - final fadeTransition = tester.widget(find - .descendant( - of: find.byType(ScrollablePositionedList), - matching: find.byType(FadeTransition)) - .last); + final fadeTransition = tester.widget(fadeTransitionFinder); expect(fadeTransition.opacity.value, 1.0); await tester.pumpAndSettle(); }); - final fadeTransitionFinder = find.descendant( - of: find.byType(ScrollablePositionedList), - matching: find.byType(FadeTransition), - ); - testWidgets('Scroll to 0 stop before half way', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest(tester, itemScrollController: itemScrollController); @@ -1022,14 +986,13 @@ void main() { itemScrollController.scrollTo(index: 0, duration: scrollDuration)); await tester.pump(); await tester.pump(); - await tester.pump(scrollDuration ~/ 2 + scrollDuration ~/ 20); + await tester.pump(scrollDuration ~/ 2); await tester.tap(find.byType(ScrollablePositionedList)); await tester.pump(); - expect(tester.getTopLeft(find.text('Item 9')).dy, closeTo(0, tolerance)); - final fadeTransition = tester.widget(fadeTransitionFinder); - expect(fadeTransition.opacity.value, 1.0); + expect(tester.getTopLeft(find.text('Item 90')).dy, 0); + expect(fadeTransitionFinder, findsNWidgets(1)); await tester.pumpAndSettle(); }); @@ -1098,6 +1061,34 @@ void main() { expect(find.text('Item 100'), findsNothing); }); + testWidgets("Second scroll future doesn't complete until scroll is done", + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2); + + final scrollFuture2 = + itemScrollController.scrollTo(index: 250, duration: scrollDuration); + + var futureComplete = false; + unawaited(scrollFuture2.then((_) => futureComplete = true)); + + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2); + + expect(futureComplete, isFalse); + + await tester.pumpAndSettle(); + + expect(futureComplete, isTrue); + }); + testWidgets('Scroll to 250, scroll to 100, scroll to 0 half way', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); @@ -1145,34 +1136,35 @@ void main() { }, skip: true); testWidgets( - '''Jump to 400 at bottom, manually scroll, scroll to 100 at bottom and back''', - (WidgetTester tester) async { - final itemScrollController = ItemScrollController(); - final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + 'Jump to 400 at bottom, manually scroll, scroll to 100 at bottom and back', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); - itemScrollController.jumpTo(index: 400, alignment: 1); - await tester.pumpAndSettle(); + itemScrollController.jumpTo(index: 400, alignment: 1); + await tester.pumpAndSettle(); - final listFinder = find.byType(ScrollablePositionedList); + final listFinder = find.byType(ScrollablePositionedList); - await tester.drag(listFinder, const Offset(0, -screenHeight)); - await tester.pumpAndSettle(); + await tester.drag(listFinder, const Offset(0, -screenHeight)); + await tester.pumpAndSettle(); - unawaited(itemScrollController.scrollTo( - index: 100, alignment: 1, duration: scrollDuration)); - await tester.pumpAndSettle(); + unawaited(itemScrollController.scrollTo( + index: 100, alignment: 1, duration: scrollDuration)); + await tester.pumpAndSettle(); - unawaited(itemScrollController.scrollTo( - index: 400, alignment: 1, duration: scrollDuration)); - await tester.pumpAndSettle(); + unawaited(itemScrollController.scrollTo( + index: 400, alignment: 1, duration: scrollDuration)); + await tester.pumpAndSettle(); - final itemFinder = find.text('Item 399'); - expect(itemFinder, findsOneWidget); - expect(tester.getBottomLeft(itemFinder).dy, screenHeight); - }); + final itemFinder = find.text('Item 399'); + expect(itemFinder, findsOneWidget); + expect(tester.getBottomLeft(itemFinder).dy, screenHeight); + }, + ); testWidgets('physics', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); @@ -1664,70 +1656,71 @@ void main() { }); testWidgets( - '''Maintain programmatic and user position (9 half way off top) in page view''', - (WidgetTester tester) async { - final itemPositionsListener = ItemPositionsListener.create(); - final itemScrollController = ItemScrollController(); - - tester.binding.window.devicePixelRatioTestValue = 1.0; - tester.binding.window.physicalSizeTestValue = - const Size(screenWidth, screenHeight); - - await tester.pumpWidget( - MaterialApp( - home: PageView( - children: [ - KeyedSubtree( - key: const PageStorageKey('key'), - child: ScrollablePositionedList.builder( - itemCount: defaultItemCount, - itemScrollController: itemScrollController, - itemBuilder: (context, index) => SizedBox( - height: itemHeight, - child: Text('Item $index'), + 'Maintain programmatic and user position (9 half way off top) in page view', + (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + final itemScrollController = ItemScrollController(); + + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + await tester.pumpWidget( + MaterialApp( + home: PageView( + children: [ + KeyedSubtree( + key: const PageStorageKey('key'), + child: ScrollablePositionedList.builder( + itemCount: defaultItemCount, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + itemPositionsListener: itemPositionsListener, ), - itemPositionsListener: itemPositionsListener, ), - ), - const Center( - child: Text('Test'), - ) - ], + const Center( + child: Text('Test'), + ) + ], + ), ), - ), - ); + ); - itemScrollController.jumpTo(index: 9); - await tester.pump(); + itemScrollController.jumpTo(index: 9); + await tester.pump(); - expect(tester.getBottomRight(find.text('Item 9')).dy, itemHeight); - - await tester.drag( - find.byType(ScrollablePositionedList), const Offset(0, -itemHeight)); - await tester.pumpAndSettle(); + expect(tester.getBottomRight(find.text('Item 9')).dy, itemHeight); - final item9Bottom = tester.getBottomRight(find.text('Item 9')).dy; - expect(item9Bottom, lessThan(itemHeight)); + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, -itemHeight)); + await tester.pumpAndSettle(); - await tester.drag(find.byType(PageView), const Offset(-500, 0)); - await tester.pumpAndSettle(); + final item9Bottom = tester.getBottomRight(find.text('Item 9')).dy; + expect(item9Bottom, lessThan(itemHeight)); - await tester.drag(find.byType(PageView), const Offset(500, 0)); - await tester.pumpAndSettle(); + await tester.drag(find.byType(PageView), const Offset(-500, 0)); + await tester.pumpAndSettle(); - expect(tester.getBottomRight(find.text('Item 9')).dy, item9Bottom); + await tester.drag(find.byType(PageView), const Offset(500, 0)); + await tester.pumpAndSettle(); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemLeadingEdge, - -(itemHeight / screenHeight) / 2); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemTrailingEdge, - (itemHeight / screenHeight) / 2); - }); + expect(tester.getBottomRight(find.text('Item 9')).dy, item9Bottom); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemLeadingEdge, + -(itemHeight / screenHeight) / 2); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemTrailingEdge, + (itemHeight / screenHeight) / 2); + }, + ); testWidgets('List with no items', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); @@ -1751,21 +1744,24 @@ void main() { MaterialApp( home: ValueListenableBuilder( valueListenable: itemCount, - builder: (context, itemCount, child) => - ScrollablePositionedList.builder( - initialScrollIndex: min(100, itemCount), - itemCount: itemCount, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener, - itemBuilder: (context, index) { - assert(index >= 0 && index <= itemCount - 1, - 'index not bigger than 0 and smaller than itemCount - 1'); - return SizedBox( - height: itemHeight, - child: Text('Item $index'), - ); - }, - ), + builder: (context, itemCount, child) { + return ScrollablePositionedList.builder( + initialScrollIndex: min(100, itemCount), + itemCount: itemCount, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + itemBuilder: (context, index) { + assert( + index >= 0 && index <= itemCount - 1, + 'index must be in the range of 0 to itemCount - 1', + ); + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ); + }, ), ), ); @@ -1795,19 +1791,22 @@ void main() { MaterialApp( home: ValueListenableBuilder( valueListenable: itemCount, - builder: (context, itemCount, child) => - ScrollablePositionedList.builder( - initialScrollIndex: min(100, itemCount - 1), - itemCount: itemCount, - itemBuilder: (context, index) { - assert(index >= 0 && index <= itemCount - 1, - 'index not bigger than 0 and smaller than itemCount -1'); - return SizedBox( - height: itemHeight, - child: Text('Item $index'), - ); - }, - ), + builder: (context, itemCount, child) { + return ScrollablePositionedList.builder( + initialScrollIndex: min(100, itemCount - 1), + itemCount: itemCount, + itemBuilder: (context, index) { + assert( + index >= 0 && index <= itemCount - 1, + 'index must be in the range of 0 to itemCount - 1', + ); + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ); + }, ), ), ); @@ -1834,19 +1833,22 @@ void main() { MaterialApp( home: ValueListenableBuilder( valueListenable: itemCount, - builder: (context, itemCount, child) => - ScrollablePositionedList.builder( - initialScrollIndex: itemCount - 1, - itemCount: itemCount, - itemBuilder: (context, index) { - assert(index >= 0 && index <= itemCount - 1, - 'index not bigger than 0 and smaller than itemCount -1'); - return SizedBox( - height: itemHeight, - child: Text('Item $index'), - ); - }, - ), + builder: (context, itemCount, child) { + return ScrollablePositionedList.builder( + initialScrollIndex: itemCount - 1, + itemCount: itemCount, + itemBuilder: (context, index) { + assert( + index >= 0 && index <= itemCount - 1, + 'index must be in the range of 0 to itemCount - 1', + ); + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ); + }, ), ), ); @@ -1878,11 +1880,7 @@ void main() { minCacheExtent: 10, ); - var fadeTransition = tester.widget(find - .descendant( - of: find.byType(ScrollablePositionedList), - matching: find.byType(FadeTransition)) - .last); + var fadeTransition = tester.widget(fadeTransitionFinder); final initialOpacity = fadeTransition.opacity; unawaited( @@ -1891,11 +1889,7 @@ void main() { await tester.pump(); await tester.pump(scrollDuration ~/ 2); - fadeTransition = tester.widget(find - .descendant( - of: find.byType(ScrollablePositionedList), - matching: find.byType(FadeTransition)) - .last); + fadeTransition = tester.widget(fadeTransitionFinder); expect(fadeTransition.opacity, initialOpacity); await tester.pumpAndSettle(); @@ -1914,11 +1908,9 @@ void main() { minCacheExtent: itemHeight * 200, ); - var fadeTransition = tester.widget(find - .descendant( - of: find.byType(ScrollablePositionedList), - matching: find.byType(FadeTransition)) - .last); + var fadeTransition = tester.widget( + fadeTransitionFinder, + ); final initialOpacity = fadeTransition.opacity; unawaited( @@ -1927,11 +1919,7 @@ void main() { await tester.pump(); await tester.pump(scrollDuration ~/ 2); - fadeTransition = tester.widget(find - .descendant( - of: find.byType(ScrollablePositionedList), - matching: find.byType(FadeTransition)) - .last); + fadeTransition = tester.widget(fadeTransitionFinder); expect(fadeTransition.opacity, initialOpacity); await tester.pumpAndSettle(); @@ -1965,17 +1953,21 @@ void main() { MaterialApp( home: ValueListenableBuilder( valueListenable: key, - builder: (context, key, child) => Container( - key: key, - child: ScrollablePositionedList.builder( - itemCount: 200, - itemScrollController: itemScrollController, - itemBuilder: (context, index) => SizedBox( - height: itemHeight, - child: Text('Item $index'), + builder: (context, key, child) { + return Container( + key: key, + child: ScrollablePositionedList.builder( + itemCount: 200, + itemScrollController: itemScrollController, + itemBuilder: (context, index) { + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, ), - ), - ), + ); + }, ), ), ); @@ -2054,15 +2046,19 @@ void main() { MaterialApp( home: ValueListenableBuilder( valueListenable: key, - builder: (context, key, child) => ScrollablePositionedList.builder( - key: key, - itemCount: 10, - itemScrollController: itemScrollController, - itemBuilder: (context, index) => SizedBox( - height: itemHeight, - child: Text('Item $index'), - ), - ), + builder: (context, key, child) { + return ScrollablePositionedList.builder( + key: key, + itemCount: 10, + itemScrollController: itemScrollController, + itemBuilder: (context, index) { + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ); + }, ), ), ); @@ -2084,17 +2080,21 @@ void main() { MaterialApp( home: ValueListenableBuilder( valueListenable: key, - builder: (context, key, child) => Container( - key: key, - child: ScrollablePositionedList.builder( - itemCount: 100, - itemScrollController: itemScrollController, - itemBuilder: (context, index) => SizedBox( - height: itemHeight, - child: Text('Item $index'), + builder: (context, key, child) { + return Container( + key: key, + child: ScrollablePositionedList.builder( + itemCount: 100, + itemScrollController: itemScrollController, + itemBuilder: (context, index) { + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, ), - ), - ), + ); + }, ), ), ); @@ -2124,18 +2124,22 @@ void main() { MaterialApp( home: ValueListenableBuilder( valueListenable: containerKey, - builder: (context, key, child) => Container( - key: key, - child: ScrollablePositionedList.builder( - key: scrollKey, - itemCount: 100, - itemScrollController: itemScrollController, - itemBuilder: (context, index) => SizedBox( - height: itemHeight, - child: Text('Item $index'), + builder: (context, key, child) { + return Container( + key: key, + child: ScrollablePositionedList.builder( + key: scrollKey, + itemCount: 100, + itemScrollController: itemScrollController, + itemBuilder: (context, index) { + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, ), - ), - ), + ); + }, ), ), ); @@ -2166,15 +2170,18 @@ void main() { MaterialApp( home: ValueListenableBuilder( valueListenable: itemScrollControllerListenable, - builder: (context, itemScrollController, child) => - ScrollablePositionedList.builder( - itemCount: 100, - itemScrollController: itemScrollController, - itemBuilder: (context, index) => SizedBox( - height: itemHeight, - child: Text('Item $index'), - ), - ), + builder: (context, itemScrollController, child) { + return ScrollablePositionedList.builder( + itemCount: 100, + itemScrollController: itemScrollController, + itemBuilder: (context, index) { + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ); + }, ), ), ); @@ -2215,29 +2222,35 @@ void main() { Expanded( child: ValueListenableBuilder( valueListenable: topItemScrollControllerListenable, - builder: (context, itemScrollController, child) => - ScrollablePositionedList.builder( - itemCount: 100, - itemScrollController: itemScrollController, - itemBuilder: (context, index) => SizedBox( - height: itemHeight, - child: Text('Item $index'), - ), - ), + builder: (context, itemScrollController, child) { + return ScrollablePositionedList.builder( + itemCount: 100, + itemScrollController: itemScrollController, + itemBuilder: (context, index) { + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ); + }, ), ), Expanded( child: ValueListenableBuilder( valueListenable: bottomItemScrollControllerListenable, - builder: (context, itemScrollController, child) => - ScrollablePositionedList.builder( - itemCount: 100, - itemScrollController: itemScrollController, - itemBuilder: (context, index) => SizedBox( - height: itemHeight, - child: Text('Item $index'), - ), - ), + builder: (context, itemScrollController, child) { + return ScrollablePositionedList.builder( + itemCount: 100, + itemScrollController: itemScrollController, + itemBuilder: (context, index) { + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ); + }, ), ), ], diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart index df1035e29..c7439d5cc 100644 --- a/packages/stream_chat_flutter/test/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart @@ -2,10 +2,9 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:pedantic/pedantic.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/src/scroll_view.dart'; @@ -497,20 +496,21 @@ void main() { MaterialApp( home: ValueListenableBuilder( valueListenable: itemCount, - builder: (context, itemCount, child) => - ScrollablePositionedList.separated( - itemCount: itemCount, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener, - itemBuilder: (context, index) => SizedBox( - height: itemHeight, - child: Text('Item $index'), - ), - separatorBuilder: (context, index) => SizedBox( - height: separatorHeight, - child: Text('Separator $index'), - ), - ), + builder: (context, itemCount, child) { + return ScrollablePositionedList.separated( + itemCount: itemCount, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + separatorBuilder: (context, index) => SizedBox( + height: separatorHeight, + child: Text('Separator $index'), + ), + ); + }, ), ), ); @@ -538,20 +538,21 @@ void main() { MaterialApp( home: ValueListenableBuilder( valueListenable: itemCount, - builder: (context, itemCount, child) => - ScrollablePositionedList.separated( - itemCount: itemCount, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener, - itemBuilder: (context, index) => SizedBox( - height: itemHeight, - child: Text('Item $index'), - ), - separatorBuilder: (context, index) => SizedBox( - height: separatorHeight, - child: Text('Separator $index'), - ), - ), + builder: (context, itemCount, child) { + return ScrollablePositionedList.separated( + itemCount: itemCount, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + separatorBuilder: (context, index) => SizedBox( + height: separatorHeight, + child: Text('Separator $index'), + ), + ); + }, ), ), ); diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart index c26ee43fd..f92c9903a 100644 --- a/packages/stream_chat_flutter/test/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart @@ -2,10 +2,9 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:pedantic/pedantic.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; const screenHeight = 400.0; diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/shrink_wrap_position_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/shrink_wrap_position_list_test.dart new file mode 100644 index 000000000..c5d94670a --- /dev/null +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/shrink_wrap_position_list_test.dart @@ -0,0 +1,477 @@ +// Copyright 2019 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_notifier.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/positioned_list.dart'; + +const screenHeight = 400.0; +const screenWidth = 400.0; +const itemHeight = screenHeight / 10.0; +const defaultItemCount = 500; + +void main() { + final itemPositionsNotifier = ItemPositionsListener.create(); + + Future setUpWidgetTest( + WidgetTester tester, { + int topItem = 0, + Key? key, + ScrollController? scrollController, + double anchor = 0, + int itemCount = defaultItemCount, + bool reverse = false, + }) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + await tester.pumpWidget( + MaterialApp( + // Use flex layout to ensure that the minimum height is not limited to + // screenHeight. + home: Column(children: [ + // Use Constrained to make max height not more than screenHeight + ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: screenHeight, maxWidth: screenWidth), + child: PositionedList( + key: key, + itemCount: itemCount, + positionedIndex: topItem, + alignment: anchor, + controller: scrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + itemPositionsNotifier: + itemPositionsNotifier as ItemPositionsNotifier, + shrinkWrap: true, + reverse: reverse, + ), + ), + ]), + ), + ); + } + + testWidgets('short list with shrink wrap', (WidgetTester tester) async { + const itemCount = 5; + const key = Key('short_list'); + await setUpWidgetTest(tester, itemCount: itemCount, key: key); + await tester.pump(); + + expect( + tester.getBottomRight(find.text('Item 4')).dy, itemHeight * itemCount); + expect(find.text('Item 4'), findsOneWidget); + expect(find.text('Item 5'), findsNothing); + + final positionList = find.byKey(key); + expect(tester.getBottomRight(positionList).dy, itemHeight * itemCount); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 4) + .itemTrailingEdge, + 1.0); + }); + + testWidgets('List positioned with 0 at top and shrink wrap', + (WidgetTester tester) async { + await setUpWidgetTest(tester); + await tester.pump(); + + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item 9'), findsOneWidget); + expect(find.text('Item 10'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemTrailingEdge, + 1); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 10) + .itemLeadingEdge, + 1); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 10) + .itemTrailingEdge, + 11 / 10); + }); + + testWidgets('List positioned with 5 at top and shrink wrap', + (WidgetTester tester) async { + await setUpWidgetTest(tester, topItem: 5); + await tester.pump(); + + expect(find.text('Item 4'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + expect(find.text('Item 14'), findsOneWidget); + expect(find.text('Item 15'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 4) + .itemLeadingEdge, + -1 / 10); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 4) + .itemTrailingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 14) + .itemTrailingEdge, + 1); + }); + + testWidgets('List positioned with 20 at bottom and shrink wrap', + (WidgetTester tester) async { + await setUpWidgetTest(tester, topItem: 20, anchor: 1); + await tester.pump(); + + expect(find.text('Item 20'), findsNothing); + expect(find.text('Item 19'), findsOneWidget); + expect(find.text('Item 10'), findsOneWidget); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 10) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 19) + .itemLeadingEdge, + 9 / 10); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 19) + .itemTrailingEdge, + 1); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 20) + .itemLeadingEdge, + 1); + }); + + testWidgets('List positioned with 20 at halfway and shrink wrap', + (WidgetTester tester) async { + await setUpWidgetTest(tester, topItem: 20, anchor: 0.5); + await tester.pump(); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 20) + .itemLeadingEdge, + 0.5); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 20) + .itemTrailingEdge, + 0.5 + itemHeight / screenHeight); + }); + + testWidgets('List positioned with 20 half off top of screen and shrink wrap', + (WidgetTester tester) async { + await setUpWidgetTest(tester, + topItem: 20, anchor: -(itemHeight / screenHeight) / 2); + await tester.pump(); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 20) + .itemLeadingEdge, + -(itemHeight / screenHeight) / 2); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 20) + .itemTrailingEdge, + (itemHeight / screenHeight) / 2); + }); + + testWidgets('List positioned with 5 at top then scroll up 2 and shrink wrap', + (WidgetTester tester) async { + await setUpWidgetTest(tester, topItem: 5); + + await tester.drag( + find.byType(PositionedList), const Offset(0, itemHeight * 2)); + await tester.pump(); + + expect(find.text('Item 2'), findsNothing); + expect(find.text('Item 3'), findsOneWidget); + expect(find.text('Item 12'), findsOneWidget); + expect(find.text('Item 13'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 2) + .itemLeadingEdge, + -1 / 10); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 3) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 12) + .itemTrailingEdge, + 1); + }); + + testWidgets( + 'List positioned with 5 at top then scroll down 1/2 and shrink wrap', + (WidgetTester tester) async { + await setUpWidgetTest(tester, topItem: 5); + + await tester.drag( + find.byType(PositionedList), const Offset(0, -1 / 2 * itemHeight)); + await tester.pump(); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemTrailingEdge, + 1 / 20); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 14) + .itemLeadingEdge, + 17 / 20); + }); + + testWidgets('List positioned with 0 at top scroll up 5 and shrink wrap', + (WidgetTester tester) async { + final scrollController = ScrollController(); + await setUpWidgetTest(tester, scrollController: scrollController); + await tester.pump(); + + scrollController.jumpTo(itemHeight * 5); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text('Item 4'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + expect(find.text('Item 14'), findsOneWidget); + expect(find.text('Item 15'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 4) + .itemLeadingEdge, + -1 / 10); + }); + + testWidgets( + '''List positioned with 5 at top then scroll up 2 programatically and shrink wrap''', + (WidgetTester tester) async { + final scrollController = ScrollController(); + await setUpWidgetTest(tester, + topItem: 5, scrollController: scrollController); + + scrollController.jumpTo(-2 * itemHeight); + await tester.pump(); + + expect(find.text('Item 2'), findsNothing); + expect(find.text('Item 3'), findsOneWidget); + expect(find.text('Item 12'), findsOneWidget); + expect(find.text('Item 13'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 2) + .itemLeadingEdge, + -1 / 10); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 3) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 12) + .itemTrailingEdge, + 1); + }, + ); + + testWidgets( + '''List positioned with 5 at top then scroll down 20 programatically and shrink wrap''', + (WidgetTester tester) async { + final scrollController = ScrollController(); + await setUpWidgetTest(tester, + topItem: 5, scrollController: scrollController); + + scrollController.jumpTo(itemHeight * 20); + await tester.pump(); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 23) + .itemLeadingEdge, + -2 / 10); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 24) + .itemLeadingEdge, + -1 / 10); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 25) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 4) + .itemLeadingEdge, + -21 / 10); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemLeadingEdge, + -20 / 10); + }, + ); + + testWidgets( + 'List positioned with 5 at top and initial scroll offset and shrink wrap', + (WidgetTester tester) async { + final scrollController = + ScrollController(initialScrollOffset: -2 * itemHeight); + await setUpWidgetTest(tester, + topItem: 5, scrollController: scrollController); + + expect(find.text('Item 2'), findsNothing); + expect(find.text('Item 3'), findsOneWidget); + expect(find.text('Item 12'), findsOneWidget); + expect(find.text('Item 13'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 3) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 12) + .itemTrailingEdge, + 1); + }); + + testWidgets('short List with reverse and shrink wrap', + (WidgetTester tester) async { + const itemCount = 5; + const key = Key('short_list'); + await setUpWidgetTest(tester, + itemCount: itemCount, key: key, reverse: true); + await tester.pump(); + + expect(find.text('Item 4'), findsOneWidget); + expect(find.text('Item 5'), findsNothing); + expect( + tester.getBottomRight(find.text('Item 0')).dy, itemHeight * itemCount); + expect(tester.getTopLeft(find.text('Item 4')).dy, 0); + + final positionList = find.byKey(key); + expect(tester.getBottomRight(positionList).dy, itemHeight * itemCount); + expect(tester.getTopLeft(positionList).dy, 0); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 4) + .itemTrailingEdge, + 1.0); + }); + + testWidgets('test nested positioned list', (WidgetTester tester) async { + const itemCount = 50; + const key = Key('short_list'); + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + await tester.pumpWidget( + MaterialApp( + // Use flex layout to ensure that the minimum height is not limited to + // screenHeight. + home: PositionedList( + itemCount: 5, + itemBuilder: (context, index) { + if (index == 0) { + return PositionedList( + key: key, + itemCount: itemCount, + shrinkWrap: true, + itemBuilder: (context, idx) => SizedBox( + height: itemHeight, + child: Text('Item $idx'), + )); + } else { + return SizedBox( + height: itemHeight, + child: Text('Item ${itemCount + index - 1}'), + ); + } + }, + itemPositionsNotifier: itemPositionsNotifier as ItemPositionsNotifier, + ), + ), + ); + await tester.pump(); + + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item 50'), findsNothing); + expect(tester.getTopLeft(find.text('Item 0')).dy, 0); + expect(tester.getBottomRight(find.text('Item 9')).dy, screenHeight); + + final positionList = find.byKey(key); + expect(tester.getBottomRight(positionList).dy, itemHeight * itemCount); + expect(tester.getTopLeft(positionList).dy, 0); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemTrailingEdge, + 5.0); + }); +} diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/shrink_wrap_scrollable_position_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/shrink_wrap_scrollable_position_list_test.dart new file mode 100644 index 000000000..cc3725aa9 --- /dev/null +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/shrink_wrap_scrollable_position_list_test.dart @@ -0,0 +1,247 @@ +// Copyright 2019 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pedantic/pedantic.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; + +const screenHeight = 400.0; +const screenWidth = 400.0; +const itemHeight = screenHeight / 10.0; +const itemCount = 500; +const scrollDuration = Duration(seconds: 1); + +void main() { + Future setUpWidgetTest( + WidgetTester tester, { + ItemScrollController? itemScrollController, + ItemPositionsListener? itemPositionsListener, + EdgeInsets? padding, + int initialIndex = 0, + }) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + await tester.pumpWidget( + MaterialApp( + // Use flex layout to ensure that the minimum height is not limited to + // screenHeight. + home: Column(children: [ + // Use Constrained to make max height not more than screenHeight + ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: screenHeight, maxWidth: screenWidth), + child: ScrollablePositionedList.builder( + itemCount: itemCount, + initialScrollIndex: initialIndex, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + itemPositionsListener: itemPositionsListener, + shrinkWrap: true, + padding: padding, + ), + ), + ]), + ), + ); + } + + testWidgets('List positioned with 0 at top and shrink wrap', + (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener); + + expect(tester.getTopLeft(find.text('Item 0')).dy, 0); + expect(tester.getBottomRight(find.text('Item 9')).dy, screenHeight); + expect(find.text('Item 10'), findsNothing); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemTrailingEdge, + 1); + }); + + testWidgets('Scroll to 1 then 2 (both already on screen) with shrink wrap', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + unawaited( + itemScrollController.scrollTo(index: 1, duration: scrollDuration)); + await tester.pump(); + await tester.pump(scrollDuration); + expect(find.text('Item 0'), findsNothing); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 1) + .itemLeadingEdge, + 0); + expect(tester.getTopLeft(find.text('Item 1')).dy, 0); + + unawaited( + itemScrollController.scrollTo(index: 2, duration: scrollDuration)); + await tester.pump(); + await tester.pump(scrollDuration); + + expect(find.text('Item 1'), findsNothing); + expect(tester.getTopLeft(find.text('Item 2')).dy, 0); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 2) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 11) + .itemTrailingEdge, + 1); + }); + + testWidgets( + 'Scroll to 5 (already on screen) and then back to 0 with shrink wrap', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + unawaited( + itemScrollController.scrollTo(index: 5, duration: scrollDuration)); + await tester.pumpAndSettle(); + unawaited( + itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + await tester.pumpAndSettle(); + + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item 9'), findsOneWidget); + expect(find.text('Item 10'), findsNothing); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemTrailingEdge, + 1); + }); + + testWidgets('Scroll to 100 (not already on screen) with shrink wrap', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + + expect(find.text('Item 99'), findsNothing); + expect(find.text('Item 100'), findsOneWidget); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 100) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 109) + .itemTrailingEdge, + 1); + }); + + testWidgets('Jump to 100 with shrink wrap', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + itemScrollController.jumpTo(index: 100); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Item 100')).dy, 0); + expect(tester.getBottomRight(find.text('Item 109')).dy, screenHeight); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 100) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 109) + .itemTrailingEdge, + 1); + }); + + testWidgets('padding test - centered sliver at bottom with shrink wrap', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + padding: const EdgeInsets.all(10), + ); + + expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); + expect(tester.getTopLeft(find.text('Item 1')), + const Offset(10, itemHeight + 10)); + expect(tester.getBottomRight(find.text('Item 1')), + const Offset(screenWidth - 10, 10 + itemHeight * 2)); + + unawaited( + itemScrollController.scrollTo(index: 490, duration: scrollDuration)); + await tester.pumpAndSettle(); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, -100)); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Item 499')), + const Offset(10, screenHeight - itemHeight - 10)); + }); + + testWidgets('padding test - centered sliver not at bottom', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + initialIndex: 2, + padding: const EdgeInsets.all(10), + ); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, 200)); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); + expect(tester.getTopLeft(find.text('Item 2')), + const Offset(10, 10 + itemHeight * 2)); + expect(tester.getTopLeft(find.text('Item 3')), + const Offset(10, 10 + itemHeight * 3)); + }); +} diff --git a/packages/stream_chat_flutter/test/src/goldens/command_button_0.png b/packages/stream_chat_flutter/test/src/goldens/command_button_0.png index 72719d491..c294b279e 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/command_button_0.png and b/packages/stream_chat_flutter/test/src/goldens/command_button_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/deleted_message_custom.png b/packages/stream_chat_flutter/test/src/goldens/deleted_message_custom.png index c464bc01d..36f80c62b 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/deleted_message_custom.png and b/packages/stream_chat_flutter/test/src/goldens/deleted_message_custom.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/deleted_message_dark.png b/packages/stream_chat_flutter/test/src/goldens/deleted_message_dark.png index 7db0a6ef1..42243eb06 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/deleted_message_dark.png and b/packages/stream_chat_flutter/test/src/goldens/deleted_message_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/deleted_message_light.png b/packages/stream_chat_flutter/test/src/goldens/deleted_message_light.png index d5a453ffc..6770182ce 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/deleted_message_light.png and b/packages/stream_chat_flutter/test/src/goldens/deleted_message_light.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/gallery_header_0.png b/packages/stream_chat_flutter/test/src/goldens/gallery_header_0.png index a0b780e93..3c8e020f8 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/gallery_header_0.png and b/packages/stream_chat_flutter/test/src/goldens/gallery_header_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_2.png b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_2.png index 05cfdbbef..9a4aac364 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_2.png and b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_2.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_3_dark.png b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_3_dark.png index cb7e48944..1694dcb6c 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_3_dark.png and b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_3_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_3_light.png b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_3_light.png index 8caefa4f6..d67e928d2 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_3_light.png and b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_3_light.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_like_dark.png b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_like_dark.png index 844442152..86a7ae2cc 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_like_dark.png and b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_like_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_like_light.png b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_like_light.png index b198aef5a..86cdacacc 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_like_light.png and b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_like_light.png differ diff --git a/packages/stream_chat_flutter/test/src/theme/message_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/message_theme_test.dart index 3b6c3a6fa..246d9d907 100644 --- a/packages/stream_chat_flutter/test/src/theme/message_theme_test.dart +++ b/packages/stream_chat_flutter/test/src/theme/message_theme_test.dart @@ -60,7 +60,7 @@ final _messageThemeControl = StreamMessageThemeData( messageLinksStyle: TextStyle( color: StreamColorTheme.light().accentPrimary, ), - linkBackgroundColor: StreamColorTheme.light().linkBg, + urlAttachmentBackgroundColor: StreamColorTheme.light().linkBg, ); final _messageThemeControlDark = StreamMessageThemeData( @@ -89,5 +89,5 @@ final _messageThemeControlDark = StreamMessageThemeData( messageLinksStyle: TextStyle( color: StreamColorTheme.dark().accentPrimary, ), - linkBackgroundColor: StreamColorTheme.dark().linkBg, + urlAttachmentBackgroundColor: StreamColorTheme.dark().linkBg, ); diff --git a/packages/stream_chat_flutter/test/src/utils/extension_test.dart b/packages/stream_chat_flutter/test/src/utils/extension_test.dart index 269807274..eef7677e1 100644 --- a/packages/stream_chat_flutter/test/src/utils/extension_test.dart +++ b/packages/stream_chat_flutter/test/src/utils/extension_test.dart @@ -81,6 +81,71 @@ void main() { expect('๐ŸŒถ1'.isOnlyEmoji, false); expect('๐Ÿ‘จโ€๐Ÿ‘จ๐Ÿ‘จโ€๐Ÿ‘จ'.isOnlyEmoji, true); expect('๐Ÿ‘จโ€๐Ÿ‘จ๐Ÿ‘จโ€๐Ÿ‘จ '.isOnlyEmoji, true); + expect('๐Ÿ‘จ๐Ÿ‘จ๐Ÿ‘จ๐Ÿ‘จ'.isOnlyEmoji, false); + expect('โญโญโญ'.isOnlyEmoji, true); + expect('โญ•โญ•โญ'.isOnlyEmoji, true); + expect('โœ…'.isOnlyEmoji, true); + expect('โ˜บ๏ธ'.isOnlyEmoji, true); + }); + + test('Korean vowels', () { + expect('ใ…'.isOnlyEmoji, false); + expect('ใ…‘'.isOnlyEmoji, false); + expect('ใ…“'.isOnlyEmoji, false); + expect('ใ…•'.isOnlyEmoji, false); + expect('ใ…—'.isOnlyEmoji, false); + expect('ใ…›'.isOnlyEmoji, false); + expect('ใ…œ'.isOnlyEmoji, false); + expect('ใ… '.isOnlyEmoji, false); + expect('ใ…ก'.isOnlyEmoji, false); + expect('ใ…ฃ'.isOnlyEmoji, false); + }); + + test('Korean consonants', () { + expect('ใ„ฑ'.isOnlyEmoji, false); + expect('ใ„ด'.isOnlyEmoji, false); + expect('ใ„ท'.isOnlyEmoji, false); + expect('ใ„น'.isOnlyEmoji, false); + expect('ใ…'.isOnlyEmoji, false); + expect('ใ…‚'.isOnlyEmoji, false); + expect('ใ……'.isOnlyEmoji, false); + expect('ใ…‡'.isOnlyEmoji, false); + expect('ใ…ˆ'.isOnlyEmoji, false); + expect('ใ…Š'.isOnlyEmoji, false); + expect('ใ…‹'.isOnlyEmoji, false); + expect('ใ…Œ'.isOnlyEmoji, false); + expect('ใ…'.isOnlyEmoji, false); + expect('ใ…Ž'.isOnlyEmoji, false); + }); + + test('Korean syllables', () { + expect('๊ฐ€'.isOnlyEmoji, false); + expect('๋‚˜'.isOnlyEmoji, false); + expect('๋‹ค'.isOnlyEmoji, false); + expect('๋ผ'.isOnlyEmoji, false); + expect('๋งˆ'.isOnlyEmoji, false); + expect('๋ฐ”'.isOnlyEmoji, false); + expect('์‚ฌ'.isOnlyEmoji, false); + expect('์•„'.isOnlyEmoji, false); + expect('์ž'.isOnlyEmoji, false); + expect('์ฐจ'.isOnlyEmoji, false); + expect('์นด'.isOnlyEmoji, false); + expect('ํƒ€'.isOnlyEmoji, false); + expect('ํŒŒ'.isOnlyEmoji, false); + expect('ํ•˜'.isOnlyEmoji, false); + }); + + // https://github.com/GetStream/stream-chat-flutter/issues/1502 + test('Issue:#1502', () { + expect('ใ„ด'.isOnlyEmoji, false); + expect('ใ„ดใ…‡'.isOnlyEmoji, false); + expect('ใ…‡ใ…‹'.isOnlyEmoji, false); + }); + + // https://github.com/GetStream/stream-chat-flutter/issues/1505 + test('Issue:#1505', () { + expect('ใ…Žใ…Žใ…Ž'.isOnlyEmoji, false); + expect('ใ…Žใ…Žใ…Žใ…Ž'.isOnlyEmoji, false); }); }); } diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index 29b6a27fd..3359bf8c5 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -1,3 +1,10 @@ +## 6.1.0 + +- Updated `dart` sdk environment range to support `3.0.0`. +- Updated `stream_chat` dependency to [`6.1.0`](https://pub.dev/packages/stream_chat/changelog). +- [[#1356]](https://github.com/GetStream/stream-chat-flutter/issues/1356) Channel doesn't auto display again after being + hidden. + ## 6.0.0 - Updated dependencies to resolvable versions. 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 9842c3e2a..bbfdea5bc 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_channel.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart @@ -387,12 +387,20 @@ class StreamChannelState extends State { (it) => it.user.id == channel.client.state.currentUser?.id, ); - if (read != null && - !(channel.state!.messages - .any((it) => it.createdAt.compareTo(read.lastRead) > 0) && - channel.state!.messages - .any((it) => it.createdAt.compareTo(read.lastRead) <= 0))) { - _futures.add(_loadChannelAtTimestamp(read.lastRead)); + if (read == null) return; + + final messages = channel.state!.messages; + final lastRead = read.lastRead; + + final hasNewMessages = + messages.any((it) => it.createdAt.isAfter(lastRead)); + final hasOldMessages = + messages.any((it) => it.createdAt.isBeforeOrEqualTo(lastRead)); + + // Only load messages if the unread message is in-between the messages. + // Otherwise, we can just load the channel normally. + if (hasNewMessages && hasOldMessages) { + _futures.add(_loadChannelAtTimestamp(lastRead)); } } } @@ -449,3 +457,9 @@ class StreamChannelState extends State { return child; } } + +extension on DateTime { + bool isBeforeOrEqualTo(DateTime other) { + return isBefore(other) || isAtSameMomentAs(other); + } +} diff --git a/packages/stream_chat_flutter_core/lib/src/stream_channel_list_event_handler.dart b/packages/stream_chat_flutter_core/lib/src/stream_channel_list_event_handler.dart index 92b5650bc..0fc34dfdf 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_channel_list_event_handler.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_channel_list_event_handler.dart @@ -101,14 +101,19 @@ class StreamChannelListEventHandler { /// we are currently watching. /// /// By default, this moves the channel to the top of the list. - void onMessageNew(Event event, StreamChannelListController controller) { + void onMessageNew(Event event, StreamChannelListController controller) async { final channelCid = event.cid; if (channelCid == null) return; final channels = [...controller.currentItems]; final channelIndex = channels.indexWhere((it) => it.cid == channelCid); - if (channelIndex <= 0) return; + if (channelIndex <= 0) { + // If the channel is not in the list, It might be hidden. + // So, we just refresh the list. + await controller.refresh(resetValue: false); + return; + } final channel = channels.removeAt(channelIndex); channels.insert(0, channel); diff --git a/packages/stream_chat_flutter_core/pubspec.yaml b/packages/stream_chat_flutter_core/pubspec.yaml index 35a6d0671..031099b86 100644 --- a/packages/stream_chat_flutter_core/pubspec.yaml +++ b/packages/stream_chat_flutter_core/pubspec.yaml @@ -1,12 +1,12 @@ name: stream_chat_flutter_core homepage: https://github.com/GetStream/stream-chat-flutter description: Stream Chat official Flutter SDK Core. Build your own chat experience using Dart and Flutter. -version: 6.0.0 +version: 6.1.0 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues environment: - sdk: '>=2.17.0 <3.0.0' + sdk: '>=2.17.0 <4.0.0' flutter: ">=1.17.0" dependencies: @@ -17,7 +17,7 @@ dependencies: freezed_annotation: ^2.0.3 meta: ^1.8.0 rxdart: ^0.27.0 - stream_chat: ^6.0.0 + stream_chat: ^6.1.0 dev_dependencies: build_runner: ^2.3.3 diff --git a/packages/stream_chat_localizations/CHANGELOG.md b/packages/stream_chat_localizations/CHANGELOG.md index dc42f2448..b435fd724 100644 --- a/packages/stream_chat_localizations/CHANGELOG.md +++ b/packages/stream_chat_localizations/CHANGELOG.md @@ -1,3 +1,8 @@ +## 5.1.0 + +* Updated `dart` sdk environment range to support `3.0.0`. +* Updated `stream_chat_flutter` dependency to [`6.1.0`](https://pub.dev/packages/stream_chat_flutter/changelog). + ## 5.0.0 * Updated `stream_chat_flutter` dependency to [`6.0.0`](https://pub.dev/packages/stream_chat_flutter/changelog). @@ -6,9 +11,12 @@ โœ… Added -* Added support for [Catalan](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart) locale. +* Added support + for [Catalan](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart) + locale. * Added translations for new `noPhotoOrVideoLabel` label. -* Changed text in New messages separator. Now is doesn't count the new messages and only shows "New messages". All the translations were updated. +* Changed text in New messages separator. Now is doesn't count the new messages and only shows "New messages". All the + translations were updated. ๐Ÿ”„ Changed @@ -39,7 +47,9 @@ ## 3.3.0 -* Added support for [Norwegian](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart) locale. +* Added support + for [Norwegian](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart) + locale. ## 3.2.0 @@ -49,7 +59,9 @@ ## 3.1.0 -* Added support for [German](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart) locale. +* Added support + for [German](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart) + locale. ## 3.0.0 @@ -63,7 +75,9 @@ โœ… Added -* Added support for [Portuguese](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart) locale. +* Added support + for [Portuguese](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart) + locale. ๐Ÿ”„ Changed @@ -81,9 +95,15 @@ โœ… Added -* Added support for [Spanish](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart) locale. -* Added support for [Korean](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart) locale. -* Added support for [Japanese](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart) locale. +* Added support + for [Spanish](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart) + locale. +* Added support + for [Korean](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart) + locale. +* Added support + for [Japanese](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart) + locale. * Added translations for cooldown mode. * Added translations for attachmentLimitExceed. @@ -94,7 +114,7 @@ - 'เคคเคธเฅเคตเฅ€เคฐเฅ‡เค‚' -> 'เฅžเฅ‹เคŸเฅ‹เคœ' - 'เคฌเคฟเคคเคพ เคนเฅเค† เค•เคฒ' -> 'เค•เคฒ' - 'เคšเฅˆเคจเคฒ เคฎเฅŒเคจ เคนเฅˆ' -> 'เคšเฅˆเคจเคฒ เคฎเฅเคฏเฅ‚เคŸ เคนเฅˆ' - + ## 1.0.2 * Updated `stream_chat_flutter` dependency diff --git a/packages/stream_chat_localizations/pubspec.yaml b/packages/stream_chat_localizations/pubspec.yaml index 1590d7413..e55f63010 100644 --- a/packages/stream_chat_localizations/pubspec.yaml +++ b/packages/stream_chat_localizations/pubspec.yaml @@ -1,12 +1,12 @@ name: stream_chat_localizations description: The Official localizations for Stream Chat Flutter, a service for building chat applications -version: 5.0.0 +version: 5.1.0 homepage: https://github.com/GetStream/stream-chat-flutter repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues environment: - sdk: '>=2.17.0 <3.0.0' + sdk: '>=2.17.0 <4.0.0' flutter: ">=1.20.0" dependencies: @@ -14,7 +14,7 @@ dependencies: sdk: flutter flutter_localizations: sdk: flutter - stream_chat_flutter: ^6.0.0 + stream_chat_flutter: ^6.1.0 dev_dependencies: dart_code_metrics: ^5.7.2 diff --git a/packages/stream_chat_persistence/CHANGELOG.md b/packages/stream_chat_persistence/CHANGELOG.md index d980edcc8..3e56f2ec3 100644 --- a/packages/stream_chat_persistence/CHANGELOG.md +++ b/packages/stream_chat_persistence/CHANGELOG.md @@ -1,3 +1,8 @@ +## 6.1.0 + +- Updated `dart` sdk environment range to support `3.0.0`. +- Updated `stream_chat` dependency to [`6.1.0`](https://pub.dev/packages/stream_chat/changelog). + ## 6.0.0 - Updated `drift` to `^2.7.0`. @@ -23,7 +28,8 @@ ## 4.4.0 -- Allowed experimental use of indexedDb on web with `webUseExperimentalIndexedDb` parameter on `StreamChatPersistenceClient`. +- Allowed experimental use of indexedDb on web with `webUseExperimentalIndexedDb` parameter + on `StreamChatPersistenceClient`. Thanks [geweald](https://github.com/geweald). ## 4.3.0 diff --git a/packages/stream_chat_persistence/README.md b/packages/stream_chat_persistence/README.md index 0851335b8..1d7f8bafe 100644 --- a/packages/stream_chat_persistence/README.md +++ b/packages/stream_chat_persistence/README.md @@ -37,7 +37,7 @@ The usage is pretty simple. ```dart final chatPersistentClient = StreamChatPersistenceClient( logLevel: Level.INFO, - connectionMode: ConnectionMode.background, + connectionMode: ConnectionMode.regular, ); ``` 2. Pass the instance to the official Stream chat client. diff --git a/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart b/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart index 48e563dc9..3d5a5bdbc 100644 --- a/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart +++ b/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart @@ -1,7 +1,5 @@ import 'package:flutter/foundation.dart'; -import 'package:mutex/mutex.dart'; import 'package:stream_chat/stream_chat.dart'; - import 'package:stream_chat_persistence/src/db/drift_chat_database.dart'; /// Various connection modes on which [StreamChatPersistenceClient] can work @@ -48,7 +46,6 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { final Logger _logger; final ConnectionMode _connectionMode; final bool _webUseIndexedDbIfSupported; - final _mutex = ReadWriteMutex(); void _defaultLogHandler(LogRecord record) { print( @@ -59,9 +56,6 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { if (record.stackTrace != null) print(record.stackTrace); } - Future _readProtected(AsyncValueGetter func) => - _mutex.protectRead(func); - bool get _debugIsConnected { assert(() { if (db == null) { @@ -96,6 +90,7 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { 'disconnect the previous instance before connecting again.', ); } + _logger.info('connect'); db = databaseProvider?.call(userId, _connectionMode) ?? await _defaultDatabaseProvider(userId, _connectionMode); } @@ -104,90 +99,84 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { Future getConnectionInfo() { assert(_debugIsConnected, ''); _logger.info('getConnectionInfo'); - return _readProtected(() => db!.connectionEventDao.connectionEvent); + return db!.connectionEventDao.connectionEvent; } @override Future updateConnectionInfo(Event event) { assert(_debugIsConnected, ''); _logger.info('updateConnectionInfo'); - return _readProtected( - () => db!.connectionEventDao.updateConnectionEvent(event), - ); + return db!.connectionEventDao.updateConnectionEvent(event); } @override Future updateLastSyncAt(DateTime lastSyncAt) { assert(_debugIsConnected, ''); _logger.info('updateLastSyncAt'); - return _readProtected( - () => db!.connectionEventDao.updateLastSyncAt(lastSyncAt), - ); + return db!.connectionEventDao.updateLastSyncAt(lastSyncAt); } @override Future getLastSyncAt() { assert(_debugIsConnected, ''); _logger.info('getLastSyncAt'); - return _readProtected(() => db!.connectionEventDao.lastSyncAt); + return db!.connectionEventDao.lastSyncAt; } @override Future deleteChannels(List cids) { assert(_debugIsConnected, ''); _logger.info('deleteChannels'); - return _readProtected(() => db!.channelDao.deleteChannelByCids(cids)); + return db!.channelDao.deleteChannelByCids(cids); } @override Future> getChannelCids() { assert(_debugIsConnected, ''); _logger.info('getChannelCids'); - return _readProtected(() => db!.channelDao.cids); + return db!.channelDao.cids; } @override Future deleteMessageByIds(List messageIds) { assert(_debugIsConnected, ''); _logger.info('deleteMessageByIds'); - return _readProtected(() => db!.messageDao.deleteMessageByIds(messageIds)); + return db!.messageDao.deleteMessageByIds(messageIds); } @override Future deletePinnedMessageByIds(List messageIds) { assert(_debugIsConnected, ''); _logger.info('deletePinnedMessageByIds'); - return _readProtected( - () => db!.pinnedMessageDao.deleteMessageByIds(messageIds), - ); + return db!.pinnedMessageDao.deleteMessageByIds(messageIds); } @override Future deleteMessageByCids(List cids) { assert(_debugIsConnected, ''); _logger.info('deleteMessageByCids'); - return _readProtected(() => db!.messageDao.deleteMessageByCids(cids)); + return db!.messageDao.deleteMessageByCids(cids); } @override Future deletePinnedMessageByCids(List cids) { assert(_debugIsConnected, ''); _logger.info('deletePinnedMessageByCids'); - return _readProtected(() => db!.pinnedMessageDao.deleteMessageByCids(cids)); + return db!.pinnedMessageDao.deleteMessageByCids(cids); } @override Future> getMembersByCid(String cid) { assert(_debugIsConnected, ''); _logger.info('getMembersByCid'); - return _readProtected(() => db!.memberDao.getMembersByCid(cid)); + return db!.memberDao.getMembersByCid(cid); } @override Future getChannelByCid(String cid) { assert(_debugIsConnected, ''); _logger.info('getChannelByCid'); - return _readProtected(() => db!.channelDao.getChannelByCid(cid)); + return db!.channelDao.getChannelByCid(cid); } @override @@ -197,11 +186,9 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { }) { assert(_debugIsConnected, ''); _logger.info('getMessagesByCid'); - return _readProtected( - () => db!.messageDao.getMessagesByCid( - cid, - messagePagination: messagePagination, - ), + return db!.messageDao.getMessagesByCid( + cid, + messagePagination: messagePagination, ); } @@ -212,37 +199,34 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { }) { assert(_debugIsConnected, ''); _logger.info('getPinnedMessagesByCid'); - return _readProtected( - () => db!.pinnedMessageDao.getMessagesByCid( - cid, - messagePagination: messagePagination, - ), + return db!.pinnedMessageDao.getMessagesByCid( + cid, + messagePagination: messagePagination, ); } @override - Future> getReadsByCid(String cid) { + Future> getReadsByCid(String cid) async { assert(_debugIsConnected, ''); _logger.info('getReadsByCid'); - return _readProtected(() => db!.readDao.getReadsByCid(cid)); + return db!.readDao.getReadsByCid(cid); } @override - Future>> getChannelThreads(String cid) { + Future>> getChannelThreads(String cid) async { assert(_debugIsConnected, ''); _logger.info('getChannelThreads'); - return _readProtected(() async { - final messages = await db!.messageDao.getThreadMessages(cid); - final messageByParentIdDictionary = >{}; - for (final message in messages) { - final parentId = message.parentId!; - messageByParentIdDictionary[parentId] = [ - ...messageByParentIdDictionary[parentId] ?? [], - message, - ]; - } - return messageByParentIdDictionary; - }); + final messages = await db!.messageDao.getThreadMessages(cid); + final messageByParentIdDictionary = >{}; + for (final message in messages) { + final parentId = message.parentId!; + messageByParentIdDictionary[parentId] = [ + ...messageByParentIdDictionary[parentId] ?? [], + message, + ]; + } + + return messageByParentIdDictionary; } @override @@ -252,11 +236,9 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { }) { assert(_debugIsConnected, ''); _logger.info('getReplies'); - return _readProtected( - () => db!.messageDao.getThreadMessagesByParentId( - parentId, - options: options, - ), + return db!.messageDao.getThreadMessagesByParentId( + parentId, + options: options, ); } @@ -268,73 +250,45 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { Please use channelStateSort instead.''') List>? sort, List>? channelStateSort, PaginationParams? paginationParams, - }) { + }) async { assert(_debugIsConnected, ''); assert( sort == null || channelStateSort == null, 'sort and channelStateSort cannot be used together', ); _logger.info('getChannelStates'); - return _readProtected( - () async { - final channels = await db!.channelQueryDao.getChannels( - filter: filter, - sort: sort, - ); - final channelStates = await Future.wait( - channels.map((e) => getChannelStateByCid(e.cid)), - ); + final channels = await db!.channelQueryDao.getChannels( + filter: filter, + sort: sort, + ); - // Only sort the channel states if the channels are not already sorted. - if (sort == null) { - var chainedComparator = (ChannelState a, ChannelState b) { - final dateA = a.channel?.lastMessageAt ?? a.channel?.createdAt; - final dateB = b.channel?.lastMessageAt ?? b.channel?.createdAt; - - if (dateA == null && dateB == null) { - return 0; - } else if (dateA == null) { - return 1; - } else if (dateB == null) { - return -1; - } else { - return dateB.compareTo(dateA); - } - }; - - if (channelStateSort != null && channelStateSort.isNotEmpty) { - chainedComparator = (a, b) { - int result; - for (final comparator in channelStateSort - .map((it) => it.comparator) - .withNullifyer) { - try { - result = comparator(a, b); - } catch (e) { - result = 0; - } - if (result != 0) return result; - } - return 0; - }; - } - - channelStates.sort(chainedComparator); - } - - final offset = paginationParams?.offset; - if (offset != null && offset > 0 && channelStates.isNotEmpty) { - channelStates.removeRange(0, offset); - } - - if (paginationParams?.limit != null) { - return channelStates.take(paginationParams!.limit).toList(); - } - - return channelStates; - }, + final channelStates = await Future.wait( + channels.map((e) => getChannelStateByCid(e.cid)), ); + + // Only sort the channel states if the channels are not already sorted. + if (sort == null) { + var comparator = _defaultChannelStateComparator; + if (channelStateSort != null && channelStateSort.isNotEmpty) { + comparator = _combineComparators( + channelStateSort.map((it) => it.comparator).withNullifyer, + ); + } + + channelStates.sort(comparator); + } + + final offset = paginationParams?.offset; + if (offset != null && offset > 0 && channelStates.isNotEmpty) { + channelStates.removeRange(0, offset); + } + + if (paginationParams?.limit != null) { + return channelStates.take(paginationParams!.limit).toList(); + } + + return channelStates; } @override @@ -345,12 +299,10 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { }) { assert(_debugIsConnected, ''); _logger.info('updateChannelQueries'); - return _readProtected( - () => db!.channelQueryDao.updateChannelQueries( - filter, - cids, - clearQueryCache: clearQueryCache, - ), + return db!.channelQueryDao.updateChannelQueries( + filter, + cids, + clearQueryCache: clearQueryCache, ); } @@ -358,60 +310,56 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { Future updateChannels(List channels) { assert(_debugIsConnected, ''); _logger.info('updateChannels'); - return _readProtected(() => db!.channelDao.updateChannels(channels)); + return db!.channelDao.updateChannels(channels); } @override Future bulkUpdateMembers(Map?> members) { assert(_debugIsConnected, ''); _logger.info('bulkUpdateMembers'); - return _readProtected(() => db!.memberDao.bulkUpdateMembers(members)); + return db!.memberDao.bulkUpdateMembers(members); } @override Future bulkUpdateMessages(Map?> messages) { assert(_debugIsConnected, ''); _logger.info('bulkUpdateMessages'); - return _readProtected(() => db!.messageDao.bulkUpdateMessages(messages)); + return db!.messageDao.bulkUpdateMessages(messages); } @override Future bulkUpdatePinnedMessages(Map?> messages) { assert(_debugIsConnected, ''); _logger.info('bulkUpdatePinnedMessages'); - return _readProtected( - () => db!.pinnedMessageDao.bulkUpdateMessages(messages), - ); + return db!.pinnedMessageDao.bulkUpdateMessages(messages); } @override Future updatePinnedMessageReactions(List reactions) { assert(_debugIsConnected, ''); _logger.info('updatePinnedMessageReactions'); - return _readProtected( - () => db!.pinnedMessageReactionDao.updateReactions(reactions), - ); + return db!.pinnedMessageReactionDao.updateReactions(reactions); } @override Future updateReactions(List reactions) { assert(_debugIsConnected, ''); _logger.info('updateReactions'); - return _readProtected(() => db!.reactionDao.updateReactions(reactions)); + return db!.reactionDao.updateReactions(reactions); } @override Future bulkUpdateReads(Map?> reads) { assert(_debugIsConnected, ''); _logger.info('bulkUpdateReads'); - return _readProtected(() => db!.readDao.bulkUpdateReads(reads)); + return db!.readDao.bulkUpdateReads(reads); } @override Future updateUsers(List users) { assert(_debugIsConnected, ''); _logger.info('updateUsers'); - return _readProtected(() => db!.userDao.updateUsers(users)); + return db!.userDao.updateUsers(users); } @override @@ -420,53 +368,83 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { ) { assert(_debugIsConnected, ''); _logger.info('deletePinnedMessageReactionsByMessageId'); - return _readProtected( - () => - db!.pinnedMessageReactionDao.deleteReactionsByMessageIds(messageIds), - ); + return db!.pinnedMessageReactionDao.deleteReactionsByMessageIds(messageIds); } @override Future deleteReactionsByMessageId(List messageIds) { assert(_debugIsConnected, ''); _logger.info('deleteReactionsByMessageId'); - return _readProtected( - () => db!.reactionDao.deleteReactionsByMessageIds(messageIds), - ); + return db!.reactionDao.deleteReactionsByMessageIds(messageIds); } @override Future deleteMembersByCids(List cids) { assert(_debugIsConnected, ''); _logger.info('deleteMembersByCids'); - return _readProtected(() => db!.memberDao.deleteMemberByCids(cids)); + return db!.memberDao.deleteMemberByCids(cids); + } + + @override + Future updateChannelThreads( + String cid, + Map> threads, + ) { + assert(_debugIsConnected, ''); + _logger.info('updateChannelThreads'); + return db!.transaction(() => super.updateChannelThreads(cid, threads)); } @override Future updateChannelStates(List channelStates) { assert(_debugIsConnected, ''); _logger.info('updateChannelStates'); - return _readProtected( - () async => db!.transaction( - () async { - await super.updateChannelStates(channelStates); - }, - ), - ); + return db!.transaction(() => super.updateChannelStates(channelStates)); } @override - Future disconnect({bool flush = false}) async => - _mutex.protectWrite(() async { - _logger.info('disconnect'); - if (db != null) { - _logger.info('Disconnecting'); - if (flush) { - _logger.info('Flushing'); - await db!.flush(); - } - await db!.disconnect(); - db = null; - } - }); + Future disconnect({bool flush = false}) async { + _logger.info('disconnect'); + if (db != null) { + _logger.info('Disconnecting'); + if (flush) { + _logger.info('Flushing'); + await db!.flush(); + } + await db!.disconnect(); + db = null; + } + } +} + +// Creates a new combined [Comparator] which sorts items +// by the given [comparators]. +Comparator _combineComparators(Iterable> comparators) { + return (T a, T b) { + for (final comparator in comparators) { + try { + final result = comparator(a, b); + if (result != 0) return result; + } catch (e) { + // If the comparator throws an exception, we ignore it and + // continue with the next comparator. + continue; + } + } + return 0; + }; +} + +// The default [Comparator] used to sort [ChannelState]s. +int _defaultChannelStateComparator(ChannelState a, ChannelState b) { + final dateA = a.channel?.lastMessageAt ?? a.channel?.createdAt; + final dateB = b.channel?.lastMessageAt ?? b.channel?.createdAt; + + if (dateA == null && dateB == null) return 0; + if (dateA == null) return 1; + if (dateB == null) { + return -1; + } else { + return dateB.compareTo(dateA); + } } diff --git a/packages/stream_chat_persistence/pubspec.yaml b/packages/stream_chat_persistence/pubspec.yaml index 232753961..341faf1fe 100644 --- a/packages/stream_chat_persistence/pubspec.yaml +++ b/packages/stream_chat_persistence/pubspec.yaml @@ -1,12 +1,12 @@ name: stream_chat_persistence homepage: https://github.com/GetStream/stream-chat-flutter description: Official Stream Chat Persistence library. Build your own chat experience using Dart and Flutter. -version: 6.0.0 +version: 6.1.0 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues environment: - sdk: '>=2.17.0 <3.0.0' + sdk: '>=2.17.0 <4.0.0' flutter: ">=1.17.0" dependencies: @@ -15,11 +15,10 @@ dependencies: sdk: flutter logging: ^1.0.1 meta: ^1.8.0 - mutex: ^3.0.0 path: ^1.8.2 path_provider: ^2.0.1 sqlite3_flutter_libs: ^0.5.0 - stream_chat: ^6.0.0 + stream_chat: ^6.1.0 dev_dependencies: build_runner: ^2.3.3 diff --git a/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart b/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart index 8d7ab8be2..c99668fd9 100644 --- a/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart +++ b/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart @@ -55,6 +55,15 @@ void main() { expect(client.db, isNull); }); + test('client function throws stateError if db is not yet connected', () { + final client = StreamChatPersistenceClient(logLevel: Level.ALL); + expect( + // Running a function that requires db connection. + () => client.getReplies('testParentId'), + throwsA(isA()), + ); + }); + group('client functions', () { const userId = 'testUserId'; final mockDatabase = MockChatDatabase(); @@ -66,6 +75,10 @@ void main() { await client.connect(userId, databaseProvider: _mockDatabaseProvider); }); + tearDown(() async { + await client.disconnect(); + }); + test('getReplies', () async { const parentId = 'testParentId'; final replies = List.generate(3, (index) => Message(id: 'testId$index'));