Merge pull request #1538 from GetStream/release/v6.1.0

This commit is contained in:
Sahil Kumar
2023-05-12 18:20:53 +05:30
committed by GitHub
71 changed files with 3576 additions and 1203 deletions
@@ -8,6 +8,11 @@ on:
pull_request: pull_request:
paths: paths:
- 'packages/**' - 'packages/**'
types:
- opened
- reopened
- synchronize
- ready_for_review
push: push:
branches: branches:
- master - master
+42
View File
@@ -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 ## 6.0.0
🐞 Fixed 🐞 Fixed
@@ -746,6 +746,7 @@ class Channel {
state!.deleteMessage( state!.deleteMessage(
message.copyWith( message.copyWith(
type: 'deleted', type: 'deleted',
deletedAt: message.deletedAt ?? DateTime.now(),
status: MessageSendingStatus.sent, status: MessageSendingStatus.sent,
), ),
hardDelete: hardDelete, hardDelete: hardDelete,
@@ -997,26 +998,24 @@ class Channel {
) async { ) async {
final type = reaction.type; final type = reaction.type;
final reactionCounts = {...message.reactionCounts ?? <String, int>{}}; final reactionCounts = {...?message.reactionCounts};
if (reactionCounts.containsKey(type)) { if (reactionCounts.containsKey(type)) {
reactionCounts.update(type, (value) => value - 1); reactionCounts.update(type, (value) => value - 1);
} }
final reactionScores = {...message.reactionScores ?? <String, int>{}}; final reactionScores = {...?message.reactionScores};
if (reactionScores.containsKey(type)) { if (reactionScores.containsKey(type)) {
reactionScores.update(type, (value) => value - 1); reactionScores.update(type, (value) => value - 1);
} }
final latestReactions = [...message.latestReactions ?? <Reaction>[]] final latestReactions = [...?message.latestReactions]..removeWhere((r) =>
..removeWhere((r) => r.userId == reaction.userId &&
r.userId == reaction.userId && r.type == reaction.type &&
r.type == reaction.type && r.messageId == reaction.messageId);
r.messageId == reaction.messageId);
final ownReactions = message.ownReactions final ownReactions = [...?message.ownReactions]..removeWhere((r) =>
?..removeWhere((r) => r.userId == reaction.userId &&
r.userId == reaction.userId && r.type == reaction.type &&
r.type == reaction.type && r.messageId == reaction.messageId);
r.messageId == reaction.messageId);
final newMessage = message.copyWith( final newMessage = message.copyWith(
reactionCounts: reactionCounts..removeWhere((_, value) => value == 0), reactionCounts: reactionCounts..removeWhere((_, value) => value == 0),
@@ -1485,19 +1484,11 @@ class Channel {
/// will be removed for the user. /// will be removed for the user.
Future<EmptyResponse> hide({bool clearHistory = false}) async { Future<EmptyResponse> hide({bool clearHistory = false}) async {
_checkInitialized(); _checkInitialized();
final response = await _client.hideChannel( return _client.hideChannel(
id!, id!,
type, type,
clearHistory: clearHistory, 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. /// Removes the hidden status for the channel.
@@ -1938,11 +1929,9 @@ class ChannelClientState {
void _listenMessageDeleted() { void _listenMessageDeleted() {
_subscriptions.add(_channel.on(EventType.messageDeleted).listen((event) { _subscriptions.add(_channel.on(EventType.messageDeleted).listen((event) {
final message = event.message!; final message = event.message!;
if (event.hardDelete == true) { final hardDelete = event.hardDelete ?? false;
removeMessage(message);
} else { deleteMessage(message, hardDelete: hardDelete);
updateMessage(message);
}
})); }));
} }
@@ -1967,18 +1956,35 @@ class ChannelClientState {
/// Updates the [message] in the state if it exists. Adds it otherwise. /// Updates the [message] in the state if it exists. Adds it otherwise.
void updateMessage(Message message) { void updateMessage(Message message) {
// Regular messages, which are shown in channel.
if (message.parentId == null || message.showInChannel == true) { if (message.parentId == null || message.showInChannel == true) {
final newMessages = [...messages]; var newMessages = [...messages];
final oldIndex = newMessages.indexWhere((m) => m.id == message.id); final oldIndex = newMessages.indexWhere((m) => m.id == message.id);
if (oldIndex != -1) { 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) { if (message.quotedMessageId != null && message.quotedMessage == null) {
final oldMessage = newMessages[oldIndex]; final oldMessage = newMessages[oldIndex];
m = message.copyWith( updatedMessage = updatedMessage.copyWith(
quotedMessage: oldMessage.quotedMessage, 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 { } else {
newMessages.add(message); newMessages.add(message);
} }
@@ -2007,6 +2013,7 @@ class ChannelClientState {
); );
} }
// Thread messages, which are shown in thread page.
if (message.parentId != null) { if (message.parentId != null) {
updateThreadInfo(message.parentId!, [message]); updateThreadInfo(message.parentId!, [message]);
} }
@@ -2036,9 +2043,22 @@ class ChannelClientState {
} }
// Remove regular message, thread message shown in channel // 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( _channelState = _channelState.copyWith(
messages: allMessages..removeWhere((e) => e.id == message.id), messages: updatedMessages,
); );
} }
+39 -33
View File
@@ -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/connection_status.dart';
import 'package:stream_chat/src/ws/websocket.dart'; import 'package:stream_chat/src/ws/websocket.dart';
import 'package:stream_chat/version.dart'; import 'package:stream_chat/version.dart';
import 'package:synchronized/extension.dart';
/// Handler function used for logging records. Function requires a single /// Handler function used for logging records. Function requires a single
/// [LogRecord] as the only parameter. /// [LogRecord] as the only parameter.
@@ -72,6 +73,7 @@ class StreamChatClient {
WebSocket? ws, WebSocket? ws,
AttachmentFileUploaderProvider attachmentFileUploaderProvider = AttachmentFileUploaderProvider attachmentFileUploaderProvider =
StreamAttachmentFileUploader.new, StreamAttachmentFileUploader.new,
Iterable<Interceptor>? chatApiInterceptors,
}) { }) {
logger.info('Initiating new StreamChatClient'); logger.info('Initiating new StreamChatClient');
@@ -90,6 +92,7 @@ class StreamChatClient {
connectionIdManager: _connectionIdManager, connectionIdManager: _connectionIdManager,
attachmentFileUploaderProvider: attachmentFileUploaderProvider, attachmentFileUploaderProvider: attachmentFileUploaderProvider,
logger: detachedLogger('🕸️'), logger: detachedLogger('🕸️'),
interceptors: chatApiInterceptors,
); );
_ws = ws ?? _ws = ws ??
@@ -488,37 +491,40 @@ class StreamChatClient {
/// Get the events missed while offline to sync the offline storage /// Get the events missed while offline to sync the offline storage
/// Will automatically fetch [cids] and [lastSyncedAt] if [persistenceEnabled] /// Will automatically fetch [cids] and [lastSyncedAt] if [persistenceEnabled]
Future<void> sync({List<String>? cids, DateTime? lastSyncAt}) async { Future<void> sync({List<String>? cids, DateTime? lastSyncAt}) {
cids ??= await _chatPersistenceClient?.getChannelCids(); return synchronized(() async {
if (cids == null || cids.isEmpty) { final channels = cids ?? await _chatPersistenceClient?.getChannelCids();
return; if (channels == null || channels.isEmpty) {
} return;
lastSyncAt ??= await _chatPersistenceClient?.getLastSyncAt();
if (lastSyncAt == 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');
}
handleEvent(event);
} }
final now = DateTime.now(); final syncAt =
_lastSyncedAt = now; lastSyncAt ?? await _chatPersistenceClient?.getLastSyncAt();
_chatPersistenceClient?.updateLastSyncAt(now); if (syncAt == null) {
} catch (e, stk) { return;
logger.severe('Error during sync', e, stk); }
}
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);
}
final now = DateTime.now();
_lastSyncedAt = now;
_chatPersistenceClient?.updateLastSyncAt(now);
} catch (e, stk) {
logger.severe('Error during sync', e, stk);
}
});
} }
final _queryChannelsStreams = <String, Future<List<Channel>>>{}; final _queryChannelsStreams = <String, Future<List<Channel>>>{};
@@ -1567,7 +1573,7 @@ class ClientState {
_client.on(EventType.channelHidden).listen((event) async { _client.on(EventType.channelHidden).listen((event) async {
final eventChannel = event.channel!; final eventChannel = event.channel!;
await _client.chatPersistenceClient?.deleteChannels([eventChannel.cid]); 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 { .listen((Event event) async {
final eventChannel = event.channel!; final eventChannel = event.channel!;
await _client.chatPersistenceClient?.deleteChannels([eventChannel.cid]); await _client.chatPersistenceClient?.deleteChannels([eventChannel.cid]);
channels[eventChannel.cid]?.dispose(); channels.remove(eventChannel.cid)?.dispose();
}), }),
); );
} }
@@ -1713,9 +1719,9 @@ class ClientState {
_unreadChannelsController.close(); _unreadChannelsController.close();
_totalUnreadCountController.close(); _totalUnreadCountController.close();
final channels = this.channels.values.toList(); final channels = [...this.channels.keys];
for (final channel in channels) { for (final channel in channels) {
channel.dispose(); this.channels.remove(channel)?.dispose();
} }
_channelsController.close(); _channelsController.close();
} }
@@ -1,3 +1,4 @@
import 'package:dio/dio.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:stream_chat/src/core/api/attachment_file_uploader.dart'; import 'package:stream_chat/src/core/api/attachment_file_uploader.dart';
import 'package:stream_chat/src/core/api/call_api.dart'; import 'package:stream_chat/src/core/api/call_api.dart';
@@ -26,6 +27,7 @@ class StreamChatApi {
AttachmentFileUploaderProvider attachmentFileUploaderProvider = AttachmentFileUploaderProvider attachmentFileUploaderProvider =
StreamAttachmentFileUploader.new, StreamAttachmentFileUploader.new,
Logger? logger, Logger? logger,
Iterable<Interceptor>? interceptors,
}) : _fileUploaderProvider = attachmentFileUploaderProvider, }) : _fileUploaderProvider = attachmentFileUploaderProvider,
_client = client ?? _client = client ??
StreamHttpClient( StreamHttpClient(
@@ -34,6 +36,7 @@ class StreamChatApi {
tokenManager: tokenManager, tokenManager: tokenManager,
connectionIdManager: connectionIdManager, connectionIdManager: connectionIdManager,
logger: logger, logger: logger,
interceptors: interceptors,
); );
final StreamHttpClient _client; final StreamHttpClient _client;
@@ -101,8 +101,7 @@ class LoggingInterceptor extends Interceptor {
options.data as Map?, options.data as Map?,
header: 'Body', header: 'Body',
); );
} } else if (data is FormData) {
if (data is FormData) {
final formDataMap = <String, dynamic>{} final formDataMap = <String, dynamic>{}
..addEntries(data.fields) ..addEntries(data.fields)
..addEntries(data.files); ..addEntries(data.files);
@@ -163,7 +162,7 @@ class LoggingInterceptor extends Interceptor {
_logPrintResponse(''); _logPrintResponse('');
_printResponse(_logPrintResponse, response); _printResponse(_logPrintResponse, response);
_logPrintResponse(''); _logPrintResponse('');
_logPrintResponse(''); _printLine(_logPrintResponse, '');
} }
super.onResponse(response, handler); super.onResponse(response, handler);
} }
@@ -25,6 +25,7 @@ class StreamHttpClient {
TokenManager? tokenManager, TokenManager? tokenManager,
ConnectionIdManager? connectionIdManager, ConnectionIdManager? connectionIdManager,
Logger? logger, Logger? logger,
Iterable<Interceptor>? interceptors,
}) : _options = options ?? const StreamHttpClientOptions(), }) : _options = options ?? const StreamHttpClientOptions(),
httpClient = dio ?? Dio() { httpClient = dio ?? Dio() {
httpClient httpClient
@@ -45,20 +46,25 @@ class StreamHttpClient {
if (tokenManager != null) AuthInterceptor(this, tokenManager), if (tokenManager != null) AuthInterceptor(this, tokenManager),
if (connectionIdManager != null) if (connectionIdManager != null)
ConnectionIdInterceptor(connectionIdManager), ConnectionIdInterceptor(connectionIdManager),
if (logger != null && logger.level != Level.OFF) ...interceptors ??
LoggingInterceptor( [
requestHeader: true, // Add a default logging interceptor if no interceptors are
logPrint: (step, message) { // provided.
switch (step) { if (logger != null && logger.level != Level.OFF)
case InterceptStep.request: LoggingInterceptor(
return logger.info(message); requestHeader: true,
case InterceptStep.response: logPrint: (step, message) {
return logger.info(message); switch (step) {
case InterceptStep.error: case InterceptStep.request:
return logger.severe(message); return logger.info(message);
} case InterceptStep.response:
}, return logger.info(message);
), case InterceptStep.error:
return logger.severe(message);
}
},
),
],
]); ]);
} }
@@ -1,5 +1,4 @@
import 'package:json_annotation/json_annotation.dart'; 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/src/core/util/serializer.dart';
import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat/stream_chat.dart';
@@ -3,6 +3,7 @@ library stream_chat;
export 'package:async/async.dart'; export 'package:async/async.dart';
export 'package:dio/src/cancel_token.dart'; export 'package:dio/src/cancel_token.dart';
export 'package:dio/src/dio_error.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/multipart_file.dart';
export 'package:dio/src/options.dart'; export 'package:dio/src/options.dart';
export 'package:dio/src/options.dart' show ProgressCallback; 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' show PushProvider;
export 'src/core/api/stream_chat_api.dart'; export 'src/core/api/stream_chat_api.dart';
export 'src/core/error/error.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/action.dart';
export 'src/core/models/attachment.dart'; export 'src/core/models/attachment.dart';
export 'src/core/models/attachment_file.dart'; export 'src/core/models/attachment_file.dart';
export 'src/core/models/channel_config.dart'; export 'src/core/models/channel_config.dart';
export 'src/core/models/channel_model.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/channel_state.dart';
export 'src/core/models/command.dart'; export 'src/core/models/command.dart';
export 'src/core/models/device.dart'; export 'src/core/models/device.dart';
+1 -1
View File
@@ -3,4 +3,4 @@ import 'package:stream_chat/src/client/client.dart';
/// Current package version /// Current package version
/// Used in [StreamChatClient] to build the `x-stream-client` header /// Used in [StreamChatClient] to build the `x-stream-client` header
// ignore: constant_identifier_names // ignore: constant_identifier_names
const PACKAGE_VERSION = '6.0.0'; const PACKAGE_VERSION = '6.1.0';
+3 -2
View File
@@ -1,12 +1,12 @@
name: stream_chat name: stream_chat
homepage: https://getstream.io/ homepage: https://getstream.io/
description: The official Dart client for Stream Chat, a service for building chat applications. 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 repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
environment: environment:
sdk: '>=2.17.0 <3.0.0' sdk: '>=2.17.0 <4.0.0'
dependencies: dependencies:
async: ^2.10.0 async: ^2.10.0
@@ -22,6 +22,7 @@ dependencies:
mime: ^1.0.4 mime: ^1.0.4
rate_limiter: ^1.0.0 rate_limiter: ^1.0.0
rxdart: ^0.27.7 rxdart: ^0.27.7
synchronized: ^3.0.0
uuid: ^3.0.7 uuid: ^3.0.7
web_socket_channel: ^2.3.0 web_socket_channel: ^2.3.0
@@ -86,41 +86,73 @@ void main() {
}, },
); );
test('loggingInterceptor should be added if logger is provided', () { group('loggingInterceptor', () {
const apiKey = 'api-key'; test('should be added if logger is provided', () {
final client = StreamHttpClient( const apiKey = 'api-key';
apiKey, final client = StreamHttpClient(
logger: Logger('test-logger'), apiKey,
); logger: Logger('test-logger'),
);
expect( expect(
client.httpClient.interceptors.whereType<LoggingInterceptor>().length, client.httpClient.interceptors.whereType<LoggingInterceptor>().length,
1, 1,
); );
}); });
test('loggingInterceptor should log requests', () async { test('should not be added if logger.level is OFF', () {
const apiKey = 'api-key'; const apiKey = 'api-key';
final logger = MockLogger(); final client = StreamHttpClient(
final client = StreamHttpClient(apiKey, logger: logger); apiKey,
logger: Logger.detached('test-logger')..level = Level.OFF,
);
try { expect(
await client.get('path'); client.httpClient.interceptors.whereType<LoggingInterceptor>().length,
} catch (_) {} 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 { expect(
const apiKey = 'api-key'; client.httpClient.interceptors.whereType<LoggingInterceptor>().length,
final logger = MockLogger(); 0,
final client = StreamHttpClient(apiKey, logger: logger); );
});
try { test('should log requests', () async {
await client.get('path'); const apiKey = 'api-key';
} catch (_) {} final logger = MockLogger();
final client = StreamHttpClient(apiKey, logger: logger);
verify(() => logger.severe(any())).called(greaterThan(0)); 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));
});
}); });
test('`.close` should close the dio client', () async { test('`.close` should close the dio client', () async {
@@ -1,5 +1,4 @@
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import 'package:stream_chat/src/core/models/channel_mute.dart';
import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat/stream_chat.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
+101 -13
View File
@@ -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 ## 6.0.0
🐞 Fixed 🐞 Fixed
- [#1456](https://github.com/GetStream/stream-chat-flutter/issues/1456) Fixed logic for showing that a message was read using sending indicator. - [[#1456]](https://github.com/GetStream/stream-chat-flutter/issues/1456) Fixed logic for showing that a message was
- [#1462](https://github.com/GetStream/stream-chat-flutter/issues/1462) Fixed support for iPad in the share button for images. read using sending indicator.
- [#1475](https://github.com/GetStream/stream-chat-flutter/issues/1475) Fixed typo to fix compilation. - [[#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 ✅ Added
- Now it is possible to customize the max lines of the title of a url attachment. Before it was always 1 line. - 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 `attachmentActionsModalBuilder` parameter to `StreamMessageWidget` that allows to
- Added `StreamMessageInput.sendMessageKeyPredicate` and `StreamMessageInput.clearQuotedMessageKeyPredicate` to customize the keys used to send and clear the quoted message. customize `AttachmentActionsModal`.
- Added `StreamMessageInput.sendMessageKeyPredicate` and `StreamMessageInput.clearQuotedMessageKeyPredicate` to
customize the keys used to send and clear the quoted message.
🔄 Changed 🔄 Changed
- Updated dependencies to resolvable versions. - Updated dependencies to resolvable versions.
🚀 Improved 🚀 Improved
-
- Improved draw of reaction options. [#1455](https://github.com/GetStream/stream-chat-flutter/pull/1455) - Improved draw of reaction options. [#1455](https://github.com/GetStream/stream-chat-flutter/pull/1455)
## 5.3.0 ## 5.3.0
@@ -27,17 +104,22 @@
- Updated `photo_manager` dependency to `^2.5.2` - Updated `photo_manager` dependency to `^2.5.2`
🐞 Fixed 🐞 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 a bug where the `AttachmentPickerBottomSheet` was not able to identify the mobile browser.
- Fixed uploading files on Windows - fixed temp file path. - Fixed uploading files on Windows - fixed temp file path.
✅ Added ✅ Added
- New `noPhotoOrVideoLabel` displayed when there is no files to choose. - New `noPhotoOrVideoLabel` displayed when there is no files to choose.
## 5.2.0 ## 5.2.0
✅ Added ✅ 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 🔄 Changed
@@ -47,14 +129,20 @@
- Updated `dart_vlc` dependency to `^0.4.0` - Updated `dart_vlc` dependency to `^0.4.0`
- Updated `file_picker` dependency to `^5.2.4` - Updated `file_picker` dependency to `^5.2.4`
- Deprecated `StreamMessageWidget.bottomRowBuilder` in favor of `StreamMessageWidget.bottomRowBuilderWithDefaultWidget`. - 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`. - Deprecated `StreamMessageWidget.usernameBuilder` in favor of `StreamMessageWidget.bottomRowBuilderWithDefaultWidget`.
🐞 Fixed 🐞 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`. - [[#1379]](https://github.com/GetStream/stream-chat-flutter/issues/1379) Fixed "Issues with photo attachments on web",
- [[1346]](https://github.com/GetStream/stream-chat-flutter/issues/1346) Fixed a render issue while uploading video on web. where the cached image attachment would not render while uploading.
- [[#1347]](https://github.com/GetStream/stream-chat-flutter/issues/1347) `onReply` not working in `AttachmentActionsModal` which is used by `StreamImageAttachment` and `StreamImageGroup`. - 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 ## 5.1.0
@@ -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_listener.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_notifier.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/scroll_view.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/wrapping.dart';
/// A list of widgets similar to [ListView], except scroll control /// A list of widgets similar to [ListView], except scroll control
/// and position reporting is based on index rather than pixel offset. /// and position reporting is based on index rather than pixel offset.
@@ -35,28 +36,20 @@ class PositionedList extends StatefulWidget {
this.alignment = 0, this.alignment = 0,
this.scrollDirection = Axis.vertical, this.scrollDirection = Axis.vertical,
this.reverse = false, this.reverse = false,
this.shrinkWrap = false,
this.physics, this.physics,
this.padding, this.padding,
this.cacheExtent, this.cacheExtent,
this.semanticChildCount, this.semanticChildCount,
this.findChildIndexCallback,
this.addSemanticIndexes = true, this.addSemanticIndexes = true,
this.addRepaintBoundaries = true, this.addRepaintBoundaries = true,
this.addAutomaticKeepAlives = true, this.addAutomaticKeepAlives = true,
this.keyboardDismissBehavior, this.findChildIndexCallback,
}) : assert((positionedIndex == 0) || (positionedIndex < itemCount), this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
'positionedIndex cannot be 0 and must be smaller than itemCount'); }) : assert(
(positionedIndex == 0) || (positionedIndex < itemCount),
/// Called to find the new index of a child based on its key in case of 'positionedIndex must be 0 or a value less than itemCount',
/// 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;
/// Number of items the [itemBuilder] can produce. /// Number of items the [itemBuilder] can produce.
final int itemCount; final int itemCount;
@@ -98,6 +91,15 @@ class PositionedList extends StatefulWidget {
/// See [ScrollView.reverse]. /// See [ScrollView.reverse].
final bool 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. /// How the scroll view should respond to user input.
/// ///
/// For example, determines how the scroll view continues to animate after the /// For example, determines how the scroll view continues to animate after the
@@ -132,9 +134,22 @@ class PositionedList extends StatefulWidget {
/// See [SliverChildBuilderDelegate.addAutomaticKeepAlives]. /// See [SliverChildBuilderDelegate.addAutomaticKeepAlives].
final bool addAutomaticKeepAlives; final bool addAutomaticKeepAlives;
/// [ScrollViewKeyboardDismissBehavior] the defines how this [PositionedList] will /// Called to find the new index of a child based on its key in case of reordering.
/// dismiss the keyboard automatically. ///
final ScrollViewKeyboardDismissBehavior? keyboardDismissBehavior; /// 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 @override
State<StatefulWidget> createState() => _PositionedListState(); State<StatefulWidget> createState() => _PositionedListState();
@@ -175,12 +190,13 @@ class _PositionedListState extends State<PositionedList> {
anchor: widget.alignment, anchor: widget.alignment,
center: _centerKey, center: _centerKey,
controller: scrollController, controller: scrollController,
keyboardDismissBehavior: widget.keyboardDismissBehavior,
scrollDirection: widget.scrollDirection, scrollDirection: widget.scrollDirection,
reverse: widget.reverse, reverse: widget.reverse,
cacheExtent: widget.cacheExtent, cacheExtent: widget.cacheExtent,
physics: widget.physics, physics: widget.physics,
shrinkWrap: widget.shrinkWrap,
semanticChildCount: widget.semanticChildCount ?? widget.itemCount, semanticChildCount: widget.semanticChildCount ?? widget.itemCount,
keyboardDismissBehavior: widget.keyboardDismissBehavior,
slivers: <Widget>[ slivers: <Widget>[
if (widget.positionedIndex > 0) if (widget.positionedIndex > 0)
SliverPadding( SliverPadding(
@@ -196,9 +212,9 @@ class _PositionedListState extends State<PositionedList> {
? widget.positionedIndex ? widget.positionedIndex
: widget.positionedIndex * 2, : widget.positionedIndex * 2,
addSemanticIndexes: false, addSemanticIndexes: false,
findChildIndexCallback: widget.findChildIndexCallback,
addRepaintBoundaries: widget.addRepaintBoundaries, addRepaintBoundaries: widget.addRepaintBoundaries,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives, addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
findChildIndexCallback: widget.findChildIndexCallback,
), ),
), ),
), ),
@@ -213,10 +229,10 @@ class _PositionedListState extends State<PositionedList> {
index + widget.positionedIndex * 2, index + widget.positionedIndex * 2,
), ),
childCount: widget.itemCount != 0 ? 1 : 0, childCount: widget.itemCount != 0 ? 1 : 0,
findChildIndexCallback: widget.findChildIndexCallback,
addSemanticIndexes: false, addSemanticIndexes: false,
addRepaintBoundaries: widget.addRepaintBoundaries, addRepaintBoundaries: widget.addRepaintBoundaries,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives, addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
findChildIndexCallback: widget.findChildIndexCallback,
), ),
), ),
), ),
@@ -234,10 +250,10 @@ class _PositionedListState extends State<PositionedList> {
childCount: widget.separatorBuilder == null childCount: widget.separatorBuilder == null
? widget.itemCount - widget.positionedIndex - 1 ? widget.itemCount - widget.positionedIndex - 1
: 2 * (widget.itemCount - widget.positionedIndex - 1), : 2 * (widget.itemCount - widget.positionedIndex - 1),
findChildIndexCallback: widget.findChildIndexCallback,
addSemanticIndexes: false, addSemanticIndexes: false,
addRepaintBoundaries: widget.addRepaintBoundaries, addRepaintBoundaries: widget.addRepaintBoundaries,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives, addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
findChildIndexCallback: widget.findChildIndexCallback,
), ),
), ),
), ),
@@ -319,25 +335,33 @@ class _PositionedListState extends State<PositionedList> {
if (!updateScheduled) { if (!updateScheduled) {
updateScheduled = true; updateScheduled = true;
SchedulerBinding.instance.addPostFrameCallback((_) { SchedulerBinding.instance.addPostFrameCallback((_) {
if (registeredElements.value == null) { final elements = registeredElements.value;
if (elements == null) {
updateScheduled = false; updateScheduled = false;
return; return;
} }
final positions = <ItemPosition>[]; final positions = <ItemPosition>[];
RenderViewport? viewport; RenderViewportBase? viewport;
for (final element in registeredElements.value!) { for (final element in elements) {
final box = element.renderObject as RenderBox?; final box = element.renderObject! as RenderBox;
viewport ??= RenderAbstractViewport.of(box) as RenderViewport?; viewport ??= RenderAbstractViewport.of(box) as RenderViewportBase?;
if (viewport == null || box == null) { var anchor = 0.0;
break; 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) { if (widget.scrollDirection == Axis.vertical) {
final reveal = viewport.getOffsetToReveal(box, 0).offset; final reveal = viewport!.getOffsetToReveal(box, 0).offset;
if (!reveal.isFinite) continue; if (!reveal.isFinite) continue;
final itemOffset = reveal - final itemOffset =
viewport.offset.pixels + reveal - viewport.offset.pixels + anchor * viewport.size.height;
viewport.anchor * viewport.size.height;
positions.add(ItemPosition( positions.add(ItemPosition(
index: key.index, index: key.index,
itemLeadingEdge: itemOffset.round() / itemLeadingEdge: itemOffset.round() /
@@ -348,6 +372,7 @@ class _PositionedListState extends State<PositionedList> {
} else { } else {
final itemOffset = final itemOffset =
box.localToGlobal(Offset.zero, ancestor: viewport).dx; box.localToGlobal(Offset.zero, ancestor: viewport).dx;
if (!itemOffset.isFinite) continue;
positions.add(ItemPosition( positions.add(ItemPosition(
index: key.index, index: key.index,
itemLeadingEdge: (widget.reverse itemLeadingEdge: (widget.reverse
@@ -5,13 +5,14 @@
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.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/viewport.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/wrapping.dart';
/// {@template custom_scroll_view} /// {@template unbounded_custom_scroll_view}
/// A version of [CustomScrollView] that does not constrict the extents /// A version of [CustomScrollView] that allows does not constrict the extents
/// to be within 0 and 1. See [CustomScrollView] for more information. /// to be within 0 and 1. See [CustomScrollView] for more information.
/// {@endtemplate} /// {@endtemplate}
class UnboundedCustomScrollView extends CustomScrollView { class UnboundedCustomScrollView extends CustomScrollView {
/// {@macro custom_scroll_view} /// {@macro unbounded_custom_scroll_view}
const UnboundedCustomScrollView({ const UnboundedCustomScrollView({
super.key, super.key,
super.scrollDirection, super.scrollDirection,
@@ -19,19 +20,19 @@ class UnboundedCustomScrollView extends CustomScrollView {
super.controller, super.controller,
super.primary, super.primary,
super.physics, super.physics,
super.shrinkWrap, bool shrinkWrap = false,
super.center, super.center,
double anchor = 0.0, double anchor = 0.0,
super.cacheExtent, super.cacheExtent,
super.slivers, super.slivers,
super.semanticChildCount, super.semanticChildCount,
super.dragStartBehavior, super.dragStartBehavior,
ScrollViewKeyboardDismissBehavior? keyboardDismissBehavior, super.keyboardDismissBehavior,
}) : _anchor = anchor, }) : _shrinkWrap = shrinkWrap,
super( _anchor = anchor,
keyboardDismissBehavior: keyboardDismissBehavior ?? super(shrinkWrap: false);
ScrollViewKeyboardDismissBehavior.manual,
); final bool _shrinkWrap;
// [CustomScrollView] enforces constraints on [CustomScrollView.anchor], so // [CustomScrollView] enforces constraints on [CustomScrollView.anchor], so
// we need our own version. // we need our own version.
@@ -49,11 +50,14 @@ class UnboundedCustomScrollView extends CustomScrollView {
AxisDirection axisDirection, AxisDirection axisDirection,
List<Widget> slivers, List<Widget> slivers,
) { ) {
if (shrinkWrap) { if (_shrinkWrap) {
return ShrinkWrappingViewport( return CustomShrinkWrappingViewport(
axisDirection: axisDirection, axisDirection: axisDirection,
offset: offset, offset: offset,
slivers: slivers, slivers: slivers,
cacheExtent: cacheExtent,
center: center,
anchor: anchor,
); );
} }
return UnboundedViewport( return UnboundedViewport(
@@ -37,6 +37,7 @@ class ScrollablePositionedList extends StatefulWidget {
required this.itemBuilder, required this.itemBuilder,
super.key, super.key,
this.itemScrollController, this.itemScrollController,
this.shrinkWrap = false,
ItemPositionsListener? itemPositionsListener, ItemPositionsListener? itemPositionsListener,
this.initialScrollIndex = 0, this.initialScrollIndex = 0,
this.initialAlignment = 0, this.initialAlignment = 0,
@@ -50,7 +51,7 @@ class ScrollablePositionedList extends StatefulWidget {
this.addRepaintBoundaries = true, this.addRepaintBoundaries = true,
this.minCacheExtent, this.minCacheExtent,
this.findChildIndexCallback, this.findChildIndexCallback,
this.keyboardDismissBehavior, this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
}) : itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?, }) : itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?,
separatorBuilder = null; separatorBuilder = null;
@@ -61,6 +62,7 @@ class ScrollablePositionedList extends StatefulWidget {
required this.itemBuilder, required this.itemBuilder,
required IndexedWidgetBuilder this.separatorBuilder, required IndexedWidgetBuilder this.separatorBuilder,
super.key, super.key,
this.shrinkWrap = false,
this.itemScrollController, this.itemScrollController,
ItemPositionsListener? itemPositionsListener, ItemPositionsListener? itemPositionsListener,
this.initialScrollIndex = 0, this.initialScrollIndex = 0,
@@ -75,24 +77,9 @@ class ScrollablePositionedList extends StatefulWidget {
this.addRepaintBoundaries = true, this.addRepaintBoundaries = true,
this.minCacheExtent, this.minCacheExtent,
this.findChildIndexCallback, this.findChildIndexCallback,
this.keyboardDismissBehavior, this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
}) : itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?; }) : 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. /// Number of items the [itemBuilder] can produce.
final int itemCount; final int itemCount;
@@ -131,6 +118,15 @@ class ScrollablePositionedList extends StatefulWidget {
/// See [ScrollView.reverse]. /// See [ScrollView.reverse].
final bool 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. /// How the scroll view should respond to user input.
/// ///
/// For example, determines how the scroll view continues to animate after the /// For example, determines how the scroll view continues to animate after the
@@ -171,6 +167,23 @@ class ScrollablePositionedList extends StatefulWidget {
/// cache extent. /// cache extent.
final double? minCacheExtent; 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 @override
State<StatefulWidget> createState() => _ScrollablePositionedListState(); State<StatefulWidget> createState() => _ScrollablePositionedListState();
} }
@@ -233,11 +246,15 @@ class ItemScrollController {
Curve curve = Curves.linear, Curve curve = Curves.linear,
List<double> opacityAnimationWeights = const [40, 20, 40], List<double> opacityAnimationWeights = const [40, 20, 40],
}) { }) {
assert(_scrollableListState != null, '_scrollableListState cannot be null'); assert(
assert(opacityAnimationWeights.length == 3, _scrollableListState != null,
'opacityAnimationWeights.length is not equal to 3'); '''ScrollController must be attached to a ScrollablePositionedList to scroll.''',
assert(duration > Duration.zero, );
'duration needs to be bigger than Duration.zero'); assert(
opacityAnimationWeights.length == 3,
'opacityAnimationWeights must have exactly three elements.',
);
assert(duration > Duration.zero, 'Duration must be greater than zero.');
return _scrollableListState!._scrollTo( return _scrollableListState!._scrollTo(
index: index, index: index,
alignment: alignment, alignment: alignment,
@@ -249,7 +266,9 @@ class ItemScrollController {
void _attach(_ScrollablePositionedListState scrollableListState) { void _attach(_ScrollablePositionedListState scrollableListState) {
assert( assert(
_scrollableListState == null, '_scrollableListState needs to be null'); _scrollableListState == null,
'''ScrollController must not be attached to multiple ScrollablePositionedLists.''',
);
_scrollableListState = scrollableListState; _scrollableListState = scrollableListState;
} }
@@ -273,11 +292,12 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList>
bool _isTransitioning = false; bool _isTransitioning = false;
AnimationController? _animationController;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final ItemPosition? initialPosition = final initialPosition = PageStorage.of(context).readState(context);
PageStorage.of(context).readState(context);
primary primary
..target = initialPosition?.index ?? widget.initialScrollIndex ..target = initialPosition?.index ?? widget.initialScrollIndex
..alignment = initialPosition?.itemLeadingEdge ?? widget.initialAlignment; ..alignment = initialPosition?.itemLeadingEdge ?? widget.initialAlignment;
@@ -301,6 +321,7 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList>
.removeListener(_updatePositions); .removeListener(_updatePositions);
secondary.itemPositionsNotifier.itemPositions secondary.itemPositionsNotifier.itemPositions
.removeListener(_updatePositions); .removeListener(_updatePositions);
_animationController?.dispose();
super.dispose(); super.dispose();
} }
@@ -329,84 +350,90 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList>
} }
@override @override
Widget build(BuildContext context) => LayoutBuilder( Widget build(BuildContext context) {
builder: (context, constraints) { return LayoutBuilder(
final cacheExtent = _cacheExtent(constraints); builder: (context, constraints) {
return GestureDetector( final cacheExtent = _cacheExtent(constraints);
onPanDown: (_) => _stopScroll(canceled: true), return GestureDetector(
excludeFromSemantics: true, onPanDown: (_) => _stopScroll(canceled: true),
child: Stack( excludeFromSemantics: true,
children: <Widget>[ child: Stack(
children: <Widget>[
PostMountCallback(
key: primary.key,
callback: startAnimationCallback,
child: FadeTransition(
opacity: ReverseAnimation(opacity),
child: NotificationListener<ScrollNotification>(
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( PostMountCallback(
key: primary.key, key: secondary.key,
callback: startAnimationCallback, callback: startAnimationCallback,
child: FadeTransition( child: FadeTransition(
opacity: ReverseAnimation(opacity), opacity: opacity,
child: NotificationListener<ScrollNotification>( child: NotificationListener<ScrollNotification>(
onNotification: (_) => _isTransitioning, onNotification: (_) => false,
child: PositionedList( child: PositionedList(
keyboardDismissBehavior: widget.keyboardDismissBehavior,
itemBuilder: widget.itemBuilder, itemBuilder: widget.itemBuilder,
separatorBuilder: widget.separatorBuilder, separatorBuilder: widget.separatorBuilder,
itemCount: widget.itemCount, itemCount: widget.itemCount,
positionedIndex: primary.target, itemPositionsNotifier: secondary.itemPositionsNotifier,
controller: primary.scrollController, positionedIndex: secondary.target,
itemPositionsNotifier: primary.itemPositionsNotifier, controller: secondary.scrollController,
scrollDirection: widget.scrollDirection, scrollDirection: widget.scrollDirection,
reverse: widget.reverse, reverse: widget.reverse,
cacheExtent: cacheExtent, cacheExtent: cacheExtent,
alignment: primary.alignment, alignment: secondary.alignment,
physics: widget.physics, physics: widget.physics,
shrinkWrap: widget.shrinkWrap,
addSemanticIndexes: widget.addSemanticIndexes, addSemanticIndexes: widget.addSemanticIndexes,
semanticChildCount: widget.semanticChildCount, semanticChildCount: widget.semanticChildCount,
padding: widget.padding, padding: widget.padding,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives, addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
addRepaintBoundaries: widget.addRepaintBoundaries, addRepaintBoundaries: widget.addRepaintBoundaries,
findChildIndexCallback: widget.findChildIndexCallback, findChildIndexCallback: widget.findChildIndexCallback,
keyboardDismissBehavior: widget.keyboardDismissBehavior,
), ),
), ),
), ),
), ),
if (_isTransitioning) ],
PostMountCallback( ),
key: secondary.key, );
callback: startAnimationCallback, },
child: FadeTransition( );
opacity: opacity, }
child: NotificationListener<ScrollNotification>(
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( double _cacheExtent(BoxConstraints constraints) => max(
constraints.maxHeight * _screenScrollCount, (widget.scrollDirection == Axis.vertical
? constraints.maxHeight
: constraints.maxWidth) *
_screenScrollCount,
widget.minCacheExtent ?? 0, widget.minCacheExtent ?? 0,
); );
@@ -434,16 +461,19 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList>
index = widget.itemCount - 1; index = widget.itemCount - 1;
} }
if (_isTransitioning) { if (_isTransitioning) {
final scrollCompleter = Completer<void>();
_stopScroll(canceled: true); _stopScroll(canceled: true);
SchedulerBinding.instance.addPostFrameCallback((_) { SchedulerBinding.instance.addPostFrameCallback((_) async {
_startScroll( await _startScroll(
index: index, index: index,
alignment: alignment, alignment: alignment,
duration: duration, duration: duration,
curve: curve, curve: curve,
opacityAnimationWeights: opacityAnimationWeights, opacityAnimationWeights: opacityAnimationWeights,
); );
scrollCompleter.complete();
}); });
await scrollCompleter.future;
} else { } else {
await _startScroll( await _startScroll(
index: index, index: index,
@@ -486,10 +516,11 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList>
startAnimationCallback = () { startAnimationCallback = () {
SchedulerBinding.instance.addPostFrameCallback((_) { SchedulerBinding.instance.addPostFrameCallback((_) {
startAnimationCallback = () {}; startAnimationCallback = () {};
_animationController?.dispose();
opacity.parent = _opacityAnimation(opacityAnimationWeights).animate( _animationController =
AnimationController(vsync: this, duration: duration)..forward(), AnimationController(vsync: this, duration: duration)..forward();
); opacity.parent = _opacityAnimation(opacityAnimationWeights)
.animate(_animationController!);
secondary.scrollController.jumpTo(-direction * secondary.scrollController.jumpTo(-direction *
(_screenScrollCount * (_screenScrollCount *
primary.scrollController.position.viewportDimension - primary.scrollController.position.viewportDimension -
@@ -532,17 +563,19 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList>
} }
} }
setState(() { if (mounted) {
if (opacity.value >= 0.5) { setState(() {
// Secondary [ListView] is more visible than the primary; make it the if (opacity.value >= 0.5) {
// new primary. // Secondary [ListView] is more visible than the primary; make it the
final temp = primary; // new primary.
primary = secondary; final temp = primary;
secondary = temp; primary = secondary;
} secondary = temp;
_isTransitioning = false; }
opacity.parent = const AlwaysStoppedAnimation<double>(0); _isTransitioning = false;
}); opacity.parent = const AlwaysStoppedAnimation<double>(0);
});
}
} }
Animatable<double> _opacityAnimation(List<double> opacityAnimationWeights) { Animatable<double> _opacityAnimation(List<double> opacityAnimationWeights) {
@@ -2,8 +2,6 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
// ignore_for_file: lines_longer_than_80_chars
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
@@ -15,7 +13,7 @@ import 'package:flutter/widgets.dart';
/// Version of [Viewport] with some modifications to how extents are /// Version of [Viewport] with some modifications to how extents are
/// computed to allow scroll extents outside 0 to 1. See [Viewport] /// computed to allow scroll extents outside 0 to 1. See [Viewport]
/// for more information. /// for more information.
/// description /// {@endtemplate}
class UnboundedViewport extends Viewport { class UnboundedViewport extends Viewport {
/// {@macro unbounded_viewport} /// {@macro unbounded_viewport}
UnboundedViewport({ UnboundedViewport({
@@ -37,15 +35,16 @@ class UnboundedViewport extends Viewport {
double get anchor => _anchor; double get anchor => _anchor;
@override @override
RenderViewport createRenderObject(BuildContext context) => RenderViewport createRenderObject(BuildContext context) {
UnboundedRenderViewport( return UnboundedRenderViewport(
axisDirection: axisDirection, axisDirection: axisDirection,
crossAxisDirection: crossAxisDirection ?? crossAxisDirection: crossAxisDirection ??
Viewport.getDefaultCrossAxisDirection(context, axisDirection), Viewport.getDefaultCrossAxisDirection(context, axisDirection),
anchor: anchor, anchor: anchor,
offset: offset, offset: offset,
cacheExtent: cacheExtent, cacheExtent: cacheExtent,
); );
}
} }
/// A render object that is bigger on the inside. /// A render object that is bigger on the inside.
@@ -137,14 +136,20 @@ class UnboundedRenderViewport extends RenderViewport {
@override @override
void performLayout() { void performLayout() {
if (center == null) { 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; _minScrollExtent = 0.0;
_maxScrollExtent = 0.0; _maxScrollExtent = 0.0;
_hasVisualOverflow = false; _hasVisualOverflow = false;
offset.applyContentDimensions(0, 0); offset.applyContentDimensions(0, 0);
return; 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 mainAxisExtent;
late double crossAxisExtent; late double crossAxisExtent;
@@ -186,7 +191,7 @@ class UnboundedRenderViewport extends RenderViewport {
} while (count < _maxLayoutCycles); } while (count < _maxLayoutCycles);
assert(() { assert(() {
if (count >= _maxLayoutCycles) { if (count >= _maxLayoutCycles) {
assert(count != 1, 'count not equal to 1'); assert(count != 1);
throw FlutterError( throw FlutterError(
'A RenderViewport exceeded its maximum number of layout cycles.\n' 'A RenderViewport exceeded its maximum number of layout cycles.\n'
'RenderViewport render objects, during layout, can retry if either their ' 'RenderViewport render objects, during layout, can retry if either their '
@@ -207,7 +212,7 @@ class UnboundedRenderViewport extends RenderViewport {
); );
} }
return true; return true;
}(), 'count needs to be bigger than _maxLayoutCycles'); }());
} }
double _attemptLayout( double _attemptLayout(
@@ -215,11 +220,11 @@ class UnboundedRenderViewport extends RenderViewport {
double crossAxisExtent, double crossAxisExtent,
double correctedOffset, double correctedOffset,
) { ) {
assert(!mainAxisExtent.isNaN, 'assert mainAxisExtent.isNaN'); assert(!mainAxisExtent.isNaN, 'The main axis extent cannot be NaN.');
assert(mainAxisExtent >= 0.0, 'assert mainAxisExtent >= 0.0'); assert(mainAxisExtent >= 0.0, 'The main axis extent cannot be negative.');
assert(crossAxisExtent.isFinite, 'assert crossAxisExtent.isFinite'); assert(crossAxisExtent.isFinite, 'The cross axis extent must be finite.');
assert(crossAxisExtent >= 0.0, 'assert crossAxisExtent >= 0.0'); assert(crossAxisExtent >= 0.0, 'The cross axis extent cannot be negative.');
assert(correctedOffset.isFinite, 'assert correctedOffset.isFinite'); assert(correctedOffset.isFinite, 'The corrected offset must be finite.');
_minScrollExtent = 0.0; _minScrollExtent = 0.0;
_maxScrollExtent = 0.0; _maxScrollExtent = 0.0;
_hasVisualOverflow = false; _hasVisualOverflow = false;
File diff suppressed because it is too large Load Diff
@@ -36,8 +36,6 @@ class StreamUrlAttachment extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final chatThemeData = StreamChatTheme.of(context);
return ConstrainedBox( return ConstrainedBox(
constraints: const BoxConstraints( constraints: const BoxConstraints(
maxWidth: 400, maxWidth: 400,
@@ -79,7 +77,7 @@ class StreamUrlAttachment extends StatelessWidget {
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.only(
topRight: Radius.circular(16), topRight: Radius.circular(16),
), ),
color: messageTheme.linkBackgroundColor, color: messageTheme.urlAttachmentBackgroundColor,
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
@@ -89,9 +87,7 @@ class StreamUrlAttachment extends StatelessWidget {
), ),
child: Text( child: Text(
hostDisplayName, hostDisplayName,
style: chatThemeData.textTheme.bodyBold.copyWith( style: messageTheme.urlAttachmentHostStyle,
color: chatThemeData.colorTheme.accentPrimary,
),
), ),
), ),
), ),
@@ -109,14 +105,12 @@ class StreamUrlAttachment extends StatelessWidget {
urlAttachment.title!.trim(), urlAttachment.title!.trim(),
maxLines: messageTheme.urlAttachmentTitleMaxLine ?? 1, maxLines: messageTheme.urlAttachmentTitleMaxLine ?? 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: chatThemeData.textTheme.body style: messageTheme.urlAttachmentTitleStyle,
.copyWith(fontWeight: FontWeight.w700),
), ),
if (urlAttachment.text != null) if (urlAttachment.text != null)
Text( Text(
urlAttachment.text!, urlAttachment.text!,
style: chatThemeData.textTheme.body style: messageTheme.urlAttachmentTextStyle,
.copyWith(fontWeight: FontWeight.w400),
), ),
], ],
), ),
@@ -22,7 +22,6 @@ class StreamQuotedMessageWidget extends StatelessWidget {
this.padding = const EdgeInsets.all(8), this.padding = const EdgeInsets.all(8),
this.onTap, this.onTap,
this.onQuotedMessageClear, this.onQuotedMessageClear,
this.composing = true,
}); });
/// The message /// The message
@@ -53,9 +52,6 @@ class StreamQuotedMessageWidget extends StatelessWidget {
/// Callback for clearing quoted messages. /// Callback for clearing quoted messages.
final VoidCallback? onQuotedMessageClear; final VoidCallback? onQuotedMessageClear;
/// True if the message is being composed
final bool composing;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final children = [ final children = [
@@ -63,11 +59,10 @@ class StreamQuotedMessageWidget extends StatelessWidget {
child: _QuotedMessage( child: _QuotedMessage(
message: message, message: message,
textLimit: textLimit, textLimit: textLimit,
composing: composing,
onQuotedMessageClear: onQuotedMessageClear,
messageTheme: messageTheme, messageTheme: messageTheme,
showBorder: showBorder, showBorder: showBorder,
reverse: reverse, reverse: reverse,
onQuotedMessageClear: onQuotedMessageClear,
attachmentThumbnailBuilders: attachmentThumbnailBuilders, attachmentThumbnailBuilders: attachmentThumbnailBuilders,
), ),
), ),
@@ -104,17 +99,15 @@ class _QuotedMessage extends StatelessWidget {
const _QuotedMessage({ const _QuotedMessage({
required this.message, required this.message,
required this.textLimit, required this.textLimit,
required this.composing,
required this.onQuotedMessageClear,
required this.messageTheme, required this.messageTheme,
required this.showBorder, required this.showBorder,
required this.reverse, required this.reverse,
this.onQuotedMessageClear,
this.attachmentThumbnailBuilders, this.attachmentThumbnailBuilders,
}); });
final Message message; final Message message;
final int textLimit; final int textLimit;
final bool composing;
final VoidCallback? onQuotedMessageClear; final VoidCallback? onQuotedMessageClear;
final StreamMessageThemeData messageTheme; final StreamMessageThemeData messageTheme;
final bool showBorder; final bool showBorder;
@@ -134,6 +127,8 @@ class _QuotedMessage extends StatelessWidget {
bool get _isGiphy => bool get _isGiphy =>
message.attachments.any((element) => element.type == 'giphy'); message.attachments.any((element) => element.type == 'giphy');
bool get _isDeleted => message.isDeleted || message.deletedAt != null;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isOnlyEmoji = message.text!.isOnlyEmoji; final isOnlyEmoji = message.text!.isOnlyEmoji;
@@ -144,39 +139,54 @@ class _QuotedMessage extends StatelessWidget {
msg = msg.copyWith(text: '${msg.text!.substring(0, textLimit - 3)}...'); msg = msg.copyWith(text: '${msg.text!.substring(0, textLimit - 3)}...');
} }
final children = [ List<Widget> children;
if (composing) if (_isDeleted) {
PlatformWidgetBuilder( // Show deleted message text
web: (context, child) => child, children = [
desktop: (context, child) => child, Text(
child: ClearInputItemButton( context.translations.messageDeletedLabel,
onTap: onQuotedMessageClear, style: messageTheme.messageTextStyle?.copyWith(
fontStyle: FontStyle.italic,
color: messageTheme.createdAtStyle?.color,
), ),
), ),
if (_hasAttachments) ];
_ParseAttachments( } else {
message: message, // Show quoted message
messageTheme: messageTheme, children = [
attachmentThumbnailBuilders: attachmentThumbnailBuilders, if (onQuotedMessageClear != null)
), PlatformWidgetBuilder(
if (msg.text!.isNotEmpty && !_isGiphy) web: (context, child) => child,
Flexible( desktop: (context, child) => child,
child: StreamMessageText( child: ClearInputItemButton(
message: msg, onTap: onQuotedMessageClear,
messageTheme: isOnlyEmoji && _containsText ),
? messageTheme.copyWith(
messageTextStyle: messageTheme.messageTextStyle?.copyWith(
fontSize: 32,
),
)
: messageTheme.copyWith(
messageTextStyle: messageTheme.messageTextStyle?.copyWith(
fontSize: 12,
),
),
), ),
), if (_hasAttachments)
].insertBetween(const SizedBox(width: 8)); _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));
}
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -204,8 +214,8 @@ class _QuotedMessage extends StatelessWidget {
} }
Color? _getBackgroundColor(BuildContext context) { Color? _getBackgroundColor(BuildContext context) {
if (_containsLinkAttachment) { if (_containsLinkAttachment && !_isDeleted) {
return messageTheme.linkBackgroundColor; return messageTheme.urlAttachmentBackgroundColor;
} }
return messageTheme.messageBackgroundColor; return messageTheme.messageBackgroundColor;
} }
@@ -25,6 +25,33 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart';
const _kCommandTrigger = '/'; const _kCommandTrigger = '/';
const _kMentionTrigger = '@'; 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: /// Inactive state:
/// ///
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_input.png) /// ![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.sendMessageKeyPredicate = _defaultSendMessageKeyPredicate,
this.clearQuotedMessageKeyPredicate = this.clearQuotedMessageKeyPredicate =
_defaultClearQuotedMessageKeyPredicate, _defaultClearQuotedMessageKeyPredicate,
this.ogPreviewFilter = _defaultOgPreviewFilter,
this.hintGetter = _defaultHintGetter,
}); });
/// The predicate used to send a message on desktop/web /// 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 /// Callback for when the quoted message is cleared
final VoidCallback? onQuotedMessageCleared; 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) => static bool _defaultValidator(Message message) =>
message.text?.isNotEmpty == true || message.attachments.isNotEmpty; message.text?.isNotEmpty == true || message.attachments.isNotEmpty;
@@ -962,18 +1022,20 @@ class StreamMessageInputState extends State<StreamMessageInput>
leading: true, leading: true,
); );
String _getHint(BuildContext context) { String? _getHint(BuildContext context) {
HintType hintType;
if (_commandEnabled && _effectiveController.message.command == 'giphy') { if (_commandEnabled && _effectiveController.message.command == 'giphy') {
return context.translations.searchGifLabel; hintType = HintType.searchGif;
} } else if (_effectiveController.attachments.isNotEmpty) {
if (_effectiveController.attachments.isNotEmpty) { hintType = HintType.addACommentOrSend;
return context.translations.addACommentOrSendLabel; } else if (_timeOut != 0) {
} hintType = HintType.slowModeOn;
if (_timeOut != 0) { } else {
return context.translations.slowModeOnLabel; hintType = HintType.writeAMessage;
} }
return context.translations.writeAMessageLabel; return widget.hintGetter.call(context, hintType);
} }
String? _lastSearchedContainsUrlText; String? _lastSearchedContainsUrlText;
@@ -990,11 +1052,13 @@ class StreamMessageInputState extends State<StreamMessageInput>
if (_lastSearchedContainsUrlText == value) return; if (_lastSearchedContainsUrlText == value) return;
_lastSearchedContainsUrlText = value; _lastSearchedContainsUrlText = value;
final matchedUrls = _urlRegex.allMatches(value).toList() final matchedUrls = _urlRegex.allMatches(value).where((it) {
..removeWhere((it) { final _parsedMatch = Uri.tryParse(it.group(0) ?? '')?.withScheme;
final _parsedMatch = Uri.tryParse(it.group(0) ?? '')?.withScheme; if (_parsedMatch == null) return false;
return _parsedMatch?.host.split('.').last.isValidTLD() == 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 // Reset the og attachment if the text doesn't contain any url
if (matchedUrls.isEmpty || if (matchedUrls.isEmpty ||
@@ -71,7 +71,7 @@ enum SpacingType {
/// A [StreamChannel] ancestor widget is required in order to provide the /// A [StreamChannel] ancestor widget is required in order to provide the
/// information about the channels. /// 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]. /// The UI is rendered based on the first ancestor of type [StreamChatTheme].
/// Modify it to change the widget's appearance. /// Modify it to change the widget's appearance.
@@ -88,8 +88,10 @@ class StreamMessageListView extends StatefulWidget {
this.threadBuilder, this.threadBuilder,
this.onThreadTap, this.onThreadTap,
this.dateDividerBuilder, this.dateDividerBuilder,
this.scrollPhysics = // we need to use ClampingScrollPhysics to avoid the list view to bounce
const ClampingScrollPhysics(), // we need to use ClampingScrollPhysics to avoid the list view to animate and break while loading // 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.initialScrollIndex,
this.initialAlignment, this.initialAlignment,
this.scrollController, this.scrollController,
@@ -113,6 +115,7 @@ class StreamMessageListView extends StatefulWidget {
this.unreadMessagesSeparatorBuilder, this.unreadMessagesSeparatorBuilder,
this.messageListController, this.messageListController,
this.reverse = true, this.reverse = true,
this.shrinkWrap = false,
this.paginationLimit = 20, this.paginationLimit = 20,
this.paginationLoadingIndicatorBuilder, this.paginationLoadingIndicatorBuilder,
this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.onDrag, this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.onDrag,
@@ -133,6 +136,14 @@ class StreamMessageListView extends StatefulWidget {
/// See [ScrollView.reverse]. /// See [ScrollView.reverse].
final bool 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 /// Limit used during pagination
final int paginationLimit; final int paginationLimit;
@@ -271,9 +282,14 @@ class StreamMessageListView extends StatefulWidget {
BuildContext context, BuildContext context,
List<SpacingType> spacingTypes, List<SpacingType> 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: 8);
} }
return const SizedBox(height: 2); return const SizedBox(height: 2);
} }
@@ -548,6 +564,7 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
physics: widget.scrollPhysics, physics: widget.scrollPhysics,
itemScrollController: _scrollController, itemScrollController: _scrollController,
reverse: widget.reverse, reverse: widget.reverse,
shrinkWrap: widget.shrinkWrap,
itemCount: itemCount, itemCount: itemCount,
findChildIndexCallback: (Key key) { findChildIndexCallback: (Key key) {
final indexedKey = key as IndexedKey; final indexedKey = key as IndexedKey;
@@ -555,6 +572,10 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
if (valueKey != null) { if (valueKey != null) {
final index = messagesIndex[valueKey.value]; final index = messagesIndex[valueKey.value];
if (index != null) { 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; return ((index + 2) * 2) - 1;
} }
} }
@@ -628,29 +649,27 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
Widget separator; Widget separator;
final isThread = message.replyCount! > 0; final isPartOfThread = message.replyCount! > 0 ||
message.showInChannel == true;
if (!Jiffy(message.createdAt.toLocal()).isSame( final createdAt = message.createdAt.toLocal();
nextMessage.createdAt.toLocal(), final nextCreatedAt = nextMessage.createdAt.toLocal();
Units.DAY, if (!Jiffy(createdAt).isSame(nextCreatedAt, Units.DAY)) {
)) {
separator = _buildDateDivider(nextMessage); separator = _buildDateDivider(nextMessage);
} else { } else {
final timeDiff = final hasTimeDiff = !Jiffy(createdAt).isSame(
Jiffy(nextMessage.createdAt.toLocal()).diff( nextCreatedAt,
message.createdAt.toLocal(),
Units.MINUTE, Units.MINUTE,
); );
final isNextUserSame = final isNextUserSame =
message.user!.id == nextMessage.user?.id; message.user!.id == nextMessage.user?.id;
final isDeleted = message.isDeleted; final isDeleted = message.isDeleted;
final hasTimeDiff = timeDiff >= 1;
final spacingRules = [ final spacingRules = [
if (hasTimeDiff) SpacingType.timeDiff, if (hasTimeDiff) SpacingType.timeDiff,
if (!isNextUserSame) SpacingType.otherUser, if (!isNextUserSame) SpacingType.otherUser,
if (isThread) SpacingType.thread, if (isPartOfThread) SpacingType.thread,
if (isDeleted) SpacingType.deleted, if (isDeleted) SpacingType.deleted,
]; ];
@@ -664,7 +683,7 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
); );
} }
if (!isThread && if (!isPartOfThread &&
unreadCount > 0 && unreadCount > 0 &&
_oldestUnreadMessage?.id == nextMessage.id) { _oldestUnreadMessage?.id == nextMessage.id) {
final unreadMessagesSeparator = final unreadMessagesSeparator =
@@ -877,6 +896,11 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
final currentUserMember = final currentUserMember =
members.firstWhereOrNull((e) => e.user!.id == currentUser!.id); 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( final defaultMessageWidget = StreamMessageWidget(
showReplyMessage: false, showReplyMessage: false,
showResendMessage: false, showResendMessage: false,
@@ -901,7 +925,7 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
vertical: 8, vertical: 8,
horizontal: isOnlyEmoji ? 0 : 16.0, horizontal: isOnlyEmoji ? 0 : 16.0,
), ),
borderSide: isMyMessage || isOnlyEmoji ? BorderSide.none : null, borderSide: borderSide,
showUserAvatar: isMyMessage ? DisplayWidget.gone : DisplayWidget.show, showUserAvatar: isMyMessage ? DisplayWidget.gone : DisplayWidget.show,
messageTheme: isMyMessage messageTheme: isMyMessage
? _streamTheme.ownMessageTheme ? _streamTheme.ownMessageTheme
@@ -1037,10 +1061,10 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
final isNextUserSame = final isNextUserSame =
nextMessage != null && message.user!.id == nextMessage.user!.id; nextMessage != null && message.user!.id == nextMessage.user!.id;
num timeDiff = 0; var hasTimeDiff = false;
if (nextMessage != null) { if (nextMessage != null) {
timeDiff = Jiffy(nextMessage.createdAt.toLocal()).diff( hasTimeDiff = !Jiffy(message.createdAt.toLocal()).isSame(
message.createdAt.toLocal(), nextMessage.createdAt.toLocal(),
Units.MINUTE, Units.MINUTE,
); );
} }
@@ -1057,21 +1081,21 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
final showTimeStamp = (!isThreadMessage || _isThreadConversation) && final showTimeStamp = (!isThreadMessage || _isThreadConversation) &&
!hasReplies && !hasReplies &&
(timeDiff >= 1 || !isNextUserSame); (hasTimeDiff || !isNextUserSame);
final showUsername = !isMyMessage && final showUsername = !isMyMessage &&
(!isThreadMessage || _isThreadConversation) && (!isThreadMessage || _isThreadConversation) &&
!hasReplies && !hasReplies &&
(timeDiff >= 1 || !isNextUserSame); (hasTimeDiff || !isNextUserSame);
final showUserAvatar = isMyMessage final showUserAvatar = isMyMessage
? DisplayWidget.gone ? DisplayWidget.gone
: (timeDiff >= 1 || !isNextUserSame) : (hasTimeDiff || !isNextUserSame)
? DisplayWidget.show ? DisplayWidget.show
: DisplayWidget.hide; : DisplayWidget.hide;
final showSendingIndicator = final showSendingIndicator =
isMyMessage && (index == 0 || timeDiff >= 1 || !isNextUserSame); isMyMessage && (index == 0 || hasTimeDiff || !isNextUserSame);
final showInChannelIndicator = !_isThreadConversation && isThreadMessage; final showInChannelIndicator = !_isThreadConversation && isThreadMessage;
final showThreadReplyIndicator = !_isThreadConversation && hasReplies; final showThreadReplyIndicator = !_isThreadConversation && hasReplies;
@@ -1080,10 +1104,7 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
final hasUrlAttachment = final hasUrlAttachment =
message.attachments.any((it) => it.ogScrapeUrl != null); message.attachments.any((it) => it.ogScrapeUrl != null);
final borderSide = final borderSide = isOnlyEmoji || hasUrlAttachment ? BorderSide.none : null;
isOnlyEmoji || hasUrlAttachment || (isMyMessage && !hasFileAttachment)
? BorderSide.none
: null;
final currentUser = StreamChat.of(context).currentUser; final currentUser = StreamChat.of(context).currentUser;
final members = StreamChannel.of(context).channel.state?.members ?? []; final members = StreamChannel.of(context).channel.state?.members ?? [];
@@ -1133,7 +1154,7 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
bottomLeft: isMyMessage bottomLeft: isMyMessage
? Radius.circular(attachmentBorderRadius) ? Radius.circular(attachmentBorderRadius)
: Radius.circular( : Radius.circular(
(timeDiff >= 1 || !isNextUserSame) && (hasTimeDiff || !isNextUserSame) &&
!(hasReplies || isThreadMessage || hasFileAttachment) !(hasReplies || isThreadMessage || hasFileAttachment)
? 0 ? 0
: attachmentBorderRadius, : attachmentBorderRadius,
@@ -1141,7 +1162,7 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
topRight: Radius.circular(attachmentBorderRadius), topRight: Radius.circular(attachmentBorderRadius),
bottomRight: isMyMessage bottomRight: isMyMessage
? Radius.circular( ? Radius.circular(
(timeDiff >= 1 || !isNextUserSame) && (hasTimeDiff || !isNextUserSame) &&
!(hasReplies || isThreadMessage || hasFileAttachment) !(hasReplies || isThreadMessage || hasFileAttachment)
? 0 ? 0
: attachmentBorderRadius, : attachmentBorderRadius,
@@ -1154,7 +1175,7 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
bottomLeft: isMyMessage bottomLeft: isMyMessage
? const Radius.circular(16) ? const Radius.circular(16)
: Radius.circular( : Radius.circular(
(timeDiff >= 1 || !isNextUserSame) && (hasTimeDiff || !isNextUserSame) &&
!(hasReplies || isThreadMessage) !(hasReplies || isThreadMessage)
? 0 ? 0
: 16, : 16,
@@ -1162,7 +1183,7 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
topRight: const Radius.circular(16), topRight: const Radius.circular(16),
bottomRight: isMyMessage bottomRight: isMyMessage
? Radius.circular( ? Radius.circular(
(timeDiff >= 1 || !isNextUserSame) && (hasTimeDiff || !isNextUserSame) &&
!(hasReplies || isThreadMessage) !(hasReplies || isThreadMessage)
? 0 ? 0
: 16, : 16,
@@ -147,11 +147,10 @@ class BottomRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (isDeleted) { if (isDeleted) {
return deletedBottomRowBuilder?.call( final deletedBottomRowBuilder = this.deletedBottomRowBuilder;
context, if (deletedBottomRowBuilder != null) {
message, return deletedBottomRowBuilder(context, message);
) ?? }
const Offstage();
} }
final children = <WidgetSpan>[]; final children = <WidgetSpan>[];
@@ -32,7 +32,6 @@ class StreamDeletedMessage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final chatThemeData = StreamChatTheme.of(context);
return Material( return Material(
color: messageTheme.messageBackgroundColor, color: messageTheme.messageBackgroundColor,
shape: shape ?? shape: shape ??
@@ -40,9 +39,7 @@ class StreamDeletedMessage extends StatelessWidget {
borderRadius: borderRadiusGeometry ?? BorderRadius.zero, borderRadius: borderRadiusGeometry ?? BorderRadius.zero,
side: borderSide ?? side: borderSide ??
BorderSide( BorderSide(
color: Theme.of(context).brightness == Brightness.dark color: messageTheme.messageBorderColor ?? Colors.transparent,
? chatThemeData.colorTheme.barsBg.withAlpha(24)
: chatThemeData.colorTheme.textHighEmphasis.withAlpha(24),
), ),
), ),
child: Padding( child: Padding(
@@ -139,7 +139,8 @@ class _MessageCardState extends State<MessageCard> {
RoundedRectangleBorder( RoundedRectangleBorder(
side: widget.borderSide ?? side: widget.borderSide ??
BorderSide( BorderSide(
color: widget.messageTheme.messageBorderColor ?? Colors.grey, color: widget.messageTheme.messageBorderColor ??
Colors.transparent,
), ),
borderRadius: widget.borderRadiusGeometry ?? BorderRadius.zero, borderRadius: widget.borderRadiusGeometry ?? BorderRadius.zero,
), ),
@@ -215,7 +216,7 @@ class _MessageCardState extends State<MessageCard> {
} }
if (widget.hasUrlAttachments) { if (widget.hasUrlAttachments) {
return widget.messageTheme.linkBackgroundColor; return widget.messageTheme.urlAttachmentBackgroundColor;
} }
if (widget.isOnlyEmoji) { if (widget.isOnlyEmoji) {
@@ -794,8 +794,7 @@ class _StreamMessageWidgetState extends State<StreamMessageWidget>
showUsername || showUsername ||
showTimeStamp || showTimeStamp ||
showInChannel || showInChannel ||
showSendingIndicator || showSendingIndicator;
isDeleted;
/// {@template isPinned} /// {@template isPinned}
/// Whether [StreamMessageWidget.message] is pinned or not. /// Whether [StreamMessageWidget.message] is pinned or not.
@@ -1010,7 +1009,8 @@ class _StreamMessageWidgetState extends State<StreamMessageWidget>
title: Text(context.translations.copyMessageLabel), title: Text(context.translations.copyMessageLabel),
onClick: () { onClick: () {
Navigator.of(context, rootNavigator: true).pop(); 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) ...[ if (shouldShowEditAction) ...[
@@ -1035,6 +1035,7 @@ class _StreamMessageWidgetState extends State<StreamMessageWidget>
builder: (_) => EditMessageSheet( builder: (_) => EditMessageSheet(
message: widget.message, message: widget.message,
channel: StreamChannel.of(context).channel, channel: StreamChannel.of(context).channel,
editMessageInputBuilder: widget.editMessageInputBuilder,
), ),
); );
}, },
@@ -1143,6 +1144,7 @@ class _StreamMessageWidgetState extends State<StreamMessageWidget>
showDialog( showDialog(
useRootNavigator: false, useRootNavigator: false,
context: context, context: context,
useSafeArea: false,
barrierColor: _streamChatTheme.colorTheme.overlay, barrierColor: _streamChatTheme.colorTheme.overlay,
builder: (context) => StreamChannel( builder: (context) => StreamChannel(
channel: channel, channel: channel,
@@ -1168,8 +1170,10 @@ class _StreamMessageWidgetState extends State<StreamMessageWidget>
? DisplayWidget.gone ? DisplayWidget.gone
: DisplayWidget.show, : DisplayWidget.show,
), ),
onCopyTap: (message) => onCopyTap: (message) {
Clipboard.setData(ClipboardData(text: message.text)), final text = message.text;
if (text != null) Clipboard.setData(ClipboardData(text: text));
},
messageTheme: widget.messageTheme, messageTheme: widget.messageTheme,
reverse: widget.reverse, reverse: widget.reverse,
showDeleteMessage: shouldShowDeleteAction, showDeleteMessage: shouldShowDeleteAction,
@@ -379,6 +379,8 @@ class MessageWidgetContent extends StatelessWidget {
showUserAvatar == DisplayWidget.show && showUserAvatar == DisplayWidget.show &&
message.user != null) ...[ message.user != null) ...[
UserAvatarTransform( UserAvatarTransform(
onUserAvatarTap: onUserAvatarTap,
userAvatarBuilder: userAvatarBuilder,
translateUserAvatar: translateUserAvatar, translateUserAvatar: translateUserAvatar,
messageTheme: messageTheme, messageTheme: messageTheme,
message: message, message: message,
@@ -431,6 +433,7 @@ class MessageWidgetContent extends StatelessWidget {
showDialog( showDialog(
useRootNavigator: false, useRootNavigator: false,
context: context, context: context,
useSafeArea: false,
barrierColor: streamChatTheme.colorTheme.overlay, barrierColor: streamChatTheme.colorTheme.overlay,
builder: (context) => StreamChannel( builder: (context) => StreamChannel(
channel: channel, channel: channel,
@@ -65,7 +65,6 @@ class _QuotedMessageState extends State<QuotedMessage> {
top: 8, top: 8,
bottom: widget.hasNonUrlAttachments ? 8 : 0, bottom: widget.hasNonUrlAttachments ? 8 : 0,
), ),
composing: false,
); );
} }
} }
@@ -2,6 +2,7 @@ import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_portal/flutter_portal.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'; import 'package:stream_chat_flutter/stream_chat_flutter.dart';
// ignore_for_file: cascade_invocations // ignore_for_file: cascade_invocations
@@ -75,6 +76,7 @@ class _DesktopReactionsBuilderState extends State<DesktopReactionsBuilder> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final streamChat = StreamChat.of(context); final streamChat = StreamChat.of(context);
final currentUser = streamChat.currentUser!;
final reactionIcons = StreamChatConfiguration.of(context).reactionIcons; final reactionIcons = StreamChatConfiguration.of(context).reactionIcons;
final streamChatTheme = StreamChatTheme.of(context); final streamChatTheme = StreamChatTheme.of(context);
@@ -83,13 +85,13 @@ class _DesktopReactionsBuilderState extends State<DesktopReactionsBuilder> {
if (widget.shouldShowReactions) { if (widget.shouldShowReactions) {
widget.message.latestReactions?.forEach((element) { widget.message.latestReactions?.forEach((element) {
if (!reactionsMap.containsKey(element.type) || if (!reactionsMap.containsKey(element.type) ||
element.user!.id == streamChat.currentUser?.id) { element.user!.id == currentUser.id) {
reactionsMap[element.type] = element; reactionsMap[element.type] = element;
} }
}); });
reactionsList = reactionsMap.values.toList() 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( return PortalTarget(
@@ -114,44 +116,10 @@ class _DesktopReactionsBuilderState extends State<DesktopReactionsBuilder> {
maxWidth: 336, maxWidth: 336,
maxHeight: 342, maxHeight: 342,
), ),
child: Card( child: ReactionsCard(
color: streamChatTheme.colorTheme.barsBg, currentUser: currentUser,
shape: RoundedRectangleBorder( message: widget.message,
borderRadius: BorderRadius.circular(16), messageTheme: widget.messageTheme,
),
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(),
],
),
),
),
],
),
), ),
), ),
), ),
@@ -163,23 +131,32 @@ class _DesktopReactionsBuilderState extends State<DesktopReactionsBuilder> {
onExit: (event) { onExit: (event) {
setState(() => _showReactionsPopup = !_showReactionsPopup); setState(() => _showReactionsPopup = !_showReactionsPopup);
}, },
child: Wrap( child: Padding(
children: [ padding: EdgeInsets.symmetric(
...reactionsList.map((reaction) { vertical: 2,
final reactionIcon = reactionIcons.firstWhereOrNull( horizontal: widget.reverse ? 0 : 4,
(r) => r.type == reaction.type, ),
); child: Wrap(
spacing: 4,
runSpacing: 4,
children: [
...reactionsList.map((reaction) {
final reactionIcon = reactionIcons.firstWhereOrNull(
(r) => r.type == reaction.type,
);
return _BottomReaction( return _BottomReaction(
reaction: reaction, currentUser: currentUser,
message: widget.message, reaction: reaction,
borderSide: widget.borderSide, message: widget.message,
messageTheme: widget.messageTheme, borderSide: widget.borderSide,
reactionIcon: reactionIcon, messageTheme: widget.messageTheme,
streamChatTheme: streamChatTheme, reactionIcon: reactionIcon,
); streamChatTheme: streamChatTheme,
}).toList(), );
], }).toList(),
],
),
), ),
), ),
); );
@@ -188,6 +165,7 @@ class _DesktopReactionsBuilderState extends State<DesktopReactionsBuilder> {
class _BottomReaction extends StatelessWidget { class _BottomReaction extends StatelessWidget {
const _BottomReaction({ const _BottomReaction({
required this.currentUser,
required this.reaction, required this.reaction,
required this.message, required this.message,
required this.borderSide, required this.borderSide,
@@ -196,6 +174,7 @@ class _BottomReaction extends StatelessWidget {
required this.streamChatTheme, required this.streamChatTheme,
}); });
final User currentUser;
final Reaction reaction; final Reaction reaction;
final Message message; final Message message;
final BorderSide? borderSide; final BorderSide? borderSide;
@@ -205,7 +184,10 @@ class _BottomReaction extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final userId = StreamChat.of(context).currentUser?.id; final userId = currentUser.id;
final backgroundColor = messageTheme?.reactionsBackgroundColor;
return GestureDetector( return GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: () { onTap: () {
@@ -225,37 +207,38 @@ class _BottomReaction extends StatelessWidget {
} }
}, },
child: Card( 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 ?? side: borderSide ??
BorderSide( BorderSide(
color: messageTheme?.messageBorderColor ?? Colors.grey, color: messageTheme?.reactionsBorderColor ?? Colors.transparent,
), ),
borderRadius: BorderRadius.circular(10),
), ),
color: messageTheme?.messageBackgroundColor, color: backgroundColor,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
horizontal: 6,
vertical: 2,
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
ConstrainedBox( ConstrainedBox(
constraints: BoxConstraints.tight( constraints: BoxConstraints.tight(
const Size.square(16), const Size.square(14),
), ),
child: reactionIcon?.builder( child: reactionIcon?.builder(
context, context,
reaction.user?.id == userId, reaction.user?.id == userId,
16, 14,
) ?? ) ??
Icon( Icon(
Icons.help_outline_rounded, Icons.help_outline_rounded,
size: 16, size: 14,
color: reaction.user?.id == userId color: reaction.user?.id == userId
? streamChatTheme.colorTheme.accentPrimary ? streamChatTheme.colorTheme.accentPrimary
: streamChatTheme.colorTheme.textHighEmphasis : streamChatTheme.colorTheme.textLowEmphasis,
.withOpacity(0.5),
), ),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
@@ -280,87 +263,3 @@ class _BottomReaction extends StatelessWidget {
properties.add(DiagnosticsProperty<Message>('message', message)); properties.add(DiagnosticsProperty<Message>('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', reaction),
);
properties.add(
DiagnosticsProperty<StreamReactionIcon?>(
'reactionIcon',
reactionIcon,
),
);
}
}
@@ -1,8 +1,8 @@
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; 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_align.dart';
import 'package:stream_chat_flutter/src/message_widget/reactions/reactions_card.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// {@template streamMessageReactionsModal} /// {@template streamMessageReactionsModal}
@@ -83,9 +83,10 @@ class StreamMessageReactionsModal extends StatelessWidget {
), ),
if (message.latestReactions?.isNotEmpty == true) ...[ if (message.latestReactions?.isNotEmpty == true) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
_buildReactionCard( ReactionsCard(
context, currentUser: user!,
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,
),
],
),
);
}
} }
@@ -124,24 +124,22 @@ class StreamReactionBubble extends StatelessWidget {
final chatThemeData = StreamChatTheme.of(context); final chatThemeData = StreamChatTheme.of(context);
final userId = StreamChat.of(context).currentUser?.id; final userId = StreamChat.of(context).currentUser?.id;
return Padding( return Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: 4),
horizontal: 4,
),
child: reactionIcon != null child: reactionIcon != null
? ConstrainedBox( ? ConstrainedBox(
constraints: BoxConstraints.tight(const Size.square(16)), constraints: BoxConstraints.tight(const Size.square(14)),
child: reactionIcon.builder( child: reactionIcon.builder(
context, context,
!highlightOwnReactions || reaction.user?.id == userId, highlightOwnReactions && reaction.user?.id == userId,
16, 16,
), ),
) )
: Icon( : Icon(
Icons.help_outline_rounded, Icons.help_outline_rounded,
size: 16, size: 14,
color: (!highlightOwnReactions || reaction.user?.id == userId) color: (highlightOwnReactions && reaction.user?.id == userId)
? chatThemeData.colorTheme.accentPrimary ? chatThemeData.colorTheme.accentPrimary
: chatThemeData.colorTheme.textHighEmphasis.withOpacity(0.5), : chatThemeData.colorTheme.textLowEmphasis,
), ),
); );
} }
@@ -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,
),
],
),
);
}
}
@@ -1,5 +1,14 @@
import 'package:flutter/material.dart'; 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} /// {@template streamReactionIcon}
/// Reaction icon data /// Reaction icon data
/// {@endtemplate} /// {@endtemplate}
@@ -13,10 +22,6 @@ class StreamReactionIcon {
/// Type of reaction /// Type of reaction
final String type; final String type;
/// Asset to display for reaction /// {@macro reactionIconBuilder}
final Widget Function( final ReactionIconBuilder builder;
BuildContext,
bool highlighted,
double size,
) builder;
} }
@@ -165,7 +165,7 @@ class StreamChatConfigurationData {
return StreamSvgIcon.loveReaction( return StreamSvgIcon.loveReaction(
color: highlighted color: highlighted
? theme.colorTheme.accentPrimary ? theme.colorTheme.accentPrimary
: theme.primaryIconTheme.color!.withOpacity(0.5), : theme.primaryIconTheme.color,
size: size, size: size,
); );
}, },
@@ -177,7 +177,7 @@ class StreamChatConfigurationData {
return StreamSvgIcon.thumbsUpReaction( return StreamSvgIcon.thumbsUpReaction(
color: highlighted color: highlighted
? theme.colorTheme.accentPrimary ? theme.colorTheme.accentPrimary
: theme.primaryIconTheme.color!.withOpacity(0.5), : theme.primaryIconTheme.color,
size: size, size: size,
); );
}, },
@@ -189,7 +189,7 @@ class StreamChatConfigurationData {
return StreamSvgIcon.thumbsDownReaction( return StreamSvgIcon.thumbsDownReaction(
color: highlighted color: highlighted
? theme.colorTheme.accentPrimary ? theme.colorTheme.accentPrimary
: theme.primaryIconTheme.color!.withOpacity(0.5), : theme.primaryIconTheme.color,
size: size, size: size,
); );
}, },
@@ -201,7 +201,7 @@ class StreamChatConfigurationData {
return StreamSvgIcon.lolReaction( return StreamSvgIcon.lolReaction(
color: highlighted color: highlighted
? theme.colorTheme.accentPrimary ? theme.colorTheme.accentPrimary
: theme.primaryIconTheme.color!.withOpacity(0.5), : theme.primaryIconTheme.color,
size: size, size: size,
); );
}, },
@@ -213,7 +213,7 @@ class StreamChatConfigurationData {
return StreamSvgIcon.wutReaction( return StreamSvgIcon.wutReaction(
color: highlighted color: highlighted
? theme.colorTheme.accentPrimary ? theme.colorTheme.accentPrimary
: theme.primaryIconTheme.color!.withOpacity(0.5), : theme.primaryIconTheme.color,
size: size, size: size,
); );
}, },
@@ -20,9 +20,15 @@ class StreamMessageThemeData with Diagnosticable {
this.reactionsMaskColor, this.reactionsMaskColor,
this.avatarTheme, this.avatarTheme,
this.createdAtStyle, this.createdAtStyle,
this.linkBackgroundColor, @Deprecated('Use urlAttachmentBackgroundColor instead')
Color? linkBackgroundColor,
Color? urlAttachmentBackgroundColor,
this.urlAttachmentHostStyle,
this.urlAttachmentTitleStyle,
this.urlAttachmentTextStyle,
this.urlAttachmentTitleMaxLine, this.urlAttachmentTitleMaxLine,
}); }) : urlAttachmentBackgroundColor =
urlAttachmentBackgroundColor ?? linkBackgroundColor;
/// Text style for message text /// Text style for message text
final TextStyle? messageTextStyle; final TextStyle? messageTextStyle;
@@ -58,9 +64,22 @@ class StreamMessageThemeData with Diagnosticable {
final StreamAvatarThemeData? avatarTheme; final StreamAvatarThemeData? avatarTheme;
/// Background color for messages with url attachments. /// 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; final int? urlAttachmentTitleMaxLine;
/// Copy with a theme /// Copy with a theme
@@ -76,7 +95,12 @@ class StreamMessageThemeData with Diagnosticable {
Color? reactionsBackgroundColor, Color? reactionsBackgroundColor,
Color? reactionsBorderColor, Color? reactionsBorderColor,
Color? reactionsMaskColor, Color? reactionsMaskColor,
Color? linkBackgroundColor, @Deprecated('Use urlAttachmentBackgroundColor instead')
Color? linkBackgroundColor,
Color? urlAttachmentBackgroundColor,
TextStyle? urlAttachmentHostStyle,
TextStyle? urlAttachmentTitleStyle,
TextStyle? urlAttachmentTextStyle,
int? urlAttachmentTitleMaxLine, int? urlAttachmentTitleMaxLine,
}) { }) {
return StreamMessageThemeData( return StreamMessageThemeData(
@@ -93,7 +117,15 @@ class StreamMessageThemeData with Diagnosticable {
reactionsBackgroundColor ?? this.reactionsBackgroundColor, reactionsBackgroundColor ?? this.reactionsBackgroundColor,
reactionsBorderColor: reactionsBorderColor ?? this.reactionsBorderColor, reactionsBorderColor: reactionsBorderColor ?? this.reactionsBorderColor,
reactionsMaskColor: reactionsMaskColor ?? this.reactionsMaskColor, 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:
urlAttachmentTitleMaxLine ?? this.urlAttachmentTitleMaxLine, urlAttachmentTitleMaxLine ?? this.urlAttachmentTitleMaxLine,
); );
@@ -129,8 +161,23 @@ class StreamMessageThemeData with Diagnosticable {
reactionsMaskColor: reactionsMaskColor:
Color.lerp(a.reactionsMaskColor, b.reactionsMaskColor, t), Color.lerp(a.reactionsMaskColor, b.reactionsMaskColor, t),
repliesStyle: TextStyle.lerp(a.repliesStyle, b.repliesStyle, t), repliesStyle: TextStyle.lerp(a.repliesStyle, b.repliesStyle, t),
linkBackgroundColor: urlAttachmentBackgroundColor: Color.lerp(
Color.lerp(a.linkBackgroundColor, b.linkBackgroundColor, t), 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, reactionsBackgroundColor: other.reactionsBackgroundColor,
reactionsBorderColor: other.reactionsBorderColor, reactionsBorderColor: other.reactionsBorderColor,
reactionsMaskColor: other.reactionsMaskColor, reactionsMaskColor: other.reactionsMaskColor,
linkBackgroundColor: other.linkBackgroundColor, urlAttachmentBackgroundColor: other.urlAttachmentBackgroundColor,
urlAttachmentHostStyle: other.urlAttachmentHostStyle,
urlAttachmentTitleStyle: other.urlAttachmentTitleStyle,
urlAttachmentTextStyle: other.urlAttachmentTextStyle,
urlAttachmentTitleMaxLine: other.urlAttachmentTitleMaxLine, urlAttachmentTitleMaxLine: other.urlAttachmentTitleMaxLine,
); );
} }
@@ -175,7 +225,10 @@ class StreamMessageThemeData with Diagnosticable {
reactionsBorderColor == other.reactionsBorderColor && reactionsBorderColor == other.reactionsBorderColor &&
reactionsMaskColor == other.reactionsMaskColor && reactionsMaskColor == other.reactionsMaskColor &&
avatarTheme == other.avatarTheme && avatarTheme == other.avatarTheme &&
linkBackgroundColor == other.linkBackgroundColor && urlAttachmentBackgroundColor == other.urlAttachmentBackgroundColor &&
urlAttachmentHostStyle == other.urlAttachmentHostStyle &&
urlAttachmentTitleStyle == other.urlAttachmentTitleStyle &&
urlAttachmentTextStyle == other.urlAttachmentTextStyle &&
urlAttachmentTitleMaxLine == other.urlAttachmentTitleMaxLine; urlAttachmentTitleMaxLine == other.urlAttachmentTitleMaxLine;
@override @override
@@ -191,7 +244,10 @@ class StreamMessageThemeData with Diagnosticable {
reactionsBorderColor.hashCode ^ reactionsBorderColor.hashCode ^
reactionsMaskColor.hashCode ^ reactionsMaskColor.hashCode ^
avatarTheme.hashCode ^ avatarTheme.hashCode ^
linkBackgroundColor.hashCode ^ urlAttachmentBackgroundColor.hashCode ^
urlAttachmentHostStyle.hashCode ^
urlAttachmentTitleStyle.hashCode ^
urlAttachmentTextStyle.hashCode ^
urlAttachmentTitleMaxLine.hashCode; urlAttachmentTitleMaxLine.hashCode;
@override @override
@@ -209,7 +265,22 @@ class StreamMessageThemeData with Diagnosticable {
..add(ColorProperty('reactionsBackgroundColor', reactionsBackgroundColor)) ..add(ColorProperty('reactionsBackgroundColor', reactionsBackgroundColor))
..add(ColorProperty('reactionsBorderColor', reactionsBorderColor)) ..add(ColorProperty('reactionsBorderColor', reactionsBorderColor))
..add(ColorProperty('reactionsMaskColor', reactionsMaskColor)) ..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( ..add(DiagnosticsProperty(
'urlAttachmentTitleMaxLine', 'urlAttachmentTitleMaxLine',
urlAttachmentTitleMaxLine, urlAttachmentTitleMaxLine,
@@ -126,8 +126,7 @@ class StreamChatThemeData {
StreamTextTheme textTheme, StreamTextTheme textTheme,
) { ) {
final accentColor = colorTheme.accentPrimary; final accentColor = colorTheme.accentPrimary;
final iconTheme = final iconTheme = IconThemeData(color: colorTheme.textLowEmphasis);
IconThemeData(color: colorTheme.textHighEmphasis.withOpacity(0.5));
final channelHeaderTheme = StreamChannelHeaderThemeData( final channelHeaderTheme = StreamChannelHeaderThemeData(
avatarTheme: StreamAvatarThemeData( avatarTheme: StreamAvatarThemeData(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
@@ -184,11 +183,11 @@ class StreamChatThemeData {
createdAtStyle: createdAtStyle:
textTheme.footnote.copyWith(color: colorTheme.textLowEmphasis), textTheme.footnote.copyWith(color: colorTheme.textLowEmphasis),
repliesStyle: textTheme.footnoteBold.copyWith(color: accentColor), repliesStyle: textTheme.footnoteBold.copyWith(color: accentColor),
messageBackgroundColor: colorTheme.disabled, messageBackgroundColor: colorTheme.borders,
messageBorderColor: colorTheme.borders,
reactionsBackgroundColor: colorTheme.barsBg, reactionsBackgroundColor: colorTheme.barsBg,
reactionsBorderColor: colorTheme.borders, reactionsBorderColor: colorTheme.borders,
reactionsMaskColor: colorTheme.appBg, reactionsMaskColor: colorTheme.appBg,
messageBorderColor: colorTheme.disabled,
avatarTheme: StreamAvatarThemeData( avatarTheme: StreamAvatarThemeData(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
constraints: const BoxConstraints.tightFor( constraints: const BoxConstraints.tightFor(
@@ -199,11 +198,16 @@ class StreamChatThemeData {
messageLinksStyle: TextStyle( messageLinksStyle: TextStyle(
color: accentColor, 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( otherMessageTheme: StreamMessageThemeData(
reactionsBackgroundColor: colorTheme.disabled, reactionsBackgroundColor: colorTheme.borders,
reactionsBorderColor: colorTheme.barsBg, reactionsBorderColor: colorTheme.borders,
reactionsMaskColor: colorTheme.appBg, reactionsMaskColor: colorTheme.appBg,
messageTextStyle: textTheme.body, messageTextStyle: textTheme.body,
createdAtStyle: createdAtStyle:
@@ -223,7 +227,12 @@ class StreamChatThemeData {
width: 32, 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( messageInputTheme: StreamMessageInputThemeData(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
@@ -4,7 +4,7 @@ import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
bool get isWeb => CurrentPlatform.isWeb; bool get isWeb => CurrentPlatform.isWeb;
/// Returns true if the app is running in a mobile device. /// 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. /// Returns true if the app is running in a desktop device.
bool get isDesktopDevice => bool get isDesktopDevice =>
@@ -22,7 +22,7 @@ bool get isDesktopVideoPlayerSupported =>
bool get isMobileDeviceOrWeb => isWeb || isMobileDevice; bool get isMobileDeviceOrWeb => isWeb || isMobileDevice;
/// Returns true if the app is running in a desktop or web. /// 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. /// Returns true if the app is running in a flutter test environment.
bool get isTestEnvironment => CurrentPlatform.isFlutterTest; bool get isTestEnvironment => CurrentPlatform.isFlutterTest;
@@ -34,7 +34,7 @@ extension StringExtension on String {
if (trimmedString.isEmpty) return false; if (trimmedString.isEmpty) return false;
if (trimmedString.characters.length > 3) return false; if (trimmedString.characters.length > 3) return false;
final emojiRegex = RegExp( 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, multiLine: true,
caseSensitive: false, caseSensitive: false,
); );
+3 -3
View File
@@ -1,12 +1,12 @@
name: stream_chat_flutter name: stream_chat_flutter
homepage: https://github.com/GetStream/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. 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 repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
environment: environment:
sdk: ">=2.17.0 <3.0.0" sdk: ">=2.17.0 <4.0.0"
flutter: ">=1.20.0" flutter: ">=1.20.0"
dependencies: dependencies:
@@ -38,7 +38,7 @@ dependencies:
rxdart: ^0.27.0 rxdart: ^0.27.0
share_plus: ^6.3.0 share_plus: ^6.3.0
shimmer: ^2.0.0 shimmer: ^2.0.0
stream_chat_flutter_core: ^6.0.0 stream_chat_flutter_core: ^6.1.0
synchronized: ^3.0.0 synchronized: ^3.0.0
thumblr: ^0.0.4 thumblr: ^0.0.4
url_launcher: ^6.1.0 url_launcher: ^6.1.0
@@ -2,13 +2,12 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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/scrollable_positioned_list.dart';
const screenHeight = 400.0; const screenHeight = 100.0;
const screenWidth = 400.0; const screenWidth = 400.0;
const itemWidth = screenWidth / 10.0; const itemWidth = screenWidth / 10.0;
const itemCount = 500; 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 { testWidgets('List positioned with 0 at left', (WidgetTester tester) async {
final itemPositionsListener = ItemPositionsListener.create(); final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener); await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener);
@@ -172,7 +176,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('Item 100')).dx, 0); 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( expect(
itemPositionsListener.itemPositions.value itemPositionsListener.itemPositions.value
@@ -196,6 +200,31 @@ void main() {
1); 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<FadeTransition>(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<FadeTransition>(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', testWidgets('padding test - centered sliver at left',
(WidgetTester tester) async { (WidgetTester tester) async {
final itemScrollController = ItemScrollController(); final itemScrollController = ItemScrollController();
@@ -360,4 +360,57 @@ void main() {
.itemTrailingEdge, .itemTrailingEdge,
1); 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<OverlayState>(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);
});
} }
@@ -2,10 +2,9 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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/scrollable_positioned_list.dart';
const screenHeight = 400.0; const screenHeight = 400.0;
@@ -2,12 +2,12 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:async';
import 'dart:math'; import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.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/scrollable_positioned_list.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/scroll_view.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/src/scroll_view.dart';
@@ -48,8 +48,10 @@ void main() {
itemCount: itemCount, itemCount: itemCount,
itemScrollController: itemScrollController, itemScrollController: itemScrollController,
itemBuilder: (context, index) { itemBuilder: (context, index) {
assert(index >= 0 && index <= itemCount - 1, assert(
'''index needs to be bigger or equal to 0 and smallert than itemCount -1'''); index >= 0 && index <= itemCount - 1,
'index must be in the range of 0 to itemCount - 1',
);
return SizedBox( return SizedBox(
height: height:
variableHeight ? (itemHeight + (index % 13) * 5) : itemHeight, 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 { testWidgets('List positioned with 0 at top', (WidgetTester tester) async {
final itemPositionsListener = ItemPositionsListener.create(); final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener); await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener);
@@ -394,11 +401,7 @@ void main() {
itemScrollController: itemScrollController, itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener); itemPositionsListener: itemPositionsListener);
var fadeTransition = tester.widget<FadeTransition>(find var fadeTransition = tester.widget<FadeTransition>(fadeTransitionFinder);
.descendant(
of: find.byType(ScrollablePositionedList),
matching: find.byType(FadeTransition))
.last);
final initialOpacity = fadeTransition.opacity; final initialOpacity = fadeTransition.opacity;
unawaited( unawaited(
@@ -407,11 +410,7 @@ void main() {
await tester.pump(); await tester.pump();
await tester.pump(scrollDuration ~/ 2); await tester.pump(scrollDuration ~/ 2);
fadeTransition = tester.widget<FadeTransition>(find fadeTransition = tester.widget<FadeTransition>(fadeTransitionFinder);
.descendant(
of: find.byType(ScrollablePositionedList),
matching: find.byType(FadeTransition))
.last);
expect(fadeTransition.opacity, initialOpacity); expect(fadeTransition.opacity, initialOpacity);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -456,10 +455,6 @@ void main() {
final itemScrollController = ItemScrollController(); final itemScrollController = ItemScrollController();
await setUpWidgetTest(tester, itemScrollController: itemScrollController); await setUpWidgetTest(tester, itemScrollController: itemScrollController);
final fadeTransitionFinder = find.descendant(
of: find.byType(ScrollablePositionedList),
matching: find.byType(FadeTransition));
unawaited( unawaited(
itemScrollController.scrollTo(index: 100, duration: scrollDuration)); itemScrollController.scrollTo(index: 100, duration: scrollDuration));
await tester.pump(); await tester.pump();
@@ -533,26 +528,14 @@ void main() {
await tester.pump(); await tester.pump();
await tester.pump(); await tester.pump();
expect( expect(
tester tester.widget<FadeTransition>(fadeTransitionFinder.last).opacity.value,
.widget<FadeTransition>(find closeTo(0, 0.01),
.descendant( );
of: find.byType(ScrollablePositionedList),
matching: find.byType(FadeTransition))
.last)
.opacity
.value,
closeTo(0, 0.01));
await tester.pump(scrollDuration + scrollDurationTolerance); await tester.pump(scrollDuration + scrollDurationTolerance);
expect( expect(
tester tester.widget<FadeTransition>(fadeTransitionFinder.last).opacity.value,
.widget<FadeTransition>(find closeTo(1, 0.01),
.descendant( );
of: find.byType(ScrollablePositionedList),
matching: find.byType(FadeTransition))
.last)
.opacity
.value,
closeTo(1, 0.01));
expect(find.text('Item 0'), findsOneWidget); expect(find.text('Item 0'), findsOneWidget);
expect(tester.getTopLeft(find.text('Item 0')).dy, 0); 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.getTopLeft(find.text('Item 10')).dy, 0);
expect(tester.getBottomLeft(find.text('Item 19')).dy, screenHeight); expect(tester.getBottomLeft(find.text('Item 19')).dy, screenHeight);
expect( expect(
tester tester.widget<FadeTransition>(fadeTransitionFinder.last).opacity.value,
.widget<FadeTransition>(find closeTo(0.5, 0.01),
.descendant( );
of: find.byType(ScrollablePositionedList),
matching: find.byType(FadeTransition))
.last)
.opacity
.value,
closeTo(0.5, 0.01));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
}); });
@@ -899,11 +876,7 @@ void main() {
await tester.pump(); await tester.pump();
expect(tester.getTopLeft(find.text('Item 9')).dy, 0); expect(tester.getTopLeft(find.text('Item 9')).dy, 0);
final fadeTransition = tester.widget<FadeTransition>(find final fadeTransition = tester.widget<FadeTransition>(fadeTransitionFinder);
.descendant(
of: find.byType(ScrollablePositionedList),
matching: find.byType(FadeTransition))
.last);
expect(fadeTransition.opacity.value, 1.0); expect(fadeTransition.opacity.value, 1.0);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -923,21 +896,12 @@ void main() {
await tester.pump(); await tester.pump();
expect(tester.getTopLeft(find.text('Item 10')).dy, 0); expect(tester.getTopLeft(find.text('Item 10')).dy, 0);
final fadeTransition = tester.widget<FadeTransition>(find final fadeTransition = tester.widget<FadeTransition>(fadeTransitionFinder);
.descendant(
of: find.byType(ScrollablePositionedList),
matching: find.byType(FadeTransition))
.last);
expect(fadeTransition.opacity.value, 1.0); expect(fadeTransition.opacity.value, 1.0);
await tester.pumpAndSettle(); 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 { testWidgets('Scroll to 0 stop before half way', (WidgetTester tester) async {
final itemScrollController = ItemScrollController(); final itemScrollController = ItemScrollController();
await setUpWidgetTest(tester, itemScrollController: itemScrollController); await setUpWidgetTest(tester, itemScrollController: itemScrollController);
@@ -1022,14 +986,13 @@ void main() {
itemScrollController.scrollTo(index: 0, duration: scrollDuration)); itemScrollController.scrollTo(index: 0, duration: scrollDuration));
await tester.pump(); await tester.pump();
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.tap(find.byType(ScrollablePositionedList));
await tester.pump(); await tester.pump();
expect(tester.getTopLeft(find.text('Item 9')).dy, closeTo(0, tolerance)); expect(tester.getTopLeft(find.text('Item 90')).dy, 0);
final fadeTransition = tester.widget<FadeTransition>(fadeTransitionFinder); expect(fadeTransitionFinder, findsNWidgets(1));
expect(fadeTransition.opacity.value, 1.0);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
}); });
@@ -1098,6 +1061,34 @@ void main() {
expect(find.text('Item 100'), findsNothing); 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', testWidgets('Scroll to 250, scroll to 100, scroll to 0 half way',
(WidgetTester tester) async { (WidgetTester tester) async {
final itemScrollController = ItemScrollController(); final itemScrollController = ItemScrollController();
@@ -1145,34 +1136,35 @@ void main() {
}, skip: true); }, skip: true);
testWidgets( testWidgets(
'''Jump to 400 at bottom, manually scroll, scroll to 100 at bottom and back''', 'Jump to 400 at bottom, manually scroll, scroll to 100 at bottom and back',
(WidgetTester tester) async { (WidgetTester tester) async {
final itemScrollController = ItemScrollController(); final itemScrollController = ItemScrollController();
final itemPositionsListener = ItemPositionsListener.create(); final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(tester, await setUpWidgetTest(tester,
itemScrollController: itemScrollController, itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener); itemPositionsListener: itemPositionsListener);
itemScrollController.jumpTo(index: 400, alignment: 1); itemScrollController.jumpTo(index: 400, alignment: 1);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final listFinder = find.byType(ScrollablePositionedList); final listFinder = find.byType(ScrollablePositionedList);
await tester.drag(listFinder, const Offset(0, -screenHeight)); await tester.drag(listFinder, const Offset(0, -screenHeight));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
unawaited(itemScrollController.scrollTo( unawaited(itemScrollController.scrollTo(
index: 100, alignment: 1, duration: scrollDuration)); index: 100, alignment: 1, duration: scrollDuration));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
unawaited(itemScrollController.scrollTo( unawaited(itemScrollController.scrollTo(
index: 400, alignment: 1, duration: scrollDuration)); index: 400, alignment: 1, duration: scrollDuration));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final itemFinder = find.text('Item 399'); final itemFinder = find.text('Item 399');
expect(itemFinder, findsOneWidget); expect(itemFinder, findsOneWidget);
expect(tester.getBottomLeft(itemFinder).dy, screenHeight); expect(tester.getBottomLeft(itemFinder).dy, screenHeight);
}); },
);
testWidgets('physics', (WidgetTester tester) async { testWidgets('physics', (WidgetTester tester) async {
final itemScrollController = ItemScrollController(); final itemScrollController = ItemScrollController();
@@ -1664,70 +1656,71 @@ void main() {
}); });
testWidgets( testWidgets(
'''Maintain programmatic and user position (9 half way off top) in page view''', 'Maintain programmatic and user position (9 half way off top) in page view',
(WidgetTester tester) async { (WidgetTester tester) async {
final itemPositionsListener = ItemPositionsListener.create(); final itemPositionsListener = ItemPositionsListener.create();
final itemScrollController = ItemScrollController(); final itemScrollController = ItemScrollController();
tester.binding.window.devicePixelRatioTestValue = 1.0; tester.binding.window.devicePixelRatioTestValue = 1.0;
tester.binding.window.physicalSizeTestValue = tester.binding.window.physicalSizeTestValue =
const Size(screenWidth, screenHeight); const Size(screenWidth, screenHeight);
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: PageView( home: PageView(
children: [ children: [
KeyedSubtree( KeyedSubtree(
key: const PageStorageKey('key'), key: const PageStorageKey('key'),
child: ScrollablePositionedList.builder( child: ScrollablePositionedList.builder(
itemCount: defaultItemCount, itemCount: defaultItemCount,
itemScrollController: itemScrollController, itemScrollController: itemScrollController,
itemBuilder: (context, index) => SizedBox( itemBuilder: (context, index) => SizedBox(
height: itemHeight, height: itemHeight,
child: Text('Item $index'), child: Text('Item $index'),
),
itemPositionsListener: itemPositionsListener,
), ),
itemPositionsListener: itemPositionsListener,
), ),
), const Center(
const Center( child: Text('Test'),
child: Text('Test'), )
) ],
], ),
), ),
), );
);
itemScrollController.jumpTo(index: 9); itemScrollController.jumpTo(index: 9);
await tester.pump(); await tester.pump();
expect(tester.getBottomRight(find.text('Item 9')).dy, itemHeight); expect(tester.getBottomRight(find.text('Item 9')).dy, itemHeight);
await tester.drag( await tester.drag(
find.byType(ScrollablePositionedList), const Offset(0, -itemHeight)); find.byType(ScrollablePositionedList), const Offset(0, -itemHeight));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final item9Bottom = tester.getBottomRight(find.text('Item 9')).dy; final item9Bottom = tester.getBottomRight(find.text('Item 9')).dy;
expect(item9Bottom, lessThan(itemHeight)); expect(item9Bottom, lessThan(itemHeight));
await tester.drag(find.byType(PageView), const Offset(-500, 0)); await tester.drag(find.byType(PageView), const Offset(-500, 0));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.drag(find.byType(PageView), const Offset(500, 0)); await tester.drag(find.byType(PageView), const Offset(500, 0));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(tester.getBottomRight(find.text('Item 9')).dy, item9Bottom); expect(tester.getBottomRight(find.text('Item 9')).dy, item9Bottom);
expect( expect(
itemPositionsListener.itemPositions.value itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 9) .firstWhere((position) => position.index == 9)
.itemLeadingEdge, .itemLeadingEdge,
-(itemHeight / screenHeight) / 2); -(itemHeight / screenHeight) / 2);
expect( expect(
itemPositionsListener.itemPositions.value itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 9) .firstWhere((position) => position.index == 9)
.itemTrailingEdge, .itemTrailingEdge,
(itemHeight / screenHeight) / 2); (itemHeight / screenHeight) / 2);
}); },
);
testWidgets('List with no items', (WidgetTester tester) async { testWidgets('List with no items', (WidgetTester tester) async {
final itemScrollController = ItemScrollController(); final itemScrollController = ItemScrollController();
@@ -1751,21 +1744,24 @@ void main() {
MaterialApp( MaterialApp(
home: ValueListenableBuilder<int>( home: ValueListenableBuilder<int>(
valueListenable: itemCount, valueListenable: itemCount,
builder: (context, itemCount, child) => builder: (context, itemCount, child) {
ScrollablePositionedList.builder( return ScrollablePositionedList.builder(
initialScrollIndex: min(100, itemCount), initialScrollIndex: min(100, itemCount),
itemCount: itemCount, itemCount: itemCount,
itemScrollController: itemScrollController, itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener, itemPositionsListener: itemPositionsListener,
itemBuilder: (context, index) { itemBuilder: (context, index) {
assert(index >= 0 && index <= itemCount - 1, assert(
'index not bigger than 0 and smaller than itemCount - 1'); index >= 0 && index <= itemCount - 1,
return SizedBox( 'index must be in the range of 0 to itemCount - 1',
height: itemHeight, );
child: Text('Item $index'), return SizedBox(
); height: itemHeight,
}, child: Text('Item $index'),
), );
},
);
},
), ),
), ),
); );
@@ -1795,19 +1791,22 @@ void main() {
MaterialApp( MaterialApp(
home: ValueListenableBuilder<int>( home: ValueListenableBuilder<int>(
valueListenable: itemCount, valueListenable: itemCount,
builder: (context, itemCount, child) => builder: (context, itemCount, child) {
ScrollablePositionedList.builder( return ScrollablePositionedList.builder(
initialScrollIndex: min(100, itemCount - 1), initialScrollIndex: min(100, itemCount - 1),
itemCount: itemCount, itemCount: itemCount,
itemBuilder: (context, index) { itemBuilder: (context, index) {
assert(index >= 0 && index <= itemCount - 1, assert(
'index not bigger than 0 and smaller than itemCount -1'); index >= 0 && index <= itemCount - 1,
return SizedBox( 'index must be in the range of 0 to itemCount - 1',
height: itemHeight, );
child: Text('Item $index'), return SizedBox(
); height: itemHeight,
}, child: Text('Item $index'),
), );
},
);
},
), ),
), ),
); );
@@ -1834,19 +1833,22 @@ void main() {
MaterialApp( MaterialApp(
home: ValueListenableBuilder<int>( home: ValueListenableBuilder<int>(
valueListenable: itemCount, valueListenable: itemCount,
builder: (context, itemCount, child) => builder: (context, itemCount, child) {
ScrollablePositionedList.builder( return ScrollablePositionedList.builder(
initialScrollIndex: itemCount - 1, initialScrollIndex: itemCount - 1,
itemCount: itemCount, itemCount: itemCount,
itemBuilder: (context, index) { itemBuilder: (context, index) {
assert(index >= 0 && index <= itemCount - 1, assert(
'index not bigger than 0 and smaller than itemCount -1'); index >= 0 && index <= itemCount - 1,
return SizedBox( 'index must be in the range of 0 to itemCount - 1',
height: itemHeight, );
child: Text('Item $index'), return SizedBox(
); height: itemHeight,
}, child: Text('Item $index'),
), );
},
);
},
), ),
), ),
); );
@@ -1878,11 +1880,7 @@ void main() {
minCacheExtent: 10, minCacheExtent: 10,
); );
var fadeTransition = tester.widget<FadeTransition>(find var fadeTransition = tester.widget<FadeTransition>(fadeTransitionFinder);
.descendant(
of: find.byType(ScrollablePositionedList),
matching: find.byType(FadeTransition))
.last);
final initialOpacity = fadeTransition.opacity; final initialOpacity = fadeTransition.opacity;
unawaited( unawaited(
@@ -1891,11 +1889,7 @@ void main() {
await tester.pump(); await tester.pump();
await tester.pump(scrollDuration ~/ 2); await tester.pump(scrollDuration ~/ 2);
fadeTransition = tester.widget<FadeTransition>(find fadeTransition = tester.widget<FadeTransition>(fadeTransitionFinder);
.descendant(
of: find.byType(ScrollablePositionedList),
matching: find.byType(FadeTransition))
.last);
expect(fadeTransition.opacity, initialOpacity); expect(fadeTransition.opacity, initialOpacity);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -1914,11 +1908,9 @@ void main() {
minCacheExtent: itemHeight * 200, minCacheExtent: itemHeight * 200,
); );
var fadeTransition = tester.widget<FadeTransition>(find var fadeTransition = tester.widget<FadeTransition>(
.descendant( fadeTransitionFinder,
of: find.byType(ScrollablePositionedList), );
matching: find.byType(FadeTransition))
.last);
final initialOpacity = fadeTransition.opacity; final initialOpacity = fadeTransition.opacity;
unawaited( unawaited(
@@ -1927,11 +1919,7 @@ void main() {
await tester.pump(); await tester.pump();
await tester.pump(scrollDuration ~/ 2); await tester.pump(scrollDuration ~/ 2);
fadeTransition = tester.widget<FadeTransition>(find fadeTransition = tester.widget<FadeTransition>(fadeTransitionFinder);
.descendant(
of: find.byType(ScrollablePositionedList),
matching: find.byType(FadeTransition))
.last);
expect(fadeTransition.opacity, initialOpacity); expect(fadeTransition.opacity, initialOpacity);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -1965,17 +1953,21 @@ void main() {
MaterialApp( MaterialApp(
home: ValueListenableBuilder<Key>( home: ValueListenableBuilder<Key>(
valueListenable: key, valueListenable: key,
builder: (context, key, child) => Container( builder: (context, key, child) {
key: key, return Container(
child: ScrollablePositionedList.builder( key: key,
itemCount: 200, child: ScrollablePositionedList.builder(
itemScrollController: itemScrollController, itemCount: 200,
itemBuilder: (context, index) => SizedBox( itemScrollController: itemScrollController,
height: itemHeight, itemBuilder: (context, index) {
child: Text('Item $index'), return SizedBox(
height: itemHeight,
child: Text('Item $index'),
);
},
), ),
), );
), },
), ),
), ),
); );
@@ -2054,15 +2046,19 @@ void main() {
MaterialApp( MaterialApp(
home: ValueListenableBuilder<Key>( home: ValueListenableBuilder<Key>(
valueListenable: key, valueListenable: key,
builder: (context, key, child) => ScrollablePositionedList.builder( builder: (context, key, child) {
key: key, return ScrollablePositionedList.builder(
itemCount: 10, key: key,
itemScrollController: itemScrollController, itemCount: 10,
itemBuilder: (context, index) => SizedBox( itemScrollController: itemScrollController,
height: itemHeight, itemBuilder: (context, index) {
child: Text('Item $index'), return SizedBox(
), height: itemHeight,
), child: Text('Item $index'),
);
},
);
},
), ),
), ),
); );
@@ -2084,17 +2080,21 @@ void main() {
MaterialApp( MaterialApp(
home: ValueListenableBuilder<Key>( home: ValueListenableBuilder<Key>(
valueListenable: key, valueListenable: key,
builder: (context, key, child) => Container( builder: (context, key, child) {
key: key, return Container(
child: ScrollablePositionedList.builder( key: key,
itemCount: 100, child: ScrollablePositionedList.builder(
itemScrollController: itemScrollController, itemCount: 100,
itemBuilder: (context, index) => SizedBox( itemScrollController: itemScrollController,
height: itemHeight, itemBuilder: (context, index) {
child: Text('Item $index'), return SizedBox(
height: itemHeight,
child: Text('Item $index'),
);
},
), ),
), );
), },
), ),
), ),
); );
@@ -2124,18 +2124,22 @@ void main() {
MaterialApp( MaterialApp(
home: ValueListenableBuilder<Key>( home: ValueListenableBuilder<Key>(
valueListenable: containerKey, valueListenable: containerKey,
builder: (context, key, child) => Container( builder: (context, key, child) {
key: key, return Container(
child: ScrollablePositionedList.builder( key: key,
key: scrollKey, child: ScrollablePositionedList.builder(
itemCount: 100, key: scrollKey,
itemScrollController: itemScrollController, itemCount: 100,
itemBuilder: (context, index) => SizedBox( itemScrollController: itemScrollController,
height: itemHeight, itemBuilder: (context, index) {
child: Text('Item $index'), return SizedBox(
height: itemHeight,
child: Text('Item $index'),
);
},
), ),
), );
), },
), ),
), ),
); );
@@ -2166,15 +2170,18 @@ void main() {
MaterialApp( MaterialApp(
home: ValueListenableBuilder<ItemScrollController>( home: ValueListenableBuilder<ItemScrollController>(
valueListenable: itemScrollControllerListenable, valueListenable: itemScrollControllerListenable,
builder: (context, itemScrollController, child) => builder: (context, itemScrollController, child) {
ScrollablePositionedList.builder( return ScrollablePositionedList.builder(
itemCount: 100, itemCount: 100,
itemScrollController: itemScrollController, itemScrollController: itemScrollController,
itemBuilder: (context, index) => SizedBox( itemBuilder: (context, index) {
height: itemHeight, return SizedBox(
child: Text('Item $index'), height: itemHeight,
), child: Text('Item $index'),
), );
},
);
},
), ),
), ),
); );
@@ -2215,29 +2222,35 @@ void main() {
Expanded( Expanded(
child: ValueListenableBuilder<ItemScrollController>( child: ValueListenableBuilder<ItemScrollController>(
valueListenable: topItemScrollControllerListenable, valueListenable: topItemScrollControllerListenable,
builder: (context, itemScrollController, child) => builder: (context, itemScrollController, child) {
ScrollablePositionedList.builder( return ScrollablePositionedList.builder(
itemCount: 100, itemCount: 100,
itemScrollController: itemScrollController, itemScrollController: itemScrollController,
itemBuilder: (context, index) => SizedBox( itemBuilder: (context, index) {
height: itemHeight, return SizedBox(
child: Text('Item $index'), height: itemHeight,
), child: Text('Item $index'),
), );
},
);
},
), ),
), ),
Expanded( Expanded(
child: ValueListenableBuilder<ItemScrollController>( child: ValueListenableBuilder<ItemScrollController>(
valueListenable: bottomItemScrollControllerListenable, valueListenable: bottomItemScrollControllerListenable,
builder: (context, itemScrollController, child) => builder: (context, itemScrollController, child) {
ScrollablePositionedList.builder( return ScrollablePositionedList.builder(
itemCount: 100, itemCount: 100,
itemScrollController: itemScrollController, itemScrollController: itemScrollController,
itemBuilder: (context, index) => SizedBox( itemBuilder: (context, index) {
height: itemHeight, return SizedBox(
child: Text('Item $index'), height: itemHeight,
), child: Text('Item $index'),
), );
},
);
},
), ),
), ),
], ],
@@ -2,10 +2,9 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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/scrollable_positioned_list.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/scroll_view.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/src/scroll_view.dart';
@@ -497,20 +496,21 @@ void main() {
MaterialApp( MaterialApp(
home: ValueListenableBuilder<int>( home: ValueListenableBuilder<int>(
valueListenable: itemCount, valueListenable: itemCount,
builder: (context, itemCount, child) => builder: (context, itemCount, child) {
ScrollablePositionedList.separated( return ScrollablePositionedList.separated(
itemCount: itemCount, itemCount: itemCount,
itemScrollController: itemScrollController, itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener, itemPositionsListener: itemPositionsListener,
itemBuilder: (context, index) => SizedBox( itemBuilder: (context, index) => SizedBox(
height: itemHeight, height: itemHeight,
child: Text('Item $index'), child: Text('Item $index'),
), ),
separatorBuilder: (context, index) => SizedBox( separatorBuilder: (context, index) => SizedBox(
height: separatorHeight, height: separatorHeight,
child: Text('Separator $index'), child: Text('Separator $index'),
), ),
), );
},
), ),
), ),
); );
@@ -538,20 +538,21 @@ void main() {
MaterialApp( MaterialApp(
home: ValueListenableBuilder<int>( home: ValueListenableBuilder<int>(
valueListenable: itemCount, valueListenable: itemCount,
builder: (context, itemCount, child) => builder: (context, itemCount, child) {
ScrollablePositionedList.separated( return ScrollablePositionedList.separated(
itemCount: itemCount, itemCount: itemCount,
itemScrollController: itemScrollController, itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener, itemPositionsListener: itemPositionsListener,
itemBuilder: (context, index) => SizedBox( itemBuilder: (context, index) => SizedBox(
height: itemHeight, height: itemHeight,
child: Text('Item $index'), child: Text('Item $index'),
), ),
separatorBuilder: (context, index) => SizedBox( separatorBuilder: (context, index) => SizedBox(
height: separatorHeight, height: separatorHeight,
child: Text('Separator $index'), child: Text('Separator $index'),
), ),
), );
},
), ),
), ),
); );
@@ -2,10 +2,9 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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/scrollable_positioned_list.dart';
const screenHeight = 400.0; const screenHeight = 400.0;
@@ -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<void> 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);
});
}
@@ -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<void> 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));
});
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -60,7 +60,7 @@ final _messageThemeControl = StreamMessageThemeData(
messageLinksStyle: TextStyle( messageLinksStyle: TextStyle(
color: StreamColorTheme.light().accentPrimary, color: StreamColorTheme.light().accentPrimary,
), ),
linkBackgroundColor: StreamColorTheme.light().linkBg, urlAttachmentBackgroundColor: StreamColorTheme.light().linkBg,
); );
final _messageThemeControlDark = StreamMessageThemeData( final _messageThemeControlDark = StreamMessageThemeData(
@@ -89,5 +89,5 @@ final _messageThemeControlDark = StreamMessageThemeData(
messageLinksStyle: TextStyle( messageLinksStyle: TextStyle(
color: StreamColorTheme.dark().accentPrimary, color: StreamColorTheme.dark().accentPrimary,
), ),
linkBackgroundColor: StreamColorTheme.dark().linkBg, urlAttachmentBackgroundColor: StreamColorTheme.dark().linkBg,
); );
@@ -81,6 +81,71 @@ void main() {
expect('🌶1'.isOnlyEmoji, false); expect('🌶1'.isOnlyEmoji, false);
expect('👨‍👨👨‍👨'.isOnlyEmoji, true); expect('👨‍👨👨‍👨'.isOnlyEmoji, true);
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);
}); });
}); });
} }
@@ -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 ## 6.0.0
- Updated dependencies to resolvable versions. - Updated dependencies to resolvable versions.
@@ -387,12 +387,20 @@ class StreamChannelState extends State<StreamChannel> {
(it) => it.user.id == channel.client.state.currentUser?.id, (it) => it.user.id == channel.client.state.currentUser?.id,
); );
if (read != null && if (read == null) return;
!(channel.state!.messages
.any((it) => it.createdAt.compareTo(read.lastRead) > 0) && final messages = channel.state!.messages;
channel.state!.messages final lastRead = read.lastRead;
.any((it) => it.createdAt.compareTo(read.lastRead) <= 0))) {
_futures.add(_loadChannelAtTimestamp(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<StreamChannel> {
return child; return child;
} }
} }
extension on DateTime {
bool isBeforeOrEqualTo(DateTime other) {
return isBefore(other) || isAtSameMomentAs(other);
}
}
@@ -101,14 +101,19 @@ class StreamChannelListEventHandler {
/// we are currently watching. /// we are currently watching.
/// ///
/// By default, this moves the channel to the top of the list. /// 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; final channelCid = event.cid;
if (channelCid == null) return; if (channelCid == null) return;
final channels = [...controller.currentItems]; final channels = [...controller.currentItems];
final channelIndex = channels.indexWhere((it) => it.cid == channelCid); 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); final channel = channels.removeAt(channelIndex);
channels.insert(0, channel); channels.insert(0, channel);
@@ -1,12 +1,12 @@
name: stream_chat_flutter_core name: stream_chat_flutter_core
homepage: https://github.com/GetStream/stream-chat-flutter homepage: https://github.com/GetStream/stream-chat-flutter
description: Stream Chat official Flutter SDK Core. Build your own chat experience using Dart and 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 repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
environment: environment:
sdk: '>=2.17.0 <3.0.0' sdk: '>=2.17.0 <4.0.0'
flutter: ">=1.17.0" flutter: ">=1.17.0"
dependencies: dependencies:
@@ -17,7 +17,7 @@ dependencies:
freezed_annotation: ^2.0.3 freezed_annotation: ^2.0.3
meta: ^1.8.0 meta: ^1.8.0
rxdart: ^0.27.0 rxdart: ^0.27.0
stream_chat: ^6.0.0 stream_chat: ^6.1.0
dev_dependencies: dev_dependencies:
build_runner: ^2.3.3 build_runner: ^2.3.3
@@ -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 ## 5.0.0
* Updated `stream_chat_flutter` dependency to [`6.0.0`](https://pub.dev/packages/stream_chat_flutter/changelog). * Updated `stream_chat_flutter` dependency to [`6.0.0`](https://pub.dev/packages/stream_chat_flutter/changelog).
@@ -6,9 +11,12 @@
✅ Added ✅ 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. * 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 🔄 Changed
@@ -39,7 +47,9 @@
## 3.3.0 ## 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 ## 3.2.0
@@ -49,7 +59,9 @@
## 3.1.0 ## 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 ## 3.0.0
@@ -63,7 +75,9 @@
✅ Added ✅ 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 🔄 Changed
@@ -81,9 +95,15 @@
✅ Added ✅ 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
* 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. for [Spanish](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart)
* 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. 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 cooldown mode.
* Added translations for attachmentLimitExceed. * Added translations for attachmentLimitExceed.
@@ -1,12 +1,12 @@
name: stream_chat_localizations name: stream_chat_localizations
description: The Official localizations for Stream Chat Flutter, a service for building chat applications 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 homepage: https://github.com/GetStream/stream-chat-flutter
repository: 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 issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
environment: environment:
sdk: '>=2.17.0 <3.0.0' sdk: '>=2.17.0 <4.0.0'
flutter: ">=1.20.0" flutter: ">=1.20.0"
dependencies: dependencies:
@@ -14,7 +14,7 @@ dependencies:
sdk: flutter sdk: flutter
flutter_localizations: flutter_localizations:
sdk: flutter sdk: flutter
stream_chat_flutter: ^6.0.0 stream_chat_flutter: ^6.1.0
dev_dependencies: dev_dependencies:
dart_code_metrics: ^5.7.2 dart_code_metrics: ^5.7.2
@@ -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 ## 6.0.0
- Updated `drift` to `^2.7.0`. - Updated `drift` to `^2.7.0`.
@@ -23,7 +28,8 @@
## 4.4.0 ## 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). Thanks [geweald](https://github.com/geweald).
## 4.3.0 ## 4.3.0
+1 -1
View File
@@ -37,7 +37,7 @@ The usage is pretty simple.
```dart ```dart
final chatPersistentClient = StreamChatPersistenceClient( final chatPersistentClient = StreamChatPersistenceClient(
logLevel: Level.INFO, logLevel: Level.INFO,
connectionMode: ConnectionMode.background, connectionMode: ConnectionMode.regular,
); );
``` ```
2. Pass the instance to the official Stream chat client. 2. Pass the instance to the official Stream chat client.
@@ -1,7 +1,5 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:mutex/mutex.dart';
import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat/stream_chat.dart';
import 'package:stream_chat_persistence/src/db/drift_chat_database.dart'; import 'package:stream_chat_persistence/src/db/drift_chat_database.dart';
/// Various connection modes on which [StreamChatPersistenceClient] can work /// Various connection modes on which [StreamChatPersistenceClient] can work
@@ -48,7 +46,6 @@ class StreamChatPersistenceClient extends ChatPersistenceClient {
final Logger _logger; final Logger _logger;
final ConnectionMode _connectionMode; final ConnectionMode _connectionMode;
final bool _webUseIndexedDbIfSupported; final bool _webUseIndexedDbIfSupported;
final _mutex = ReadWriteMutex();
void _defaultLogHandler(LogRecord record) { void _defaultLogHandler(LogRecord record) {
print( print(
@@ -59,9 +56,6 @@ class StreamChatPersistenceClient extends ChatPersistenceClient {
if (record.stackTrace != null) print(record.stackTrace); if (record.stackTrace != null) print(record.stackTrace);
} }
Future<T> _readProtected<T>(AsyncValueGetter<T> func) =>
_mutex.protectRead(func);
bool get _debugIsConnected { bool get _debugIsConnected {
assert(() { assert(() {
if (db == null) { if (db == null) {
@@ -96,6 +90,7 @@ class StreamChatPersistenceClient extends ChatPersistenceClient {
'disconnect the previous instance before connecting again.', 'disconnect the previous instance before connecting again.',
); );
} }
_logger.info('connect');
db = databaseProvider?.call(userId, _connectionMode) ?? db = databaseProvider?.call(userId, _connectionMode) ??
await _defaultDatabaseProvider(userId, _connectionMode); await _defaultDatabaseProvider(userId, _connectionMode);
} }
@@ -104,90 +99,84 @@ class StreamChatPersistenceClient extends ChatPersistenceClient {
Future<Event?> getConnectionInfo() { Future<Event?> getConnectionInfo() {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
_logger.info('getConnectionInfo'); _logger.info('getConnectionInfo');
return _readProtected(() => db!.connectionEventDao.connectionEvent); return db!.connectionEventDao.connectionEvent;
} }
@override @override
Future<void> updateConnectionInfo(Event event) { Future<void> updateConnectionInfo(Event event) {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
_logger.info('updateConnectionInfo'); _logger.info('updateConnectionInfo');
return _readProtected( return db!.connectionEventDao.updateConnectionEvent(event);
() => db!.connectionEventDao.updateConnectionEvent(event),
);
} }
@override @override
Future<void> updateLastSyncAt(DateTime lastSyncAt) { Future<void> updateLastSyncAt(DateTime lastSyncAt) {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
_logger.info('updateLastSyncAt'); _logger.info('updateLastSyncAt');
return _readProtected( return db!.connectionEventDao.updateLastSyncAt(lastSyncAt);
() => db!.connectionEventDao.updateLastSyncAt(lastSyncAt),
);
} }
@override @override
Future<DateTime?> getLastSyncAt() { Future<DateTime?> getLastSyncAt() {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
_logger.info('getLastSyncAt'); _logger.info('getLastSyncAt');
return _readProtected(() => db!.connectionEventDao.lastSyncAt); return db!.connectionEventDao.lastSyncAt;
} }
@override @override
Future<void> deleteChannels(List<String> cids) { Future<void> deleteChannels(List<String> cids) {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
_logger.info('deleteChannels'); _logger.info('deleteChannels');
return _readProtected(() => db!.channelDao.deleteChannelByCids(cids)); return db!.channelDao.deleteChannelByCids(cids);
} }
@override @override
Future<List<String>> getChannelCids() { Future<List<String>> getChannelCids() {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
_logger.info('getChannelCids'); _logger.info('getChannelCids');
return _readProtected(() => db!.channelDao.cids); return db!.channelDao.cids;
} }
@override @override
Future<void> deleteMessageByIds(List<String> messageIds) { Future<void> deleteMessageByIds(List<String> messageIds) {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
_logger.info('deleteMessageByIds'); _logger.info('deleteMessageByIds');
return _readProtected(() => db!.messageDao.deleteMessageByIds(messageIds)); return db!.messageDao.deleteMessageByIds(messageIds);
} }
@override @override
Future<void> deletePinnedMessageByIds(List<String> messageIds) { Future<void> deletePinnedMessageByIds(List<String> messageIds) {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
_logger.info('deletePinnedMessageByIds'); _logger.info('deletePinnedMessageByIds');
return _readProtected( return db!.pinnedMessageDao.deleteMessageByIds(messageIds);
() => db!.pinnedMessageDao.deleteMessageByIds(messageIds),
);
} }
@override @override
Future<void> deleteMessageByCids(List<String> cids) { Future<void> deleteMessageByCids(List<String> cids) {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
_logger.info('deleteMessageByCids'); _logger.info('deleteMessageByCids');
return _readProtected(() => db!.messageDao.deleteMessageByCids(cids)); return db!.messageDao.deleteMessageByCids(cids);
} }
@override @override
Future<void> deletePinnedMessageByCids(List<String> cids) { Future<void> deletePinnedMessageByCids(List<String> cids) {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
_logger.info('deletePinnedMessageByCids'); _logger.info('deletePinnedMessageByCids');
return _readProtected(() => db!.pinnedMessageDao.deleteMessageByCids(cids)); return db!.pinnedMessageDao.deleteMessageByCids(cids);
} }
@override @override
Future<List<Member>> getMembersByCid(String cid) { Future<List<Member>> getMembersByCid(String cid) {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
_logger.info('getMembersByCid'); _logger.info('getMembersByCid');
return _readProtected(() => db!.memberDao.getMembersByCid(cid)); return db!.memberDao.getMembersByCid(cid);
} }
@override @override
Future<ChannelModel?> getChannelByCid(String cid) { Future<ChannelModel?> getChannelByCid(String cid) {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
_logger.info('getChannelByCid'); _logger.info('getChannelByCid');
return _readProtected(() => db!.channelDao.getChannelByCid(cid)); return db!.channelDao.getChannelByCid(cid);
} }
@override @override
@@ -197,11 +186,9 @@ class StreamChatPersistenceClient extends ChatPersistenceClient {
}) { }) {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
_logger.info('getMessagesByCid'); _logger.info('getMessagesByCid');
return _readProtected( return db!.messageDao.getMessagesByCid(
() => db!.messageDao.getMessagesByCid( cid,
cid, messagePagination: messagePagination,
messagePagination: messagePagination,
),
); );
} }
@@ -212,37 +199,34 @@ class StreamChatPersistenceClient extends ChatPersistenceClient {
}) { }) {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
_logger.info('getPinnedMessagesByCid'); _logger.info('getPinnedMessagesByCid');
return _readProtected( return db!.pinnedMessageDao.getMessagesByCid(
() => db!.pinnedMessageDao.getMessagesByCid( cid,
cid, messagePagination: messagePagination,
messagePagination: messagePagination,
),
); );
} }
@override @override
Future<List<Read>> getReadsByCid(String cid) { Future<List<Read>> getReadsByCid(String cid) async {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
_logger.info('getReadsByCid'); _logger.info('getReadsByCid');
return _readProtected(() => db!.readDao.getReadsByCid(cid)); return db!.readDao.getReadsByCid(cid);
} }
@override @override
Future<Map<String, List<Message>>> getChannelThreads(String cid) { Future<Map<String, List<Message>>> getChannelThreads(String cid) async {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
_logger.info('getChannelThreads'); _logger.info('getChannelThreads');
return _readProtected(() async { final messages = await db!.messageDao.getThreadMessages(cid);
final messages = await db!.messageDao.getThreadMessages(cid); final messageByParentIdDictionary = <String, List<Message>>{};
final messageByParentIdDictionary = <String, List<Message>>{}; for (final message in messages) {
for (final message in messages) { final parentId = message.parentId!;
final parentId = message.parentId!; messageByParentIdDictionary[parentId] = [
messageByParentIdDictionary[parentId] = [ ...messageByParentIdDictionary[parentId] ?? [],
...messageByParentIdDictionary[parentId] ?? [], message,
message, ];
]; }
}
return messageByParentIdDictionary; return messageByParentIdDictionary;
});
} }
@override @override
@@ -252,11 +236,9 @@ class StreamChatPersistenceClient extends ChatPersistenceClient {
}) { }) {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
_logger.info('getReplies'); _logger.info('getReplies');
return _readProtected( return db!.messageDao.getThreadMessagesByParentId(
() => db!.messageDao.getThreadMessagesByParentId( parentId,
parentId, options: options,
options: options,
),
); );
} }
@@ -268,73 +250,45 @@ class StreamChatPersistenceClient extends ChatPersistenceClient {
Please use channelStateSort instead.''') List<SortOption<ChannelModel>>? sort, Please use channelStateSort instead.''') List<SortOption<ChannelModel>>? sort,
List<SortOption<ChannelState>>? channelStateSort, List<SortOption<ChannelState>>? channelStateSort,
PaginationParams? paginationParams, PaginationParams? paginationParams,
}) { }) async {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
assert( assert(
sort == null || channelStateSort == null, sort == null || channelStateSort == null,
'sort and channelStateSort cannot be used together', 'sort and channelStateSort cannot be used together',
); );
_logger.info('getChannelStates'); _logger.info('getChannelStates');
return _readProtected(
() async {
final channels = await db!.channelQueryDao.getChannels(
filter: filter,
sort: sort,
);
final channelStates = await Future.wait( final channels = await db!.channelQueryDao.getChannels(
channels.map((e) => getChannelStateByCid(e.cid)), 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 @override
@@ -345,12 +299,10 @@ class StreamChatPersistenceClient extends ChatPersistenceClient {
}) { }) {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
_logger.info('updateChannelQueries'); _logger.info('updateChannelQueries');
return _readProtected( return db!.channelQueryDao.updateChannelQueries(
() => db!.channelQueryDao.updateChannelQueries( filter,
filter, cids,
cids, clearQueryCache: clearQueryCache,
clearQueryCache: clearQueryCache,
),
); );
} }
@@ -358,60 +310,56 @@ class StreamChatPersistenceClient extends ChatPersistenceClient {
Future<void> updateChannels(List<ChannelModel> channels) { Future<void> updateChannels(List<ChannelModel> channels) {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
_logger.info('updateChannels'); _logger.info('updateChannels');
return _readProtected(() => db!.channelDao.updateChannels(channels)); return db!.channelDao.updateChannels(channels);
} }
@override @override
Future<void> bulkUpdateMembers(Map<String, List<Member>?> members) { Future<void> bulkUpdateMembers(Map<String, List<Member>?> members) {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
_logger.info('bulkUpdateMembers'); _logger.info('bulkUpdateMembers');
return _readProtected(() => db!.memberDao.bulkUpdateMembers(members)); return db!.memberDao.bulkUpdateMembers(members);
} }
@override @override
Future<void> bulkUpdateMessages(Map<String, List<Message>?> messages) { Future<void> bulkUpdateMessages(Map<String, List<Message>?> messages) {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
_logger.info('bulkUpdateMessages'); _logger.info('bulkUpdateMessages');
return _readProtected(() => db!.messageDao.bulkUpdateMessages(messages)); return db!.messageDao.bulkUpdateMessages(messages);
} }
@override @override
Future<void> bulkUpdatePinnedMessages(Map<String, List<Message>?> messages) { Future<void> bulkUpdatePinnedMessages(Map<String, List<Message>?> messages) {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
_logger.info('bulkUpdatePinnedMessages'); _logger.info('bulkUpdatePinnedMessages');
return _readProtected( return db!.pinnedMessageDao.bulkUpdateMessages(messages);
() => db!.pinnedMessageDao.bulkUpdateMessages(messages),
);
} }
@override @override
Future<void> updatePinnedMessageReactions(List<Reaction> reactions) { Future<void> updatePinnedMessageReactions(List<Reaction> reactions) {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
_logger.info('updatePinnedMessageReactions'); _logger.info('updatePinnedMessageReactions');
return _readProtected( return db!.pinnedMessageReactionDao.updateReactions(reactions);
() => db!.pinnedMessageReactionDao.updateReactions(reactions),
);
} }
@override @override
Future<void> updateReactions(List<Reaction> reactions) { Future<void> updateReactions(List<Reaction> reactions) {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
_logger.info('updateReactions'); _logger.info('updateReactions');
return _readProtected(() => db!.reactionDao.updateReactions(reactions)); return db!.reactionDao.updateReactions(reactions);
} }
@override @override
Future<void> bulkUpdateReads(Map<String, List<Read>?> reads) { Future<void> bulkUpdateReads(Map<String, List<Read>?> reads) {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
_logger.info('bulkUpdateReads'); _logger.info('bulkUpdateReads');
return _readProtected(() => db!.readDao.bulkUpdateReads(reads)); return db!.readDao.bulkUpdateReads(reads);
} }
@override @override
Future<void> updateUsers(List<User> users) { Future<void> updateUsers(List<User> users) {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
_logger.info('updateUsers'); _logger.info('updateUsers');
return _readProtected(() => db!.userDao.updateUsers(users)); return db!.userDao.updateUsers(users);
} }
@override @override
@@ -420,53 +368,83 @@ class StreamChatPersistenceClient extends ChatPersistenceClient {
) { ) {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
_logger.info('deletePinnedMessageReactionsByMessageId'); _logger.info('deletePinnedMessageReactionsByMessageId');
return _readProtected( return db!.pinnedMessageReactionDao.deleteReactionsByMessageIds(messageIds);
() =>
db!.pinnedMessageReactionDao.deleteReactionsByMessageIds(messageIds),
);
} }
@override @override
Future<void> deleteReactionsByMessageId(List<String> messageIds) { Future<void> deleteReactionsByMessageId(List<String> messageIds) {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
_logger.info('deleteReactionsByMessageId'); _logger.info('deleteReactionsByMessageId');
return _readProtected( return db!.reactionDao.deleteReactionsByMessageIds(messageIds);
() => db!.reactionDao.deleteReactionsByMessageIds(messageIds),
);
} }
@override @override
Future<void> deleteMembersByCids(List<String> cids) { Future<void> deleteMembersByCids(List<String> cids) {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
_logger.info('deleteMembersByCids'); _logger.info('deleteMembersByCids');
return _readProtected(() => db!.memberDao.deleteMemberByCids(cids)); return db!.memberDao.deleteMemberByCids(cids);
}
@override
Future<void> updateChannelThreads(
String cid,
Map<String, List<Message>> threads,
) {
assert(_debugIsConnected, '');
_logger.info('updateChannelThreads');
return db!.transaction(() => super.updateChannelThreads(cid, threads));
} }
@override @override
Future<void> updateChannelStates(List<ChannelState> channelStates) { Future<void> updateChannelStates(List<ChannelState> channelStates) {
assert(_debugIsConnected, ''); assert(_debugIsConnected, '');
_logger.info('updateChannelStates'); _logger.info('updateChannelStates');
return _readProtected( return db!.transaction(() => super.updateChannelStates(channelStates));
() async => db!.transaction(
() async {
await super.updateChannelStates(channelStates);
},
),
);
} }
@override @override
Future<void> disconnect({bool flush = false}) async => Future<void> disconnect({bool flush = false}) async {
_mutex.protectWrite(() async { _logger.info('disconnect');
_logger.info('disconnect'); if (db != null) {
if (db != null) { _logger.info('Disconnecting');
_logger.info('Disconnecting'); if (flush) {
if (flush) { _logger.info('Flushing');
_logger.info('Flushing'); await db!.flush();
await db!.flush(); }
} await db!.disconnect();
await db!.disconnect(); db = null;
db = null; }
} }
}); }
// Creates a new combined [Comparator] which sorts items
// by the given [comparators].
Comparator<T> _combineComparators<T>(Iterable<Comparator<T>> 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);
}
} }
@@ -1,12 +1,12 @@
name: stream_chat_persistence name: stream_chat_persistence
homepage: https://github.com/GetStream/stream-chat-flutter homepage: https://github.com/GetStream/stream-chat-flutter
description: Official Stream Chat Persistence library. Build your own chat experience using Dart and 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 repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
environment: environment:
sdk: '>=2.17.0 <3.0.0' sdk: '>=2.17.0 <4.0.0'
flutter: ">=1.17.0" flutter: ">=1.17.0"
dependencies: dependencies:
@@ -15,11 +15,10 @@ dependencies:
sdk: flutter sdk: flutter
logging: ^1.0.1 logging: ^1.0.1
meta: ^1.8.0 meta: ^1.8.0
mutex: ^3.0.0
path: ^1.8.2 path: ^1.8.2
path_provider: ^2.0.1 path_provider: ^2.0.1
sqlite3_flutter_libs: ^0.5.0 sqlite3_flutter_libs: ^0.5.0
stream_chat: ^6.0.0 stream_chat: ^6.1.0
dev_dependencies: dev_dependencies:
build_runner: ^2.3.3 build_runner: ^2.3.3
@@ -55,6 +55,15 @@ void main() {
expect(client.db, isNull); 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<StateError>()),
);
});
group('client functions', () { group('client functions', () {
const userId = 'testUserId'; const userId = 'testUserId';
final mockDatabase = MockChatDatabase(); final mockDatabase = MockChatDatabase();
@@ -66,6 +75,10 @@ void main() {
await client.connect(userId, databaseProvider: _mockDatabaseProvider); await client.connect(userId, databaseProvider: _mockDatabaseProvider);
}); });
tearDown(() async {
await client.disconnect();
});
test('getReplies', () async { test('getReplies', () async {
const parentId = 'testParentId'; const parentId = 'testParentId';
final replies = List.generate(3, (index) => Message(id: 'testId$index')); final replies = List.generate(3, (index) => Message(id: 'testId$index'));